This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 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
+20
View File
@@ -0,0 +1,20 @@
# Django
SECRET_KEY=your-secret-key-change-in-production
DEBUG=1
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Database (MySQL)
DB_ENGINE=django.db.backends.mysql
DB_NAME=croplogic
DB_USER=croplogic
DB_PASSWORD=changeme
DB_HOST=db
DB_PORT=3306
DB_ROOT_PASSWORD=root
# Cassandra
CASSANDRA_ENABLED=1
CASSANDRA_HOSTS=cassandra
CASSANDRA_PORT=9042
CASSANDRA_KEYSPACE=sensor_hub
CASSANDRA_REPLICATION={'class': 'SimpleStrategy', 'replication_factor': 1}
@@ -0,0 +1,120 @@
name: Sensor Hub Service CI/CD
on:
push:
branches: [production]
paths:
- '**'
- '.gitea/workflows/sensor-hub.yml'
pull_request:
branches: [production]
paths:
- '**'
- '.gitea/workflows/sensor-hub.yml'
jobs:
test:
name: Lint & Test
runs-on: self-hosted
container:
image: mirror2.chabokan.net/ubuntu:22.04
options: --add-host gitea:172.17.0.1
steps:
- name: Setup Ubuntu apt mirrors
run: |
tee /etc/apt/sources.list > /dev/null <<'EOF'
deb https://mirror-linux.runflare.com/ubuntu/ noble main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-updates main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-backports main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-security main restricted universe multiverse
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal main universe
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-updates main universe
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-security main universe
deb http://mirror.iranserver.com/ubuntu/ jammy main restricted
deb-src http://mirror.iranserver.com/ubuntu/ jammy main restricted
deb http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
deb http://mirror.iranserver.com/ubuntu/ jammy universe
deb-src http://mirror.iranserver.com/ubuntu/ jammy universe
deb http://mirror.iranserver.com/ubuntu/ jammy-updates universe
deb http://mirror.iranserver.com/ubuntu/ jammy multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy multiverse
deb http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
deb http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
EOF
apt-get update
- name: Install git
run: |
apt-get install -y git
- name: Checkout repository
run: |
git clone http://15f3baa28036aa35f8eb707585567d1b87bd8977@git.crop-logic.ir/sajad-dev/SensorHub.git .
- name: Install Python
run: |
apt-get install -y python3 python3-pip python3-venv git
- name: Setup Python pip mirrors
run: |
pip3 config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple
pip3 config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple
pip3 config --user set global.trusted-host "package-mirror.liara.ir mirror.cdn.ir mirror2.chabokan.net"
- name: Install system dependencies
run: |
apt-get install -y \
python3 \
python3-pip \
python3-venv \
pkg-config \
build-essential \
default-libmysqlclient-dev
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
pip3 install pytest flake8
- name: Run lint
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{secrets.SERVER_SSH_PORT}} -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} -p ${{secrets.SERVER_SSH_PORT}}<< 'EOF'
cd application/SensorHub
git pull origin production
docker-compose -f docker-compose-prod.yaml down --remove-orphans
docker-compose -f docker-compose-prod.yaml up -d
EOF
+72
View File
@@ -0,0 +1,72 @@
name: Sensor Hub Service CI/CD
on:
push:
branches: [main]
paths:
- 'sensor-hub/**'
- 'sensor-hub/.github/workflows/sensor-hub.yml'
pull_request:
branches: [main]
paths:
- 'sensor-hub/**'
- 'sensor-hub/.github/workflows/sensor-hub.yml'
defaults:
run:
working-directory: sensor-hub
jobs:
test:
name: Lint & Test
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ['3.11']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-sensor-hub-${{ hashFiles('sensor-hub/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-sensor-hub-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest flake8
- name: Run lint
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Run tests
run: pytest --tb=short -q
deploy:
name: Deploy Sensor Hub Service
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/myproject/sensor-hub
git pull origin main
sudo systemctl restart sensor-hub
+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
+4
View File
@@ -0,0 +1,4 @@
[submodule "Schemas"]
path = Schemas
url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git
branch = develop
+45
View File
@@ -0,0 +1,45 @@
FROM docker.iranserver.com/python:3.10
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Debian/debian mirrors for apt
RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
printf '%s\n' \
'deb https://mirror-linux.runflare.com/debian/ bookworm main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian/ bookworm-updates main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian-security/ bookworm-security main contrib non-free non-free-firmware' \
'' \
'deb [trusted=yes] https://mirror2.chabokan.net/debian bookworm main contrib non-free non-free-firmware' \
'deb [trusted=yes] https://mirror2.chabokan.net/debian-security bookworm-security main contrib non-free non-free-firmware' \
'' \
'deb http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \
'deb-src http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \
> /etc/apt/sources.list
# 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 .
# Python mirrors
RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \
pip config --user set global.trusted-host package-mirror.liara.ir && \
pip config --user set global.trusted-host mirror.cdn.ir && \
pip config --user set global.trusted-host mirror-pypi.runflare.com
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
+57
View File
@@ -0,0 +1,57 @@
from .common import RouteContract
from .crop_simulation_current_farm_chart import CONTRACT as CROP_SIMULATION_CURRENT_FARM_CHART_CONTRACT
from .crop_simulation_growth import CONTRACT as CROP_SIMULATION_GROWTH_CONTRACT
from .crop_simulation_growth_status import CONTRACT as CROP_SIMULATION_GROWTH_STATUS_CONTRACT
from .crop_simulation_harvest_prediction import CONTRACT as CROP_SIMULATION_HARVEST_PREDICTION_CONTRACT
from .crop_simulation_yield_prediction import CONTRACT as CROP_SIMULATION_YIELD_PREDICTION_CONTRACT
from .economy_overview import CONTRACT as ECONOMY_OVERVIEW_CONTRACT
from .farm_alerts import CONTRACTS as FARM_ALERTS_CONTRACTS
from .farm_data_upsert import CONTRACT as FARM_DATA_UPSERT_CONTRACT
from .farm_detail import CONTRACT as FARM_DETAIL_CONTRACT
from .farm_parameter import CONTRACT as FARM_PARAMETER_CONTRACT
from .fertilization_recommend import CONTRACT as FERTILIZATION_RECOMMEND_CONTRACT
from .irrigation_methods import CONTRACTS as IRRIGATION_METHOD_CONTRACTS
from .irrigation_recommend import CONTRACT as IRRIGATION_RECOMMEND_CONTRACT
from .irrigation_water_stress import CONTRACT as IRRIGATION_WATER_STRESS_CONTRACT
from .pest_disease import CONTRACTS as PEST_DISEASE_CONTRACTS
from .plant import CONTRACTS as PLANT_CONTRACTS
from .rag_chat import CONTRACT as RAG_CHAT_CONTRACT
from .soil_data import CONTRACTS as SOIL_DATA_CONTRACTS
from .soile_anomaly_detection import CONTRACT as SOILE_ANOMALY_DETECTION_CONTRACT
from .soile_health_summary import CONTRACT as SOILE_HEALTH_SUMMARY_CONTRACT
from .soile_moisture_heatmap import CONTRACT as SOILE_MOISTURE_HEATMAP_CONTRACT
from .weather_farm_card import CONTRACT as WEATHER_FARM_CARD_CONTRACT
from .weather_water_need_prediction import CONTRACT as WEATHER_WATER_NEED_PREDICTION_CONTRACT
ALL_ROUTE_CONTRACTS: list[RouteContract] = [
RAG_CHAT_CONTRACT,
*FARM_ALERTS_CONTRACTS,
*SOIL_DATA_CONTRACTS,
SOILE_MOISTURE_HEATMAP_CONTRACT,
SOILE_HEALTH_SUMMARY_CONTRACT,
SOILE_ANOMALY_DETECTION_CONTRACT,
FARM_DATA_UPSERT_CONTRACT,
FARM_DETAIL_CONTRACT,
FARM_PARAMETER_CONTRACT,
WEATHER_FARM_CARD_CONTRACT,
WEATHER_WATER_NEED_PREDICTION_CONTRACT,
ECONOMY_OVERVIEW_CONTRACT,
*PLANT_CONTRACTS,
*PEST_DISEASE_CONTRACTS,
*IRRIGATION_METHOD_CONTRACTS,
IRRIGATION_RECOMMEND_CONTRACT,
IRRIGATION_WATER_STRESS_CONTRACT,
FERTILIZATION_RECOMMEND_CONTRACT,
CROP_SIMULATION_GROWTH_CONTRACT,
CROP_SIMULATION_GROWTH_STATUS_CONTRACT,
CROP_SIMULATION_CURRENT_FARM_CHART_CONTRACT,
CROP_SIMULATION_HARVEST_PREDICTION_CONTRACT,
CROP_SIMULATION_YIELD_PREDICTION_CONTRACT,
]
ROUTE_CONTRACTS: dict[str, RouteContract] = {
f'{contract.method} {contract.path}': contract
for contract in ALL_ROUTE_CONTRACTS
}
__all__ = ['ALL_ROUTE_CONTRACTS', 'ROUTE_CONTRACTS', 'RouteContract']
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any, Generic, TypeAlias, TypeVar
from pydantic import BaseModel, ConfigDict
JsonValue: TypeAlias = Any
JsonObject: TypeAlias = dict[str, Any]
JsonList: TypeAlias = list[Any]
T = TypeVar('T')
class SchemaModel(BaseModel):
model_config = ConfigDict(extra='allow', populate_by_name=True)
class ApiEnvelope(SchemaModel, Generic[T]):
code: int
msg: str
data: T
class RouteContract(SchemaModel):
method: str
path: str
request_model: str
response_model: str
class EmptyRequest(SchemaModel):
pass
@@ -0,0 +1,42 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/current-farm-chart/'
class CropSimulationCurrentFarmChartRequest(SchemaModel):
farm_uuid: UUID
plant_name: str | None = None
class CropSimulationCurrentFarmChartResponseData(SchemaModel):
farm_uuid: str | None = None
plant_name: str | None = None
engine: str | None = None
model_name: str | None = None
scenario_id: int | None = None
simulation_warning: str | None = None
categories: list[str] = Field(default_factory=list)
series: JsonValue | None = None
summary: JsonObject = Field(default_factory=dict)
current_state: JsonObject = Field(default_factory=dict)
metrics: JsonObject = Field(default_factory=dict)
daily_output: JsonObject = Field(default_factory=dict)
class CropSimulationCurrentFarmChartResponse(ApiEnvelope[CropSimulationCurrentFarmChartResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationCurrentFarmChartRequest.__name__,
response_model=CropSimulationCurrentFarmChartResponse.__name__,
)
@@ -0,0 +1,46 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field, model_validator
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/growth/'
class CropSimulationGrowthRequest(SchemaModel):
plant_name: str
dynamic_parameters: list[str] = Field(min_length=1)
farm_uuid: UUID | None = None
weather: JsonValue | None = None
soil_parameters: JsonObject | None = None
site_parameters: JsonObject | None = None
crop_parameters: JsonObject | None = None
agromanagement: JsonObject | None = None
page_size: int | None = Field(default=None, ge=1, le=50)
@model_validator(mode='after')
def validate_farm_or_weather(self) -> 'CropSimulationGrowthRequest':
if self.farm_uuid is None and self.weather is None:
raise ValueError('Either farm_uuid or weather must be provided.')
return self
class CropSimulationGrowthResponseData(SchemaModel):
task_id: str
status_url: str
plant_name: str
class CropSimulationGrowthResponse(ApiEnvelope[CropSimulationGrowthResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationGrowthRequest.__name__,
response_model=CropSimulationGrowthResponse.__name__,
)
@@ -0,0 +1,59 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'GET'
ROUTE_PATH = '/api/crop-simulation/growth/<task_id>/status/'
class CropSimulationGrowthStatusRequest(SchemaModel):
task_id: str
page: int | None = Field(default=None, ge=1)
page_size: int | None = Field(default=None, ge=1)
class CropSimulationPagination(SchemaModel):
page: int
page_size: int
total_items: int
total_pages: int
has_next: bool
has_previous: bool
class CropSimulationGrowthResult(SchemaModel):
plant_name: str | None = None
dynamic_parameters: list[str] = Field(default_factory=list)
engine: str | None = None
model_name: str | None = None
scenario_id: int | None = None
simulation_warning: str | None = None
summary_metrics: JsonObject = Field(default_factory=dict)
stage_timeline: JsonList = Field(default_factory=list)
stages_page: JsonList = Field(default_factory=list)
pagination: CropSimulationPagination | None = None
daily_records_count: int | None = None
default_page_size: int | None = None
class CropSimulationGrowthStatusResponseData(SchemaModel):
task_id: str
status: str
message: str | None = None
progress: JsonObject = Field(default_factory=dict)
result: CropSimulationGrowthResult | None = None
error: str | None = None
class CropSimulationGrowthStatusResponse(ApiEnvelope[CropSimulationGrowthStatusResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationGrowthStatusRequest.__name__,
response_model=CropSimulationGrowthStatusResponse.__name__,
)
@@ -0,0 +1,37 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/harvest-prediction/'
class CropSimulationHarvestPredictionRequest(SchemaModel):
farm_uuid: UUID
plant_name: str | None = None
class CropSimulationHarvestPredictionResponseData(SchemaModel):
date: str
dateFormatted: str
daysUntil: int
description: str | None = None
optimalWindowStart: str | None = None
optimalWindowEnd: str | None = None
gddDetails: JsonObject = Field(default_factory=dict)
class CropSimulationHarvestPredictionResponse(ApiEnvelope[CropSimulationHarvestPredictionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationHarvestPredictionRequest.__name__,
response_model=CropSimulationHarvestPredictionResponse.__name__,
)
@@ -0,0 +1,41 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/crop-simulation/yield-prediction/'
class CropSimulationYieldPredictionRequest(SchemaModel):
farm_uuid: UUID
plant_name: str | None = None
class CropSimulationYieldPredictionResponseData(SchemaModel):
farm_uuid: str
plant_name: str | None = None
predictedYieldTons: float | None = None
predictedYieldRaw: float | None = None
unit: str | None = None
sourceUnit: str | None = None
simulationEngine: str | None = None
simulationModel: str | None = None
scenarioId: int | None = None
simulationWarning: str | None = None
supportingMetrics: JsonObject = Field(default_factory=dict)
class CropSimulationYieldPredictionResponse(ApiEnvelope[CropSimulationYieldPredictionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=CropSimulationYieldPredictionRequest.__name__,
response_model=CropSimulationYieldPredictionResponse.__name__,
)
@@ -0,0 +1,47 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/economy/overview/'
class EconomyOverviewRequest(SchemaModel):
farm_uuid: UUID
class EconomyDataItem(SchemaModel):
title: str
value: str
subtitle: str | None = None
avatarIcon: str | None = None
avatarColor: str | None = None
class ChartSeriesItem(SchemaModel):
name: str
data: list[float] = Field(default_factory=list)
class EconomyOverviewResponseData(SchemaModel):
farm_uuid: str
source: str | None = None
economicData: list[EconomyDataItem] = Field(default_factory=list)
chartSeries: list[ChartSeriesItem] = Field(default_factory=list)
chartCategories: list[str] = Field(default_factory=list)
class EconomyOverviewResponse(ApiEnvelope[EconomyOverviewResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=EconomyOverviewRequest.__name__,
response_model=EconomyOverviewResponse.__name__,
)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
class FarmAlertsRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
query: str | None = None
class FarmAlertNotificationSchema(SchemaModel):
id: int | None = None
farm_uuid: str | None = None
endpoint: str | None = None
level: Literal['danger', 'warning', 'info'] | str
title: str
message: str
suggested_action: str | None = None
source_alert_id: str | None = None
source_metric_type: str | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: str | None = None
updated_at: str | None = None
class FarmAlertsTimelineItem(SchemaModel):
timestamp: str | None = None
level: Literal['danger', 'warning', 'info'] | str
title: str
description: str | None = None
source_alert_id: str | None = None
source_metric_type: str | None = None
class FarmAlertsTrackerResponseData(SchemaModel):
farm_uuid: str
service_id: str | None = None
knowledge_base: str | None = None
tone_file: str | None = None
tracker: JsonObject = Field(default_factory=dict)
headline: str
overview: str | None = None
status_level: Literal['danger', 'warning', 'info'] | str
notifications: list[FarmAlertNotificationSchema] = Field(default_factory=list)
raw_llm_response: str | None = None
structured_context: JsonObject = Field(default_factory=dict)
class FarmAlertsTimelineResponseData(SchemaModel):
farm_uuid: str
service_id: str | None = None
knowledge_base: str | None = None
tone_file: str | None = None
tracker: JsonObject = Field(default_factory=dict)
headline: str
overview: str | None = None
timeline: list[FarmAlertsTimelineItem] = Field(default_factory=list)
notifications: list[FarmAlertNotificationSchema] = Field(default_factory=list)
raw_llm_response: str | None = None
structured_context: JsonObject = Field(default_factory=dict)
class FarmAlertsTrackerResponse(ApiEnvelope[FarmAlertsTrackerResponseData]):
pass
class FarmAlertsTimelineResponse(ApiEnvelope[FarmAlertsTimelineResponseData]):
pass
CONTRACTS = [
RouteContract(
method='POST',
path='/api/farm-alerts/tracker/',
request_model=FarmAlertsRequest.__name__,
response_model=FarmAlertsTrackerResponse.__name__,
),
RouteContract(
method='POST',
path='/api/farm-alerts/timeline/',
request_model=FarmAlertsRequest.__name__,
response_model=FarmAlertsTimelineResponse.__name__,
),
]
@@ -0,0 +1,53 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field, model_validator
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/farm-data/'
class FarmBoundaryCorner(SchemaModel):
lat: float
lon: float
class FarmDataUpsertRequest(SchemaModel):
farm_uuid: UUID
farm_boundary: JsonObject
sensor_key: str | None = 'sensor-7-1'
sensor_payload: JsonObject | None = None
plant_ids: list[int] = Field(default_factory=list)
irrigation_method_id: int | None = None
@model_validator(mode='after')
def validate_payload_sources(self) -> 'FarmDataUpsertRequest':
if not self.sensor_payload and not self.plant_ids and self.irrigation_method_id is None:
raise ValueError('At least one of sensor_payload, plant_ids or irrigation_method_id must be provided.')
return self
class FarmDataUpsertResponseData(SchemaModel):
farm_uuid: UUID
center_location_id: int | None = None
weather_forecast_id: int | None = None
sensor_payload: JsonObject = Field(default_factory=dict)
plant_ids: list[int] = Field(default_factory=list)
irrigation_method_id: int | None = None
created_at: str | None = None
updated_at: str | None = None
class FarmDataUpsertResponse(ApiEnvelope[FarmDataUpsertResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=FarmDataUpsertRequest.__name__,
response_model=FarmDataUpsertResponse.__name__,
)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'GET'
ROUTE_PATH = '/api/farm-data/<farm_uuid>/detail/'
class FarmDetailRequest(SchemaModel):
farm_uuid: str
class FarmCenterLocationSchema(SchemaModel):
id: int
lat: float
lon: float
farm_boundary: JsonObject = Field(default_factory=dict)
class WeatherForecastDetailSchema(SchemaModel):
id: int
forecast_date: str | None = None
temperature_min: float | None = None
temperature_max: float | None = None
temperature_mean: float | None = None
precipitation: float | None = None
precipitation_probability: float | None = None
humidity_mean: float | None = None
wind_speed_max: float | None = None
et0: float | None = None
weather_code: int | None = None
class FarmSoilDepthSchema(SchemaModel):
depth_label: str
bdod: float | None = None
cec: float | None = None
cfvo: float | None = None
clay: float | None = None
nitrogen: float | None = None
ocd: float | None = None
ocs: float | None = None
phh2o: float | None = None
sand: float | None = None
silt: float | None = None
soc: float | None = None
wv0010: float | None = None
wv0033: float | None = None
wv1500: float | None = None
class FarmSoilPayloadSchema(SchemaModel):
resolved_metrics: JsonObject = Field(default_factory=dict)
metric_sources: JsonObject = Field(default_factory=dict)
depths: list[FarmSoilDepthSchema] = Field(default_factory=list)
class FarmPlantSchema(SchemaModel):
id: int
name: str
light: str | None = None
watering: str | None = None
soil: str | None = None
temperature: str | None = None
growth_stage: str | None = None
planting_season: str | None = None
harvest_time: str | None = None
spacing: str | None = None
fertilizer: str | None = None
created_at: str | None = None
updated_at: str | None = None
class FarmIrrigationMethodSchema(SchemaModel):
id: int
name: str
category: str | None = None
description: str | None = None
water_efficiency_percent: float | None = None
water_pressure_required: str | None = None
flow_rate: str | None = None
coverage_area: str | None = None
soil_type: str | None = None
climate_suitability: str | None = None
created_at: str | None = None
updated_at: str | None = None
class FarmDetailResponseData(SchemaModel):
center_location: FarmCenterLocationSchema
weather: WeatherForecastDetailSchema | None = None
sensor_payload: JsonObject = Field(default_factory=dict)
soil: FarmSoilPayloadSchema
plant_ids: list[int] = Field(default_factory=list)
plants: list[FarmPlantSchema] = Field(default_factory=list)
irrigation_method_id: int | None = None
irrigation_method: FarmIrrigationMethodSchema | None = None
created_at: str | None = None
updated_at: str | None = None
class FarmDetailResponse(ApiEnvelope[FarmDetailResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=FarmDetailRequest.__name__,
response_model=FarmDetailResponse.__name__,
)
@@ -0,0 +1,41 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/farm-data/parameters/'
class FarmParameterRequest(SchemaModel):
sensor_key: str = 'sensor-7-1'
code: str
name_fa: str
unit: str | None = ''
data_type: str | None = 'float'
metadata: JsonObject = Field(default_factory=dict)
class FarmParameterResponseData(SchemaModel):
id: int
sensor_key: str
code: str
name_fa: str
unit: str | None = None
data_type: str | None = None
metadata: JsonObject = Field(default_factory=dict)
created_at: str | None = None
action: str
class FarmParameterResponse(ApiEnvelope[FarmParameterResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=FarmParameterRequest.__name__,
response_model=FarmParameterResponse.__name__,
)
@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/fertilization/recommend/'
class FertilizationRecommendRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
plant_name: str | None = None
growth_stage: str | None = None
query: str | None = None
class FertilizationSection(SchemaModel):
type: Literal['recommendation', 'list', 'warning', 'info']
title: str
icon: str | None = None
content: str | None = None
items: list[str] = Field(default_factory=list)
fertilizerType: str | None = None
amount: str | None = None
applicationMethod: str | None = None
timing: str | None = None
validityPeriod: str | None = None
expandableExplanation: str | None = None
class FertilizationRecommendResponseData(SchemaModel):
sections: list[FertilizationSection] = Field(default_factory=list)
class FertilizationRecommendResponse(ApiEnvelope[FertilizationRecommendResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=FertilizationRecommendRequest.__name__,
response_model=FertilizationRecommendResponse.__name__,
)
@@ -0,0 +1,39 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, EmptyRequest, RouteContract, SchemaModel
HTTP_METHOD = 'GET'
ROUTE_PATH = '/api/irrigation/'
class IrrigationListRequest(EmptyRequest):
pass
class IrrigationMethodSchema(SchemaModel):
id: int
name: str
category: str | None = None
description: str | None = None
water_efficiency_percent: float | None = None
water_pressure_required: str | None = None
flow_rate: str | None = None
coverage_area: str | None = None
soil_type: str | None = None
climate_suitability: str | None = None
created_at: str | None = None
updated_at: str | None = None
class IrrigationListResponse(ApiEnvelope[list[IrrigationMethodSchema]]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=IrrigationListRequest.__name__,
response_model=IrrigationListResponse.__name__,
)
@@ -0,0 +1,91 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, EmptyRequest, JsonObject, JsonValue, RouteContract, SchemaModel
class IrrigationMethodPayload(SchemaModel):
name: str
category: str | None = None
description: str | None = None
water_efficiency_percent: float | None = None
water_pressure_required: str | None = None
flow_rate: str | None = None
coverage_area: str | None = None
soil_type: str | None = None
climate_suitability: str | None = None
class IrrigationMethodPartialPayload(SchemaModel):
name: str | None = None
category: str | None = None
description: str | None = None
water_efficiency_percent: float | None = None
water_pressure_required: str | None = None
flow_rate: str | None = None
coverage_area: str | None = None
soil_type: str | None = None
climate_suitability: str | None = None
class IrrigationMethodSchema(IrrigationMethodPayload):
id: int
created_at: str | None = None
updated_at: str | None = None
class IrrigationMethodDetailRequest(SchemaModel):
pk: int
class IrrigationMethodListResponse(ApiEnvelope[list[IrrigationMethodSchema]]):
pass
class IrrigationMethodDetailResponse(ApiEnvelope[IrrigationMethodSchema]):
pass
class IrrigationMethodDeleteResponse(ApiEnvelope[JsonValue | None]):
pass
CONTRACTS = [
RouteContract(
method='GET',
path='/api/irrigation/',
request_model=EmptyRequest.__name__,
response_model=IrrigationMethodListResponse.__name__,
),
RouteContract(
method='POST',
path='/api/irrigation/',
request_model=IrrigationMethodPayload.__name__,
response_model=IrrigationMethodDetailResponse.__name__,
),
RouteContract(
method='GET',
path='/api/irrigation/<pk>/',
request_model=IrrigationMethodDetailRequest.__name__,
response_model=IrrigationMethodDetailResponse.__name__,
),
RouteContract(
method='PUT',
path='/api/irrigation/<pk>/',
request_model=IrrigationMethodPayload.__name__,
response_model=IrrigationMethodDetailResponse.__name__,
),
RouteContract(
method='PATCH',
path='/api/irrigation/<pk>/',
request_model=IrrigationMethodPartialPayload.__name__,
response_model=IrrigationMethodDetailResponse.__name__,
),
RouteContract(
method='DELETE',
path='/api/irrigation/<pk>/',
request_model=IrrigationMethodDetailRequest.__name__,
response_model=IrrigationMethodDeleteResponse.__name__,
),
]
@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/irrigation/recommend/'
class IrrigationRecommendRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
plant_name: str | None = None
growth_stage: str | None = None
irrigation_method_name: str | None = None
query: str | None = None
class IrrigationSection(SchemaModel):
type: Literal['recommendation', 'list', 'warning', 'info']
title: str
icon: str | None = None
content: str | None = None
items: list[str] = Field(default_factory=list)
frequency: str | None = None
amount: str | None = None
timing: str | None = None
validityPeriod: str | None = None
expandableExplanation: str | None = None
class IrrigationRecommendResponseData(SchemaModel):
sections: list[IrrigationSection] = Field(default_factory=list)
class IrrigationRecommendResponse(ApiEnvelope[IrrigationRecommendResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=IrrigationRecommendRequest.__name__,
response_model=IrrigationRecommendResponse.__name__,
)
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/irrigation/water-stress/'
class IrrigationWaterStressRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
class IrrigationWaterStressResponseData(SchemaModel):
farm_uuid: str
waterStressIndex: int | float
level: Literal['low', 'medium', 'high'] | str
sourceMetric: JsonObject = Field(default_factory=dict)
class IrrigationWaterStressResponse(ApiEnvelope[IrrigationWaterStressResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=IrrigationWaterStressRequest.__name__,
response_model=IrrigationWaterStressResponse.__name__,
)
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
class PestDiseaseDetectionRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
plant_name: str | None = None
query: str | None = None
image_urls: list[str] = Field(default_factory=list)
image: str | None = None
images: list[str] = Field(default_factory=list)
class PestDiseaseDetectionResponseData(SchemaModel):
has_issue: bool
category: Literal['no_issue', 'pest', 'disease', 'nutrient_stress', 'abiotic_stress', 'unknown'] | str
confidence: float | None = None
severity: Literal['low', 'medium', 'high'] | str
summary: str
detected_signs: list[str] = Field(default_factory=list)
possible_causes: list[str] = Field(default_factory=list)
immediate_actions: list[str] = Field(default_factory=list)
reasoning: list[str] = Field(default_factory=list)
farm_uuid: str | None = None
knowledge_base: str | None = None
tone_file: str | None = None
raw_response: str | None = None
class PestDiseaseRiskRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
plant_name: str | None = None
growth_stage: str | None = None
query: str | None = None
class RiskBlock(SchemaModel):
score: float | None = None
level: Literal['low', 'medium', 'high'] | str | None = None
likely_conditions: list[str] = Field(default_factory=list)
reasoning: list[str] = Field(default_factory=list)
statsLabel: str | None = None
class PestDiseaseRiskResponseData(SchemaModel):
summary: str
forecast_window: str | None = None
overall_risk: Literal['low', 'medium', 'high'] | str
disease_risk: RiskBlock = Field(default_factory=RiskBlock)
pest_risk: RiskBlock = Field(default_factory=RiskBlock)
key_drivers: list[str] = Field(default_factory=list)
recommended_actions: list[str] = Field(default_factory=list)
farm_context: JsonObject = Field(default_factory=dict)
farm_uuid: str | None = None
knowledge_base: str | None = None
tone_file: str | None = None
raw_response: str | None = None
class PestDiseaseRiskSummaryRequest(SchemaModel):
farm_uuid: UUID
sensor_uuid: UUID | None = None
class PestDiseaseRiskSummaryDrivers(SchemaModel):
keyDrivers: list[str] = Field(default_factory=list)
summary: str | None = None
forecastWindow: str | None = None
source: str | None = None
class PestDiseaseRiskSummaryResponseData(SchemaModel):
farm_uuid: str
diseaseRisk: RiskBlock = Field(default_factory=RiskBlock)
pestRisk: RiskBlock = Field(default_factory=RiskBlock)
drivers: PestDiseaseRiskSummaryDrivers = Field(default_factory=PestDiseaseRiskSummaryDrivers)
class PestDiseaseDetectionResponse(ApiEnvelope[PestDiseaseDetectionResponseData]):
pass
class PestDiseaseRiskResponse(ApiEnvelope[PestDiseaseRiskResponseData]):
pass
class PestDiseaseRiskSummaryResponse(ApiEnvelope[PestDiseaseRiskSummaryResponseData]):
pass
CONTRACTS = [
RouteContract(
method='POST',
path='/api/pest-disease/detect/',
request_model=PestDiseaseDetectionRequest.__name__,
response_model=PestDiseaseDetectionResponse.__name__,
),
RouteContract(
method='POST',
path='/api/pest-disease/risk/',
request_model=PestDiseaseRiskRequest.__name__,
response_model=PestDiseaseRiskResponse.__name__,
),
RouteContract(
method='POST',
path='/api/pest-disease/risk-summary/',
request_model=PestDiseaseRiskSummaryRequest.__name__,
response_model=PestDiseaseRiskSummaryResponse.__name__,
),
]
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from pydantic import Field
from .common import ApiEnvelope, EmptyRequest, JsonObject, JsonValue, RouteContract, SchemaModel
class PlantPayload(SchemaModel):
name: str
light: str | None = None
watering: str | None = None
soil: str | None = None
temperature: str | None = None
growth_stage: str | None = None
planting_season: str | None = None
harvest_time: str | None = None
spacing: str | None = None
fertilizer: str | None = None
class PlantPartialPayload(SchemaModel):
name: str | None = None
light: str | None = None
watering: str | None = None
soil: str | None = None
temperature: str | None = None
growth_stage: str | None = None
planting_season: str | None = None
harvest_time: str | None = None
spacing: str | None = None
fertilizer: str | None = None
class PlantRecord(PlantPayload):
id: int
created_at: str | None = None
updated_at: str | None = None
class PlantListResponse(ApiEnvelope[list[PlantRecord]]):
pass
class PlantDetailResponse(ApiEnvelope[PlantRecord]):
pass
class PlantDeleteResponse(ApiEnvelope[JsonValue | None]):
pass
class PlantDetailRequest(SchemaModel):
pk: int
class PlantFetchInfoRequest(SchemaModel):
name: str
class PlantFetchInfoResponse(ApiEnvelope[JsonObject]):
pass
CONTRACTS = [
RouteContract(
method='GET',
path='/api/plants/',
request_model=EmptyRequest.__name__,
response_model=PlantListResponse.__name__,
),
RouteContract(
method='POST',
path='/api/plants/',
request_model=PlantPayload.__name__,
response_model=PlantDetailResponse.__name__,
),
RouteContract(
method='GET',
path='/api/plants/<pk>/',
request_model=PlantDetailRequest.__name__,
response_model=PlantDetailResponse.__name__,
),
RouteContract(
method='PUT',
path='/api/plants/<pk>/',
request_model=PlantPayload.__name__,
response_model=PlantDetailResponse.__name__,
),
RouteContract(
method='PATCH',
path='/api/plants/<pk>/',
request_model=PlantPartialPayload.__name__,
response_model=PlantDetailResponse.__name__,
),
RouteContract(
method='DELETE',
path='/api/plants/<pk>/',
request_model=PlantDetailRequest.__name__,
response_model=PlantDeleteResponse.__name__,
),
RouteContract(
method='POST',
path='/api/plants/fetch-info/',
request_model=PlantFetchInfoRequest.__name__,
response_model=PlantFetchInfoResponse.__name__,
),
]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/rag/chat/'
class RagChatRequest(SchemaModel):
farm_uuid: UUID
query: str | None = None
message: str | None = None
history: list[JsonObject] | str | None = None
image_urls: list[str] = Field(default_factory=list)
image: str | None = None
images: list[str] = Field(default_factory=list)
class RagChatSection(SchemaModel):
type: Literal['recommendation', 'list', 'warning', 'info', 'summary']
title: str
icon: str | None = None
content: str | None = None
items: list[str] = Field(default_factory=list)
primaryAction: str | None = None
timing: str | None = None
validityPeriod: str | None = None
expandableExplanation: str | None = None
metadata: JsonValue | None = None
class RagChatResponseData(SchemaModel):
sections: list[RagChatSection] = Field(default_factory=list)
class RagChatResponse(ApiEnvelope[RagChatResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=RagChatRequest.__name__,
response_model=RagChatResponse.__name__,
)
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel
class SoilDataCoordinatesRequest(SchemaModel):
lat: float
lon: float
class SoilDepthDataSchema(SchemaModel):
depth_label: str
bdod: float | None = None
cec: float | None = None
cfvo: float | None = None
clay: float | None = None
nitrogen: float | None = None
ocd: float | None = None
ocs: float | None = None
phh2o: float | None = None
sand: float | None = None
silt: float | None = None
soc: float | None = None
wv0010: float | None = None
wv0033: float | None = None
wv1500: float | None = None
class SoilLocationPayload(SchemaModel):
source: str
id: int
lon: float
lat: float
depths: list[SoilDepthDataSchema] = Field(default_factory=list)
class SoilTaskQueuedResponseData(SchemaModel):
source: str = 'task'
task_id: str
lon: float
lat: float
status_url: str | None = None
class SoilTaskStatusResponseData(SchemaModel):
task_id: str
status: str
message: str | None = None
progress: JsonObject = Field(default_factory=dict)
result: JsonValue | None = None
error: str | None = None
class NdviHealthRequest(SchemaModel):
farm_uuid: UUID
class NdviHealthDataItem(SchemaModel):
title: str
value: JsonValue | None = None
color: str
icon: str
class NdviHealthResponseData(SchemaModel):
ndviIndex: float | None = None
mean_ndvi: float | None = None
ndvi_map: JsonObject = Field(default_factory=dict)
vegetation_health_class: str | None = None
observation_date: str | None = None
satellite_source: str | None = None
healthData: list[NdviHealthDataItem] = Field(default_factory=list)
class SoilDataResponse(ApiEnvelope[SoilLocationPayload]):
pass
class SoilTaskQueuedResponse(ApiEnvelope[SoilTaskQueuedResponseData]):
pass
class SoilTaskStatusResponse(ApiEnvelope[SoilTaskStatusResponseData]):
pass
class NdviHealthResponse(ApiEnvelope[NdviHealthResponseData]):
pass
CONTRACTS = [
RouteContract(
method='GET',
path='/api/soil-data/',
request_model=SoilDataCoordinatesRequest.__name__,
response_model=SoilDataResponse.__name__,
),
RouteContract(
method='POST',
path='/api/soil-data/',
request_model=SoilDataCoordinatesRequest.__name__,
response_model=SoilDataResponse.__name__,
),
RouteContract(
method='GET',
path='/api/soil-data/tasks/<task_id>/status/',
request_model='SoilTaskStatusRequest',
response_model=SoilTaskStatusResponse.__name__,
),
RouteContract(
method='POST',
path='/api/soil-data/ndvi-health/',
request_model=NdviHealthRequest.__name__,
response_model=NdviHealthResponse.__name__,
),
]
class SoilTaskStatusRequest(SchemaModel):
task_id: str
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/soile/anomaly-detection/'
class SoileAnomalyDetectionRequest(SchemaModel):
farm_uuid: UUID
class SoileAnomalyDetectionResponseData(SchemaModel):
farm_uuid: str
summary: str
explanation: str | None = None
likely_cause: str | None = None
recommended_action: str | None = None
monitoring_priority: Literal['low', 'medium', 'high', 'urgent'] | str
confidence: float | None = None
generated_at: str | None = None
anomalies: JsonList = Field(default_factory=list)
interpretation: JsonObject = Field(default_factory=dict)
knowledge_base: str | None = None
raw_response: str | None = None
class SoileAnomalyDetectionResponse(ApiEnvelope[SoileAnomalyDetectionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=SoileAnomalyDetectionRequest.__name__,
response_model=SoileAnomalyDetectionResponse.__name__,
)
@@ -0,0 +1,37 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/soile/health-summary/'
class SoileHealthSummaryRequest(SchemaModel):
farm_uuid: UUID
class SoileHealthSummaryResponseData(SchemaModel):
farm_uuid: str
healthScore: int | float
profileSource: str | None = None
healthScoreDetails: JsonObject = Field(default_factory=dict)
healthLanguage: JsonObject = Field(default_factory=dict)
avgSoilMoisture: int | float | None = None
avgSoilMoistureRaw: float | None = None
avgSoilMoistureStatus: str | None = None
class SoileHealthSummaryResponse(ApiEnvelope[SoileHealthSummaryResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=SoileHealthSummaryRequest.__name__,
response_model=SoileHealthSummaryResponse.__name__,
)
@@ -0,0 +1,41 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonObject, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/soile/moisture-heatmap/'
class SoileMoistureHeatmapRequest(SchemaModel):
farm_uuid: UUID
class SoileMoistureHeatmapResponseData(SchemaModel):
farm_uuid: str
location: JsonObject = Field(default_factory=dict)
current_sensor: JsonObject = Field(default_factory=dict)
soil_profile: JsonList = Field(default_factory=list)
timestamp: str | None = None
grid_resolution: JsonObject = Field(default_factory=dict)
grid_cells: JsonList = Field(default_factory=list)
sensor_points: JsonList = Field(default_factory=list)
quality_legend: JsonObject = Field(default_factory=dict)
depth_layers: JsonList = Field(default_factory=list)
model_metadata: JsonObject = Field(default_factory=dict)
summary: JsonObject = Field(default_factory=dict)
class SoileMoistureHeatmapResponse(ApiEnvelope[SoileMoistureHeatmapResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=SoileMoistureHeatmapRequest.__name__,
response_model=SoileMoistureHeatmapResponse.__name__,
)
@@ -0,0 +1,41 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/weather/farm-card/'
class WeatherFarmCardRequest(SchemaModel):
farm_uuid: UUID
class WeatherChartData(SchemaModel):
labels: list[str] = Field(default_factory=list)
series: list[list[float]] = Field(default_factory=list)
class WeatherFarmCardResponseData(SchemaModel):
condition: str
temperature: float | int
unit: str
humidity: float | int
windSpeed: float | int
windUnit: str
chartData: WeatherChartData = Field(default_factory=WeatherChartData)
class WeatherFarmCardResponse(ApiEnvelope[WeatherFarmCardResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=WeatherFarmCardRequest.__name__,
response_model=WeatherFarmCardResponse.__name__,
)
@@ -0,0 +1,46 @@
from __future__ import annotations
from uuid import UUID
from pydantic import Field
from .common import ApiEnvelope, JsonList, RouteContract, SchemaModel
HTTP_METHOD = 'POST'
ROUTE_PATH = '/api/weather/water-need-prediction/'
class WeatherWaterNeedPredictionRequest(SchemaModel):
farm_uuid: UUID
class WaterNeedInsight(SchemaModel):
summary: str | None = None
irrigation_outlook: str | None = None
recommended_action: str | None = None
risk_note: str | None = None
confidence: float | None = None
class WeatherWaterNeedPredictionResponseData(SchemaModel):
farm_uuid: str
totalNext7Days: float | None = None
unit: str | None = None
categories: list[str] = Field(default_factory=list)
series: JsonList = Field(default_factory=list)
dailyBreakdown: JsonList = Field(default_factory=list)
insight: WaterNeedInsight = Field(default_factory=WaterNeedInsight)
knowledge_base: str | None = None
raw_response: str | None = None
class WeatherWaterNeedPredictionResponse(ApiEnvelope[WeatherWaterNeedPredictionResponseData]):
pass
CONTRACT = RouteContract(
method=HTTP_METHOD,
path=ROUTE_PATH,
request_model=WeatherWaterNeedPredictionRequest.__name__,
response_model=WeatherWaterNeedPredictionResponse.__name__,
)
+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",
"ingest",
"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", "sensor_hub"),
"USER": os.environ.get("DB_USER", "sensor_hub"),
"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",
],
}
if "rest_framework_simplejwt" in INSTALLED_APPS:
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [
"rest_framework_simplejwt.authentication.JWTAuthentication",
]
CORS_ALLOW_ALL_ORIGINS = DEBUG
+10
View File
@@ -0,0 +1,10 @@
from django.contrib import admin
from django.urls import include, path
from ingest.views import SensorSimulatorAppView
urlpatterns = [
path("admin/", admin.site.urls),
path("", SensorSimulatorAppView.as_view(), name="home"),
path("api/ingest/", include("ingest.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()
@@ -0,0 +1,63 @@
services:
db:
image: docker.iranserver.com/mysql:8.0
container_name: sensor-hub-db
restart: always
environment:
MYSQL_DATABASE: ${DB_NAME:-sensor_hub}
MYSQL_USER: ${DB_USER:-sensor_hub}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- sensor_hub_mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- sensor-network
web:
build:
context: .
dockerfile: Dockerfile
container_name: sensor-hub-web
restart: always
ports:
- "8010:8000"
env_file:
- .env
environment:
DB_HOST: db
depends_on:
db:
condition: service_healthy
networks:
- sensor-network
sensor-sender:
build:
context: .
dockerfile: Dockerfile
container_name: sensor-hub-sender
command: python manage.py send_sensor_data
restart: always
env_file:
- .env
environment:
DB_HOST: db
depends_on:
web:
condition: service_started
db:
condition: service_healthy
networks:
- sensor-network
volumes:
sensor_hub_mysql_data:
networks:
sensor-network:
driver: bridge
+87
View File
@@ -0,0 +1,87 @@
# Development: volumes mount source so code updates apply without rebuild
name: sensor-hub
services:
cassandra:
image: docker-mirror.liara.ir/cassandra:5.0
container_name: sensor-hub-cassandra
ports:
- "9042:9042"
volumes:
- sensor_hub_cassandra_data:/var/lib/cassandra
healthcheck:
test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE KEYSPACES' || exit 1"]
interval: 20s
timeout: 10s
retries: 10
db:
image: docker-mirror.liara.ir/mysql:8.0
container_name: sensor-hub-db
environment:
MYSQL_DATABASE: ${DB_NAME:-sensor_hub}
MYSQL_USER: ${DB_USER:-sensor_hub}
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- sensor_hub_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: docker-mirror.liara.ir/phpmyadmin:latest
container_name: sensor-hub-phpmyadmin
environment:
PMA_HOST: db
PMA_PORT: 3306
UPLOAD_LIMIT: 64M
ports:
- "8081:80"
depends_on:
db:
condition: service_healthy
web:
build: .
container_name: sensor-hub-web
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8010:8000"
env_file:
- .env
environment:
DB_HOST: db
CASSANDRA_HOSTS: cassandra
depends_on:
db:
condition: service_healthy
cassandra:
condition: service_started
sensor-sender:
build: .
container_name: sensor-hub-sender
command: python manage.py send_sensor_data
volumes:
- .:/app
env_file:
- .env
environment:
DB_HOST: db
CASSANDRA_HOSTS: cassandra
depends_on:
web:
condition: service_started
db:
condition: service_healthy
cassandra:
condition: service_started
volumes:
sensor_hub_mysql_data:
sensor_hub_cassandra_data:
+14
View File
@@ -0,0 +1,14 @@
API_TARGET_URL = "http://backend-web:8000"
API_KEY = "12345"
REQUEST_INTERVAL_SECONDS = 10
STATIC_SENSOR_PAYLOAD = {
"uuid": "11111111111111111111",
"soil_moisture": 42.5,
"soil_temperature": 24.3,
"soil_ph": 6.8,
"soil_ec": 1.4,
"nitrogen": 32,
"phosphorus": 18,
"potassium": 27,
}
@@ -0,0 +1,72 @@
import json
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from django.core.management.base import BaseCommand
from ingest.constants import API_KEY, API_TARGET_URL, REQUEST_INTERVAL_SECONDS, STATIC_SENSOR_PAYLOAD
class Command(BaseCommand):
help = "Send the static soil sensor payload to the upstream API every 10 seconds."
def add_arguments(self, parser):
parser.add_argument(
"--once",
action="store_true",
help="Send the request once and exit.",
)
def handle(self, *args, **options):
run_once = options["once"]
self.stdout.write(
self.style.SUCCESS(
f"Starting sensor sender -> {API_TARGET_URL} (interval: {REQUEST_INTERVAL_SECONDS}s)"
)
)
while True:
self.send_payload()
if run_once:
break
time.sleep(REQUEST_INTERVAL_SECONDS)
def send_payload(self):
body = json.dumps(STATIC_SENSOR_PAYLOAD).encode("utf-8")
request = Request(
API_TARGET_URL,
data=body,
headers={
"Content-Type": "application/json",
"api_key": API_KEY,
},
method="POST",
)
try:
with urlopen(request, timeout=15) as response:
response_body = response.read().decode("utf-8", errors="replace")
self.stdout.write(
self.style.SUCCESS(
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Sent payload successfully - status {response.status}"
)
)
if response_body:
self.stdout.write(response_body)
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
self.stderr.write(
self.style.ERROR(
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Upstream error - status {exc.code}"
)
)
if error_body:
self.stderr.write(error_body)
except URLError as exc:
self.stderr.write(
self.style.ERROR(
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Connection error - {exc.reason}"
)
)
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>شبیه ساز سنسور خاک</title>
<style>
:root {
--bg: linear-gradient(135deg, #f4efe6 0%, #dce8d5 100%);
--card: rgba(255, 252, 246, 0.92);
--ink: #203126;
--muted: #5a6b60;
--accent: #2f6a4f;
--accent-dark: #1e4936;
--border: rgba(47, 106, 79, 0.18);
--shadow: 0 24px 60px rgba(32, 49, 38, 0.16);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Tahoma, sans-serif;
background: var(--bg);
color: var(--ink);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell {
width: min(980px, 100%);
background: var(--card);
border: 1px solid var(--border);
border-radius: 28px;
box-shadow: var(--shadow);
overflow: hidden;
}
.hero {
padding: 32px;
background: linear-gradient(135deg, rgba(47,106,79,.95), rgba(85,130,96,.88));
color: #f9f6ee;
}
.hero h1 { margin: 0 0 12px; font-size: clamp(28px, 5vw, 42px); }
.hero p { margin: 0; line-height: 1.8; max-width: 700px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
padding: 24px;
}
.panel {
border: 1px solid var(--border);
border-radius: 22px;
padding: 20px;
background: rgba(255,255,255,.68);
}
label {
display: block;
font-weight: bold;
margin-bottom: 8px;
}
input, textarea {
width: 100%;
border: 1px solid rgba(32,49,38,.14);
border-radius: 14px;
padding: 12px 14px;
font: inherit;
background: rgba(255,255,255,.9);
color: var(--ink);
}
textarea { min-height: 320px; resize: vertical; direction: ltr; text-align: left; }
.hint {
color: var(--muted);
font-size: 14px;
margin-top: 8px;
line-height: 1.8;
}
button {
border: 0;
background: var(--accent);
color: #fff;
padding: 14px 18px;
border-radius: 14px;
font: inherit;
cursor: pointer;
min-width: 180px;
}
button:hover { background: var(--accent-dark); }
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: #18241c;
color: #d9f4e2;
border-radius: 18px;
padding: 16px;
min-height: 220px;
direction: ltr;
text-align: left;
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<h1>ارسال استاتیک داده سنسور خاک</h1>
<p>
این صفحه بدون هیچ اتصال به دیتابیس، یک payload استاتیک از داده های سنسور خاک را با متد POST
و هدر <code>api_key</code> به API مقصد ارسال می کند.
</p>
</section>
<section class="grid">
<form class="panel" id="sender-form">
<label for="target_url">آدرس API مقصد</label>
<input id="target_url" name="target_url" value="{{ default_url }}" required>
<label for="api_key" style="margin-top: 16px;">API Key</label>
<input id="api_key" name="api_key" value="{{ default_api_key }}" required>
<label for="payload" style="margin-top: 16px;">JSON ارسالی</label>
<textarea id="payload" readonly>{{ default_payload }}</textarea>
<p class="hint">
فیلدها شامل uuid، رطوبت خاک، دمای خاک، pH، EC، نیتروژن، فسفر و پتاسیم هستند و فعلا همه به صورت استاتیک تعریف شده اند.
</p>
<div style="margin-top: 18px; display: flex; gap: 12px; flex-wrap: wrap;">
<button type="submit">ارسال به API</button>
</div>
</form>
<section class="panel">
<label>نتیجه درخواست</label>
<pre id="result">هنوز درخواستی ارسال نشده است.</pre>
<p class="hint">
در پاسخ، payload ارسالی، هدرهای ارسال شده و پاسخ API مقصد نمایش داده می شود.
</p>
</section>
</section>
</main>
<script>
const form = document.getElementById('sender-form');
const result = document.getElementById('result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
result.textContent = 'در حال ارسال...';
const formData = new FormData(form);
try {
const response = await fetch('/api/ingest/forward/', {
method: 'POST',
body: formData,
});
const data = await response.json();
result.textContent = JSON.stringify(data, null, 2);
} catch (error) {
result.textContent = JSON.stringify({ error: error.message }, null, 2);
}
});
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import ForwardSensorDataView
urlpatterns = [
path("forward/", ForwardSensorDataView.as_view(), name="forward-sensor-data"),
]
+98
View File
@@ -0,0 +1,98 @@
import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from ingest.constants import API_KEY, API_TARGET_URL, STATIC_SENSOR_PAYLOAD
class SensorSimulatorAppView(View):
def get(self, request):
return render(
request,
"ingest/index.html",
{
"default_payload": json.dumps(STATIC_SENSOR_PAYLOAD, indent=2),
"default_url": API_TARGET_URL,
"default_api_key": API_KEY,
},
)
@method_decorator(csrf_exempt, name="dispatch")
class ForwardSensorDataView(View):
def post(self, request):
target_url = request.POST.get("target_url", "").strip()
api_key = request.POST.get("api_key", "").strip()
if not target_url:
return JsonResponse({"error": "target_url is required"}, status=400)
if not api_key:
return JsonResponse({"error": "api_key is required"}, status=400)
payload = STATIC_SENSOR_PAYLOAD
body = json.dumps(payload).encode("utf-8")
outbound_request = Request(
target_url,
data=body,
headers={
"Content-Type": "application/json",
"api_key": api_key,
},
method="POST",
)
try:
with urlopen(outbound_request, timeout=15) as response:
response_body = response.read().decode("utf-8")
content_type = response.headers.get("Content-Type", "")
parsed_body = response_body
if "application/json" in content_type and response_body:
parsed_body = json.loads(response_body)
return JsonResponse(
{
"status": response.status,
"sent_headers": {
"Content-Type": "application/json",
"api_key": api_key,
},
"sent_payload": payload,
"response": parsed_body,
}
)
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
return JsonResponse(
{
"error": "upstream returned an error",
"status": exc.code,
"sent_headers": {
"Content-Type": "application/json",
"api_key": api_key,
},
"sent_payload": payload,
"response": error_body,
},
status=502,
)
except URLError as exc:
return JsonResponse(
{
"error": "could not reach upstream api",
"details": str(exc.reason),
"sent_headers": {
"Content-Type": "application/json",
"api_key": api_key,
},
"sent_payload": payload,
},
status=502,
)
return HttpResponse(status=500)
+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()
+16
View File
@@ -0,0 +1,16 @@
Django>=5.0,<5.2
djangorestframework>=3.14,<3.16
djangorestframework-simplejwt>=5.3,<5.4
django-cors-headers>=4.3,<4.5
drf-spectacular>=0.27,<0.28
drf-spectacular-sidecar>=2024.7.1,<2025
celery[redis]>=5.3,<5.4
redis>=5.0,<5.1
mysqlclient>=2.2,<2.3
gunicorn>=22,<23
python-dotenv>=1.0,<1.1
cassandra-driver>=3.29,<3.30