UPDATE
This commit is contained in:
@@ -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 app’s urls.py):** Only endpoint definitions with `path()`.
|
||||
- Same view can be used for several paths; distinguish by path or `kwargs` (e.g. `kwargs={"action": "active"}`).
|
||||
- Order matters: more specific paths first (e.g. `active/`, `deactive/`), then path-param routes (e.g. `<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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
*.log
|
||||
media
|
||||
staticfiles
|
||||
.cursor
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "Schemas"]
|
||||
path = Schemas
|
||||
url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git
|
||||
branch = develop
|
||||
@@ -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"]
|
||||
@@ -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']
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
),
|
||||
]
|
||||
@@ -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__,
|
||||
),
|
||||
]
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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__,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import ForwardSensorDataView
|
||||
|
||||
urlpatterns = [
|
||||
path("forward/", ForwardSensorDataView.as_view(), name="forward-sensor-data"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user