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
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)
+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()
+9
View File
@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class FailureContract:
status: str = "error"
error_code: str = "internal_error"
message: str = ""
source: str = "application"
warnings: list[str] = field(default_factory=list)
retriable: bool = False
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
payload = {
"status": self.status,
"error_code": self.error_code,
"message": self.message,
"source": self.source,
"warnings": list(self.warnings),
"retriable": self.retriable,
}
if self.details:
payload["details"] = self.details
return payload
class StructuredServiceError(Exception):
def __init__(
self,
*,
error_code: str,
message: str,
source: str,
warnings: list[str] | None = None,
retriable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.contract = FailureContract(
error_code=error_code,
message=message,
source=source,
warnings=warnings or [],
retriable=retriable,
details=details or {},
)
def to_dict(self) -> dict[str, Any]:
return self.contract.to_dict()
+18
View File
@@ -0,0 +1,18 @@
{
"auth": "auth_access",
"account": "account_management",
"farm_hub": "farm_management",
"access_control": "access_control",
"sensor_catalog": "sensor_catalog",
"dashboard": "farm_dashboard",
"crop_zoning": "crop_zoning",
"plant_simulator": "plant_simulator",
"pest_detection": "pest_detection",
"sensor_7_in_1": "sensor-7-in-1",
"irrigation": "irrigation",
"fertilization": "fertilization",
"farm_ai_assistant": "farm_ai_assistant",
"notifications": "notifications",
"external_api_adapter": "external_api_adapter",
"sensor_external_api": "sensor_external_api"
}
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
def _isoformat(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def build_integration_meta(
*,
flow_type: str,
source_type: str,
source_service: str,
ownership: str,
live: bool,
cached: bool,
generated_at: Any = None,
snapshot_at: Any = None,
sync_attempted: bool | None = None,
sync_status: str | None = None,
notes: list[str] | None = None,
) -> dict[str, Any]:
meta = {
"flow_type": flow_type,
"source_type": source_type,
"source_service": source_service,
"ownership": ownership,
"live": live,
"cached": cached,
}
if generated_at is not None:
meta["generated_at"] = _isoformat(generated_at)
if snapshot_at is not None:
meta["snapshot_at"] = _isoformat(snapshot_at)
if sync_attempted is not None:
meta["sync_attempted"] = sync_attempted
if sync_status is not None:
meta["sync_status"] = sync_status
if notes:
meta["notes"] = notes
return meta
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import logging
import time
from collections import Counter
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
_request_id_ctx: ContextVar[str | None] = ContextVar("backend_request_id", default=None)
METRICS: Counter[str] = Counter()
def set_request_id(request_id: str | None) -> None:
_request_id_ctx.set(request_id)
def get_request_id() -> str | None:
return _request_id_ctx.get()
def record_metric(name: str, value: int = 1, **tags: Any) -> None:
suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None)
metric_key = f"{name}|{suffix}" if suffix else name
METRICS[metric_key] += value
@dataclass
class ClassifiedFailure:
error_code: str
failure_type: str
retriable: bool
def classify_exception(exc: Exception) -> ClassifiedFailure:
exc_name = exc.__class__.__name__.lower()
message = str(exc).lower()
if "timeout" in exc_name or "timeout" in message:
return ClassifiedFailure("timeout", "timeout", True)
if "json" in exc_name or "json" in message:
return ClassifiedFailure("parse_error", "parse_error", False)
if "validation" in exc_name or "invalid" in message:
return ClassifiedFailure("validation_failure", "validation_failure", False)
if "connection" in exc_name or "unavailable" in message:
return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True)
return ClassifiedFailure("provider_error", "provider_error", True)
def log_event(
*,
level: int,
message: str,
source: str,
provider: str | None,
operation: str,
result_status: str,
duration_ms: float | None = None,
error_code: str | None = None,
**extra: Any,
) -> None:
payload = {
"source": source,
"provider": provider,
"operation": operation,
"result_status": result_status,
"duration_ms": round(duration_ms, 2) if duration_ms is not None else None,
"error_code": error_code,
"request_id": get_request_id(),
}
payload.update({key: value for key, value in extra.items() if value is not None})
logger.log(level, message, extra={"event": payload})
class observe_operation:
def __init__(self, *, source: str, provider: str | None, operation: str):
self.source = source
self.provider = provider
self.operation = operation
self.started_at = 0.0
def __enter__(self):
self.started_at = time.monotonic()
log_event(
level=logging.INFO,
message="backend operation started",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="started",
)
return self
def __exit__(self, exc_type, exc, _tb):
duration_ms = (time.monotonic() - self.started_at) * 1000
if exc is None:
log_event(
level=logging.INFO,
message="backend operation completed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="success",
duration_ms=duration_ms,
)
record_metric("backend.operation.success", source=self.source, provider=self.provider, operation=self.operation)
return False
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="backend operation failed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="error",
duration_ms=duration_ms,
error_code=failure.error_code,
failure_type=failure.failure_type,
)
record_metric(
"backend.operation.failure",
source=self.source,
provider=self.provider,
operation=self.operation,
error_code=failure.error_code,
)
return False
+294
View File
@@ -0,0 +1,294 @@
import os
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv
from celery.schedules import crontab
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
def _get_csv_env(name, default=""):
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
DEBUG = os.environ.get("DEBUG", "0") == "1"
ALLOWED_HOSTS = list(
dict.fromkeys(
_get_csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0")
+ ["web", "backend-web", os.environ.get("HOSTNAME", "")]
)
)
AUTH_USER_MODEL = "account.User"
AUTHENTICATION_BACKENDS = [
"account.backends.MultiFieldBackend",
]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"auth.apps.AuthConfig",
"account.apps.AccountConfig",
"farm_hub.apps.FarmHubConfig",
"device_hub.apps.DeviceHubConfig",
"access_control.apps.AccessControlConfig",
"dashboard",
"crop_health.apps.CropHealthConfig",
"soil.apps.SoilConfig",
"crop_zoning",
"pest_detection",
"water.apps.WaterConfig",
"irrigation",
"yield_harvest.apps.YieldHarvestConfig",
"economic_overview.apps.EconomicOverviewConfig",
"farm_alerts.apps.FarmAlertsConfig",
"fertilization",
"farm_ai_assistant",
"notifications.apps.NotificationsConfig",
"plants.apps.PlantsConfig",
"farmer_calendar.apps.FarmerCalendarConfig",
"farmer_todos.apps.FarmerTodosConfig",
"external_api_adapter.apps.ExternalApiAdapterConfig",
"rest_framework",
"drf_spectacular",
"drf_spectacular_sidecar",
"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",
"access_control.middleware.RouteFeatureAccessMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": os.environ.get("DB_ENGINE", "django.db.backends.mysql"),
"NAME": os.environ.get("DB_NAME", "croplogic"),
"USER": os.environ.get("DB_USER", "croplogic"),
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
"PORT": os.environ.get("DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.getenv("CACHE_URL", os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")),
"KEY_PREFIX": "croplogic",
}
}
PEST_DISEASE_RISK_SUMMARY_CACHE_TTL = int(os.getenv("PEST_DISEASE_RISK_SUMMARY_CACHE_TTL", "14400"))
WATER_NEED_PREDICTION_CACHE_TTL = int(os.getenv("WATER_NEED_PREDICTION_CACHE_TTL", "14400"))
SOIL_SUMMARY_CACHE_TTL = int(os.getenv("SOIL_SUMMARY_CACHE_TTL", "14400"))
SOIL_ANOMALIES_CACHE_TTL = int(os.getenv("SOIL_ANOMALIES_CACHE_TTL", "14400"))
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
"access_control.permissions.FeatureAccessPermission",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "CropLogic API",
"DESCRIPTION": "Swagger/OpenAPI documentation for all CropLogic API endpoints.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
"SCHEMA_PATH_PREFIX": r"/api/",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"APPEND_COMPONENTS": {
"securitySchemes": {
"SensorExternalApiKey": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "Use API key 12345 for sensor external API endpoints.",
}
}
},
"SWAGGER_UI_SETTINGS": {
"persistAuthorization": True,
},
}
SMS_IR_API_KEY = os.environ.get("SMS_IR_API_KEY", "")
SMS_IR_LINE_NUMBER = int(os.environ.get("SMS_IR_LINE_NUMBER", "300000000000"))
CORS_ALLOW_ALL_ORIGINS = DEBUG
USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true"
EXTERNAL_API_TIMEOUT = int(os.getenv("EXTERNAL_API_TIMEOUT", "30"))
ACCESS_CONTROL_AUTHZ_ENABLED = os.getenv("ACCESS_CONTROL_AUTHZ_ENABLED", "true").lower() == "true"
ACCESS_CONTROL_AUTHZ_BASE_URL = os.getenv(
"ACCESS_CONTROL_AUTHZ_BASE_URL",
"http://croplogic-accsess-opa:8181",
)
ACCESS_CONTROL_AUTHZ_BATCH_PATH = os.getenv("ACCESS_CONTROL_AUTHZ_BATCH_PATH", "/v1/data/croplogic/authz/batch_decision")
ACCESS_CONTROL_AUTHZ_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_TIMEOUT", str(EXTERNAL_API_TIMEOUT)))
ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", "300"))
EXTERNAL_SERVICES = {
"ai": {
"base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"),
"api_key": os.getenv("AI_SERVICE_API_KEY", ""),
"host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"),
},
"farm_hub": {
"base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
"api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
"host_header": os.getenv("FARM_HUB_SERVICE_HOST_HEADER", ""),
},
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=7),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
}
CROP_ZONE_CHUNK_AREA_SQM = float(os.getenv("CROP_ZONE_CHUNK_AREA_SQM", "10000"))
CROP_ZONE_TASK_STALE_SECONDS = int(os.getenv("CROP_ZONE_TASK_STALE_SECONDS", "300"))
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL)
EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345")
SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_API_KEY", "12345")
FARM_DATA_API_HOST = os.getenv("FARM_DATA_API_HOST", "")
FARM_DATA_API_PORT = os.getenv("FARM_DATA_API_PORT", "")
FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/")
FARM_DATA_API_KEY = os.getenv("FARM_DATA_API_KEY", "")
FARM_DATA_API_TIMEOUT = int(os.getenv("FARM_DATA_API_TIMEOUT", str(EXTERNAL_API_TIMEOUT)))
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "120"))
CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "90"))
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = os.getenv("CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP", "true").lower() == "true"
FARM_ALERTS_AI_SYNC_CRON_MINUTE = os.getenv("FARM_ALERTS_AI_SYNC_CRON_MINUTE", "0")
FARM_ALERTS_AI_SYNC_CRON_HOUR = os.getenv("FARM_ALERTS_AI_SYNC_CRON_HOUR", "*")
CELERY_BEAT_SCHEDULE = {
"sync-farm-alert-trackers": {
"task": "farm_alerts.tasks.sync_farm_alert_trackers",
"schedule": crontab(
minute=FARM_ALERTS_AI_SYNC_CRON_MINUTE,
hour=FARM_ALERTS_AI_SYNC_CRON_HOUR,
),
}
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
},
},
"handlers": {
"farm_ai_assistant_file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": LOG_DIR / "farm_ai_assistant.log",
"formatter": "standard",
},
"farm_alerts_file": {
"level": "INFO",
"class": "logging.FileHandler",
"filename": LOG_DIR / "farm_alerts.log",
"formatter": "standard",
},
"external_api_adapter_file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": LOG_DIR / "external_api_adapter.log",
"formatter": "standard",
},
},
"loggers": {
"farm_ai_assistant": {
"handlers": ["farm_ai_assistant_file"],
"level": "WARNING",
"propagate": False,
},
"farm_alerts": {
"handlers": ["farm_alerts_file"],
"level": "INFO",
"propagate": False,
},
"external_api_adapter": {
"handlers": ["external_api_adapter_file"],
"level": "WARNING",
"propagate": False,
},
},
}
+55
View File
@@ -0,0 +1,55 @@
from rest_framework import serializers
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, inline_serializer
FARM_UUID_DEFAULT = "11111111-1111-1111-1111-111111111111"
class AuthTokenSerializer(serializers.Serializer):
token = serializers.CharField()
def code_response(name, data=None, token=False, extra_fields=None):
fields = {
"code": serializers.IntegerField(),
"msg": serializers.CharField(),
}
if data is not None:
fields["data"] = data
if token:
fields["token"] = serializers.CharField()
if extra_fields:
fields.update(extra_fields)
return inline_serializer(name=name, fields=fields)
def status_response(name, data=None):
fields = {
"status": serializers.CharField(default="success"),
}
if data is not None:
fields["data"] = data
return inline_serializer(name=name, fields=fields)
def farm_uuid_query_param(required=False, description="UUID of the farm."):
return OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=required,
description=description,
default=FARM_UUID_DEFAULT,
)
def sensor_uuid_query_param(required=False, description="Optional sensor UUID."):
return OpenApiParameter(
name="sensor_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=required,
description=description,
)
+44
View File
@@ -0,0 +1,44 @@
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [
path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("api/auth/", include("auth.urls")),
path("api/account/", include("account.urls")),
path("api/farm-hub/", include("farm_hub.urls")),
path("api/access-control/", include("access_control.urls")),
path("api/device-hub/", include("device_hub.urls")),
path("api/sensor-catalog/", include("device_hub.sensor_catalog_urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-health/", include("crop_health.urls")),
path("api/soil/", include("soil.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")),
# path("api/yield-harvest/", include("yield_harvest.urls")),
path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")),
path("api/pest-detection/", include("pest_detection.urls")),
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")),
path("api/sensors/", include("device_hub.comparison_urls")),
path("api/irrigation/", include("irrigation.urls")),
path("api/weather/", include("water.weather_urls")),
path("api/water/", include("water.urls")),
path("api/economy/", include("economic_overview.urls")),
path("api/fertilization/", include("fertilization.urls")),
path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")),
path("api/notifications/", include("notifications.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/plants/", include("plants.urls")),
path("api/events/", include("farmer_calendar.urls")),
path("api/farmer-todos/", include("farmer_todos.urls")),
path("api/sensor-external-api/", include("device_hub.sensor_external_api_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()