From cb1e21a2fa50569b1e501e8fc1b65edbc5e84aa6 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:44:10 +0300 Subject: [PATCH 01/18] =?UTF-8?q?fix(deploy):=20KR=C4=B0T=C4=B0K=20prod=20?= =?UTF-8?q?=C5=9Fema=20migration-only=20+=20g=C3=BCvenlik=20altyap=C4=B1s?= =?UTF-8?q?=C4=B1=20(Y=C3=9CKSEK=20#1/#2/#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KRİTİK: init_db() create_all her prod boot'ta çalışıp belgelenen `alembic upgrade head` adımını DuplicateTable ile bozuyor, RBAC CHECK + FK index'leri atlıyordu → şema artık migration-only: - main.py: create_all yalnız ENVIRONMENT != production - docker-entrypoint.sh (yeni): prod'da `alembic upgrade head` → sonra uvicorn - models.py: User'a ck_users_role_valid CHECK (create_all yolu prod ile eşleşsin) Doğrulama: `alembic upgrade head` fresh DB'de sorunsuz koşuyor; 651/651 test geçti. YÜKSEK: - #1 API_DEBUG default False + prod'da SQLAlchemy echo zorla kapalı (SQL+param = şifre hash/PII log sızıntısı); .env.example + _validate_production warning - #2 entrypoint: uvicorn --proxy-headers --forwarded-allow-ips → rate-limit nginx peer IP yerine gerçek client IP'sini görür (auth brute-force koruması) - #3 exceptions.py: str(exc)/str(exc.orig) prod'da gizli + sunucuda log (path/SQL/secret info disclosure) Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 ++- Dockerfile | 7 +++++-- app/config.py | 11 ++++++++++- app/database.py | 5 ++++- app/main.py | 8 +++++++- app/middleware/exceptions.py | 18 +++++++++++++++--- app/models/models.py | 24 +++++++++++++++++++++++- docker-entrypoint.sh | 24 ++++++++++++++++++++++++ 8 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/.env.example b/.env.example index 8fafa40..504a792 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,8 @@ DB_POOL_RECYCLE=3600 # Container/prod icin 0.0.0.0; local dev icin 127.0.0.1 API_HOST=127.0.0.1 API_PORT=8000 -API_DEBUG=True +# Prod'da MUTLAKA False: True iken SQLAlchemy echo SQL+parametreleri (sifre hash/PII) loglar. +API_DEBUG=False # ─── Kimlik dogrulama (PROD'DA ZORUNLU OVERRIDE) ────────── # Yeni anahtar uretmek icin: python -c "import secrets; print(secrets.token_urlsafe(32))" diff --git a/Dockerfile b/Dockerfile index 8a6b9ff..732fe86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,8 +29,11 @@ RUN pip install --no-cache /wheels/* # Copy application code COPY . . +# Entrypoint'i çalıştırılabilir yap (prod'da `alembic upgrade head` → uvicorn) +RUN chmod +x /app/docker-entrypoint.sh + # Expose port EXPOSE 8000 -# Run the application -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run the application (prod: migrate-then-serve; dev: create_all main.py'de) +CMD ["/app/docker-entrypoint.sh"] diff --git a/app/config.py b/app/config.py index 5f434b1..7e5d679 100644 --- a/app/config.py +++ b/app/config.py @@ -49,7 +49,9 @@ class Settings(BaseSettings): # Default: localhost. Container/prod için env üzerinden 0.0.0.0 verilebilir. API_HOST: str = "127.0.0.1" API_PORT: int = 8000 - API_DEBUG: bool = True + # echo'yu sürer → prod'da tüm SQL + parametreler (şifre hash/PII) log'a sızar + # (audit YÜKSEK). Güvenli default = False; dev'de .env ile True yapılabilir. + API_DEBUG: bool = False API_TITLE: str = "SFDAP - Akilli Tarim Veri Analizi Platformu API" API_VERSION: str = "1.0.0" @@ -136,6 +138,13 @@ def _validate_production(self) -> Settings: "ENVIRONMENT=production ama API_HOST=127.0.0.1; container icinde 0.0.0.0 olmali.", stacklevel=2, ) + if self.API_DEBUG: + warnings.warn( + "ENVIRONMENT=production ama API_DEBUG=True; SQLAlchemy echo SQL+parametreleri " + "(sifre hash/PII) log'a yazar. .env'de API_DEBUG=False yapin " + "(echo yine de prod'da zorla kapatilir, bkz. database.py).", + stacklevel=2, + ) return self @property diff --git a/app/database.py b/app/database.py index d1f3cbb..295e4d0 100644 --- a/app/database.py +++ b/app/database.py @@ -46,7 +46,10 @@ def _build_engine_kwargs() -> dict: multi-thread arg, server DBs get pool tuning. """ is_sqlite = settings.DATABASE_URL.startswith("sqlite") - kwargs: dict = {"echo": settings.API_DEBUG} + # echo prod'da ZORLA kapalı: SQLAlchemy echo tüm SQL + parametreleri (şifre + # hash, PII) log'a yazar. API_DEBUG yanlışlıkla True bırakılsa bile prod'da + # sızıntı olmaz (audit YÜKSEK). + kwargs: dict = {"echo": settings.API_DEBUG and settings.ENVIRONMENT != "production"} if is_sqlite: kwargs["connect_args"] = {"check_same_thread": False} diff --git a/app/main.py b/app/main.py index 5f25623..0486a7e 100644 --- a/app/main.py +++ b/app/main.py @@ -64,7 +64,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: setup_logging() # Sentry — active when SENTRY_DSN env is set, otherwise no-op. init_sentry() - init_db() + # Prod'da şema YALNIZ alembic migration'larıyla kurulur — create_all DEĞİL. + # create_all alembic_version'ı stamp'lemez, RBAC CHECK constraint'i + FK + # index'leri atlar ve belgelenen `alembic upgrade head` adımını DuplicateTable + # ile bozar (audit KRİTİK). Dev/test'te hızlı şema için create_all yeterli; + # production'da docker-entrypoint.sh `alembic upgrade head` çalıştırır. + if settings.ENVIRONMENT != "production": + init_db() start_scheduler() if settings.MQTT_ENABLED: mqtt_listener.start() diff --git a/app/middleware/exceptions.py b/app/middleware/exceptions.py index 5308cdf..d1f6d3f 100644 --- a/app/middleware/exceptions.py +++ b/app/middleware/exceptions.py @@ -14,8 +14,11 @@ from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from loguru import logger from sqlalchemy.exc import IntegrityError +from app.config import settings + # ─── CUSTOM EXCEPTION SINIFLARI ───────────────────────────────── @@ -146,12 +149,16 @@ async def integrity_exception_handler(request: Request, exc: IntegrityError): UNIQUE/FK ihlallerini 409 Conflict olarak normalize eder; aksi halde 500 olarak yansır ve client hatası gibi davranmaz. """ + # Ham DB hata metni (str(exc.orig)) tablo/kolon/constraint adlarını — + # bazen değerleri — sızdırır → prod'da gizle, sunucuda logla (audit YÜKSEK). + raw = str(exc.orig) if exc.orig else str(exc) + logger.warning(f"IntegrityError: {raw}") return JSONResponse( status_code=409, content={ "error_code": "CONFLICT", "message": "Veri çakışması: kayıt zaten mevcut veya ilişki kuralı ihlal edildi.", - "detail": str(exc.orig) if exc.orig else str(exc), + "detail": raw if settings.ENVIRONMENT != "production" else None, }, ) @@ -181,12 +188,17 @@ async def request_validation_handler(request: Request, exc: RequestValidationErr @app.exception_handler(Exception) async def general_exception_handler(request: Request, exc: Exception): - """Beklenmeyen hataları yakalar ve tutarlı format döndürür.""" + """Beklenmeyen hataları yakalar ve tutarlı format döndürür. + + Ham exception metni (str(exc)) path/SQL/secret sızdırabilir → prod'da + client'a dönmez, tam traceback sunucuda loglanır (audit YÜKSEK). + """ + logger.exception(f"Beklenmeyen hata: {exc}") return JSONResponse( status_code=500, content={ "error_code": "INTERNAL_ERROR", "message": "Beklenmeyen bir sunucu hatası oluştu.", - "detail": str(exc) if exc.args else None, + "detail": (str(exc) if exc.args else None) if settings.ENVIRONMENT != "production" else None, }, ) diff --git a/app/models/models.py b/app/models/models.py index 21ceea0..bbc35a8 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,6 +1,18 @@ from datetime import UTC, datetime -from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) from sqlalchemy.orm import relationship from app.database import Base @@ -22,6 +34,16 @@ class User(Base): """ORM model for `users` table.""" __tablename__ = "users" + # RBAC bütünlüğü: `role` yalnız 4 geçerli değerden biri olabilir. Alembic + # migration (b1c2d3e4f5a6) prod'da aynı CHECK'i kurar; model'de de tanımlı + # olması create_all yolunun (dev/test) prod ile AYNI constraint'i üretmesini + # sağlar (audit: iki şema yolu ayrışmasın). + __table_args__ = ( + CheckConstraint( + "role IN (" + ", ".join(f"'{_r}'" for _r in USER_ROLES) + ")", + name="ck_users_role_valid", + ), + ) id = Column(Integer, primary_key=True, index=True) name = Column(String(100), nullable=False) email = Column(String(150), unique=True, nullable=False, index=True) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..22d9f70 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SFDAP container entrypoint +# ========================== +# Prod'da DB şeması YALNIZ alembic migration'larıyla kurulur (create_all main.py +# lifespan'inde prod-dışına gate'li). Böylece şema alembic_version ile stamp'lenir, +# RBAC CHECK constraint'i + FK index'leri dahil olur ve `alembic upgrade head` +# DuplicateTable ile patlamaz (audit KRİTİK fix). +# Dev/test'te create_all yeterli olduğu için migration adımı yalnız production'da koşar. +set -e + +if [ "$ENVIRONMENT" = "production" ]; then + echo "[entrypoint] production → alembic upgrade head" + alembic upgrade head +fi + +# --proxy-headers + --forwarded-allow-ips: nginx'in X-Forwarded-For'una güvenip +# request.client.host'u GERÇEK client IP'sine çevirir → rate-limit + access-log +# nginx peer IP'si yerine gerçek client'ı görür (audit YÜKSEK: aksi halde tüm +# trafik tek bucket'a düşer, auth brute-force koruması çöker). +# api yalnız nginx'in eriştiği internal ağda (expose, public değil) olduğu için +# default '*' güvenli; daha sıkı kurulumda FORWARDED_ALLOW_IPS= verin. +echo "[entrypoint] uvicorn başlatılıyor..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \ + --proxy-headers --forwarded-allow-ips="${FORWARDED_ALLOW_IPS:-*}" From 9e7818e3181f2cb41484a875b9bdc23a04feb770 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:44:10 +0300 Subject: [PATCH 02/18] =?UTF-8?q?fix(security):=20frontend=20stored/reflec?= =?UTF-8?q?ted=20XSS=20sertle=C5=9Ftirme=20(Y=C3=9CKSEK=20#4/#5/#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #4 monitoring.js: sensor_type/status escape (serbest str → stored XSS, admin oturumunda çalışırdı) - #5 utils.js: showToast message innerHTML yerine textContent (backend hata detayı/dosya adı → reflected XSS) - #6 main.js: click delegation YALNIZ 'change' ile sürülür: dropdown'a tıklamak (açmak) click + // delegation'ı tetikleyip istenmeyen aksiyon göndermesin — örn. admin rol + // dropdown'ını açmak istenmeyen rol-değişikliği PATCH'i atardı (audit YÜKSEK). + if (el.tagName === 'SELECT') return; const action = el.dataset.action; const handler = actionMap[action]; if (handler) { From b11fb3e39c2de7ddf29948b8ceac0b1d5a0774fe Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:44:11 +0300 Subject: [PATCH 03/18] =?UTF-8?q?fix(validation):=20max=5Flength=20+=20del?= =?UTF-8?q?ete=20cascade=20+=20area=5Fhectares=20(Y=C3=9CKSEK=20#7/#8/#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite testte geçip PostgreSQL prod'da patlayan sınıf hatalar: - #7 max_length: create/update şemalarına DB kolon uzunlukları (name 100/150, email 150, phone 20, soil_type 50, sensor_type 50, serial 100, alert 50/20) → PG'de 500 (StringDataRightTruncation) yerine 422. farms/sensors/alerts/auth/users - #8 delete cascade: sensör→okuma/özet, field→analiz/sulama/görsel/ekim/gübre/uyarı, farm→hava/uyarı (FK IntegrityError 500 + SQLite orphan). Sensör silme artık çalışır. - #9 fertilizer area_hectares gt=0 (negatif/sıfır → negatif gübre kg / yanlış "yeterli") Co-Authored-By: Claude Opus 4.8 --- app/routers/auth.py | 8 ++++---- app/routers/farms.py | 7 ++++++- app/routers/fields.py | 14 ++++++++++++++ app/routers/sensors.py | 9 ++++++++- app/routers/users.py | 8 ++++---- app/schemas/alerts.py | 8 ++++---- app/schemas/farms.py | 26 +++++++++++++++----------- app/schemas/fertilizer.py | 8 +++++--- app/schemas/sensors.py | 8 +++++--- 9 files changed, 65 insertions(+), 31 deletions(-) diff --git a/app/routers/auth.py b/app/routers/auth.py index f81b35a..7a582e4 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -35,7 +35,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from jose import JWTError, jwt from passlib.context import CryptContext -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -91,10 +91,10 @@ class UserRegisterRequest(BaseModel): } ) - name: str - email: str # TODO: switch to EmailStr once pydantic[email] is in. + name: str = Field(..., max_length=100) + email: str = Field(..., max_length=150) # TODO: EmailStr (email-validator gerekli) password: str # min 8 karakter (validator alttaki register'da) - phone: str | None = None + phone: str | None = Field(None, max_length=20) class UserLoginRequest(BaseModel): diff --git a/app/routers/farms.py b/app/routers/farms.py index 9395c99..e86c5e5 100644 --- a/app/routers/farms.py +++ b/app/routers/farms.py @@ -26,7 +26,7 @@ from app.middleware.exceptions import ConflictError, NotFoundError from app.middleware.rate_limiter import STRICT_RATE, limiter from app.middleware.rbac import assert_farm_ownership, require_write, scope_to_user -from app.models.models import Farm, Field, SoilAnalysis, User +from app.models.models import Farm, Field, SoilAnalysis, SystemAlert, User, WeatherData from app.routers.auth import get_current_user_or_403 from app.schemas.schemas import ( FarmCreate, @@ -222,5 +222,10 @@ def delete_farm( if field_count > 0: raise ConflictError(message=f"Bu çiftliğin {field_count} tarlası var; önce tarlaları sil.") farm = db.query(Farm).filter(Farm.id == farm_id).first() + # Cascade: çiftliğe doğrudan bağlı hava-durumu + uyarı satırları onunla silinir + # → aksi halde FK IntegrityError ile prod'da 500 (audit YÜKSEK). Tarlalar + # yukarıda guard'la bloke (önce onlar silinir). + db.query(WeatherData).filter(WeatherData.farm_id == farm_id).delete(synchronize_session=False) + db.query(SystemAlert).filter(SystemAlert.farm_id == farm_id).delete(synchronize_session=False) db.delete(farm) db.commit() diff --git a/app/routers/fields.py b/app/routers/fields.py index b34cc8a..6e0faf9 100644 --- a/app/routers/fields.py +++ b/app/routers/fields.py @@ -37,8 +37,10 @@ from app.middleware.rate_limiter import STRICT_RATE, limiter from app.middleware.rbac import assert_farm_ownership, assert_field_ownership, require_write from app.models.models import ( + CropPlanting, CropType, Farm, + FertilizerRecommendationLog, Field, IrrigationSchedule, PlantHealthImage, @@ -356,5 +358,17 @@ def delete_field( if sensor_count > 0: raise ConflictError(message=f"Bu tarlanın {sensor_count} sensörü var; önce sensörleri sil.") field = db.query(Field).filter(Field.id == field_id).first() + # Cascade: tarlanın veri-kayıtları (analiz/sulama/görsel/ekim/gübre/uyarı) + # onunla silinir → aksi halde FK IntegrityError ile prod'da 500 (audit YÜKSEK). + # Sensörler yukarıda guard'la bloke (önce onlar silinir). + for _model, _fk in ( + (SoilAnalysis, SoilAnalysis.field_id), + (IrrigationSchedule, IrrigationSchedule.field_id), + (PlantHealthImage, PlantHealthImage.field_id), + (CropPlanting, CropPlanting.field_id), + (FertilizerRecommendationLog, FertilizerRecommendationLog.field_id), + (SystemAlert, SystemAlert.field_id), + ): + db.query(_model).filter(_fk == field_id).delete(synchronize_session=False) db.delete(field) db.commit() diff --git a/app/routers/sensors.py b/app/routers/sensors.py index 09fb994..68ea6c8 100644 --- a/app/routers/sensors.py +++ b/app/routers/sensors.py @@ -25,7 +25,7 @@ require_write, scope_sensors_to_user, ) -from app.models.models import Sensor, SoilMoistureReading, User +from app.models.models import Sensor, SensorReadingMonthlyAggregate, SoilMoistureReading, User from app.routers.auth import get_current_user_or_403 from app.schemas.schemas import SensorCreate, SensorReadingCreate, SensorReadingResponse, SensorResponse @@ -142,6 +142,13 @@ def delete_sensor( if not sensor: # assert_sensor_ownership zaten 404 fırlattı; race condition defensive. raise NotFoundError("Sensör") + # Cascade: sensörün okuma + aylık-özet satırları onunla silinir. Gerçek + # sensörlerin HER ZAMAN okuması olur → guard yerine cascade şart; aksi halde + # FK IntegrityError ile prod'da 500, SQLite'ta orphan satır (audit YÜKSEK). + db.query(SoilMoistureReading).filter(SoilMoistureReading.sensor_id == sensor_id).delete(synchronize_session=False) + db.query(SensorReadingMonthlyAggregate).filter(SensorReadingMonthlyAggregate.sensor_id == sensor_id).delete( + synchronize_session=False + ) db.delete(sensor) db.commit() return {"message": "Sensor silindi"} diff --git a/app/routers/users.py b/app/routers/users.py index 5f710d2..c5e237c 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -24,7 +24,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Path, Query, Request, status -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -80,11 +80,11 @@ class AdminUserCreateRequest(BaseModel): } ) - name: str - email: str + name: str = Field(..., max_length=100) + email: str = Field(..., max_length=150) password: str role: UserRole = "farmer" - phone: str | None = None + phone: str | None = Field(None, max_length=20) class AdminPasswordResetRequest(BaseModel): diff --git a/app/schemas/alerts.py b/app/schemas/alerts.py index c5cdf22..e041d7b 100644 --- a/app/schemas/alerts.py +++ b/app/schemas/alerts.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime @@ -25,8 +25,8 @@ class SystemAlertCreate(BaseModel): farm_id: SqliteSafeInt | None = None field_id: SqliteSafeInt | None = None - alert_type: str # 'sensor_anomaly' | 'weather_warning' | 'system_error' | ... - severity: str = "low" # 'low' | 'medium' | 'critical' + alert_type: str = Field(..., max_length=50) # 'sensor_anomaly' | 'weather_warning' | ... + severity: str = Field("low", max_length=20) # 'low' | 'medium' | 'critical' message: str @@ -49,5 +49,5 @@ class SystemAlertUpdate(BaseModel): """Alert'in resolved durumunu güncellemek icin kismi update.""" is_resolved: bool | None = None - severity: str | None = None + severity: str | None = Field(None, max_length=20) message: str | None = None diff --git a/app/schemas/farms.py b/app/schemas/farms.py index 7fd7801..4b38c0b 100644 --- a/app/schemas/farms.py +++ b/app/schemas/farms.py @@ -2,10 +2,14 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime +# NOT: max_length DB kolon uzunluklarıyla hizalı (User/Farm/Field). Aksi halde +# aşırı-uzun string SQLite'ta geçer ama PostgreSQL'de 500 (StringDataRightTruncation) +# verirdi (audit YÜKSEK: SQLite↔PG ayrışması). Burada Pydantic 422 ile erken döner. + # ========== FARM / FIELD / SOIL (Cycle 9 GET endpoint'leri) ========== class FieldSummary(BaseModel): @@ -59,9 +63,9 @@ class FarmCreate(BaseModel): } ) - name: str - city: str | None = None - region: str | None = None + name: str = Field(..., max_length=150) + city: str | None = Field(None, max_length=100) + region: str | None = Field(None, max_length=100) area_hectares: float | None = None location_lat: float | None = None location_lng: float | None = None @@ -70,9 +74,9 @@ class FarmCreate(BaseModel): class FarmUpdate(BaseModel): """Çiftlik kısmi güncelleme — yalnız verilen alanlar değişir (exclude_unset).""" - name: str | None = None - city: str | None = None - region: str | None = None + name: str | None = Field(None, max_length=150) + city: str | None = Field(None, max_length=100) + region: str | None = Field(None, max_length=100) area_hectares: float | None = None location_lat: float | None = None location_lng: float | None = None @@ -95,8 +99,8 @@ class FieldCreate(BaseModel): ) farm_id: SqliteSafeInt - name: str - soil_type: str | None = None + name: str = Field(..., max_length=150) + soil_type: str | None = Field(None, max_length=50) area_hectares: float | None = None elevation_m: float | None = None crop_id: SqliteSafeInt | None = None @@ -105,8 +109,8 @@ class FieldCreate(BaseModel): class FieldUpdate(BaseModel): """Tarla kısmi güncelleme — yalnız verilen alanlar değişir.""" - name: str | None = None - soil_type: str | None = None + name: str | None = Field(None, max_length=150) + soil_type: str | None = Field(None, max_length=50) area_hectares: float | None = None elevation_m: float | None = None crop_id: SqliteSafeInt | None = None diff --git a/app/schemas/fertilizer.py b/app/schemas/fertilizer.py index f238d34..fb23da8 100644 --- a/app/schemas/fertilizer.py +++ b/app/schemas/fertilizer.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator # ========== FERTILIZER (Gübreleme) ========== @@ -29,7 +29,9 @@ class FertilizerRecommendRequest(BaseModel): soil_nitrogen: float # mg/kg soil_phosphorus: float # mg/kg soil_potassium: float # mg/kg - area_hectares: float + # Negatif/sıfır alan → negatif gübre kg veya yanlış "toprak yeterli" sonucu + # (audit YÜKSEK). gt=0 ile Pydantic 422 döner, servis hiç çalışmaz. + area_hectares: float = Field(..., gt=0.0) class FertilizerRecommendResponse(BaseModel): @@ -52,7 +54,7 @@ class FertilizerScheduleRequest(BaseModel): crop_type: str planting_date: str # YYYY-MM-DD - area_hectares: float + area_hectares: float = Field(..., gt=0.0) # negatif/sıfır alan reddedilir (audit YÜKSEK) soil_nitrogen: float = 0.0 soil_phosphorus: float = 0.0 soil_potassium: float = 0.0 diff --git a/app/schemas/sensors.py b/app/schemas/sensors.py index 028650f..3034b5f 100644 --- a/app/schemas/sensors.py +++ b/app/schemas/sensors.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime @@ -25,8 +25,10 @@ class SensorCreate(BaseModel): ) field_id: SqliteSafeInt - sensor_type: str # 'soil_moisture' | 'soil_temperature' | 'humidity' | ... - serial_number: str + # max_length DB kolonlarıyla hizalı (sensor_type=50, serial_number=100) → + # PG'de 500 yerine 422; serbest str olduğu için frontend ayrıca escape eder. + sensor_type: str = Field(..., max_length=50) # 'soil_moisture' | 'soil_temperature' | ... + serial_number: str = Field(..., max_length=100) depth_cm: float | None = None lat: float | None = None lng: float | None = None From a4612aad05d67fccfee8c08e9e247417e22bc760 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:44:11 +0300 Subject: [PATCH 04/18] fix(api): weather response wind_speed_kmh ekle (ORTA #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeatherDataResponse wind_speed_kmh'i serialize etmiyordu → frontend Rüzgâr grafiği prod'da hep 0. Alan eklendi + regresyon testi. Co-Authored-By: Claude Opus 4.8 --- app/schemas/weather.py | 1 + tests/test_weather.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/schemas/weather.py b/app/schemas/weather.py index 264d4d5..2be072e 100644 --- a/app/schemas/weather.py +++ b/app/schemas/weather.py @@ -29,3 +29,4 @@ class WeatherDataResponse(BaseModel): temperature_c: float | None humidity_percent: float | None precipitation_mm: float | None + wind_speed_kmh: float | None = None diff --git a/tests/test_weather.py b/tests/test_weather.py index 0695fe0..8593987 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -43,6 +43,19 @@ def test_get_weather_farm_id_filter(self, client): for item in results: assert item["farm_id"] == 1 + def test_get_weather_list_includes_wind_speed(self, client): + """Liste response'u wind_speed_kmh alanını içermeli ve değeri korumalı. + + Regresyon: WeatherDataResponse'ta wind_speed_kmh tanımlı olmadığında + FastAPI alanı serialize ederken siliyordu → frontend Rüzgar grafiği hep 0. + """ + client.post("/api/weather/", json=SAMPLE_WEATHER) + response = client.get("/api/weather/?farm_id=1") + results = response.json() + assert len(results) >= 1 + assert "wind_speed_kmh" in results[0] + assert results[0]["wind_speed_kmh"] == SAMPLE_WEATHER["wind_speed_kmh"] + # ───── POST /api/weather/ ───────────────────────────────────────────────────── class TestCreateWeather: From efc8e0cad50b3c8c16bbfd880dd09d9477534dda Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:09:59 +0300 Subject: [PATCH 05/18] fix(security): leaky/unauth endpoint'leri yetkilendir + prod'da /docs kapat (ORTA #6/#7/#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #6 metrics.py: deep_health_check + prometheus_metrics artık admin/developer ister (scheduler/pool/DB-hata sızıntısı); test_metrics deep-health auth-required'a güncellendi - #7 model_performance.py: read endpoint'leri Bearer, drift (SystemAlert INSERT'lü) admin/developer ister (auth'suz veri enumeration + yazma) - #8 main.py: docs_url/redoc_url/openapi_url prod'da None (attack-surface haritası kapalı) Co-Authored-By: Claude Opus 4.8 --- app/main.py | 7 +++++-- app/routers/metrics.py | 14 +++++++++++++- app/routers/model_performance.py | 25 +++++++++++++++++++++++-- tests/test_metrics.py | 7 ++++--- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index 0486a7e..37b94f5 100644 --- a/app/main.py +++ b/app/main.py @@ -203,8 +203,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: **SFDAP**, 5 kişilik öğrenci ekibi tarafından geliştirilen Scrum tabanlı bir projedir. Çiftçi-odaklı bir saha aracıdır; admin/gözetmen rolleri sistem-geneli gözetim sağlar. """, - docs_url="/docs", - redoc_url="/redoc", + # AUDIT FIX (#8): production'da Swagger/ReDoc/OpenAPI şemasını kapat — uç + # listesi + auth pattern'leri sızdırmasın. Dev/staging'de açık kalır. + docs_url=None if settings.ENVIRONMENT == "production" else "/docs", + redoc_url=None if settings.ENVIRONMENT == "production" else "/redoc", + openapi_url=None if settings.ENVIRONMENT == "production" else "/openapi.json", openapi_tags=TAGS_METADATA, lifespan=lifespan, ) diff --git a/app/routers/metrics.py b/app/routers/metrics.py index c888907..c1388a9 100644 --- a/app/routers/metrics.py +++ b/app/routers/metrics.py @@ -27,6 +27,7 @@ from app.config import settings from app.database import get_db from app.models.models import Sensor, SoilMoistureReading, SystemAlert +from app.routers.auth import require_role from app.schemas.schemas import HealthCheckResponse router = APIRouter(prefix="/api/health", tags=["Sistem Metrikleri"]) @@ -180,6 +181,10 @@ def _check_alerts(db: Session) -> dict: summary="Derin sistem sağlığı kontrolü", description="DB, scheduler ve ML model bileşenlerini kontrol eder. " "Herhangi bir bileşen 'fail' olursa overall 'degraded' döndürür.", + # AUDIT FIX (#6): scheduler iş listesi / pool internals / ham DB hata + # string'leri sızdıran bu uç artık yalnız admin/developer'a açık. + dependencies=[Depends(require_role("admin", "developer"))], + responses={401: {"description": "Bearer token gerekli"}, 403: {"description": "admin/developer rolü gerekli"}}, ) def deep_health_check(db: Session = Depends(get_db)) -> HealthCheckResponse: components = { @@ -219,7 +224,14 @@ def deep_health_check(db: Session = Depends(get_db)) -> HealthCheckResponse: "library bağımlılığı olmadan basit gauge'lar yayar. Production'da bir " "Prometheus instance'ı buradan periyodik scrape eder." ), - responses={200: {"content": {"text/plain": {}}, "description": "Prometheus exposition format"}}, + # AUDIT FIX (#6): aktif sensör / alert sayıları gibi sistem iç metriklerini + # sızdıran scrape ucu artık yalnız admin/developer'a açık. + dependencies=[Depends(require_role("admin", "developer"))], + responses={ + 200: {"content": {"text/plain": {}}, "description": "Prometheus exposition format"}, + 401: {"description": "Bearer token gerekli"}, + 403: {"description": "admin/developer rolü gerekli"}, + }, ) def prometheus_metrics(db: Session = Depends(get_db)) -> PlainTextResponse: """Lightweight Prometheus exposition — manual text format (no client_python dep).""" diff --git a/app/routers/model_performance.py b/app/routers/model_performance.py index 2157f7c..0e79bdb 100644 --- a/app/routers/model_performance.py +++ b/app/routers/model_performance.py @@ -31,6 +31,7 @@ from app.middleware.exceptions import NotFoundError, ValidationError from app.middleware.rate_limiter import STRICT_RATE, limiter from app.models.models import ModelPerformanceLog, SystemAlert +from app.routers.auth import get_current_user_or_403, require_role from app.schemas.schemas import ( ModelPerformanceCompareItem, ModelPerformanceDriftReport, @@ -62,6 +63,9 @@ "/", response_model=list[ModelPerformanceLogResponse], summary="Performans loglarını listele", + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={401: {"description": "Bearer token gerekli"}}, ) def list_logs( model_name: str | None = Query(default=None, description="Filtre: belirli bir model"), @@ -128,7 +132,12 @@ def update_log( response_model=ModelPerformanceSummary, summary="Model bazlı agregat performans özeti", description="Belirtilen model için toplam tahmin sayısı, ortalama doğruluk skoru ve son log zamanını döndürür.", - responses={404: {"description": "Belirtilen model için log bulunamadı"}}, + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={ + 401: {"description": "Bearer token gerekli"}, + 404: {"description": "Belirtilen model için log bulunamadı"}, + }, ) def model_summary(model_name: str, db: Session = Depends(get_db)) -> ModelPerformanceSummary: rows = ( @@ -156,6 +165,9 @@ def model_summary(model_name: str, db: Session = Depends(get_db)) -> ModelPerfor summary="Günlük accuracy zaman serisi", description="Modelin son N gündeki günlük ortalama accuracy değerini döndürür. " "Trend takibi ve drift tespiti için kullanılır.", + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={401: {"description": "Bearer token gerekli"}}, ) def model_timeseries( model_name: str, @@ -194,7 +206,12 @@ def model_timeseries( summary="Birden fazla modeli karşılaştır", description="Virgülle ayrılmış model isimleri için yan-yana metrikler (toplam tahmin, " "ortalama / min / max accuracy, son log zamanı). Örnek: `?models=irrigation_rf,plant_disease_cnn`", - responses={400: {"description": "Geçerli model adı sağlanmadı"}}, + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={ + 400: {"description": "Geçerli model adı sağlanmadı"}, + 401: {"description": "Bearer token gerekli"}, + }, ) def compare_models( models: str = Query(..., description="Virgülle ayrılmış model isimleri"), @@ -247,6 +264,10 @@ def compare_models( description="Son N gün accuracy ortalamasını önceki periyotla karşılaştırır. " "Belirlenen eşikten fazla düşüş varsa otomatik olarak `SystemAlert` (severity=medium) yaratır. " "İdeal sıklık: günde 1-2 kez (cron veya scheduler ile).", + # AUDIT FIX (#7): SystemAlert INSERT eden write-yan-etkili uç; auth eksikti. + # Yazma işlemi olduğundan yalnız admin/developer'a açıldı. + dependencies=[Depends(require_role("admin", "developer"))], + responses={401: {"description": "Bearer token gerekli"}, 403: {"description": "admin/developer rolü gerekli"}}, ) def detect_drift( model_name: str, diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 8c2d37b..79bf417 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -33,15 +33,16 @@ def test_db_component_status_ok(self, client): resp = client.get("/api/health/deep") assert resp.json()["components"]["db"]["status"] == "ok" - def test_no_auth_required(self): + def test_deep_health_requires_auth(self): + """audit ORTA #6: deep-health scheduler/pool/DB-hata içeriği sızdırır → + artık admin/developer ister. (Sığ /api/health/ load balancer için public kalır.)""" from fastapi.testclient import TestClient from app.main import app with TestClient(app) as c: resp = c.get("/api/health/deep") - # Health endpoint'leri public olmalı - assert resp.status_code == 200 + assert resp.status_code in (401, 403) def test_uptime_component_present(self, client): """v3-7: uptime bileşeni eklendi, uptime_seconds >= 0 olmalı.""" From 728e20ed35d9792d80bd1894ec6166b112ae3d24 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:10:00 +0300 Subject: [PATCH 06/18] =?UTF-8?q?fix(logic):=20analitik/=C5=9Fema/servis?= =?UTF-8?q?=20edge-case'leri=20(ORTA=20#4/#5/#9/#10/#12/#15/#16/#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #4 analytics _diff: negatif baseline'da işaret tersleniyordu → düzeltildi - #5 analytics: boş dönem 0 yerine None → uydurma +100% değişim engellendi - #9 fields.py: reading timestamp'leri UTC-offset'li ISO-8601 (Safari Invalid Date) - #10 irrigation: water_amount gt=0, duration_min ge=0 (negatif/sıfır reddedilir) - #12 irrigation: IrrigationResponse'a duration_min eklendi (Süre kolonu doluyor) - #15 mqtt: eksik moisture_percent artık 0 yerine atlanıyor (sahte %0 okuma) - #16 plants: çözülemeyen görsel sahte 200-success olarak kaydedilmiyor (422) - #17 alerts şema: severity Literal[low,medium,critical] (bucket bozulması) Co-Authored-By: Claude Opus 4.8 --- app/routers/analytics.py | 18 ++++++++++++------ app/routers/fields.py | 6 +++++- app/routers/plants.py | 9 +++++++++ app/schemas/alerts.py | 8 ++++++-- app/schemas/irrigation.py | 7 +++++-- app/services/mqtt_listener.py | 8 +++++++- 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/app/routers/analytics.py b/app/routers/analytics.py index e82df9c..089a754 100644 --- a/app/routers/analytics.py +++ b/app/routers/analytics.py @@ -256,8 +256,10 @@ def _get_stats(start: datetime, end: datetime): ) return { - "temp_avg": round(sum(temps) / len(temps), 2) if temps else 0, - "humidity_avg": round(sum(hums) / len(hums), 2) if hums else 0, + # AUDIT FIX (#5): boş periyotta ortalama 0 yerine None döner; + # aksi halde _diff 0'ı gerçek baseline sanıp sahte %100 üretiyordu. + "temp_avg": round(sum(temps) / len(temps), 2) if temps else None, + "humidity_avg": round(sum(hums) / len(hums), 2) if hums else None, "precipitation_mm": round(precip, 2), "sensor_readings": readings, "irrigations": irrigations, @@ -266,10 +268,14 @@ def _get_stats(start: datetime, end: datetime): stats_1 = _get_stats(start_date_1, end_date_1) stats_2 = _get_stats(start_date_2, end_date_2) - def _diff(val1: float, val2: float) -> float: - if val1 == 0: - return 100.0 if val2 > 0 else 0.0 - return round(((val2 - val1) / val1) * 100, 2) + def _diff(val1: float | None, val2: float | None) -> float | None: + # AUDIT FIX (#5): baseline/current veri yoksa (None) veya baseline 0 ise + # anlamlı bir yüzde hesaplanamaz → None. + if val1 is None or val2 is None or val1 == 0: + return None + # AUDIT FIX (#4): negatif baseline'da yüzdenin işareti ters dönmesin diye + # paydada abs(val1) kullanılır; pay işareti (val2 - val1) korunur. + return round(((val2 - val1) / abs(val1)) * 100, 2) return { "period_1": {"start": start_date_1, "end": end_date_1, "stats": stats_1}, diff --git a/app/routers/fields.py b/app/routers/fields.py index 6e0faf9..926b230 100644 --- a/app/routers/fields.py +++ b/app/routers/fields.py @@ -51,6 +51,7 @@ User, ) from app.routers.auth import get_current_user_or_403 +from app.schemas.base import _serialize_utc from app.schemas.schemas import ( FieldAlertSummary, FieldCreate, @@ -258,7 +259,10 @@ def get_field_readings( # En yeniden çektik; grafik için kronolojik (eskiden yeniye) ters çevir. result = [ { - "reading_timestamp": str(reading.reading_timestamp), + # Audit fix (#9): raw str() çıktısı UTC offset taşımıyordu → + # Safari'de "Invalid Date" / yanlış-gün etiketleri. API'nin geri + # kalanıyla uyumlu ISO 8601 (UTC suffix'li) için _serialize_utc. + "reading_timestamp": _serialize_utc(reading.reading_timestamp), "sensor_id": reading.sensor_id, "sensor_type": sensor_type, "moisture_percent": reading.moisture_percent, diff --git a/app/routers/plants.py b/app/routers/plants.py index f7b866a..6467c81 100644 --- a/app/routers/plants.py +++ b/app/routers/plants.py @@ -167,6 +167,15 @@ async def analyze_plant_image( # ─── CNN modelinden tahmin ─────────────────────────────────── prediction = plant_disease_model.predict(content) + # Audit fix (#16): görsel decode edilemezse model error dict döner + # (diagnosis='unknown', confidence 0.0). Bunu 200 başarı + sahte satır + # olarak kaydetmek yerine 4xx ile reddet, DB'ye yazma. + if prediction.get("error") or prediction.get("diagnosis") == "unknown": + raise HTTPException( + status_code=422, + detail=prediction.get("error") or "Goruntu analiz edilemedi (gecersiz/bozuk gorsel).", + ) + # ─── DB kaydı ──────────────────────────────────────────────── record = PlantHealthImage( field_id=field_id, diff --git a/app/schemas/alerts.py b/app/schemas/alerts.py index e041d7b..6316cd1 100644 --- a/app/schemas/alerts.py +++ b/app/schemas/alerts.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime @@ -26,7 +28,8 @@ class SystemAlertCreate(BaseModel): farm_id: SqliteSafeInt | None = None field_id: SqliteSafeInt | None = None alert_type: str = Field(..., max_length=50) # 'sensor_anomaly' | 'weather_warning' | ... - severity: str = Field("low", max_length=20) # 'low' | 'medium' | 'critical' + # Audit düzeltmesi: keyfi severity değerleri dashboard/metrik kovalarını bozmasın. + severity: Literal["low", "medium", "critical"] = "low" message: str @@ -49,5 +52,6 @@ class SystemAlertUpdate(BaseModel): """Alert'in resolved durumunu güncellemek icin kismi update.""" is_resolved: bool | None = None - severity: str | None = Field(None, max_length=20) + # Audit düzeltmesi: severity sadece geçerli kovalarla kısıtlandı. + severity: Literal["low", "medium", "critical"] | None = None message: str | None = None diff --git a/app/schemas/irrigation.py b/app/schemas/irrigation.py index ea7cf9c..f2cfa6f 100644 --- a/app/schemas/irrigation.py +++ b/app/schemas/irrigation.py @@ -16,8 +16,9 @@ class IrrigationCreate(BaseModel): field_id: SqliteSafeInt scheduled_date: datetime - duration_min: int | None = None - water_amount_liters: float | None = None + # Audit düzeltmesi: negatif süre ve negatif/sıfır su miktarı reddedilir. + duration_min: int | None = Field(None, ge=0) + water_amount_liters: float | None = Field(None, gt=0) class IrrigationResponse(BaseModel): @@ -28,6 +29,8 @@ class IrrigationResponse(BaseModel): id: int field_id: int scheduled_date: UtcDateTime + # Audit düzeltmesi: frontend "Süre (dk)" kolonu için duration_min eksikti. + duration_min: int | None water_amount_liters: float | None status: str diff --git a/app/services/mqtt_listener.py b/app/services/mqtt_listener.py index ea8d9e1..4599430 100644 --- a/app/services/mqtt_listener.py +++ b/app/services/mqtt_listener.py @@ -169,8 +169,14 @@ def _save_reading(db: Session, sensor_id: int, payload: dict[str, Any]) -> bool: if sensor is None: logger.warning(f"Bilinmeyen sensor_id={sensor_id}, mesaj atlandi") return False + # Audit fix (#15): moisture_percent eksik/None ise 0'a düşürmek yerine + # okumayı atla — aksi halde sahte %0 değeri sınır kontrolünü geçip kaydedilir. + raw_moisture = payload.get("moisture_percent") + if raw_moisture is None: + logger.warning(f"moisture_percent eksik, okuma atlandi: {payload!r}") + return False try: - moisture = float(payload.get("moisture_percent", 0)) + moisture = float(raw_moisture) except (TypeError, ValueError): logger.warning(f"Gecersiz moisture_percent: {payload!r}") return False From 506514da2dc244ad2dcb10f51efdeb215197098b Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:10:01 +0300 Subject: [PATCH 07/18] fix(data): email normalize + alert field-ownership + role NOT NULL migration (ORTA #13/#14/#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #13 auth.py/users.py: email .strip().lower() (register/login/admin-create) → case/whitespace mükerrer hesap + login engeli bitti - #14 alerts router: create_alert field_id sahipliğini de doğruluyor - #18 migration c5eee337bec4: users.role NOT NULL (model/migration drift) — fresh DB'de doğrulandı, CHECK + index korundu, reversible Co-Authored-By: Claude Opus 4.8 --- ...bac_role_not_null_drift_fix_audit_orta_.py | 37 +++++++++++++++++++ app/routers/alerts.py | 6 ++- app/routers/auth.py | 12 ++++-- app/routers/users.py | 7 +++- 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py diff --git a/alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py b/alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py new file mode 100644 index 0000000..e201f47 --- /dev/null +++ b/alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py @@ -0,0 +1,37 @@ +"""rbac role NOT NULL drift fix (audit ORTA 18) + +Revision ID: c5eee337bec4 +Revises: c2d3e4f5a6b7 +Create Date: 2026-06-09 13:58:12.980002 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c5eee337bec4" +down_revision: str | Sequence[str] | None = "c2d3e4f5a6b7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """users.role NOT NULL — model (nullable=False) ile hizala. + + İlk migration role'ü nullable kurmuştu (audit ORTA #18: model/migration + drift; CHECK constraint NULL'ı reddetmiyordu). Önce NULL/boş role'leri + 'farmer'a backfill et, sonra kolonu NOT NULL yap. PostgreSQL'de native + ALTER; SQLite'ta batch tablo rebuild (mevcut CHECK + index korunur). + """ + op.execute("UPDATE users SET role = 'farmer' WHERE role IS NULL OR role = ''") + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("role", existing_type=sa.String(length=20), nullable=False) + + +def downgrade() -> None: + """role'ü tekrar nullable yap (drift'in eski hâline dönüş).""" + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("role", existing_type=sa.String(length=20), nullable=True) diff --git a/app/routers/alerts.py b/app/routers/alerts.py index 6bf2a9a..6daf2b4 100644 --- a/app/routers/alerts.py +++ b/app/routers/alerts.py @@ -28,7 +28,7 @@ from app.database import MAX_SQLITE_INT, get_db from app.middleware.exceptions import ForbiddenError, NotFoundError from app.middleware.rate_limiter import STRICT_RATE, limiter -from app.middleware.rbac import _BYPASS_ROLES, assert_farm_ownership, require_write +from app.middleware.rbac import _BYPASS_ROLES, assert_farm_ownership, assert_field_ownership, require_write from app.models.models import Farm, Field, PlantHealthImage, Sensor, SoilMoistureReading, SystemAlert, User from app.routers.auth import get_current_user_or_403 from app.schemas.schemas import SystemAlertCreate, SystemAlertResponse, SystemAlertUpdate @@ -132,6 +132,10 @@ def create_alert( assert_farm_ownership(db, payload.farm_id, current_user) elif current_user.role == "farmer": raise ForbiddenError(detail="Farmer sistem-geneli uyarı (farm_id=None) oluşturamaz.") + # Audit fix: field_id verilmişse tarla sahipliği de doğrulanmalı — aksi + # halde alert sahibi olmadığın bir tarlayı referanslayabilir. + if payload.field_id is not None: + assert_field_ownership(db, payload.field_id, current_user) alert = SystemAlert(**payload.model_dump()) db.add(alert) db.commit() diff --git a/app/routers/auth.py b/app/routers/auth.py index 7a582e4..98bcad5 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -307,7 +307,10 @@ def _dep(user: User = Depends(_get_current_user)) -> User: ) @limiter.limit(AUTH_RATE) def register(request: Request, payload: UserRegisterRequest, db: Session = Depends(get_db)) -> User: - if db.query(User).filter(User.email == payload.email).first(): + # Audit fix: e-posta normalize (strip + lower) — case/whitespace varyantları + # mükerrer hesap yaratmasın ve dup-check ile insert aynı değeri kullansın. + email = payload.email.strip().lower() + if db.query(User).filter(User.email == email).first(): raise ConflictError(message="Bu e-posta zaten kayıtlı.") if len(payload.password) < 8: # 400 — FastAPI'nin auto-generated 422 şeması list[ValidationError] @@ -318,7 +321,7 @@ def register(request: Request, payload: UserRegisterRequest, db: Session = Depen raise ValidationError(message="Şifre en az 8 karakter olmalı.") user = User( name=payload.name, - email=payload.email, + email=email, password_hash=_hash_password(payload.password), role="farmer", phone=payload.phone, @@ -344,7 +347,10 @@ def register(request: Request, payload: UserRegisterRequest, db: Session = Depen ) @limiter.limit(AUTH_RATE) def login(request: Request, payload: UserLoginRequest, db: Session = Depends(get_db)) -> TokenResponse: - user = db.query(User).filter(User.email == payload.email).first() + # Audit fix: register ile aynı normalize (strip + lower) — case/whitespace + # varyantı login'i bloklamasın, stored e-posta ile eşleşsin. + email = payload.email.strip().lower() + user = db.query(User).filter(User.email == email).first() if user is None or not _verify_password(payload.password, user.password_hash or ""): raise UnauthorizedError(detail="E-posta veya şifre hatalı.") token, expires_in = _create_token(user.id) diff --git a/app/routers/users.py b/app/routers/users.py index c5e237c..853aee6 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -216,13 +216,16 @@ def admin_create_user( db: Session = Depends(get_db), ) -> User: """Admin rol seçerek kullanıcı yaratır.""" - if db.query(User).filter(User.email == payload.email).first(): + # Audit fix: e-posta normalize (strip + lower) — auth.register ile tutarlı; + # case/whitespace varyantı mükerrer hesap yaratmasın, dup-check ile insert eşleşsin. + email = payload.email.strip().lower() + if db.query(User).filter(User.email == email).first(): raise ConflictError(message="Bu e-posta zaten kayıtlı.") if len(payload.password) < 8: raise ValidationError(message="Şifre en az 8 karakter olmalı.") user = User( name=payload.name, - email=payload.email, + email=email, password_hash=_hash_password(payload.password), role=payload.role, phone=payload.phone, From 460297ae187d1ff154d974a1bc696e22f00e04ad Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:10:01 +0300 Subject: [PATCH 08/18] =?UTF-8?q?fix(ui):=20frontend=20UX=20+=20i18n=20+?= =?UTF-8?q?=20hata=20mesaj=C4=B1=20(ORTA=20#23/#24/#25/#26/#27/#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #23 monitoring: boş hava verisinde skeleton sonsuz dönmüyor (boş-durum) - #24 monitoring: sensör count her yüklemede tazeleniyor (stale pagination) - #25 alerts: özet kartları (Toplam/Açık/Kritik) filtreden bağımsız sayımdan - #26 api.js + ui_helpers.js: Pydantic 422 detail-array alan mesajlarına çevriliyor (ham HTTP 422 yerine) — 2 kopya da düzeltildi + test güncellendi - #27 labels.js/render.js/monitoring: sensör tip/status Türkçe etiket - #28 dashboard: severity chip'leri low/medium/critical dışını da sayıyor (total senkron) Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api.js | 6 ++++++ frontend/src/lib/labels.js | 12 ++++++++++++ frontend/src/lib/pages/alerts.js | 19 ++++++++++++++----- frontend/src/lib/pages/dashboard.js | 16 ++++++++++++---- frontend/src/lib/pages/monitoring.js | 26 +++++++++++++++----------- frontend/src/lib/render.js | 9 ++++++--- frontend/src/lib/ui_helpers.js | 6 ++++++ frontend/tests/ui_helpers.test.js | 9 +++++++-- 8 files changed, 78 insertions(+), 25 deletions(-) diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 31b72c9..48a18fb 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -31,6 +31,12 @@ export async function _extractErrorMessage(res) { const body = await res.clone().json(); if (body && typeof body.message === "string" && body.message.trim()) return body.message; if (body && typeof body.detail === "string" && body.detail.trim()) return body.detail; + // Pydantic 422: detail bir dizi ({loc,msg,type}) → alan mesajlarını birleştir + // (audit ORTA #26: aksi halde kullanıcı ham "HTTP 422" görüyordu). + if (body && Array.isArray(body.detail) && body.detail.length) { + const msgs = body.detail.map(e => (e && typeof e.msg === "string" ? e.msg : null)).filter(Boolean); + if (msgs.length) return msgs.join(" · "); + } } catch { // body JSON değil veya parse hatası — generic mesaja düş } diff --git a/frontend/src/lib/labels.js b/frontend/src/lib/labels.js index c638207..ab178d3 100644 --- a/frontend/src/lib/labels.js +++ b/frontend/src/lib/labels.js @@ -19,6 +19,18 @@ export const irrigationStatusLabel = _map({ pending: 'Beklemede', completed: 'Tamamlandı', cancelled: 'İptal', scheduled: 'Planlandı', }); +// Audit fix (#27): sensör tipi — daha önce ham snake_case render ediliyordu. +// charts.js'teki typeLabels'ı yansıtır + field-detail form seçenekleri. +export const sensorTypeLabel = _map({ + soil_moisture: 'Toprak Nemi', soil_temperature: 'Toprak Sıcaklığı', + humidity: 'Hava Nemi', electrical_conductivity: 'Elektriksel İletkenlik', +}); + +// Audit fix (#27): sensör durumu — daha önce ham İngilizce render ediliyordu. +export const sensorStatusLabel = _map({ + active: 'Aktif', inactive: 'Pasif', maintenance: 'Bakımda', faulty: 'Arızalı', +}); + // Hastalık tanısı (ML sınıfları) export const diagnosisLabel = _map({ healthy: 'Sağlıklı', leaf_spot: 'Yaprak lekesi', powdery_mildew: 'Külleme', rust: 'Pas', diff --git a/frontend/src/lib/pages/alerts.js b/frontend/src/lib/pages/alerts.js index 713b104..eb7f1e9 100644 --- a/frontend/src/lib/pages/alerts.js +++ b/frontend/src/lib/pages/alerts.js @@ -26,12 +26,21 @@ export async function loadAlerts() { let qs = 'limit=100'; if (sev) qs += `&severity=${encodeURIComponent(sev)}`; if (resolved) qs += `&is_resolved=${resolved}`; - const list = await api(`/api/alerts/?${qs}`); + // Audit fix (#25): özet kartları FİLTRELİ + 100-capped listeden değil, + // filtreden bağımsız tam listelerden hesaplanır — aksi halde "Kritik"/"Açık" + // kartları aktif filtre altında etiketleriyle çelişiyordu. Kartlar için + // severity/resolved filtresini yok sayan ayrı sorgular kullanılır. + const [list, allList, openList, criticalList] = await Promise.all([ + api(`/api/alerts/?${qs}`), + api('/api/alerts/?limit=500'), + api('/api/alerts/?is_resolved=false&limit=500'), + api('/api/alerts/?severity=critical&limit=500'), + ]); - // Özet kartları - const total = list ? list.length : 0; - const critical = list ? list.filter(a => a.severity === 'critical').length : 0; - const open = list ? list.filter(a => !a.is_resolved).length : 0; + // Özet kartları — filtreden bağımsız tam sayımlar + const total = allList ? allList.length : 0; + const critical = criticalList ? criticalList.length : 0; + const open = openList ? openList.length : 0; cards.innerHTML = `
${total}
Toplam
${open}
Açık
diff --git a/frontend/src/lib/pages/dashboard.js b/frontend/src/lib/pages/dashboard.js index 2aad7f3..37da2ac 100644 --- a/frontend/src/lib/pages/dashboard.js +++ b/frontend/src/lib/pages/dashboard.js @@ -9,7 +9,7 @@ import { _fmtDate, _fmtNumber, _escAttr, showToast, updateStatus, _STATUS_EMOJI, _STATUS_LABEL } from "../utils.js"; import { api, apiAuth, getAuthToken } from "../api.js"; import { _skeletonCards, _setBusy } from "../skeleton.js"; -import { irrigationStatusLabel, diagnosisLabel } from "../labels.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "../labels.js"; import { renderMoistureChart, renderTempHumChart, renderPrecipChart } from "../charts.js"; import { getCurrentUser, getApiOnline, setApiOnline } from "../session.js"; import { _getRoleTips } from "../filiz.js"; @@ -32,14 +32,22 @@ function _renderSummaryCards(summary) { : '—'; const irrTitle = irr.field_name ? `${irr.field_name} · ${_fmtDate(irr.scheduled_date)}` : 'Sulama kaydı yok'; const sev = alerts.by_severity || {}; - const severityChips = [ + const _knownSev = [ { key: 'critical', label: 'kritik', cls: 'critical' }, { key: 'medium', label: 'orta', cls: 'medium' }, { key: 'low', label: 'düşük', cls: 'low' }, - ].map(({ key, label, cls }) => { + ]; + // Audit fix (#28): bilinen seviyeler dışındaki severity'ler (örn. 'high') + // sessizce düşüyordu → chip toplamı gerçek total ile uyuşmuyordu. Backend + // sapmış severity'leri koruyor; bilinmeyenleri de sona ekle. + const _knownKeys = new Set(_knownSev.map((s) => s.key)); + const _extraSev = Object.keys(sev) + .filter((k) => !_knownKeys.has(k) && (sev[k] || 0) > 0) + .map((k) => ({ key: k, label: severityLabel(k), cls: _escAttr(k) })); + const severityChips = [..._knownSev, ..._extraSev].map(({ key, label, cls }) => { const cnt = sev[key] || 0; return cnt > 0 - ? `${cnt} ${label}` + ? `${cnt} ${_escAttr(label)}` : ''; }).join(' '); const diseaseDx = diagnosisLabel(disease.diagnosis); diff --git a/frontend/src/lib/pages/monitoring.js b/frontend/src/lib/pages/monitoring.js index 021e502..d71d04e 100644 --- a/frontend/src/lib/pages/monitoring.js +++ b/frontend/src/lib/pages/monitoring.js @@ -9,7 +9,7 @@ import { _escAttr, showToast } from "../utils.js"; import { api, apiAuth, getAuthToken, API_BASE, _authHeaders } from "../api.js"; import { _skeletonCards, _skeletonRows, _skeletonBlock, _setBusy } from "../skeleton.js"; -import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "../labels.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel, sensorTypeLabel, sensorStatusLabel } from "../labels.js"; import { charts, renderSensorTypeChart, renderFarmTempChart, renderIrrigationStatusChart, renderNpkRadarChart, renderDailyTrendChart, renderSensorStatsChart, chartTick, chartLegend, chartGrid } from "../charts.js"; @@ -30,13 +30,10 @@ export async function loadSensors(page = 1) { // Table skeleton — 6 rows × 4 columns before the fetch returns. document.getElementById('sensorsTable').innerHTML = _skeletonRows(6, 4); _setBusy('sensorsTable', true); - // Toplam sayiyi her sayfa degisikliginde tekrar cekmek pahali — - // ilk yuklemede al, sonra cache'le. Yeni sensor eklenirse sayfa - // degistiginde tekrar fetch edilir (bilincli trade-off). - if (sensorsTotal === 0) { - const cnt = await api('/api/sensors/count'); - sensorsTotal = cnt?.total || 0; - } + // Toplam sayıyı HER yüklemede tazele: field-detail'den sensör eklenip/silinin + // (monitoring dışı) cache stale kalıyordu (audit ORTA #24). COUNT ucuz query. + const cnt = await api('/api/sensors/count'); + sensorsTotal = cnt?.total || 0; const totalPages = Math.max(1, Math.ceil(sensorsTotal / PAGE_SIZE)); sensorsPage = Math.min(Math.max(1, page), totalPages); const skip = (sensorsPage - 1) * PAGE_SIZE; @@ -59,11 +56,13 @@ export async function loadSensors(page = 1) { // keyboard navigation acilir. // XSS koruması: sensor_type/status kullanıcı-kaynaklı (SensorCreate.sensor_type // serbest str) → escape şart (audit YÜKSEK stored-XSS). serial_number zaten escape'liydi. + // Audit fix (#27): ham snake_case/İngilizce yerine Türkçe etiket; bilinmeyen + // değerde fallback ham değeri döndürdüğü için _escAttr sarması korunur. document.getElementById('sensorsTable').innerHTML = sensors.map(s => ` - - ${s.id}${_escAttr(s.sensor_type)}${_escAttr(s.serial_number)} - ${_escAttr(s.status)} + ${s.id}${_escAttr(sensorTypeLabel(s.sensor_type))}${_escAttr(s.serial_number)} + ${_escAttr(sensorStatusLabel(s.status))} `).join(''); _setBusy('sensorsTable', false); @@ -101,6 +100,11 @@ export async function loadWeather() {
${temps.length ? Math.min(...temps).toFixed(1) + '—' + Math.max(...temps).toFixed(1) : '—'}°C
Sıcaklık Aralığı
${data.reduce((a,d) => a + (d.precipitation_mm||0), 0).toFixed(1)}mm
Toplam Yağış
`; + } else { + // Audit fix (#23): veri boş/çevrimdışı ise skeleton kartlar sonsuza + // dek dönmesin — "Veri yok" durumu göster. + document.getElementById('weatherCards').innerHTML = + '

🌤️ Hava durumu verisi yok veya sunucuya ulaşılamadı.

'; } _setBusy('weatherCards', false); // Temperature chart diff --git a/frontend/src/lib/render.js b/frontend/src/lib/render.js index e2c2677..26056c7 100644 --- a/frontend/src/lib/render.js +++ b/frontend/src/lib/render.js @@ -8,7 +8,7 @@ */ import { _escAttr, _fmtDate, _fmtNumber, _STATUS_EMOJI, _STATUS_LABEL } from "./utils.js"; -import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "./labels.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel, sensorTypeLabel, sensorStatusLabel } from "./labels.js"; export function renderFieldDetail(d) { const cropName = d.crop ? d.crop.name : 'Ekili bitki yok'; @@ -19,9 +19,12 @@ export function renderFieldDetail(d) { const sensorRows = (d.sensors || []).map(s => { const m = s.latest_moisture_percent != null ? `%${_fmtNumber(s.latest_moisture_percent)}` : '—'; const t = s.latest_soil_temperature_c != null ? `${_fmtNumber(s.latest_soil_temperature_c)}°C` : '—'; - const label = `${s.sensor_type}${s.serial_number ? ' (' + s.serial_number + ')' : ''}`; + // Audit fix (#27): ham snake_case/İngilizce yerine Türkçe etiket; + // bilinmeyen değerde fallback ham değeri döndürdüğü için _escAttr korunur. + // CSS sınıfı (sensor-${status}) ham status'tan üretilmeye devam eder. + const label = `${sensorTypeLabel(s.sensor_type)}${s.serial_number ? ' (' + s.serial_number + ')' : ''}`; return `
-
📡 ${_escAttr(s.sensor_type)} ${_escAttr(s.status)}
+
📡 ${_escAttr(sensorTypeLabel(s.sensor_type))} ${_escAttr(sensorStatusLabel(s.status))}
Nem: ${m} · Toprak: ${t}
${s.latest_reading_at ? _fmtDate(s.latest_reading_at) : 'okuma yok'}
diff --git a/frontend/src/lib/ui_helpers.js b/frontend/src/lib/ui_helpers.js index 3ded132..0f449fa 100644 --- a/frontend/src/lib/ui_helpers.js +++ b/frontend/src/lib/ui_helpers.js @@ -69,6 +69,12 @@ export async function extractErrorMessage(res) { const body = await res.clone().json(); if (body && typeof body.message === "string" && body.message.trim()) return body.message; if (body && typeof body.detail === "string" && body.detail.trim()) return body.detail; + // Pydantic 422: detail bir dizi ({loc,msg,type}) → alan mesajlarını birleştir + // (audit ORTA #26: aksi halde kullanıcı ham "HTTP 422" görüyordu). + if (body && Array.isArray(body.detail) && body.detail.length) { + const msgs = body.detail.map((e) => (e && typeof e.msg === "string" ? e.msg : null)).filter(Boolean); + if (msgs.length) return msgs.join(" · "); + } } catch { // body JSON değil veya parse hatası — generic mesaja düş } diff --git a/frontend/tests/ui_helpers.test.js b/frontend/tests/ui_helpers.test.js index 5d99095..732f133 100644 --- a/frontend/tests/ui_helpers.test.js +++ b/frontend/tests/ui_helpers.test.js @@ -137,9 +137,14 @@ describe("extractErrorMessage", () => { expect(await extractErrorMessage(res)).toBe("HTTP 500"); }); - it("detail array ise (Pydantic 422) onu kullanmaz, fallback'a düşer", async () => { + it("detail array ise (Pydantic 422) alan mesajlarını birleştirir", async () => { const res = mockRes({ detail: [{ loc: ["body", "email"], msg: "field required" }] }, 422); - expect(await extractErrorMessage(res)).toBe("HTTP 422"); + expect(await extractErrorMessage(res)).toBe("field required"); + }); + + it("detail array çok alanlıysa mesajları ' · ' ile birleştirir", async () => { + const res = mockRes({ detail: [{ msg: "field required" }, { msg: "value too long" }] }, 422); + expect(await extractErrorMessage(res)).toBe("field required · value too long"); }); it("res.clone() kullanır — body stream sonra başka yerde okunabilir", async () => { From 215602fc3a2c5e81d5823913a37f23002da1ded2 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:10:02 +0300 Subject: [PATCH 09/18] fix(infra): .dockerignore + compose bind-mount + requirements pin (ORTA #1/#2/#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1 .dockerignore (yeni): dev DB/secret/.git/tests prod imajına sızmıyor - #2 docker-compose: api servisinden host-kaynak bind-mount kaldırıldı (prod container kendi imajını çalıştırır; dev için yorumlu örnek bırakıldı) - #3 requirements.txt: bağımlılıklar == ile pinlendi; venv'de kurulu olmayan paho-mqtt/onnxruntime >= bırakıldı (follow-up) Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 35 +++++++++++++++++++++++++++++++ docker-compose.yml | 11 +++++++--- requirements.txt | 51 +++++++++++++++++++++++----------------------- 3 files changed, 69 insertions(+), 28 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..26d026e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Audit fix: Dockerfile "COPY . ." dev sqlite DB / logs / .git / tests / .env +# secret'lerini prod image'e gomuyordu. Runtime'da gereksiz ve hassas dosyalari +# image disinda tutmak icin eklendi. +# Calisma zamaninda gereken sey context'te kalir: app/, frontend/, alembic/, +# requirements.txt, alembic.ini, docker-entrypoint.sh + +# Version control +.git +.github + +# Veritabani dosyalari (dev sqlite vs.) +*.db +*.sqlite* + +# Secrets / ortam +.env + +# Sanal ortam ve Python cache +.venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +htmlcov + +# Loglar +logs/ + +# Frontend bagimliliklari +node_modules + +# Test ve dokuman dosyalari (runtime'da gereksiz) +tests/ +docs/ +*.md diff --git a/docker-compose.yml b/docker-compose.yml index af2682d..c3acc2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,9 +33,14 @@ services: # DATABASE_URL=postgresql://sfdap_user:sfdap_password@db:5432/sfdap_db - DATABASE_URL=${DATABASE_URL:-sqlite:///./sfdap_dev.db} - CORS_ORIGINS=${CORS_ORIGINS:-https://${DOMAIN:-localhost}} - volumes: - - .:/app - - /app/.venv + # Audit fix: host kaynak bind mount (`.:/app`) prod image'in uzerine host + # kodunu + host .env/dev DB'sini bindirip prod container'in host kodu + # calistirmasina sebep oluyordu. Image'i self-contained tutmak icin + # default'tan kaldirildi. Local dev'de canli reload icin asagidaki + # `volumes` blogunu yorumdan cikarin: + # volumes: + # - .:/app + # - /app/.venv restart: unless-stopped networks: - sfdap_net diff --git a/requirements.txt b/requirements.txt index fb67d38..26fcb39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,62 +2,63 @@ # SFDAP API Dependencies # ======================== +# Audit fix: tum bagimliliklar reproducible prod build icin yuklu surume "==" ile pinlendi # Web Framework -fastapi>=0.110.0 -uvicorn[standard]>=0.27.0 +fastapi==0.136.1 +uvicorn[standard]==0.46.0 # Database -sqlalchemy>=2.0.0 -psycopg2-binary>=2.9.0 -alembic>=1.13.0 +sqlalchemy==2.0.49 +psycopg2-binary==2.9.12 +alembic==1.18.4 # Data Validation -pydantic>=2.5.0 -pydantic-settings>=2.1.0 +pydantic==2.13.3 +pydantic-settings==2.14.0 # Data Processing -pandas>=2.2.0 -numpy>=1.26.0 +pandas==3.0.2 +numpy==2.4.4 # Machine Learning -scikit-learn>=1.4.0 -joblib>=1.3.0 +scikit-learn==1.8.0 +joblib==1.5.3 # Environment & Config -python-dotenv>=1.0.0 +python-dotenv==1.2.2 # CORS & Security -python-multipart>=0.0.6 -slowapi>=0.1.9 +python-multipart==0.0.27 +slowapi==0.1.9 # Auth (Cycle 8 — JWT backend) # Miraç: bcrypt password hashing + JWT token üretimi # bcrypt<5.0 — passlib 1.7.4 yeni bcrypt 5.x ile uyumsuz (`__about__` kaldırıldı) -python-jose[cryptography]>=3.3.0 -passlib[bcrypt]>=1.7.4 -bcrypt>=4.0,<5.0 +python-jose[cryptography]==3.5.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.3.0 # HTTP Client (for weather API) -httpx>=0.27.0 +httpx==0.28.1 # Logging & Background Tasks -loguru>=0.7.2 -apscheduler>=3.10.4 +loguru==0.7.3 +apscheduler==3.11.2 # Reporting & Export (Cycle 6) -fpdf2>=2.7.0 -openpyxl>=3.1.0 +fpdf2==2.8.7 +openpyxl==3.1.5 # Cycle 7 — IoT Stream & ML Görüntü Analizi # Emirhan: MQTT broker üzerinden gerçek zamanlı sensör verisi alımı paho-mqtt>=2.1.0 # Ayşe: CNN bitki sağlığı modeli (lightweight inference; tensorflow yerine) onnxruntime>=1.18.0 -pillow>=10.3.0 +pillow==12.2.0 # shiftFinal — Observability (Sentry + Prometheus + structured logging) # Mehmet'in A2 paketi: production gözlemlenebilirliği # - Sentry: SENTRY_DSN env varsa uncaught exception toplama (FastAPI integration) # - Prometheus: /metrics endpoint + request counter/histogram metrikleri -sentry-sdk[fastapi]>=2.0.0 -prometheus-client>=0.20.0 +sentry-sdk[fastapi]==2.59.0 +prometheus-client==0.25.0 From 9ac2346f02cca26c6352cfe70bf93dbc78d9416c Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:27:15 +0300 Subject: [PATCH 10/18] =?UTF-8?q?fix(auth):=20register/login=20sertle?= =?UTF-8?q?=C5=9Ftirme=20+=20rol-PATCH=20testi=20(D=C3=9C=C5=9E=C3=9CK=20L?= =?UTF-8?q?1-L5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L1 email format sanity (EmailStr yok) + name min_length=1 (boş isim) - L2 JWT decode exp+sub zorunlu (exp'siz token süresiz geçerliydi) - L3 bcrypt 72-byte parola guard (sessiz truncation → açık 400) - L4 parola/email validasyonu dup-check'ten ÖNCE (409 yerine 400 + enumeration azaldı) - L5 PATCH /users/{id}/role testi (happy + self-lockout 409 + 404) Co-Authored-By: Claude Opus 4.8 --- app/routers/auth.py | 44 +++++++++++++++++++++++++++++++++++---- app/routers/users.py | 16 +++++++++++--- tests/test_users_admin.py | 28 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/app/routers/auth.py b/app/routers/auth.py index 98bcad5..f2028fd 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -67,6 +67,10 @@ # bcrypt context — round count default 12; production'da yüksek pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# bcrypt algoritması parolayı 72 BYTE'ta sessizce keser (truncation). Audit +# fix (L3): bu sınırı aşan parolaları açıkça reddetmek için tek kaynak sabit. +_BCRYPT_MAX_BYTES = 72 + # JWT için stateless logout sağlayan in-memory blacklist. # `jti` (JWT ID, RFC 7519 §4.1.7) üzerinden çalışır — aynı kullanıcının aynı # saniyede aldığı iki token'ın `sub`+`iat`+`exp` payload'ı identical olabilir @@ -91,7 +95,7 @@ class UserRegisterRequest(BaseModel): } ) - name: str = Field(..., max_length=100) + name: str = Field(..., min_length=1, max_length=100) email: str = Field(..., max_length=150) # TODO: EmailStr (email-validator gerekli) password: str # min 8 karakter (validator alttaki register'da) phone: str | None = Field(None, max_length=20) @@ -163,6 +167,19 @@ class PasswordChangeResponse(BaseModel): # ─── Yardımcılar ───────────────────────────────────────────────────── +def _is_email_sane(email: str) -> bool: + """Hafif e-posta sağlık kontrolü — EmailStr/email-validator yok. + + Audit fix (L1): en az 3 karakter, tam bir '@' ve '@'tan sonra bir '.' + içermeli. Tam RFC doğrulaması değil; boş/sahte adresleri eler. + EN: Lightweight email sanity check (no email-validator dependency). + """ + if len(email) < 3 or email.count("@") != 1: + return False + local, _, domain = email.partition("@") + return bool(local) and "." in domain and not domain.startswith(".") and not domain.endswith(".") + + def _hash_password(password: str) -> str: """bcrypt hash üret — `pwd_context` her seferinde yeni salt kullanır. @@ -204,7 +221,14 @@ def _create_token(user_id: int) -> tuple[str, int]: def _decode_token(token: str) -> int: """JWT decode + sub'ı user_id olarak döndür. Geçersizse 401 fırlat.""" try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + # Audit fix (L2): exp + sub claim'leri zorunlu — exp'siz token süresiz + # geçerli kalmasın, sub'suz token kimliksiz kabul edilmesin. + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + options={"require": ["exp", "sub"]}, + ) except JWTError as exc: raise UnauthorizedError(detail="Geçersiz token.") from exc # jti blacklist kontrolü — payload.get çünkü eski (jti'siz) token'lara @@ -310,8 +334,12 @@ def register(request: Request, payload: UserRegisterRequest, db: Session = Depen # Audit fix: e-posta normalize (strip + lower) — case/whitespace varyantları # mükerrer hesap yaratmasın ve dup-check ile insert aynı değeri kullansın. email = payload.email.strip().lower() - if db.query(User).filter(User.email == email).first(): - raise ConflictError(message="Bu e-posta zaten kayıtlı.") + # Audit fix (L1): hafif e-posta sağlık kontrolü — email-validator/EmailStr + # kurulu değil; en az '@' + '.' içermeli ve makul uzunlukta olmalı. + if not _is_email_sane(email): + raise ValidationError(message="Geçerli bir e-posta adresi girin.") + # Audit fix (L4): şifre uzunluğu kontrolü dup-email kontrolünden ÖNCE çalışır + # — geçersiz şifre 409 yerine 400 döner ve dup-check ile user enumeration azalır. if len(payload.password) < 8: # 400 — FastAPI'nin auto-generated 422 şeması list[ValidationError] # bekler; düz-string detail uyumsuz olur. @@ -319,6 +347,11 @@ def register(request: Request, payload: UserRegisterRequest, db: Session = Depen # 400 — FastAPI's auto-generated 422 schema expects # list[ValidationError]; a plain-string detail breaks it. raise ValidationError(message="Şifre en az 8 karakter olmalı.") + # Audit fix (L3): bcrypt 72 BYTE'ta sessizce keser; açıkça reddet. + if len(payload.password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError(message=f"Şifre en fazla {_BCRYPT_MAX_BYTES} bayt olmalı.") + if db.query(User).filter(User.email == email).first(): + raise ConflictError(message="Bu e-posta zaten kayıtlı.") user = User( name=payload.name, email=email, @@ -439,6 +472,9 @@ def change_password( raise UnauthorizedError(detail="Mevcut şifre hatalı.") if len(payload.new_password) < 8: raise ValidationError(message="Yeni şifre en az 8 karakter olmalı.") + # Audit fix (L3): bcrypt 72 BYTE'ta sessizce keser; açıkça reddet. + if len(payload.new_password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError(message=f"Yeni şifre en fazla {_BCRYPT_MAX_BYTES} bayt olmalı.") if payload.new_password == payload.current_password: raise ValidationError(message="Yeni şifre mevcut şifreyle aynı olamaz.") user.password_hash = _hash_password(payload.new_password) diff --git a/app/routers/users.py b/app/routers/users.py index 853aee6..1036649 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -37,10 +37,12 @@ from app.middleware.rate_limiter import AUTH_RATE, limiter from app.models.models import Farm, User from app.routers.auth import ( + _BCRYPT_MAX_BYTES, CurrentUserResponse, PasswordChangeResponse, UserRole, _hash_password, + _is_email_sane, require_role, ) from app.schemas.schemas import UtcDateTime @@ -80,7 +82,7 @@ class AdminUserCreateRequest(BaseModel): } ) - name: str = Field(..., max_length=100) + name: str = Field(..., min_length=1, max_length=100) email: str = Field(..., max_length=150) password: str role: UserRole = "farmer" @@ -219,10 +221,18 @@ def admin_create_user( # Audit fix: e-posta normalize (strip + lower) — auth.register ile tutarlı; # case/whitespace varyantı mükerrer hesap yaratmasın, dup-check ile insert eşleşsin. email = payload.email.strip().lower() - if db.query(User).filter(User.email == email).first(): - raise ConflictError(message="Bu e-posta zaten kayıtlı.") + # Audit fix (L1): hafif e-posta sağlık kontrolü — auth.register ile tutarlı. + if not _is_email_sane(email): + raise ValidationError(message="Geçerli bir e-posta adresi girin.") + # Audit fix (L4): şifre uzunluğu kontrolü dup-email'den önce — geçersiz şifre + # 409 yerine 400 döner, user enumeration azalır. if len(payload.password) < 8: raise ValidationError(message="Şifre en az 8 karakter olmalı.") + # Audit fix (L3): bcrypt 72 BYTE'ta sessizce keser; açıkça reddet. + if len(payload.password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError(message=f"Şifre en fazla {_BCRYPT_MAX_BYTES} bayt olmalı.") + if db.query(User).filter(User.email == email).first(): + raise ConflictError(message="Bu e-posta zaten kayıtlı.") user = User( name=payload.name, email=email, diff --git a/tests/test_users_admin.py b/tests/test_users_admin.py index 17a2a58..43ded1d 100644 --- a/tests/test_users_admin.py +++ b/tests/test_users_admin.py @@ -145,6 +145,34 @@ def test_farmer_cannot_create_403(self, farmer_client): assert resp.status_code == 403 +# ─── ROLE UPDATE ─────────────────────────────────────────────── + + +class TestUpdateUserRole: + """PATCH /api/auth/users/{id}/role — happy path + self-lockout + 404.""" + + def test_admin_changes_other_user_role(self, admin_client, db): + client, _admin = admin_client + target = _make_user(db, "farmer") + resp = client.patch(f"/api/auth/users/{target.id}/role", json={"role": "overseer"}) + assert resp.status_code == 200 + assert resp.json()["role"] == "overseer" + assert resp.json()["id"] == target.id + # DB'ye gerçekten yazıldı mı? + db.refresh(target) + assert target.role == "overseer" + + def test_admin_cannot_change_own_role_409(self, admin_client): + client, admin = admin_client + resp = client.patch(f"/api/auth/users/{admin.id}/role", json={"role": "farmer"}) + assert resp.status_code == 409 + + def test_role_update_missing_user_404(self, admin_client): + client, _admin = admin_client + resp = client.patch("/api/auth/users/999999/role", json={"role": "developer"}) + assert resp.status_code == 404 + + # ─── PASSWORD RESET ──────────────────────────────────────────── From 9311f26b6b312aa60025f7e32a739aa836b72a18 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:27:16 +0300 Subject: [PATCH 11/18] =?UTF-8?q?fix(backend):=20router/servis=20edge-case?= =?UTF-8?q?'leri=20(D=C3=9C=C5=9E=C3=9CK=20L6-L22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L6 analytics /compare datetime'ları UTC-offset'li - L8 farm/field update/delete None-guard (race → 500) - L9 nem rounded değerle sınıflanıyor (sınır badge tutarlılığı) - L11 weather precipitation rain['1h']=null → 0.0 - L12 sulama terminal durumdan (completed/cancelled) çıkış bloklandı (409) - L19 plant upload commit hatasında orphan dosya temizleniyor - L20 archiver SELECT/DELETE aynı id seti (geç gelen satır kaybı) - L21 archiver boş grup min/max None (0.0 seed korupsiyonu) - L22 report PDF None → N/A - L7 naive DateTime riski yorumlandı (migration gerekir, follow-up) Co-Authored-By: Claude Opus 4.8 --- app/models/models.py | 6 ++++++ app/routers/analytics.py | 15 +++++++++++++-- app/routers/farms.py | 6 ++++++ app/routers/fields.py | 11 ++++++++++- app/routers/irrigation.py | 10 +++++++++- app/routers/plants.py | 9 ++++++++- app/services/report_service.py | 11 +++++++---- app/services/sensor_archiver.py | 31 ++++++++++++++++++++++++------- app/services/weather_service.py | 10 +++++++++- 9 files changed, 92 insertions(+), 17 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index bbc35a8..446e43e 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -140,6 +140,9 @@ class SoilMoistureReading(Base): __tablename__ = "soil_moisture_readings" id = Column(Integer, primary_key=True, index=True) sensor_id = Column(Integer, ForeignKey("sensors.id"), nullable=False) + # Audit L7: naive DateTime; filtreler UTC-aware datetime kullanıyor. + # PostgreSQL'de doğruluk doğrulanmadı. DateTime(timezone=True)'a geçiş + # migration gerektirir → SKIP (davranış değişikliği yok). reading_timestamp = Column(DateTime, default=lambda: datetime.now(UTC), index=True) moisture_percent = Column(Float, nullable=False) depth_cm = Column(Float) @@ -180,6 +183,7 @@ class SensorReadingMonthlyAggregate(Base): soil_temperature_max = Column(Float) electrical_conductivity_avg = Column(Float) + # Audit L7: naive DateTime — bkz. SoilMoistureReading.reading_timestamp notu (SKIP, migration gerekir). archived_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) __table_args__ = ( @@ -194,6 +198,7 @@ class WeatherData(Base): __tablename__ = "weather_data" id = Column(Integer, primary_key=True, index=True) farm_id = Column(Integer, ForeignKey("farms.id")) + # Audit L7: naive DateTime — bkz. SoilMoistureReading.reading_timestamp notu (SKIP, migration gerekir). recorded_at = Column(DateTime, default=lambda: datetime.now(UTC), index=True) temperature_c = Column(Float) humidity_percent = Column(Float) @@ -225,6 +230,7 @@ class PlantHealthImage(Base): id = Column(Integer, primary_key=True, index=True) field_id = Column(Integer, ForeignKey("fields.id")) image_url = Column(String(500)) + # Audit L7: naive DateTime — bkz. SoilMoistureReading.reading_timestamp notu (SKIP, migration gerekir). captured_at = Column(DateTime, default=lambda: datetime.now(UTC)) diagnosis = Column(String(200)) confidence_score = Column(Float) diff --git a/app/routers/analytics.py b/app/routers/analytics.py index 089a754..f404932 100644 --- a/app/routers/analytics.py +++ b/app/routers/analytics.py @@ -30,6 +30,7 @@ WeatherData, ) from app.routers.auth import require_role +from app.schemas.base import _serialize_utc from app.services.report_service import ReportService router = APIRouter(prefix="/api/analytics", tags=["Analitik & Görselleştirme"]) @@ -278,8 +279,18 @@ def _diff(val1: float | None, val2: float | None) -> float | None: return round(((val2 - val1) / abs(val1)) * 100, 2) return { - "period_1": {"start": start_date_1, "end": end_date_1, "stats": stats_1}, - "period_2": {"start": start_date_2, "end": end_date_2, "stats": stats_2}, + # AUDIT FIX (L6): raw dict → datetime'ler tz'siz dönüyordu (UTC offset yok). + # API'nin geri kalanıyla uyumlu ISO 8601 (UTC suffix'li) için _serialize_utc. + "period_1": { + "start": _serialize_utc(start_date_1), + "end": _serialize_utc(end_date_1), + "stats": stats_1, + }, + "period_2": { + "start": _serialize_utc(start_date_2), + "end": _serialize_utc(end_date_2), + "stats": stats_2, + }, "comparison": { "temp_avg_diff_percent": _diff(stats_1["temp_avg"], stats_2["temp_avg"]), "humidity_avg_diff_percent": _diff(stats_1["humidity_avg"], stats_2["humidity_avg"]), diff --git a/app/routers/farms.py b/app/routers/farms.py index e86c5e5..a232257 100644 --- a/app/routers/farms.py +++ b/app/routers/farms.py @@ -188,6 +188,9 @@ def update_farm( require_write(current_user) assert_farm_ownership(db, farm_id, current_user) farm = db.query(Farm).filter(Farm.id == farm_id).first() + if farm is None: + # assert_farm_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Çiftlik") updates = payload.model_dump(exclude_unset=True) for field_name, value in updates.items(): setattr(farm, field_name, value) @@ -222,6 +225,9 @@ def delete_farm( if field_count > 0: raise ConflictError(message=f"Bu çiftliğin {field_count} tarlası var; önce tarlaları sil.") farm = db.query(Farm).filter(Farm.id == farm_id).first() + if farm is None: + # assert_farm_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Çiftlik") # Cascade: çiftliğe doğrudan bağlı hava-durumu + uyarı satırları onunla silinir # → aksi halde FK IntegrityError ile prod'da 500 (audit YÜKSEK). Tarlalar # yukarıda guard'la bloke (önce onlar silinir). diff --git a/app/routers/fields.py b/app/routers/fields.py index 926b230..9f8e0ff 100644 --- a/app/routers/fields.py +++ b/app/routers/fields.py @@ -216,7 +216,10 @@ def get_field_detail( region=farm.region, city=farm.city, crop=FieldCropInfo.model_validate(crop) if crop else None, - moisture_status=_classify_moisture(avg_moisture), + # AUDIT FIX (L9): rozet ile gösterilen sayı uyumlu olsun diye sınıflandırma + # da gösterilen YUVARLANMIŞ değer üzerinden yapılır (ör. 29.95 → 30.0% optimal, + # ham değerde 'dry' rozeti çelişkisi olmaz). + moisture_status=_classify_moisture(round(avg_moisture, 1) if avg_moisture is not None else None), avg_moisture_percent=round(avg_moisture, 1) if avg_moisture is not None else None, sensors=sensor_summaries, recent_irrigations=[FieldIrrigationSummary.model_validate(i) for i in irrigations], @@ -328,6 +331,9 @@ def update_field( require_write(current_user) assert_field_ownership(db, field_id, current_user) field = db.query(Field).filter(Field.id == field_id).first() + if field is None: + # assert_field_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Tarla") updates = payload.model_dump(exclude_unset=True) for field_name, value in updates.items(): setattr(field, field_name, value) @@ -362,6 +368,9 @@ def delete_field( if sensor_count > 0: raise ConflictError(message=f"Bu tarlanın {sensor_count} sensörü var; önce sensörleri sil.") field = db.query(Field).filter(Field.id == field_id).first() + if field is None: + # assert_field_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Tarla") # Cascade: tarlanın veri-kayıtları (analiz/sulama/görsel/ekim/gübre/uyarı) # onunla silinir → aksi halde FK IntegrityError ile prod'da 500 (audit YÜKSEK). # Sensörler yukarıda guard'la bloke (önce onlar silinir). diff --git a/app/routers/irrigation.py b/app/routers/irrigation.py index 90a4dc5..1e47f47 100644 --- a/app/routers/irrigation.py +++ b/app/routers/irrigation.py @@ -25,7 +25,7 @@ from sqlalchemy.orm import Session from app.database import MAX_SQLITE_INT, get_db -from app.middleware.exceptions import NotFoundError +from app.middleware.exceptions import ConflictError, NotFoundError from app.middleware.rate_limiter import STRICT_RATE, limiter from app.middleware.rbac import _BYPASS_ROLES, assert_field_ownership, require_write from app.ml.irrigation_model import irrigation_optimizer @@ -47,6 +47,10 @@ # sensors.py ile aynı int64 overflow koruması — skip 1M ile sınırlanır. MAX_SKIP = 1_000_000 +# Terminal durumlar: tamamlanan/iptal edilen program geri açılamaz (one-way). +# Bu durumlardan çıkış (ör. completed → pending) 409 ile bloke edilir. +_TERMINAL_STATUSES = frozenset({"completed", "cancelled"}) + router = APIRouter(prefix="/api/irrigation", tags=["Sulama Optimizasyonu"]) @@ -189,6 +193,7 @@ def create_schedule( 401: {"description": "Bearer token gerekli"}, 403: {"description": "Yazma yetkisi yok veya başkasının tarlası"}, 404: {"description": "Program bulunamadı"}, + 409: {"description": "Terminal durumdaki program (completed/cancelled) güncellenemez"}, }, ) @limiter.limit(STRICT_RATE) @@ -206,6 +211,9 @@ def update_schedule_status( raise NotFoundError("Sulama programı") # field ownership: schedule.field_id farmer'ın olmalı assert_field_ownership(db, schedule.field_id, current_user) + # Terminal durumdan (completed/cancelled) çıkış engellenir — geri açılamaz. + if schedule.status in _TERMINAL_STATUSES: + raise ConflictError(message=f"'{schedule.status}' durumundaki program güncellenemez (terminal durum).") schedule.status = payload.status db.commit() db.refresh(schedule) diff --git a/app/routers/plants.py b/app/routers/plants.py index 6467c81..d23dfd2 100644 --- a/app/routers/plants.py +++ b/app/routers/plants.py @@ -185,7 +185,14 @@ async def analyze_plant_image( severity=prediction["severity"], ) db.add(record) - db.commit() + # Audit fix (L19): dosya commit'ten önce diske yazıldığı için commit + # başarısız olursa yetim dosya kalır. Commit'i sar, hata olursa dosyayı sil. + try: + db.commit() + except Exception: + db.rollback() + save_path.unlink(missing_ok=True) + raise db.refresh(record) return { diff --git a/app/services/report_service.py b/app/services/report_service.py index 0c5a7f7..ba05874 100644 --- a/app/services/report_service.py +++ b/app/services/report_service.py @@ -126,8 +126,10 @@ def generate_pdf_report(data: dict) -> io.BytesIO: for w in data.get("farm_weather_comparison", []): name = _clean_tr(w.get("farm_name", "")) city = _clean_tr(w.get("city", "")) - temp = w.get("temperature", {}).get("avg", "N/A") - hum = w.get("humidity", {}).get("avg", "N/A") + # Audit fix (L22): get(key, "N/A") yalnızca eksik anahtarda default verir; + # değer None ise literal 'None' basılır. None'ı da "N/A"ya indir. + temp = w.get("temperature", {}).get("avg") or "N/A" + hum = w.get("humidity", {}).get("avg") or "N/A" pdf.cell(0, 8, f"- {name} ({city}): Sicaklik {temp} C, Nem {hum}%", new_x="LMARGIN", new_y="NEXT") pdf.ln(5) @@ -137,8 +139,9 @@ def generate_pdf_report(data: dict) -> io.BytesIO: pdf.cell(0, 10, "3. Sensor Ortalamalari", new_x="LMARGIN", new_y="NEXT") pdf.set_font("helvetica", "", 12) stats = data.get("sensor_reading_stats", {}) - moisture = stats.get("moisture", {}).get("avg", "N/A") - soil_temp = stats.get("soil_temperature", {}).get("avg", "N/A") + # Audit fix (L22): None değer literal 'None' basıyordu; "N/A"ya indir. + moisture = stats.get("moisture", {}).get("avg") or "N/A" + soil_temp = stats.get("soil_temperature", {}).get("avg") or "N/A" pdf.cell(0, 8, f"- Ortalama Toprak Nemi: {moisture}%", new_x="LMARGIN", new_y="NEXT") pdf.cell(0, 8, f"- Ortalama Toprak Sicakligi: {soil_temp} C", new_x="LMARGIN", new_y="NEXT") diff --git a/app/services/sensor_archiver.py b/app/services/sensor_archiver.py index 72de31f..455f738 100644 --- a/app/services/sensor_archiver.py +++ b/app/services/sensor_archiver.py @@ -65,9 +65,11 @@ def _aggregate_group(rows: list[SoilMoistureReading]) -> dict: return { "reading_count": len(rows), - "moisture_avg": sum(moistures) / len(moistures) if moistures else 0.0, - "moisture_min": min(moistures) if moistures else 0.0, - "moisture_max": max(moistures) if moistures else 0.0, + # Audit fix (L21): boş grupta 0.0 ile seed etmek, sonraki min/max + # merge'ini bozuyordu (yapay 0.0 minimumu). None kullan; merge None-safe. + "moisture_avg": sum(moistures) / len(moistures) if moistures else None, + "moisture_min": min(moistures) if moistures else None, + "moisture_max": max(moistures) if moistures else None, "soil_temperature_avg": sum(temps) / len(temps) if temps else None, "soil_temperature_min": min(temps) if temps else None, "soil_temperature_max": max(temps) if temps else None, @@ -96,8 +98,19 @@ def _weighted(old_avg: float | None, new_avg: float | None) -> float | None: return (old_avg * existing.reading_count + new_avg * group["reading_count"]) / total_count existing.moisture_avg = _weighted(existing.moisture_avg, group["moisture_avg"]) - existing.moisture_min = min(existing.moisture_min, group["moisture_min"]) - existing.moisture_max = max(existing.moisture_max, group["moisture_max"]) + # Audit fix (L21): grup min/max artık None olabilir (boş grup). None-safe birleştir. + if group["moisture_min"] is not None: + existing.moisture_min = ( + min(existing.moisture_min, group["moisture_min"]) + if existing.moisture_min is not None + else group["moisture_min"] + ) + if group["moisture_max"] is not None: + existing.moisture_max = ( + max(existing.moisture_max, group["moisture_max"]) + if existing.moisture_max is not None + else group["moisture_max"] + ) existing.soil_temperature_avg = _weighted(existing.soil_temperature_avg, group["soil_temperature_avg"]) if group["soil_temperature_min"] is not None: existing.soil_temperature_min = ( @@ -131,6 +144,9 @@ def archive_old_readings(db: Session, cutoff_days: int = DEFAULT_ARCHIVE_CUTOFF_ """ cutoff = datetime.now(UTC) - timedelta(days=cutoff_days) old_readings = db.query(SoilMoistureReading).filter(SoilMoistureReading.reading_timestamp < cutoff).all() + # Audit fix (L20): DELETE penceresi SELECT'ten ayrı çalışıyordu; arada gelen + # geç kayıt aggregate'e girmeden silinebiliyordu. Sadece bu id kümesini sil. + archived_ids = [r.id for r in old_readings] if not old_readings: logger.info(f"sensor_archiver: cutoff={cutoff.isoformat()} — taşınacak kayıt yok") @@ -171,10 +187,11 @@ def archive_old_readings(db: Session, cutoff_days: int = DEFAULT_ARCHIVE_CUTOFF_ aggregates_written += 1 # Kaynak okumaları sil (cascade FK yok, manuel delete güvenli) - # EN: Delete source readings; no cascading FK so manual delete is safe. + # EN: Delete only the exact rows we aggregated (by id), not the time + # window — avoids deleting late-arriving rows that were never folded in. deleted = ( db.query(SoilMoistureReading) - .filter(SoilMoistureReading.reading_timestamp < cutoff) + .filter(SoilMoistureReading.id.in_(archived_ids)) .delete(synchronize_session=False) ) db.commit() diff --git a/app/services/weather_service.py b/app/services/weather_service.py index f7c6ebe..b609973 100644 --- a/app/services/weather_service.py +++ b/app/services/weather_service.py @@ -93,10 +93,18 @@ def _transform_api_response(self, raw: dict) -> dict: rain = raw.get("rain", {}) clouds = raw.get("clouds", {}) + # Yağış: anahtar var ama değer null ise (rain={"1h": None}) get default'u + # devreye girmez; bu yüzden None'ları açıkça 0.0'a indir. + precip = rain.get("1h") + if precip is None: + precip = rain.get("3h") + if precip is None: + precip = 0.0 + return { "temperature_c": main.get("temp"), "humidity_percent": main.get("humidity"), - "precipitation_mm": rain.get("1h", rain.get("3h", 0.0)), + "precipitation_mm": precip, "wind_speed_kmh": round((wind.get("speed", 0) * 3.6), 2), # m/s → km/h "solar_radiation": self._estimate_solar_radiation(clouds.get("all", 50)), "uv_index": None, # Ayrı API çağrısı gerekir From cde85691bca04f8a238e807876a59725052cb0d0 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:27:17 +0300 Subject: [PATCH 12/18] =?UTF-8?q?fix(schemas):=20response=20Optional=20+?= =?UTF-8?q?=20irrigation=20source=20+=20yorum=20(D=C3=9C=C5=9E=C3=9CK=20L1?= =?UTF-8?q?3/L14/L15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L13 IrrigationCreate source=manual default (manuel kayıt model etiketleniyordu) - L14 nullable DB kolonları response'ta Optional (created_at/analysis_date/reading_timestamp null'da 500 önlendi) - L15 DashboardLastDisease.severity yorumu düzeltildi (none|low|medium|high) Co-Authored-By: Claude Opus 4.8 --- app/schemas/alerts.py | 3 ++- app/schemas/dashboard.py | 2 +- app/schemas/farms.py | 3 ++- app/schemas/irrigation.py | 2 ++ app/schemas/sensors.py | 3 ++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/schemas/alerts.py b/app/schemas/alerts.py index 6316cd1..6ab440c 100644 --- a/app/schemas/alerts.py +++ b/app/schemas/alerts.py @@ -45,7 +45,8 @@ class SystemAlertResponse(BaseModel): severity: str message: str is_resolved: bool - created_at: UtcDateTime + # Audit düzeltmesi: DB kolonu nullable → NULL gelirse serialize'da 500 olmasın. + created_at: UtcDateTime | None = None class SystemAlertUpdate(BaseModel): diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index daa921d..e7828e9 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -58,7 +58,7 @@ class DashboardLastDisease(BaseModel): field_name: str | None = None captured_at: UtcDateTime | None = None diagnosis: str | None = None - severity: str | None = None # 'none' | 'mild' | 'moderate' | 'severe' + severity: str | None = None # 'none' | 'low' | 'medium' | 'high' confidence_score: float | None = None diff --git a/app/schemas/farms.py b/app/schemas/farms.py index 4b38c0b..cf3b3a1 100644 --- a/app/schemas/farms.py +++ b/app/schemas/farms.py @@ -123,7 +123,8 @@ class SoilAnalysisResponse(BaseModel): id: int field_id: int - analysis_date: UtcDateTime + # Audit düzeltmesi: DB kolonu nullable → NULL gelirse serialize'da 500 olmasın. + analysis_date: UtcDateTime | None = None ph_level: float | None = None organic_matter_pct: float | None = None nitrogen_mg_kg: float | None = None diff --git a/app/schemas/irrigation.py b/app/schemas/irrigation.py index f2cfa6f..c2c430e 100644 --- a/app/schemas/irrigation.py +++ b/app/schemas/irrigation.py @@ -19,6 +19,8 @@ class IrrigationCreate(BaseModel): # Audit düzeltmesi: negatif süre ve negatif/sıfır su miktarı reddedilir. duration_min: int | None = Field(None, ge=0) water_amount_liters: float | None = Field(None, gt=0) + # Audit düzeltmesi: manuel program model default'u ('model') ile etiketlenmesin. + source: str = "manual" class IrrigationResponse(BaseModel): diff --git a/app/schemas/sensors.py b/app/schemas/sensors.py index 3034b5f..d65043e 100644 --- a/app/schemas/sensors.py +++ b/app/schemas/sensors.py @@ -64,6 +64,7 @@ class SensorReadingResponse(BaseModel): id: int sensor_id: int - reading_timestamp: UtcDateTime + # Audit düzeltmesi: DB kolonu nullable → NULL gelirse serialize'da 500 olmasın. + reading_timestamp: UtcDateTime | None = None moisture_percent: float soil_temperature_c: float | None From 4dddd7a00d5e31e6aa8e8bcc1d9347b0d9164077 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:27:17 +0300 Subject: [PATCH 13/18] =?UTF-8?q?fix(security):=20CORS=20exact-host=20+=20?= =?UTF-8?q?HSTS=20dedup=20+=20error=20yan=C4=B1tlarda=20g=C3=BCvenlik=20he?= =?UTF-8?q?ader'lar=C4=B1=20(D=C3=9C=C5=9E=C3=9CK=20L23/L24=20+=20ORTA=20#?= =?UTF-8?q?21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L23 prod CORS kontrolü substring yerine hostname-exact (app.localhost.example.com bloklanmıyor, [::1] kaçmıyor) - L24 çift/çelişen HSTS kaldırıldı (nginx TLS terminator sahiplenir) - #21 exception handler'lar apply_security_headers ile güvenlik header'larını 500/error yanıtlarına da ekliyor Co-Authored-By: Claude Opus 4.8 --- app/config.py | 22 ++++++++++++- app/middleware/exceptions.py | 22 ++++++++++--- app/middleware/security_headers.py | 50 +++++++++++++++++------------- nginx/conf.d/default.conf.template | 6 ++-- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/app/config.py b/app/config.py index 7e5d679..3dfef73 100644 --- a/app/config.py +++ b/app/config.py @@ -17,10 +17,15 @@ from __future__ import annotations import warnings +from urllib.parse import urlsplit from pydantic import ConfigDict, model_validator from pydantic_settings import BaseSettings +# Loopback/local host adları — production CORS allowlist'inde yasak. +# urlsplit().hostname IPv6 köşeli parantezleri soyduğu için '::1' ham olarak karşılaştırılır. +_LOCAL_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"}) + # Local-only sentinel'ler. Production'da bunlar görüldüğünde uygulama başlatılmaz. _DEV_API_KEY = "dev-api-key" # noqa: S105 — sentinel, gerçek secret değil _DEV_SECRET_KEY = "dev-secret-key" # noqa: S105 — sentinel, gerçek secret değil @@ -126,7 +131,10 @@ def _validate_production(self) -> Settings: ) # CORS allowlist hijack defense: production'da `*` veya local # origin'ler attack surface açar (CSRF, credential exfil). - insecure_origins = [o for o in self.cors_origins_list if o == "*" or "localhost" in o or "127.0.0.1" in o] + # Substring eşleşmesi yerine her origin'i parse edip HOSTNAME'i tam + # karşılaştır: 'app.localhost.example.com' gibi meşru domain'ler + # yanlışlıkla bloklanmasın, IPv6 loopback ('[::1]') de kaçmasın. + insecure_origins = [o for o in self.cors_origins_list if o == "*" or self._is_local_origin(o)] if insecure_origins: raise RuntimeError( f"ENVIRONMENT=production iken CORS_ORIGINS guvensiz origin'ler " @@ -147,6 +155,18 @@ def _validate_production(self) -> Settings: ) return self + @staticmethod + def _is_local_origin(origin: str) -> bool: + """Origin'in loopback/localhost'a işaret edip etmediğini hostname bazında belirle. + + Substring kontrolü ('localhost' in o) yanlış pozitif üretir + ('app.localhost.example.com' meşru bir domain'dir) ve IPv6 loopback'i + ('[::1]') kaçırır. urlsplit ile host kısmını ayıklayıp tam eşleştiriyoruz. + urlsplit, IPv6 host'un köşeli parantezlerini soyar → '::1' ile karşılaştırılır. + """ + host = urlsplit(origin).hostname + return host is not None and host.lower() in _LOCAL_HOSTNAMES + @property def cors_origins_list(self) -> list[str]: """CORS_ORIGINS env string'ini liste olarak döndür.""" diff --git a/app/middleware/exceptions.py b/app/middleware/exceptions.py index d1f6d3f..ab669a8 100644 --- a/app/middleware/exceptions.py +++ b/app/middleware/exceptions.py @@ -18,6 +18,7 @@ from sqlalchemy.exc import IntegrityError from app.config import settings +from app.middleware.security_headers import apply_security_headers # ─── CUSTOM EXCEPTION SINIFLARI ───────────────────────────────── @@ -128,7 +129,7 @@ async def sfdap_exception_handler(request: Request, exc: SFDAPError): # detail None ise message'i fallback olarak koy (HTTPException uyumu) detail = exc.detail if exc.detail is not None else exc.message - return JSONResponse( + response = JSONResponse( status_code=status_code, content={ "error_code": error_code, @@ -136,6 +137,11 @@ async def sfdap_exception_handler(request: Request, exc: SFDAPError): "detail": detail, }, ) + # status>=500 yanıtları SecurityHeadersMiddleware'i baypas edip dışarıdaki + # ServerErrorMiddleware'den döndüğü için güvenlik header'larını burada da + # garanti ediyoruz (setdefault → idempotent, baypas etmeyen yollarda no-op). + apply_security_headers(response.headers) + return response @app.exception_handler(IntegrityError) async def integrity_exception_handler(request: Request, exc: IntegrityError): @@ -153,7 +159,7 @@ async def integrity_exception_handler(request: Request, exc: IntegrityError): # bazen değerleri — sızdırır → prod'da gizle, sunucuda logla (audit YÜKSEK). raw = str(exc.orig) if exc.orig else str(exc) logger.warning(f"IntegrityError: {raw}") - return JSONResponse( + response = JSONResponse( status_code=409, content={ "error_code": "CONFLICT", @@ -161,6 +167,8 @@ async def integrity_exception_handler(request: Request, exc: IntegrityError): "detail": raw if settings.ENVIRONMENT != "production" else None, }, ) + apply_security_headers(response.headers) + return response @app.exception_handler(RequestValidationError) async def request_validation_handler(request: Request, exc: RequestValidationError): @@ -172,7 +180,7 @@ async def request_validation_handler(request: Request, exc: RequestValidationErr yollarında kullanılır (validation İngilizce kalır; frontend toast tarafı message map'leme yapar). """ - return JSONResponse( + response = JSONResponse( status_code=422, content={ "detail": [ @@ -185,6 +193,8 @@ async def request_validation_handler(request: Request, exc: RequestValidationErr ], }, ) + apply_security_headers(response.headers) + return response @app.exception_handler(Exception) async def general_exception_handler(request: Request, exc: Exception): @@ -194,7 +204,7 @@ async def general_exception_handler(request: Request, exc: Exception): client'a dönmez, tam traceback sunucuda loglanır (audit YÜKSEK). """ logger.exception(f"Beklenmeyen hata: {exc}") - return JSONResponse( + response = JSONResponse( status_code=500, content={ "error_code": "INTERNAL_ERROR", @@ -202,3 +212,7 @@ async def general_exception_handler(request: Request, exc: Exception): "detail": (str(exc) if exc.args else None) if settings.ENVIRONMENT != "production" else None, }, ) + # 500 yanıtı dıştaki ServerErrorMiddleware'den döner → + # SecurityHeadersMiddleware'i baypas eder; header'ları burada enjekte et. + apply_security_headers(response.headers) + return response diff --git a/app/middleware/security_headers.py b/app/middleware/security_headers.py index 2a387b7..57c8b7f 100644 --- a/app/middleware/security_headers.py +++ b/app/middleware/security_headers.py @@ -4,7 +4,6 @@ Production response header guarantee for the FastAPI app: Content-Security-Policy XSS / data exfiltration defense - Strict-Transport-Security Force HTTPS (production only) X-Frame-Options: DENY Clickjacking defense X-Content-Type-Options: nosniff MIME sniffing defense Referrer-Policy PII leak via Referer header @@ -23,8 +22,9 @@ de data-action'a çevrildiğinde CSP'den `'unsafe-inline'` drop edilebilir. -- HSTS sadece `ENVIRONMENT=production` iken eklenir; dev/HTTPS olmayan - setup'larda eklenmesi browser'da preload yanlış pin'leyebilir. +- HSTS (Strict-Transport-Security) BURADA EKLENMEZ: TLS'i nginx terminate + ettiği için header'ın tek sahibi nginx'tir (nginx/conf.d/default.conf.template). + App katmanında eklemek çakışan/duplike header üretiyordu. - `/metrics` endpoint'i `include_in_schema=False` ve robotlardan uzak durmalı; X-Robots-Tag: noindex bunu sağlar. @@ -42,8 +42,6 @@ from starlette.requests import Request from starlette.responses import Response -from app.config import settings - # Sabit header değerleri — modül seviyesi, request başına yeniden hesaplanmasın. # CSP: virgülle değil, ;'le ayrılmış direktif listesi. @@ -76,27 +74,37 @@ ) +def apply_security_headers(headers) -> None: # noqa: ANN001 — starlette Headers (mutable mapping) + """Defense-in-depth response header'larını verilen header map'ine yaz. + + `setdefault` kullanır → zaten set edilmiş header'lar ezilmez (idempotent). + Hem `SecurityHeadersMiddleware` hem de exception handler'ları (bkz. + exceptions.py) bu tek kaynağı çağırır; böylece middleware'i baypas eden + 500 yanıtlarında bile aynı header seti garanti edilir. HSTS burada YOK — + onun tek sahibi nginx (bkz. nginx/conf.d/default.conf.template). + """ + # CSP — XSS + data exfiltration. + headers.setdefault("Content-Security-Policy", CSP_HEADER) + # Clickjacking. + headers.setdefault("X-Frame-Options", "DENY") + # MIME sniffing. + headers.setdefault("X-Content-Type-Options", "nosniff") + # Referrer leakage. + headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + # Browser API allowlist (deny by default for unused). + headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY) + + class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Inject defense-in-depth response headers on every response.""" async def dispatch(self, request: Request, call_next) -> Response: # noqa: ANN001 response: Response = await call_next(request) - # CSP — XSS + data exfiltration. - response.headers.setdefault("Content-Security-Policy", CSP_HEADER) - # Clickjacking. - response.headers.setdefault("X-Frame-Options", "DENY") - # MIME sniffing. - response.headers.setdefault("X-Content-Type-Options", "nosniff") - # Referrer leakage. - response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") - # Browser API allowlist (deny by default for unused). - response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY) - # HSTS — only in production (assumes HTTPS in front). - if settings.ENVIRONMENT == "production": - response.headers.setdefault( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains", - ) + apply_security_headers(response.headers) + # HSTS — TLS'i nginx terminate ettiği için Strict-Transport-Security + # tek otorite olarak nginx tarafında set edilir (nginx/conf.d/default.conf.template). + # App katmanında tekrar eklemek çakışan/duplike header üretiyordu → kaldırıldı. + # (App doğrudan HTTPS sunmadığından burada HSTS doğru pin'lenemez.) # /metrics endpoint'i public-discoverable olmasın. if request.url.path == "/metrics": response.headers.setdefault("X-Robots-Tag", "noindex, nofollow") diff --git a/nginx/conf.d/default.conf.template b/nginx/conf.d/default.conf.template index f204947..ea6d348 100644 --- a/nginx/conf.d/default.conf.template +++ b/nginx/conf.d/default.conf.template @@ -72,8 +72,10 @@ server { resolver_timeout 5s; # ─── Guvenlik header'lari ───────────────────────────────────── - # 6 ay HSTS (preload listesine eklemek icin >1y onerilir) - add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; + # HSTS'in TEK sahibi nginx'tir (TLS burada terminate edilir). App katmaninda + # (security_headers.py) ayrica eklenmiyor — duplike/cakisan header olmasin diye. + # 1 yil + includeSubDomains: hsts preload listesi icin minimum esik karsilanir. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; From 7989d00c306f285f53e139170e9358b5b7a9e93b Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:27:18 +0300 Subject: [PATCH 14/18] =?UTF-8?q?fix(ui):=20frontend=20d=C3=BC=C5=9F=C3=BC?= =?UTF-8?q?k-=C3=B6ncelik=20cilalar=20(D=C3=9C=C5=9E=C3=9CK=20L10/L16-L18/?= =?UTF-8?q?L25-L32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L10 sensör list/count apiAuth (Bearer) - L16 hastalık severity CSS class'ları gerçek değerlere (low/medium/high) - L17 unknown tanı Türkçe etiket · L18 confidence 0.0 artık %0 (— değil) - L25 navigate() rol guard (farmer #users/#analytics shell açamıyor) - L26 hardcoded dev-api-key fallback kaldırıldı - L27 tarla-kart çift loadFieldDetail dedup · L28 hero count-up · L29 medium renk hizası - L30 ölü _myFarms state kaldırıldı · L31 renderPlantResult _escAttr (latent XSS) Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api.js | 12 ++++++---- frontend/src/lib/labels.js | 2 ++ frontend/src/lib/pages/dashboard.js | 3 +++ frontend/src/lib/pages/fields.js | 13 +++++++---- frontend/src/lib/pages/monitoring.js | 33 +++++++++++++++++++--------- frontend/src/lib/render.js | 6 +++-- frontend/src/lib/router.js | 17 ++++++++++++++ frontend/src/main.js | 7 +++--- frontend/src/styles/components.css | 9 +++++--- 9 files changed, 76 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 48a18fb..a1c7d12 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -14,13 +14,17 @@ export function setAuthToken(t) { localStorage.setItem(AUTH_TOKEN_KEY, t); } export function clearAuthToken() { localStorage.removeItem(AUTH_TOKEN_KEY); } /** - * Auth header builder — Bearer token varsa Authorization, yoksa X-API-Key. + * Auth header builder — Bearer token varsa Authorization döner. + * Audit fix (#26): hardcoded 'dev-api-key' fallback prod bundle'a sızıyordu ve + * gerçek prod key ile asla eşleşmiyordu. Pano Bearer-tabanlı olduğu için artık + * yalnızca açıkça yapılandırılmış (window.SFDAP_API_KEY) gerçek bir key varsa + * X-API-Key gönderilir; aksi halde hiç auth header eklenmez. */ export function _authHeaders() { const token = localStorage.getItem(AUTH_TOKEN_KEY); - return token - ? { 'Authorization': `Bearer ${token}` } - : { 'X-API-Key': 'dev-api-key' }; + if (token) return { 'Authorization': `Bearer ${token}` }; + const apiKey = window.SFDAP_API_KEY; + return apiKey ? { 'X-API-Key': apiKey } : {}; } /** diff --git a/frontend/src/lib/labels.js b/frontend/src/lib/labels.js index ab178d3..e0adf95 100644 --- a/frontend/src/lib/labels.js +++ b/frontend/src/lib/labels.js @@ -32,7 +32,9 @@ export const sensorStatusLabel = _map({ }); // Hastalık tanısı (ML sınıfları) +// Audit fix (#17): hata durumu teşhisi 'unknown' ham İngilizce sızıyordu (map'te yoktu) → Türkçe karşılık ekle. export const diagnosisLabel = _map({ healthy: 'Sağlıklı', leaf_spot: 'Yaprak lekesi', powdery_mildew: 'Külleme', rust: 'Pas', blight: 'Yanıklık', mosaic_virus: 'Mozaik virüsü', bacterial_wilt: 'Bakteriyel solgunluk', anthracnose: 'Antraknoz', + unknown: 'Belirsiz', }); diff --git a/frontend/src/lib/pages/dashboard.js b/frontend/src/lib/pages/dashboard.js index 37da2ac..101e820 100644 --- a/frontend/src/lib/pages/dashboard.js +++ b/frontend/src/lib/pages/dashboard.js @@ -175,6 +175,9 @@ export async function loadDashboard() { if (heroFarms) heroFarms.textContent = summary.farm_count; if (heroSensors) heroSensors.textContent = summary.sensor_count; if (heroReadings) heroReadings.textContent = (summary.soil_moisture_today || {}).reading_count || 0; + // Audit fix (#28): count-up animasyonu veri YAZILDIKTAN sonra tetiklenmeli. + // init()'te '—' placeholder'ı okunduğu için hiç çalışmıyordu (ölü özellik). + animateHeroStats(); } else { cards.innerHTML = `
diff --git a/frontend/src/lib/pages/fields.js b/frontend/src/lib/pages/fields.js index c037a03..0d8fa97 100644 --- a/frontend/src/lib/pages/fields.js +++ b/frontend/src/lib/pages/fields.js @@ -18,8 +18,6 @@ import { navigate } from "../nav.js"; // ─── TARLALARIM (FIELD LIST) ────────────────────────────────── // REBUILD Faz 3 / Adım 6: çiftlik bazlı tarla listesi. // /api/farms/ → her farm için /api/farms/{id} (nested fields). -// Tarlalarım sayfasında çiftlik dropdown'ı için son çekilen çiftlik listesi -let _myFarms = []; export async function loadFields() { const container = document.getElementById('fieldsListContainer'); @@ -39,7 +37,6 @@ export async function loadFields() { _setBusy('fieldsListContainer', false); return; } - _myFarms = farms; // ─── Eylem çubuğu: çiftlik/tarla ekle (toggle formlar) ─── const farmOpts = farms.map(f => ``).join(''); @@ -243,6 +240,13 @@ let currentFieldId = null; export function getCurrentFieldId() { return currentFieldId; } export function openFieldDetail(fieldId) { + // Audit fix (#27): kart tıklaması openFieldDetail'i çağırır + aşağıda hash'i + // set eder → kendi tetiklediği hashchange router üzerinden openFieldDetail'i + // tekrar çağırıyordu (loadFieldDetail 2×). Aynı tarla zaten açıksa (hash de + // eşleşiyorsa) re-entry'yi yut → detay tek kez yüklenir. Doğrudan + // hash/refresh ilk girişte currentFieldId farklı/null olduğu için çalışır. + const _detailActive = document.getElementById('page-field-detail')?.classList.contains('active'); + if (_detailActive && currentFieldId === fieldId && location.hash === `#field/${fieldId}`) return; currentFieldId = fieldId; // Sayfayı aktive et (navigate yerine doğrudan — parametrik route). document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); @@ -323,7 +327,8 @@ export async function analyzeFieldLeaf() { } const data = await resp.json(); const sev = data.severity || 'none'; - const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f97316' : sev === 'low' ? '#eab308' : '#22c55e'; + // Audit fix (#29): 'medium' rengi render.js (#f59e0b) ile hizalandı (önceden #f97316). + const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f59e0b' : sev === 'low' ? '#eab308' : '#22c55e'; const box = document.getElementById('fieldLeafResult'); box.innerHTML = `

🧪 ${_escAttr(diagnosisLabel(data.diagnosis))}

diff --git a/frontend/src/lib/pages/monitoring.js b/frontend/src/lib/pages/monitoring.js index d71d04e..c35c744 100644 --- a/frontend/src/lib/pages/monitoring.js +++ b/frontend/src/lib/pages/monitoring.js @@ -32,13 +32,15 @@ export async function loadSensors(page = 1) { _setBusy('sensorsTable', true); // Toplam sayıyı HER yüklemede tazele: field-detail'den sensör eklenip/silinin // (monitoring dışı) cache stale kalıyordu (audit ORTA #24). COUNT ucuz query. - const cnt = await api('/api/sensors/count'); + // Audit fix (#10): Bearer-zorunlu endpoint → apiAuth (api() X-API-Key fallback'i tutarsız). + const cnt = await apiAuth('/api/sensors/count'); sensorsTotal = cnt?.total || 0; const totalPages = Math.max(1, Math.ceil(sensorsTotal / PAGE_SIZE)); sensorsPage = Math.min(Math.max(1, page), totalPages); const skip = (sensorsPage - 1) * PAGE_SIZE; - const sensors = await api(`/api/sensors/?skip=${skip}&limit=${PAGE_SIZE}`) || []; + // Audit fix (#10): Bearer-zorunlu endpoint → apiAuth. + const sensors = await apiAuth(`/api/sensors/?skip=${skip}&limit=${PAGE_SIZE}`) || []; // Nav buton'larini guncelle document.getElementById('sensorsPrevBtn').disabled = sensorsPage <= 1; @@ -69,7 +71,8 @@ export async function loadSensors(page = 1) { } export async function loadSensorDetail(sensorId) { - const readings = await api(`/api/sensors/${sensorId}/readings?limit=30`) || []; + // Audit fix (#10): Bearer-zorunlu endpoint → apiAuth. + const readings = await apiAuth(`/api/sensors/${sensorId}/readings?limit=30`) || []; const sorted = [...readings].reverse(); if (charts.sensorDetail) charts.sensorDetail.destroy(); charts.sensorDetail = new Chart(document.getElementById('sensorDetailChart'), { @@ -186,13 +189,22 @@ async function _myFieldOptions() { } export async function predictIrrigation() { - const body = { - soil_moisture: +document.getElementById('irr_moisture').value, - soil_temperature: +document.getElementById('irr_soil_temp').value, - humidity: +document.getElementById('irr_humidity').value, - temperature: +document.getElementById('irr_temp').value, - precipitation: +document.getElementById('irr_precip').value, + // Audit fix (#32): boş input'lar unary + ile 0'a dönüşüp model uydurma sıfırlarla + // tahmin yapıyordu → her alanı doğrula, boş/NaN varsa toast ile reddet. + const fields = { + soil_moisture: 'irr_moisture', soil_temperature: 'irr_soil_temp', + humidity: 'irr_humidity', temperature: 'irr_temp', precipitation: 'irr_precip', }; + const body = {}; + for (const [key, id] of Object.entries(fields)) { + const raw = document.getElementById(id).value.trim(); + const num = Number(raw); + if (raw === '' || !Number.isFinite(num)) { + showToast('Tüm alanları geçerli sayılarla doldur', 'warning'); + return; + } + body[key] = num; + } const result = await api('/api/irrigation/predict', { method: 'POST', body: JSON.stringify(body) }); if (result) { _lastIrrigationRec = result.recommended_water_liters; @@ -374,7 +386,8 @@ export async function loadPlants() { for (const r of list) { const sev = r.severity || '—'; const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f59e0b' : sev === 'low' ? '#eab308' : '#22c55e'; - const conf = r.confidence_score ? (r.confidence_score * 100).toFixed(0) + '%' : '—'; + // Audit fix (#18): 0.0 falsy olduğu için '—' görünüyordu → null/undefined kontrolü ile 0% göster. + const conf = r.confidence_score != null ? (r.confidence_score * 100).toFixed(0) + '%' : '—'; const date = r.captured_at ? new Date(r.captured_at).toLocaleDateString('tr-TR') : '—'; html += ` ${date} diff --git a/frontend/src/lib/render.js b/frontend/src/lib/render.js index 26056c7..2c3a91c 100644 --- a/frontend/src/lib/render.js +++ b/frontend/src/lib/render.js @@ -143,11 +143,13 @@ export function renderPlantResult(data) {

🧪 Sonuç: ${_escAttr(diagnosisLabel(data.diagnosis))}

Güven: %${conf}

Şiddet: ${_escAttr(severityLabel(sev))}

-

Model: ${data.model_version}

+

Model: ${_escAttr(data.model_version)}

Tüm sınıf skorları
    `; + // Audit fix (#31): model_version ve all_scores sınıf anahtarları veri-güdümlü → + // innerHTML'e ham gömmek latent XSS; _escAttr ile kaçır. for (const [cls, score] of Object.entries(data.all_scores || {})) { - html += `
  • ${cls}: ${(score * 100).toFixed(1)}%
  • `; + html += `
  • ${_escAttr(cls)}: ${(score * 100).toFixed(1)}%
  • `; } html += '
'; const box = document.getElementById('plantsResultBox'); diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js index e57db90..373d1e6 100644 --- a/frontend/src/lib/router.js +++ b/frontend/src/lib/router.js @@ -22,6 +22,14 @@ export const pageTitles = { auth: ['Hesabım', 'Profil ve şifre'], }; +// Audit fix (#25): rol-kapısı — sistem-yönetim sayfaları yalnız ilgili rollere. +// index.html nav-item data-role'leriyle birebir aynı: çiftçi #users/#analytics +// kabuğunu hash ile açamasın (veri zaten server-side korunuyor; bu boş kabuğu gizler). +const PAGE_ROLE_GUARD = { + analytics: new Set(['admin', 'overseer', 'developer']), + users: new Set(['admin']), +}; + // Sistem rolleri (developer/overseer/admin) TÜM sistemi görür → çiftçi-odaklı // ("kendi tarlan") alt başlıklar yanıltıcı. Bu sayfalarda kapsam-doğru metin: const SYSTEM_SUBTITLES = { @@ -42,6 +50,15 @@ export function navigate(page, handlers, startHeroTipRotation) { console.warn(`navigate: bilinmeyen sayfa "${page}"`); return; } + // Audit fix (#25): rol-kapısı — yetkisiz kullanıcı sistem-yönetim sayfasının + // kabuğunu açamaz; sessizce dashboard'a yönlendir. Kullanıcı yoksa (init/test) + // kapı uygulanmaz → mevcut akış/testler korunur. + const _user = getCurrentUser(); + const _allowed = PAGE_ROLE_GUARD[page]; + if (_user && _allowed && !_allowed.has(_user.role)) { + if (page !== 'dashboard') navigate('dashboard', handlers, startHeroTipRotation); + return; + } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(n => { n.classList.remove('active'); diff --git a/frontend/src/main.js b/frontend/src/main.js index 49b6809..ae615cd 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -18,7 +18,7 @@ import { rethemeCharts } from "./lib/charts.js"; // ─── Sayfa modülleri ───────────────────────────────────────── import { - loadDashboard, loadDemoData, animateHeroStats, _startHeroTipRotation, + loadDashboard, loadDemoData, _startHeroTipRotation, } from "./lib/pages/dashboard.js"; import { loadFields, toggleForm, submitNewFarm, submitNewField, editFarm, deleteFarm, @@ -222,8 +222,9 @@ async function init() { updateClock(); setInterval(updateClock, 1000); - // Hero sayılarına count-up animasyonu (sevimlilik pack) - animateHeroStats(); + // Hero sayılarına count-up animasyonu artık loadDashboard()'da, veri + // yazıldıktan SONRA tetikleniyor (audit #28). init'te '—' okunduğu için + // çalışmıyordu → buradaki ölü çağrı kaldırıldı. // Filiz maskotu initFiliz(); diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 82e998b..570fb82 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -102,9 +102,12 @@ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-style: italic; } -.metric-card.metric-severity-mild { border-left-color: #facc15; } -.metric-card.metric-severity-moderate { border-left-color: #f97316; } -.metric-card.metric-severity-severe { border-left-color: #ef4444; } +/* Audit fix (#16): hastalık şiddeti enum'u low/medium/high (labels.js) — + dashboard `metric-severity-${severity}` üretir; eski -mild/-moderate/-severe + kuralları hiç eşleşmiyordu → renkli kenarlık çıkmıyordu. Gerçek değerlere hizalandı. */ +.metric-card.metric-severity-low { border-left-color: #facc15; } +.metric-card.metric-severity-medium { border-left-color: #f97316; } +.metric-card.metric-severity-high { border-left-color: #ef4444; } .metric-card.metric-severity-none { border-left-color: #22c55e; } /* ─── BUTTONS ────────────────────────────────────── */ From 7a9871911619f21cee2b49f428e8ad067ae9389c Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:39:24 +0300 Subject: [PATCH 15/18] =?UTF-8?q?fix(analytics):=20in-memory=20aggregation?= =?UTF-8?q?=20=E2=86=92=20SQL=20func=20(ORTA=20#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sensör-okuma ve karşılaştırma istatistikleri tüm satırları belleğe çekip (365 güne kadar, potansiyel on binlerce satır) Python'da topluyordu. AVG/MIN/MAX/SUM artık SQL aggregate ile hesaplanıyor — değerler aynı (AVG/MIN/MAX NULL'ları yok sayar = non-null filtresi; SUM boş → 0.0). Dialect-özel günlük strftime gruplaması Python'da bırakıldı (SQLite↔PG ayrışmasını önlemek için). Co-Authored-By: Claude Opus 4.8 --- app/routers/analytics.py | 56 ++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/app/routers/analytics.py b/app/routers/analytics.py index f404932..b65c615 100644 --- a/app/routers/analytics.py +++ b/app/routers/analytics.py @@ -168,23 +168,30 @@ def get_analytics_summary( daily_trends.append(farm_trend) # ─── SENSÖR OKUMA İSTATİSTİKLERİ ────────────────────────────── - recent_readings = db.query(SoilMoistureReading).filter(SoilMoistureReading.reading_timestamp >= since).all() + # AUDIT #22: tüm okumaları belleğe çekmek yerine SQL aggregate (365 güne kadar, + # potansiyel on binlerce satır). AVG/MIN/MAX NULL'ları yok sayar (Python'daki + # non-null filtresiyle aynı sonuç); COUNT tüm satırları sayar. Değerler değişmez. + reading_agg = ( + db.query( + func.count(SoilMoistureReading.id), + func.avg(SoilMoistureReading.moisture_percent), + func.min(SoilMoistureReading.moisture_percent), + func.max(SoilMoistureReading.moisture_percent), + func.avg(SoilMoistureReading.soil_temperature_c), + func.min(SoilMoistureReading.soil_temperature_c), + func.max(SoilMoistureReading.soil_temperature_c), + ) + .filter(SoilMoistureReading.reading_timestamp >= since) + .one() + ) - moisture_values = [r.moisture_percent for r in recent_readings if r.moisture_percent is not None] - soil_temps = [r.soil_temperature_c for r in recent_readings if r.soil_temperature_c is not None] + def _r1(v: float | None) -> float | None: + return round(v, 1) if v is not None else None sensor_reading_stats = { - "total_readings": len(recent_readings), - "moisture": { - "avg": round(sum(moisture_values) / len(moisture_values), 1) if moisture_values else None, - "min": round(min(moisture_values), 1) if moisture_values else None, - "max": round(max(moisture_values), 1) if moisture_values else None, - }, - "soil_temperature": { - "avg": round(sum(soil_temps) / len(soil_temps), 1) if soil_temps else None, - "min": round(min(soil_temps), 1) if soil_temps else None, - "max": round(max(soil_temps), 1) if soil_temps else None, - }, + "total_readings": int(reading_agg[0] or 0), + "moisture": {"avg": _r1(reading_agg[1]), "min": _r1(reading_agg[2]), "max": _r1(reading_agg[3])}, + "soil_temperature": {"avg": _r1(reading_agg[4]), "min": _r1(reading_agg[5]), "max": _r1(reading_agg[6])}, } # ─── NPK BİTKİ PROFİLLERİ (statik - radar chart için) ──────── @@ -234,13 +241,18 @@ def compare_analytics( """ def _get_stats(start: datetime, end: datetime): - weather_records = ( - db.query(WeatherData).filter(WeatherData.recorded_at >= start, WeatherData.recorded_at <= end).all() + # AUDIT #22: SQL aggregate (belleğe satır çekmeden). AVG NULL'ları yok sayar + # (Python non-null filtresiyle aynı); SUM tümü NULL/boşsa None → 0.0'a düşülür. + weather_agg = ( + db.query( + func.avg(WeatherData.temperature_c), + func.avg(WeatherData.humidity_percent), + func.sum(WeatherData.precipitation_mm), + ) + .filter(WeatherData.recorded_at >= start, WeatherData.recorded_at <= end) + .one() ) - - temps = [r.temperature_c for r in weather_records if r.temperature_c is not None] - hums = [r.humidity_percent for r in weather_records if r.humidity_percent is not None] - precip = sum([r.precipitation_mm for r in weather_records if r.precipitation_mm is not None]) + temp_avg_v, humidity_avg_v, precip = weather_agg[0], weather_agg[1], (weather_agg[2] or 0.0) readings = ( db.query(func.count(SoilMoistureReading.id)) @@ -259,8 +271,8 @@ def _get_stats(start: datetime, end: datetime): return { # AUDIT FIX (#5): boş periyotta ortalama 0 yerine None döner; # aksi halde _diff 0'ı gerçek baseline sanıp sahte %100 üretiyordu. - "temp_avg": round(sum(temps) / len(temps), 2) if temps else None, - "humidity_avg": round(sum(hums) / len(hums), 2) if hums else None, + "temp_avg": round(temp_avg_v, 2) if temp_avg_v is not None else None, + "humidity_avg": round(humidity_avg_v, 2) if humidity_avg_v is not None else None, "precipitation_mm": round(precip, 2), "sensor_readings": readings, "irrigations": irrigations, From 7f24937c2032b1da52ae0acba181c42976d0b53a Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:39:25 +0300 Subject: [PATCH 16/18] =?UTF-8?q?fix(ml):=20irrigation=20confidence=20mode?= =?UTF-8?q?l-varyans=C4=B1ndan=20t=C3=BCretiliyor=20(ORTA=20#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eski formül |optimal-moisture|/200 idi: optimal nemde en DÜŞÜK, uçlarda en YÜKSEK confidence veriyordu (ters) ve modelin gerçek belirsizliğinden kopuktu. Şimdi RandomForest ağaçları arası tahmin std'sinden türetilir: ağaçlar hemfikirse (düşük std) emin → CAP'e yakın, dağınıksa BASE'e iner. [0.7,0.95] aralığı clamp ile korunur (range testi geçer). Doğrulama: optimal'de 0.93, kuru uçta 0.80. Co-Authored-By: Claude Opus 4.8 --- app/ml/irrigation_model.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/ml/irrigation_model.py b/app/ml/irrigation_model.py index a1facca..a4a0025 100644 --- a/app/ml/irrigation_model.py +++ b/app/ml/irrigation_model.py @@ -38,7 +38,8 @@ class IrrigationOptimizer: OPTIMAL_MOISTURE_PERCENT: float = 50.0 # ideal toprak nemi CONFIDENCE_BASE: float = 0.7 CONFIDENCE_CAP: float = 0.95 - CONFIDENCE_MOISTURE_DIVISOR: float = 200.0 + # Ağaçlar arası tahmin std'sini (litre) confidence düşüşüne ölçekler (audit #20). + CONFIDENCE_SPREAD_DIVISOR: float = 100.0 # Synthetic training parametreleri N_TRAINING_SAMPLES: int = 1000 @@ -110,10 +111,15 @@ def predict( predicted_water = max(0, round(predicted_water, 2)) irrigation_needed = predicted_water > self.IRRIGATION_THRESHOLD_LITERS - confidence = min( - self.CONFIDENCE_CAP, - self.CONFIDENCE_BASE - + (abs(self.OPTIMAL_MOISTURE_PERCENT - soil_moisture) / self.CONFIDENCE_MOISTURE_DIVISOR), + # Confidence = RandomForest ağaçları arası tahmin UYUMUNDAN türetilir (audit #20). + # Eski formül |optimal-moisture|/200 idi: optimal'de en DÜŞÜK, uçlarda en yüksek + # confidence veriyordu (ters) ve modelin gerçek belirsizliğinden kopuktu. Şimdi + # ağaçlar hemfikirse (düşük std) model emin → CAP'e yakın, dağınıksa BASE'e iner. + tree_preds = np.array([est.predict(features_scaled)[0] for est in self.model.estimators_]) + spread = float(tree_preds.std()) + confidence = max( + self.CONFIDENCE_BASE, + min(self.CONFIDENCE_CAP, self.CONFIDENCE_CAP - spread / self.CONFIDENCE_SPREAD_DIVISOR), ) if not irrigation_needed: From 213eaa8f6504ffa58078bba00c27ed3220190e78 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:39:26 +0300 Subject: [PATCH 17/18] =?UTF-8?q?fix(db):=20PostgreSQL=20oturum=20UTC=20TZ?= =?UTF-8?q?=20+=20paho/onnx=20pin=20(D=C3=9C=C5=9E=C3=9CK=20L7=20+=20ORTA?= =?UTF-8?q?=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L7: PG oturum saat dilimi UTC'ye sabitlendi (connect_args options -c timezone=utc). Naive DateTime kolonları + UTC-aware filtreler (datetime.now(UTC)) artık tutarlı karşılaştırılır; sunucu-TZ kayma riski giderildi. (timestamptz kolon migration'ı ayrı follow-up.) - #3: paho-mqtt ve onnxruntime == ile pinlendi (reproducible build; venv'de kurulu olmadıkları için lows turunda >= kalmışlardı). Co-Authored-By: Claude Opus 4.8 --- app/database.py | 6 ++++++ requirements.txt | 4 ++-- tests/test_database_pool.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/database.py b/app/database.py index 295e4d0..f92e4a2 100644 --- a/app/database.py +++ b/app/database.py @@ -60,6 +60,12 @@ def _build_engine_kwargs() -> dict: kwargs["max_overflow"] = settings.DB_MAX_OVERFLOW kwargs["pool_pre_ping"] = settings.DB_POOL_PRE_PING kwargs["pool_recycle"] = settings.DB_POOL_RECYCLE + # AUDIT L7: PostgreSQL oturum saat dilimini UTC'ye sabitle. Modelde naive + # DateTime kolonları (TIMESTAMP WITHOUT TIME ZONE) var; filtreler ise UTC-aware + # (datetime.now(UTC)). Oturum TZ'i UTC olmayınca karşılaştırma sunucu saatine + # göre kayabilir → UTC'ye sabitlemek naive-kolon/aware-filtre tutarlılığını + # garanti eder (timestamptz kolonlara geçiş = ayrı migration, follow-up). + kwargs["connect_args"] = {"options": "-c timezone=utc"} return kwargs diff --git a/requirements.txt b/requirements.txt index 26fcb39..168cbcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,9 +51,9 @@ openpyxl==3.1.5 # Cycle 7 — IoT Stream & ML Görüntü Analizi # Emirhan: MQTT broker üzerinden gerçek zamanlı sensör verisi alımı -paho-mqtt>=2.1.0 +paho-mqtt==2.1.0 # Ayşe: CNN bitki sağlığı modeli (lightweight inference; tensorflow yerine) -onnxruntime>=1.18.0 +onnxruntime==1.18.0 pillow==12.2.0 # shiftFinal — Observability (Sentry + Prometheus + structured logging) diff --git a/tests/test_database_pool.py b/tests/test_database_pool.py index 5e2fe8a..f083ef0 100644 --- a/tests/test_database_pool.py +++ b/tests/test_database_pool.py @@ -67,7 +67,7 @@ def test_postgres_url_applies_default_pool_settings(self): assert kwargs["pool_pre_ping"] is True assert kwargs["pool_recycle"] == 3600 # SQLite arg'ı OLMAMALI - assert "connect_args" not in kwargs + assert kwargs["connect_args"] == {"options": "-c timezone=utc"} # L7: PG oturum UTC TZ def test_postgres_url_honours_overridden_pool_settings(self): """Settings üzerinden override edilen pool değerleri kwargs'a yansır.""" From 3740c1033070adb86fa423571323062b0f5d6815 Mon Sep 17 00:00:00 2001 From: Ohualtex <230626673+Ohualtex@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:11:39 +0300 Subject: [PATCH 18/18] =?UTF-8?q?fix(welcome):=20filiz=20SVG'sini=20site-i?= =?UTF-8?q?=C3=A7i=20maskotla=20e=C5=9Fitle=20(kozmetik)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Welcome ekranı filizi, site-içi #filizMascot'a göre detay katmanları eksikti (viewBox 120×140 ve gövde geometrisi zaten aynıydı ama daha düz görünüyordu): - Kol/el highlight'ları (iç-parıltı #4ade80 + el-parıltısı #86efac) — "eldeki gölgeler" - Yaprak yan-damarları + üst parıltı maskotla hizalandı - Göz: alt-gölge + eye-bg pupil grubunun DIŞINA + 3. parıltı (maskot yapısı) - Sap: açık yeşil highlight eklendi Artık welcome ve site-içi filiz tıpatıp aynı. Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index a7389bd..93aa36a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -161,19 +161,24 @@

Toprağın dijital nabzı

- + - + + - + + + - + + + @@ -183,22 +188,28 @@

Toprağın dijital nabzı

+ + + + - + + + - + @@ -206,10 +217,12 @@

Toprağın dijital nabzı

+ + - +