diff --git a/backend/admin_api.py b/backend/admin_api.py index 120ecc8..fd5d9f0 100644 --- a/backend/admin_api.py +++ b/backend/admin_api.py @@ -3,7 +3,7 @@ import time from fastapi import APIRouter, Depends from server import verify_admin -from users import get_all_users, delete_user_data, reset_user_password, set_require_approval, get_require_approval, approve_user_db, get_ai_config, set_ai_config +from users import get_all_users, delete_user_data, reset_user_password, set_require_approval, get_require_approval, approve_user_db, get_ai_config, set_ai_config, get_default_location, set_default_location from messages import cleanup_old_messages from chat import connected_clients # 👈 SOURCE OF TRUTH @@ -103,6 +103,20 @@ def admin_cleanup_files(days: int, admin=Depends(verify_admin)): "message": f"Deleted {deleted_count} files. Freed {freed_mb} MB space." } + + +# ================= WEATHER LOCATION SETTINGS ================= +@router.get("/weather_location") +def admin_get_weather_location(admin=Depends(verify_admin)): + lat, lon = get_default_location() + return {"success": True, "default_lat": lat, "default_lon": lon} + + +@router.post("/weather_location") +def admin_set_weather_location(lat: float, lon: float, admin=Depends(verify_admin)): + set_default_location(lat, lon) + return {"success": True, "message": "Weather location updated", "default_lat": lat, "default_lon": lon} + # ================= AI SETTINGS ================= @router.get("/ai_settings") def admin_get_ai_settings(admin=Depends(verify_admin)): diff --git a/backend/requirements.txt b/backend/requirements.txt index 69c31d9..41d1201 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,4 +9,6 @@ pytesseract requests PyPDF2 chromadb -psutil \ No newline at end of file +psutil +apscheduler +httpx diff --git a/backend/server.py b/backend/server.py index 23f6480..3449de4 100644 --- a/backend/server.py +++ b/backend/server.py @@ -3,7 +3,9 @@ # lan_server/server.py import os import secrets +from contextlib import asynccontextmanager from fastapi import FastAPI +from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -12,13 +14,13 @@ from jose import jwt from datetime import datetime, timedelta, timezone import chat, files, calls, messages +from tasks import run_proactive_weather_sentinel import profiles # 👈 1. Import profiles module from users import init_db, register_user, verify_user, get_admin_key_db, set_admin_key_db from messages import init_msg_db from users import get_all_users from messages import get_recent_messages from users import delete_user_data # 👈 Import the new function -from fastapi.staticfiles import StaticFiles # ================= JWT CONFIG ================= SECRET_KEY = os.getenv("JWT_SECRET_KEY", "CHANGE_THIS_TO_SOMETHING_RANDOM_AND_LONG") @@ -69,8 +71,29 @@ def verify_admin(x_admin_key: str = Header(None)): print(f"🚫 Admin Access Denied: Key mismatch. Received length: {len(clean_key)} (Expected: {len(clean_secret)}), Masked input: {masked_key}") print("💡 TIP: Copy the EXACT random secret from the server logs above.") raise HTTPException(status_code=403, detail="Admin access denied") +# ================= BACKGROUND TASKS & SCHEDULER ================= +@asynccontextmanager +async def lifespan(app: FastAPI): + scheduler = AsyncIOScheduler(timezone="Asia/Kolkata") + scheduler.add_job( + run_proactive_weather_sentinel, + trigger="cron", + hour=7, + minute=0, + id="proactive_weather_sentinel", + replace_existing=True, + ) + scheduler.start() + print("🌅 Weather Sentinel scheduler started (Runs at 07:00 AM IST).") + try: + yield + finally: + scheduler.shutdown(wait=False) + print("🌅 Weather Sentinel scheduler stopped.") + + # ================= FASTAPI APP ================= -app = FastAPI(title="LAN Chat Server (modular)") +app = FastAPI(title="LAN Chat Server (modular)", lifespan=lifespan) # CORS: allow origins (disabled credentials for security) app.add_middleware( diff --git a/backend/tasks.py b/backend/tasks.py new file mode 100644 index 0000000..f084a78 --- /dev/null +++ b/backend/tasks.py @@ -0,0 +1,107 @@ +import asyncio +import json +import logging +from datetime import datetime, timedelta, timezone + +import httpx + +from chat import connected_clients, send_to_user +from lumir.ai_engine import ask_ai +from messages import create_delivery_entries, save_message +from users import get_ai_config, get_all_users, get_default_location + +LOGGER = logging.getLogger(__name__) + +IST = timezone(timedelta(hours=5, minutes=30)) +DEFAULT_LAT = 26.2183 +DEFAULT_LON = 78.1828 + + + +def _get_default_coordinates() -> tuple[float, float]: + try: + return get_default_location() + except Exception: + LOGGER.debug("Weather sentinel could not read configured coordinates.", exc_info=True) + return DEFAULT_LAT, DEFAULT_LON + + +async def _broadcast_family_warning(message_text: str) -> None: + timestamp = int(datetime.now(IST).timestamp() * 1000) + payload = { + "type": "text", + "text": message_text, + "sender": "Lumir", + "receiver": "Family Group", + "timestamp": timestamp, + } + + msg_id = save_message( + text=message_text, + sender="Lumir", + receiver="Family Group", + msg_type="text", + ) + + recipients = [u["username"] for u in get_all_users() if u.get("username") != "Lumir"] + if recipients: + create_delivery_entries(msg_id, recipients) + + payload_json = json.dumps(payload) + for username in list(connected_clients.keys()): + await send_to_user(username, payload_json) + + +async def run_proactive_weather_sentinel() -> None: + try: + lat, lon = _get_default_coordinates() + location = f"{lat:.4f}, {lon:.4f}" + + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": lat, + "longitude": lon, + "daily": "precipitation_sum,temperature_2m_max", + "timezone": "Asia/Kolkata", + "forecast_days": 1, + }, + ) + response.raise_for_status() + forecast = response.json().get("daily", {}) + + precipitation_values = forecast.get("precipitation_sum") or [] + max_temp_values = forecast.get("temperature_2m_max") or [] + + precipitation_sum = float(precipitation_values[0]) if precipitation_values else 0.0 + temperature_2m_max = float(max_temp_values[0]) if max_temp_values else 0.0 + + alert_reason = None + if precipitation_sum > 2.0: + alert_reason = f"{precipitation_sum:.1f}mm rain" + elif temperature_2m_max > 40.0: + alert_reason = f"{temperature_2m_max:.1f}°C heat" + + if not alert_reason: + return + + hidden_prompt = ( + "System Context: Aaj {location} mein {alert_reason} hone wali hai. " + "Act like a caring family member and write a warning message for the Family Group. " + "Keep it under 15 words. Do NOT use markdown. Say good morning." + ).format(location=location, alert_reason=alert_reason) + + ai_config = get_ai_config() + generated_message = await asyncio.to_thread( + ask_ai, + prompt=hidden_prompt, + config=ai_config, + history=[], + sender="Family Group", + ) + + await _broadcast_family_warning((generated_message or "Good morning. Please stay safe today.").strip()) + + except Exception: + LOGGER.debug("Weather sentinel failed during scheduled execution.", exc_info=True) diff --git a/backend/users.py b/backend/users.py index c4535f9..aa47817 100644 --- a/backend/users.py +++ b/backend/users.py @@ -44,6 +44,49 @@ def init_db(): # --- Config Helpers --- + + +# --- Weather Location Config Helpers --- +def get_default_location(): + """ + Fetch weather default location from config table. + Falls back to Maheshpura coordinates when missing/invalid. + """ + lat = 26.2183 + lon = 78.1828 + + conn = sqlite3.connect(DATABASE_NAME) + cursor = conn.cursor() + cursor.execute("SELECT key, value FROM config WHERE key IN ('default_lat', 'default_lon')") + rows = cursor.fetchall() + conn.close() + + for key, value in rows: + try: + if key == "default_lat": + lat = float(value) + elif key == "default_lon": + lon = float(value) + except (TypeError, ValueError): + continue + + return lat, lon + + +def set_default_location(lat: float, lon: float): + conn = sqlite3.connect(DATABASE_NAME) + cursor = conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES ('default_lat', ?)", + (str(lat),), + ) + cursor.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES ('default_lon', ?)", + (str(lon),), + ) + conn.commit() + conn.close() + def set_require_approval(enabled: bool): conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() diff --git a/intra_admin/admin.py b/intra_admin/admin.py index 462651f..db1065d 100644 --- a/intra_admin/admin.py +++ b/intra_admin/admin.py @@ -488,8 +488,36 @@ def create_settings_page(self): card_layout.addWidget(self.settings_status) card_layout.addWidget(save_btn) + # Weather Location Card + weather_card = QFrame() + weather_card.setStyleSheet("background-color: #181825; border: 1px solid #313244; border-radius: 12px;") + weather_layout = QFormLayout(weather_card) + weather_layout.setContentsMargins(30, 30, 30, 30) + weather_layout.setSpacing(15) + + self.weather_lat_input = QLineEdit() + self.weather_lat_input.setPlaceholderText("e.g. 26.2183") + self.weather_lon_input = QLineEdit() + self.weather_lon_input.setPlaceholderText("e.g. 78.1828") + + self.weather_status = QLabel("") + self.weather_status.setStyleSheet("font-weight: bold; font-size: 14px;") + + weather_save_btn = QPushButton("💾 SAVE WEATHER LOCATION") + weather_save_btn.setObjectName("SaveBtn") + weather_save_btn.setMinimumHeight(50) + weather_save_btn.setCursor(Qt.PointingHandCursor) + weather_save_btn.clicked.connect(self.save_weather_location) + + weather_layout.addRow("Default Latitude:", self.weather_lat_input) + weather_layout.addRow("Default Longitude:", self.weather_lon_input) + weather_layout.addRow(self.weather_status) + weather_layout.addRow(weather_save_btn) + layout.addWidget(lbl) layout.addWidget(card) + layout.addSpacing(15) + layout.addWidget(weather_card) layout.addStretch() return page @@ -502,6 +530,7 @@ def load_settings(self): self.chk_approval.blockSignals(True) self.chk_approval.setChecked(bool(data["require_approval"])) self.chk_approval.blockSignals(False) + self.load_weather_location() except Exception as e: print("load_settings error:", e) @@ -525,6 +554,52 @@ def save_settings(self): self.settings_status.setText(f"❌ Error: {str(e)}") self.settings_status.setStyleSheet("color: #f38ba8;") + def load_weather_location(self): + try: + self.weather_status.setText("") + res = requests.get(f"{self.server_url}/admin/weather_location", headers=self.headers, timeout=3) + data = res.json() + if data.get("success"): + self.weather_lat_input.setText(str(data.get("default_lat", ""))) + self.weather_lon_input.setText(str(data.get("default_lon", ""))) + except Exception as e: + self.weather_status.setText(f"❌ Load failed: {str(e)}") + self.weather_status.setStyleSheet("color: #f38ba8;") + + def save_weather_location(self): + try: + lat = float(self.weather_lat_input.text().strip()) + lon = float(self.weather_lon_input.text().strip()) + except ValueError: + QMessageBox.warning(self, "Invalid Input", "Latitude/Longitude must be valid numbers.") + return + + if not (-90 <= lat <= 90): + QMessageBox.warning(self, "Invalid Latitude", "Latitude must be between -90 and 90.") + return + + if not (-180 <= lon <= 180): + QMessageBox.warning(self, "Invalid Longitude", "Longitude must be between -180 and 180.") + return + + try: + self.weather_status.setText("Saving weather location...") + self.weather_status.setStyleSheet("color: #89b4fa;") + res = requests.post( + f"{self.server_url}/admin/weather_location", + headers=self.headers, + params={"lat": lat, "lon": lon}, + timeout=3, + ) + if res.status_code == 200: + self.weather_status.setText("✅ Weather location saved successfully!") + self.weather_status.setStyleSheet("color: #a6e3a1;") + else: + raise Exception(f"Server error: {res.status_code}") + except Exception as e: + self.weather_status.setText(f"❌ Error: {str(e)}") + self.weather_status.setStyleSheet("color: #f38ba8;") + # ================= PAGE: AI SETTINGS ================= def create_ai_page(self):