diff --git a/config/__init__.py b/config/__init__.py index b19b4fb..726a50f 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,3 +1,15 @@ +try: + import pymysql +except ImportError: # pragma: no cover - optional fallback when mysqlclient is unavailable + pymysql = None +else: # pragma: no cover - import side effect + # Django 5's MySQL backend checks the mysqlclient version string during import. + # PyMySQL exposes a legacy compatibility version, so override it before installing + # the MySQLdb shim. + pymysql.version_info = (2, 2, 1, "final", 0) + pymysql.__version__ = "2.2.1" + pymysql.install_as_MySQLdb() + try: from .celery import app as celery_app except ImportError: # pragma: no cover - fallback for test environments diff --git a/config/settings.py b/config/settings.py index 100ec2b..f62dc63 100644 --- a/config/settings.py +++ b/config/settings.py @@ -12,7 +12,21 @@ load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent LOG_DIR = Path(os.environ.get("LOG_DIR", BASE_DIR / "logs")) -LOG_DIR.mkdir(parents=True, exist_ok=True) + + +def _can_use_file_logging(log_dir: Path) -> bool: + try: + log_dir.mkdir(parents=True, exist_ok=True) + probe_file = log_dir / ".write_test" + with probe_file.open("a", encoding="utf-8"): + pass + probe_file.unlink(missing_ok=True) + return True + except OSError: + return False + + +FILE_LOGGING_ENABLED = _can_use_file_logging(LOG_DIR) SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") DEBUG = os.environ.get("DEBUG", "0") == "1" @@ -192,29 +206,34 @@ LOGGING = { "class": "logging.StreamHandler", "formatter": "standard", }, - "file": { - "class": "logging.handlers.TimedRotatingFileHandler", - "filename": str(LOG_DIR / "app.log"), - "when": "midnight", - "backupCount": 14, - "encoding": "utf-8", - "formatter": "standard", - }, }, "loggers": { "django": { - "handlers": ["console", "file"], + "handlers": ["console"], "level": os.environ.get("DJANGO_LOG_LEVEL", "INFO"), "propagate": False, }, "rag": { - "handlers": ["console", "file"], + "handlers": ["console"], "level": os.environ.get("RAG_LOG_LEVEL", "INFO"), "propagate": False, }, }, "root": { - "handlers": ["console", "file"], + "handlers": ["console"], "level": os.environ.get("ROOT_LOG_LEVEL", "INFO"), }, } + +if FILE_LOGGING_ENABLED: + LOGGING["handlers"]["file"] = { + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": str(LOG_DIR / "app.log"), + "when": "midnight", + "backupCount": 14, + "encoding": "utf-8", + "formatter": "standard", + } + LOGGING["loggers"]["django"]["handlers"].append("file") + LOGGING["loggers"]["rag"]["handlers"].append("file") + LOGGING["root"]["handlers"].append("file") diff --git a/integration_tests/test_reporting_and_ai_api_flow.py b/integration_tests/test_reporting_and_ai_api_flow.py index 1d534d1..aaae0ba 100644 --- a/integration_tests/test_reporting_and_ai_api_flow.py +++ b/integration_tests/test_reporting_and_ai_api_flow.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import patch import uuid +from django.apps import apps from django.test import override_settings from crop_simulation.models import SimulationRun, SimulationScenario @@ -206,9 +207,11 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase): broken_simulation_service = SimpleNamespace( get_water_stress=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulation offline")) ) - with patch( - "irrigation.indicators.apps.get_app_config", - return_value=SimpleNamespace(get_water_stress_service=lambda: broken_simulation_service), + crop_simulation_app = apps.get_app_config("crop_simulation") + with patch.object( + crop_simulation_app, + "get_water_stress_service", + return_value=broken_simulation_service, ): water_stress_response = self.client.post( "/api/irrigation/water-stress/", @@ -509,12 +512,22 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase): "supportingMetrics": {"biomass": 12.1}, } ) - with patch( - "crop_simulation.views.apps.get_app_config", - return_value=SimpleNamespace( - get_current_farm_chart_simulator=lambda: current_chart_service, - get_harvest_prediction_service=lambda: harvest_service, - get_yield_prediction_service=lambda: yield_service, + crop_simulation_app = apps.get_app_config("crop_simulation") + with ( + patch.object( + crop_simulation_app, + "get_current_farm_chart_simulator", + return_value=current_chart_service, + ), + patch.object( + crop_simulation_app, + "get_harvest_prediction_service", + return_value=harvest_service, + ), + patch.object( + crop_simulation_app, + "get_yield_prediction_service", + return_value=yield_service, ), ): current_chart_response = self.client.post( @@ -539,11 +552,15 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase): task_state: dict[str, object] = {} def growth_delay_stub(payload): + serializable_payload = { + **payload, + "farm_uuid": str(payload["farm_uuid"]) if payload.get("farm_uuid") else None, + } scenario = SimulationScenario.objects.create( name=f"integration-{payload['plant_name']}", scenario_type=SimulationScenario.ScenarioType.SINGLE, status=SimulationScenario.Status.SUCCESS, - input_payload=payload, + input_payload=serializable_payload, result_payload={"engine": "stub"}, ) SimulationRun.objects.create( diff --git a/location_data/migrations/0007_ndviobservation.py b/location_data/migrations/0007_ndviobservation.py new file mode 100644 index 0000000..50699e4 --- /dev/null +++ b/location_data/migrations/0007_ndviobservation.py @@ -0,0 +1,46 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0006_remove_soillocation_ideal_sensor_profile"), + ] + + operations = [ + migrations.CreateModel( + name="NdviObservation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("observation_date", models.DateField(db_index=True)), + ("mean_ndvi", models.FloatField()), + ("ndvi_map", models.JSONField(blank=True, default=dict)), + ("vegetation_health_class", models.CharField(max_length=64)), + ("satellite_source", models.CharField(default="sentinel-2", max_length=64)), + ("cloud_cover", models.FloatField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ( + "location", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ndvi_observations", + to="location_data.soillocation", + ), + ), + ], + options={ + "verbose_name": "NDVI Observation", + "verbose_name_plural": "NDVI Observations", + "db_table": "dashboard_data_ndviobservation", + "ordering": ["-observation_date", "-created_at"], + "constraints": [ + models.UniqueConstraint( + fields=("location", "observation_date", "satellite_source"), + name="ndvi_unique_location_date_source", + ), + ], + }, + ), + ]