Spaces:
Running
Running
| import os | |
| import json | |
| from datetime import datetime, timedelta | |
| from fastapi import FastAPI, HTTPException, Request, BackgroundTasks | |
| from fastapi.responses import FileResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from geopy.geocoders import Nominatim | |
| import requests | |
| import pandas as pd | |
| from io import BytesIO | |
| import uuid | |
| import logging | |
| import math | |
| import base64 | |
| import hashlib | |
| import hmac | |
| from typing import Optional, Dict, Any | |
| from pydantic import BaseModel | |
| import aiohttp | |
| import asyncio | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| app = FastAPI() | |
| # Enable CORS for dashboard | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ============ CONFIGURATION ============ | |
| ADMIN_TELEGRAM_ID = os.getenv("ADMIN_TELEGRAM_ID", "") | |
| HF_TOKEN = os.getenv("HF_TOKEN", "") | |
| ADMIN_KEY = os.getenv("ADMIN_KEY", "your-secret-key-here") | |
| # ToyyibPay Configuration | |
| TOYYIBPAY_SECRET_KEY = os.getenv("TOYYIBPAY_SECRET_KEY", "") | |
| TOYYIBPAY_CATEGORY = os.getenv("TOYYIBPAY_CATEGORY", "") | |
| TOYYIBPAY_API_URL = "https://toyyibpay.com/api" | |
| # Telegram Bot Token | |
| TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") | |
| # Subscription amount in cents (RM3.00 = 300 cents) | |
| SUBSCRIPTION_AMOUNT = int(os.getenv("SUBSCRIPTION_AMOUNT", "300")) | |
| GRACE_PERIOD_DAYS = int(os.getenv("GRACE_PERIOD_DAYS", "7")) | |
| # File-based storage | |
| USERS_FILE = "mileage_users.csv" | |
| TRIPS_FILE = "mileage_trips.csv" | |
| REQUESTS_FILE = "mileage_requests.csv" | |
| PAYMENTS_FILE = "mileage_payments.csv" | |
| # Hugging Face Dataset names | |
| HF_USERS_DATASET = "mileage_tracker_yukee1992_users" | |
| HF_TRIPS_DATASET = "mileage_tracker_yukee1992_trips" | |
| HF_PAYMENTS_DATASET = "mileage_tracker_yukee1992_payments" | |
| # ============ INITIALIZATION ============ | |
| geolocator = Nominatim(user_agent="mileage_tracker", timeout=15) | |
| user_sessions = {} | |
| pending_actions = {} | |
| # ============ PYDANTIC MODELS ============ | |
| class UserRegister(BaseModel): | |
| telegram_id: str | |
| username: str | |
| full_name: str | |
| email: Optional[str] = None | |
| chat_id: Optional[int] = None | |
| class TripStart(BaseModel): | |
| user_id: str | |
| place: str | |
| chat_id: Optional[int] = None | |
| class TripEnd(BaseModel): | |
| user_id: str | |
| place: str | |
| chat_id: Optional[int] = None | |
| class ReportRequest(BaseModel): | |
| user_id: str | |
| month: str | |
| year: str | |
| request_reason: Optional[str] = None | |
| chat_id: Optional[int] = None | |
| class ApprovalAction(BaseModel): | |
| request_id: str | |
| action: str | |
| admin_id: str | |
| # ============ HELPER FUNCTIONS ============ | |
| def get_current_month(): | |
| return datetime.now().strftime("%Y-%m") | |
| def get_current_month_display(): | |
| return datetime.now().strftime("%B %Y") | |
| def get_last_day_of_month(date=None): | |
| if date is None: | |
| date = datetime.now() | |
| next_month = date.replace(day=28) + timedelta(days=4) | |
| return next_month - timedelta(days=next_month.day) | |
| def is_admin(telegram_id): | |
| """Check if user is admin (either by Telegram ID or role)""" | |
| if str(telegram_id) == str(ADMIN_TELEGRAM_ID): | |
| return True | |
| user = get_user(telegram_id) | |
| if user: | |
| role = user.get('role', 'user') | |
| return role == 'admin' | |
| return False | |
| def is_subscriber(telegram_id): | |
| """Check if user has an active subscription for the current month""" | |
| if is_admin(telegram_id): | |
| return True | |
| user = get_user(telegram_id) | |
| if not user: | |
| return False | |
| role = user.get('role', 'user') | |
| if role == 'subscriber': | |
| current_month = get_current_month() | |
| paid_months_raw = user.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| return current_month in paid_months | |
| return False | |
| def safe_parse_paid_months(paid_months_raw): | |
| """Safely parse paid_months from various formats to a list""" | |
| if paid_months_raw is None: | |
| return [] | |
| if pd.isna(paid_months_raw): | |
| return [] | |
| if isinstance(paid_months_raw, list): | |
| return paid_months_raw | |
| if isinstance(paid_months_raw, str): | |
| if paid_months_raw == '': | |
| return [] | |
| try: | |
| return json.loads(paid_months_raw) | |
| except: | |
| return [] | |
| if isinstance(paid_months_raw, (int, float)): | |
| return [] | |
| return [] | |
| def has_paid_for_month(telegram_id, month_str): | |
| """Check if user paid for a specific month""" | |
| user = get_user(telegram_id) | |
| if not user: | |
| return False | |
| if is_admin(telegram_id): | |
| return True | |
| paid_months_raw = user.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if month_str in paid_months: | |
| role = user.get('role', 'user') | |
| if role != 'admin' and role != 'subscriber': | |
| try: | |
| df = load_users() | |
| telegram_id = str(telegram_id) | |
| df['telegram_id'] = df['telegram_id'].astype(str) | |
| idx = df[df['telegram_id'] == telegram_id].index[0] | |
| df.at[idx, 'role'] = 'subscriber' | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| logger.info(f"Fixed role for user {telegram_id} to 'subscriber'") | |
| except Exception as e: | |
| logger.error(f"Failed to fix role: {e}") | |
| return True | |
| return False | |
| def is_registered(telegram_id): | |
| """Check if user is registered with name and email""" | |
| user = get_user(telegram_id) | |
| if not user: | |
| return False | |
| full_name = user.get('full_name', '') | |
| email = user.get('email', '') | |
| logger.info(f"Checking registration for {telegram_id}: name='{full_name}', email='{email}'") | |
| # Check if user has provided name and email | |
| # Allow any name that's not empty, and any email that contains @ | |
| has_valid_name = full_name and full_name.strip() and full_name != 'User' and full_name != 'nan' | |
| has_valid_email = email and '@' in email and email.strip() and email != 'nan' | |
| logger.info(f"Registration check: has_valid_name={has_valid_name}, has_valid_email={has_valid_email}") | |
| return has_valid_name and has_valid_email | |
| def add_paid_month(telegram_id, month_str): | |
| """Add a month to user's paid months with proper type handling""" | |
| try: | |
| df = load_users() | |
| if df.empty: | |
| logger.error("No users found in database") | |
| return False | |
| telegram_id = str(telegram_id) | |
| df['telegram_id'] = df['telegram_id'].astype(str) | |
| if telegram_id not in df['telegram_id'].values: | |
| logger.error(f"User {telegram_id} not found") | |
| return False | |
| idx = df[df['telegram_id'] == telegram_id].index[0] | |
| if 'paid_months' not in df.columns: | |
| df['paid_months'] = '[]' | |
| else: | |
| df['paid_months'] = df['paid_months'].astype(str) | |
| if 'last_paid_month' not in df.columns: | |
| df['last_paid_month'] = '' | |
| else: | |
| df['last_paid_month'] = df['last_paid_month'].astype(str) | |
| if 'last_payment_date' not in df.columns: | |
| df['last_payment_date'] = '' | |
| else: | |
| df['last_payment_date'] = df['last_payment_date'].astype(str) | |
| if 'role' not in df.columns: | |
| df['role'] = 'user' | |
| else: | |
| df['role'] = df['role'].astype(str) | |
| paid_months_raw = df.at[idx, 'paid_months'] | |
| logger.info(f"User {telegram_id} existing paid_months: {paid_months_raw}") | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if month_str not in paid_months: | |
| paid_months.append(month_str) | |
| df.at[idx, 'paid_months'] = json.dumps(paid_months) | |
| df.at[idx, 'last_paid_month'] = str(month_str) | |
| df.at[idx, 'last_payment_date'] = str(datetime.now().isoformat()) | |
| logger.info(f"Updated paid_months for user {telegram_id}: {paid_months}") | |
| current_role = df.at[idx, 'role'] | |
| if current_role != 'admin': | |
| df.at[idx, 'role'] = 'subscriber' | |
| logger.info(f"Set role for user {telegram_id} to 'subscriber'") | |
| else: | |
| logger.info(f"User {telegram_id} already paid for {month_str}") | |
| return True | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"Saved users file with updated paid_months") | |
| sync_users_to_hf() | |
| sync_payments_to_hf() | |
| verify_user = get_user(telegram_id) | |
| if verify_user: | |
| logger.info(f"Verified user {telegram_id} paid_months: {verify_user.get('paid_months')}") | |
| logger.info(f"Verified user {telegram_id} role: {verify_user.get('role')}") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Error in add_paid_month: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return False | |
| def get_user_subscription_status(telegram_id): | |
| """Get detailed subscription status for a user""" | |
| user = get_user(telegram_id) | |
| if not user: | |
| return { | |
| 'status': 'never_subscribed', | |
| 'paid_months': [], | |
| 'last_paid_month': None, | |
| 'is_active': False, | |
| 'is_admin': is_admin(telegram_id), | |
| 'role': 'user' | |
| } | |
| paid_months_raw = user.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| current_month = get_current_month() | |
| is_active = has_paid_for_month(telegram_id, current_month) or is_admin(telegram_id) | |
| role = user.get('role', 'user') | |
| return { | |
| 'status': 'active' if is_active else 'expired', | |
| 'paid_months': paid_months, | |
| 'last_paid_month': user.get('last_paid_month'), | |
| 'is_active': is_active, | |
| 'is_admin': is_admin(telegram_id), | |
| 'role': role | |
| } | |
| # ============ ENHANCED: Get user current month stats ============ | |
| def get_user_current_month_stats_enhanced(user_id: str) -> dict: | |
| """Get current month stats for a user based on actual trip dates""" | |
| df = load_trips() | |
| if df.empty: | |
| return {"total_trips": 0, "total_distance": 0.0} | |
| df_user = df[df['user_id'].astype(str) == str(user_id)] | |
| if df_user.empty: | |
| return {"total_trips": 0, "total_distance": 0.0} | |
| now = datetime.now() | |
| current_month = now.month | |
| current_year = now.year | |
| df_user = df_user.copy() | |
| def parse_date(date_str): | |
| if pd.isna(date_str) or date_str == '': | |
| return None | |
| try: | |
| return datetime.strptime(str(date_str), "%d/%m/%Y") | |
| except: | |
| try: | |
| return datetime.strptime(str(date_str), "%Y-%m-%d") | |
| except: | |
| return None | |
| df_user['trip_date'] = df_user['date'].apply(parse_date) | |
| df_user = df_user[df_user['trip_date'].notna()] | |
| df_current_month = df_user[ | |
| (df_user['trip_date'].dt.month == current_month) & | |
| (df_user['trip_date'].dt.year == current_year) | |
| ] | |
| total_distance = df_current_month['distance_km'].sum() | |
| return { | |
| "total_trips": len(df_current_month), | |
| "total_distance": round(total_distance, 2) | |
| } | |
| # ============ PAYMENT HISTORY FUNCTIONS ============ | |
| def load_payments(): | |
| if os.path.exists(PAYMENTS_FILE): | |
| df = pd.read_csv(PAYMENTS_FILE) | |
| if not df.empty: | |
| for col in df.columns: | |
| if col in ['user_id', 'month', 'year', 'payment_id', 'user_name', 'transaction_id', 'status']: | |
| df[col] = df[col].astype(str) | |
| elif col == 'amount': | |
| df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0) | |
| logger.info(f"Loaded {len(df)} payment records") | |
| return df | |
| return pd.DataFrame(columns=['payment_id', 'user_id', 'user_name', 'month', 'year', | |
| 'amount', 'payment_date', 'transaction_id', 'status']) | |
| def save_payment(payment_data): | |
| """Save payment with proper data type handling""" | |
| df = load_payments() | |
| payment_data['user_id'] = str(payment_data['user_id']) | |
| payment_data['month'] = str(payment_data['month']) | |
| payment_data['year'] = str(payment_data['year']) | |
| payment_data['payment_id'] = str(payment_data['payment_id']) | |
| payment_data['user_name'] = str(payment_data['user_name']) | |
| payment_data['amount'] = float(payment_data['amount']) | |
| payment_data['transaction_id'] = str(payment_data['transaction_id']) | |
| payment_data['status'] = str(payment_data['status']) | |
| df_new = pd.DataFrame([payment_data]) | |
| df_combined = pd.concat([df, df_new], ignore_index=True) | |
| for col in df_combined.columns: | |
| if col in ['user_id', 'month', 'year', 'payment_id', 'user_name', 'transaction_id', 'status']: | |
| df_combined[col] = df_combined[col].astype(str) | |
| elif col == 'amount': | |
| df_combined[col] = pd.to_numeric(df_combined[col], errors='coerce').fillna(0) | |
| df_combined.to_csv(PAYMENTS_FILE, index=False) | |
| logger.info(f"✅ Saved payment: {payment_data['payment_id']}") | |
| sync_payments_to_hf() | |
| return len(df_combined) | |
| def sync_payments_to_hf(): | |
| if not HF_TOKEN: | |
| return False | |
| try: | |
| from datasets import Dataset | |
| if not os.path.exists(PAYMENTS_FILE): | |
| return False | |
| df = pd.read_csv(PAYMENTS_FILE) | |
| if df.empty: | |
| return False | |
| for col in df.columns: | |
| if col in ['user_id', 'month', 'year', 'payment_id', 'user_name', 'transaction_id', 'status']: | |
| df[col] = df[col].astype(str) | |
| elif col == 'amount': | |
| df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0) | |
| df = df.fillna("") | |
| hf_dataset = Dataset.from_pandas(df) | |
| hf_dataset.push_to_hub(HF_PAYMENTS_DATASET, token=HF_TOKEN, private=False, split="train") | |
| logger.info(f"✅ Synced {len(df)} payments to HF Dataset") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to sync payments: {e}") | |
| return False | |
| # ============ HUGGING FACE DATASET SYNC & RESTORE ============ | |
| def load_from_hf_dataset(dataset_name): | |
| """Load data from Hugging Face Dataset using datasets-server API""" | |
| if not HF_TOKEN: | |
| logger.warning("HF_TOKEN not set, skipping dataset load") | |
| return None | |
| full_dataset_name = f"yukee1992/{dataset_name}" | |
| logger.info(f"Attempting to load: {full_dataset_name}") | |
| if dataset_name == HF_USERS_DATASET: | |
| try: | |
| import requests | |
| response = requests.get( | |
| f"https://datasets-server.huggingface.co/rows?dataset={full_dataset_name}&config=default&split=train&offset=0&length=1000" | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| rows = data.get('rows', []) | |
| if rows: | |
| df = pd.DataFrame([row['row'] for row in rows]) | |
| logger.info(f"✅ Loaded {len(df)} rows from {full_dataset_name} via datasets-server") | |
| if 'role' not in df.columns: | |
| df['role'] = 'user' | |
| logger.info("Added missing 'role' column with default 'user'") | |
| return df | |
| except Exception as e: | |
| logger.warning(f"datasets-server for users failed: {e}") | |
| try: | |
| import requests | |
| response = requests.get( | |
| f"https://datasets-server.huggingface.co/rows?dataset={full_dataset_name}&split=train&offset=0&length=1000" | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| rows = data.get('rows', []) | |
| if rows: | |
| df = pd.DataFrame([row['row'] for row in rows]) | |
| logger.info(f"✅ Loaded {len(df)} rows from {full_dataset_name} via datasets-server") | |
| return df | |
| except Exception as e: | |
| logger.warning(f"datasets-server failed: {e}") | |
| try: | |
| from datasets import load_dataset | |
| dataset = load_dataset( | |
| full_dataset_name, | |
| split="train", | |
| token=HF_TOKEN, | |
| trust_remote_code=True, | |
| ignore_verifications=True | |
| ) | |
| df = dataset.to_pandas() | |
| if not df.empty: | |
| logger.info(f"✅ Loaded {len(df)} rows from {full_dataset_name}") | |
| return df | |
| except Exception as e: | |
| logger.error(f"Could not load from {dataset_name}: {e}") | |
| return None | |
| def sync_users_to_hf(): | |
| """Sync users to HF Dataset with detailed logging""" | |
| if not HF_TOKEN: | |
| logger.error("❌ HF_TOKEN is not set in environment variables") | |
| return False | |
| try: | |
| from datasets import Dataset | |
| if not os.path.exists(USERS_FILE): | |
| logger.info("No users file found, skipping sync") | |
| return False | |
| df = pd.read_csv(USERS_FILE) | |
| if df.empty: | |
| logger.info("Users file is empty, skipping sync") | |
| return False | |
| logger.info(f"🔄 Syncing {len(df)} users to HF Dataset...") | |
| logger.info(f"📊 Sample users: {df[['telegram_id', 'role']].head(3).to_dict(orient='records')}") | |
| # Ensure all columns are proper types - CONVERT ALL TO STRING FIRST | |
| for col in df.columns: | |
| # Convert all columns to string to avoid type issues | |
| df[col] = df[col].astype(str) | |
| # Special handling for boolean-like columns | |
| bool_columns = ['is_active', 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent'] | |
| for col in bool_columns: | |
| if col in df.columns: | |
| # Convert to string representation of boolean | |
| df[col] = df[col].apply(lambda x: str(x).lower() == 'true' if pd.notna(x) else 'false') | |
| df[col] = df[col].astype(str) | |
| # Ensure role column exists and is string | |
| if 'role' not in df.columns: | |
| df['role'] = 'user' | |
| df['role'] = df['role'].astype(str) | |
| # Fill any NaN values | |
| df = df.fillna("") | |
| # Log the data types for debugging | |
| logger.info(f"📋 Column types: {df.dtypes.to_dict()}") | |
| # Create dataset and push | |
| hf_dataset = Dataset.from_pandas(df) | |
| hf_dataset.push_to_hub( | |
| HF_USERS_DATASET, | |
| token=HF_TOKEN, | |
| private=False, | |
| split="train" | |
| ) | |
| logger.info(f"✅ Successfully synced {len(df)} users to HF Dataset") | |
| return True | |
| except Exception as e: | |
| logger.error(f"❌ Failed to sync users: {e}") | |
| import traceback | |
| logger.error(traceback.format_exc()) | |
| return False | |
| def sync_trips_to_hf(): | |
| if not HF_TOKEN: | |
| return False | |
| try: | |
| from datasets import Dataset | |
| if not os.path.exists(TRIPS_FILE): | |
| logger.info("No trips file found, skipping sync") | |
| return False | |
| df = pd.read_csv(TRIPS_FILE) | |
| if df.empty: | |
| logger.info("Trips file is empty, skipping sync") | |
| return False | |
| required_cols = ['trip_id', 'user_id', 'user_name', 'place_name', 'timestamp', 'date', 'month', | |
| 'start_place', 'start_gps_lat', 'start_gps_lon', | |
| 'end_place', 'end_gps_lat', 'end_gps_lon', | |
| 'distance_km', 'status', 'purpose'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| df[col] = '' | |
| logger.info(f"Added missing column '{col}' to trips data") | |
| df = df.fillna("") | |
| df = df.replace([float('inf'), float('-inf')], 0) | |
| hf_dataset = Dataset.from_pandas(df) | |
| hf_dataset.push_to_hub( | |
| HF_TRIPS_DATASET, | |
| token=HF_TOKEN, | |
| private=False, | |
| split="train" | |
| ) | |
| logger.info(f"✅ Synced {len(df)} trips to HF Dataset") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to sync trips: {e}") | |
| return False | |
| def restore_data_from_hf(): | |
| """Restore data from HF datasets on startup - NEVER delete existing data""" | |
| logger.info("Attempting to restore data from Hugging Face datasets...") | |
| # Restore Users | |
| logger.info("Restoring users data...") | |
| users_restored = False | |
| try: | |
| logger.info("Downloading users parquet file directly from HF...") | |
| import requests | |
| import tempfile | |
| hf_token = HF_TOKEN | |
| headers = {"Authorization": f"Bearer {hf_token}"} if hf_token else {} | |
| api_url = "https://huggingface.co/api/datasets/yukee1992/mileage_tracker_yukee1992_users" | |
| response = requests.get(api_url, headers=headers) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if 'siblings' in data: | |
| for sibling in data['siblings']: | |
| if sibling.get('rfilename', '').endswith('.parquet'): | |
| file_url = f"https://huggingface.co/datasets/yukee1992/mileage_tracker_yukee1992_users/resolve/main/{sibling['rfilename']}" | |
| logger.info(f"Downloading users: {file_url}") | |
| response = requests.get(file_url, headers=headers) | |
| if response.status_code == 200: | |
| with tempfile.NamedTemporaryFile(suffix='.parquet', delete=False) as tmp_file: | |
| tmp_file.write(response.content) | |
| tmp_path = tmp_file.name | |
| df = pd.read_parquet(tmp_path) | |
| os.unlink(tmp_path) | |
| if not df.empty: | |
| logger.info(f"✅ Loaded {len(df)} users from HF parquet file") | |
| required_cols = ['telegram_id', 'username', 'full_name', 'email', 'registered_date', 'is_active', | |
| 'paid_months', 'last_paid_month', 'last_payment_date', | |
| 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent', 'role'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| if col == 'role': | |
| df[col] = 'user' | |
| logger.info(f"Added missing column '{col}' with default 'user'") | |
| elif col in ['is_active', 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent']: | |
| df[col] = False | |
| else: | |
| df[col] = '' | |
| logger.info(f"Added missing column '{col}' to restored users data") | |
| df['telegram_id'] = df['telegram_id'].astype(str) | |
| if 'role' in df.columns: | |
| df['role'] = df['role'].astype(str) | |
| if os.path.exists(USERS_FILE): | |
| local_df = pd.read_csv(USERS_FILE) | |
| if not local_df.empty and len(local_df) > 0: | |
| logger.info(f"Local users file has {len(local_df)} users, merging...") | |
| if 'role' not in local_df.columns: | |
| local_df['role'] = 'user' | |
| combined_df = pd.concat([local_df, df], ignore_index=True) | |
| combined_df = combined_df.drop_duplicates(subset=['telegram_id'], keep='first') | |
| combined_df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Merged: {len(combined_df)} total users") | |
| else: | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} users from HF parquet") | |
| else: | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} users from HF parquet") | |
| sync_users_to_hf() | |
| users_restored = True | |
| break | |
| except Exception as e: | |
| logger.error(f"Failed to restore users via parquet download: {e}") | |
| if not users_restored: | |
| try: | |
| logger.info("Trying datasets-server for users with different parameters...") | |
| import requests | |
| response = requests.get( | |
| f"https://datasets-server.huggingface.co/rows?dataset=yukee1992/mileage_tracker_yukee1992_users&split=train&offset=0&length=1000" | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| rows = data.get('rows', []) | |
| if rows: | |
| df = pd.DataFrame([row['row'] for row in rows]) | |
| logger.info(f"✅ Loaded {len(df)} users via datasets-server") | |
| if 'role' not in df.columns: | |
| df['role'] = 'user' | |
| if os.path.exists(USERS_FILE): | |
| local_df = pd.read_csv(USERS_FILE) | |
| if not local_df.empty and len(local_df) > 0: | |
| combined_df = pd.concat([local_df, df], ignore_index=True) | |
| combined_df = combined_df.drop_duplicates(subset=['telegram_id'], keep='first') | |
| combined_df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Merged users: {len(combined_df)} total") | |
| else: | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} users") | |
| else: | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} users") | |
| sync_users_to_hf() | |
| users_restored = True | |
| except Exception as e: | |
| logger.error(f"datasets-server fallback failed: {e}") | |
| if not users_restored: | |
| logger.warning("⚠️ All methods to restore users failed. Checking local file...") | |
| if os.path.exists(USERS_FILE): | |
| local_df = pd.read_csv(USERS_FILE) | |
| if not local_df.empty: | |
| logger.info(f"Found local users file with {len(local_df)} users, keeping local data") | |
| sync_users_to_hf() | |
| else: | |
| logger.info("Local users file is empty, creating new empty file") | |
| else: | |
| logger.info("No users file found, creating new empty file") | |
| # Restore Trips | |
| logger.info("Attempting to restore trips data...") | |
| trips_restored = False | |
| try: | |
| logger.info("Method 1: Trying to download parquet file directly from HF...") | |
| import requests | |
| import tempfile | |
| hf_token = HF_TOKEN | |
| headers = {"Authorization": f"Bearer {hf_token}"} if hf_token else {} | |
| api_url = "https://huggingface.co/api/datasets/yukee1992/mileage_tracker_yukee1992_trips" | |
| response = requests.get(api_url, headers=headers) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if 'siblings' in data: | |
| for sibling in data['siblings']: | |
| if sibling.get('rfilename', '').endswith('.parquet'): | |
| file_url = f"https://huggingface.co/datasets/yukee1992/mileage_tracker_yukee1992_trips/resolve/main/{sibling['rfilename']}" | |
| logger.info(f"Downloading: {file_url}") | |
| response = requests.get(file_url, headers=headers) | |
| if response.status_code == 200: | |
| with tempfile.NamedTemporaryFile(suffix='.parquet', delete=False) as tmp_file: | |
| tmp_file.write(response.content) | |
| tmp_path = tmp_file.name | |
| df = pd.read_parquet(tmp_path) | |
| os.unlink(tmp_path) | |
| if not df.empty: | |
| logger.info(f"✅ Loaded {len(df)} trips from HF parquet file") | |
| required_cols = ['trip_id', 'user_id', 'user_name', 'place_name', 'timestamp', 'date', 'month', | |
| 'start_place', 'start_gps_lat', 'start_gps_lon', | |
| 'end_place', 'end_gps_lat', 'end_gps_lon', | |
| 'distance_km', 'status', 'purpose'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| df[col] = '' | |
| logger.info(f"Added missing column '{col}' to restored trips data") | |
| if os.path.exists(TRIPS_FILE): | |
| local_df = pd.read_csv(TRIPS_FILE) | |
| if not local_df.empty and len(local_df) > 0: | |
| logger.info(f"Local trips file has {len(local_df)} trips, merging...") | |
| combined_df = pd.concat([local_df, df], ignore_index=True) | |
| combined_df = combined_df.drop_duplicates(subset=['trip_id'], keep='first') | |
| combined_df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Merged: {len(combined_df)} total trips") | |
| else: | |
| df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} trips from HF parquet") | |
| else: | |
| df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} trips from HF parquet") | |
| sync_trips_to_hf() | |
| trips_restored = True | |
| break | |
| except Exception as e: | |
| logger.error(f"Method 1 failed: {e}") | |
| if not trips_restored: | |
| try: | |
| logger.info("Method 2: Trying to load using huggingface_hub...") | |
| from huggingface_hub import hf_hub_download | |
| try: | |
| path = hf_hub_download( | |
| repo_id="yukee1992/mileage_tracker_yukee1992_trips", | |
| filename="data/train-00000-of-00001.parquet", | |
| repo_type="dataset", | |
| token=HF_TOKEN if HF_TOKEN else None | |
| ) | |
| df = pd.read_parquet(path) | |
| if not df.empty: | |
| logger.info(f"✅ Loaded {len(df)} trips via huggingface_hub") | |
| required_cols = ['trip_id', 'user_id', 'user_name', 'place_name', 'timestamp', 'date', 'month', | |
| 'start_place', 'start_gps_lat', 'start_gps_lon', | |
| 'end_place', 'end_gps_lat', 'end_gps_lon', | |
| 'distance_km', 'status', 'purpose'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| df[col] = '' | |
| if os.path.exists(TRIPS_FILE): | |
| local_df = pd.read_csv(TRIPS_FILE) | |
| if not local_df.empty and len(local_df) > 0: | |
| combined_df = pd.concat([local_df, df], ignore_index=True) | |
| combined_df = combined_df.drop_duplicates(subset=['trip_id'], keep='first') | |
| combined_df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Merged: {len(combined_df)} total trips") | |
| else: | |
| df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} trips") | |
| else: | |
| df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} trips") | |
| sync_trips_to_hf() | |
| trips_restored = True | |
| except Exception as e: | |
| logger.error(f"Method 2 failed: {e}") | |
| except Exception as e: | |
| logger.error(f"Method 2 import failed: {e}") | |
| if not trips_restored: | |
| try: | |
| logger.info("Method 3: Trying datasets-server with split parameter...") | |
| import requests | |
| response = requests.get( | |
| f"https://datasets-server.huggingface.co/rows?dataset=yukee1992/mileage_tracker_yukee1992_trips&config=default&split=train&offset=0&length=1000" | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| rows = data.get('rows', []) | |
| if rows: | |
| df = pd.DataFrame([row['row'] for row in rows]) | |
| logger.info(f"✅ Loaded {len(df)} trips via datasets-server") | |
| required_cols = ['trip_id', 'user_id', 'user_name', 'place_name', 'timestamp', 'date', 'month', | |
| 'start_place', 'start_gps_lat', 'start_gps_lon', | |
| 'end_place', 'end_gps_lat', 'end_gps_lon', | |
| 'distance_km', 'status', 'purpose'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| df[col] = '' | |
| if os.path.exists(TRIPS_FILE): | |
| local_df = pd.read_csv(TRIPS_FILE) | |
| if not local_df.empty and len(local_df) > 0: | |
| combined_df = pd.concat([local_df, df], ignore_index=True) | |
| combined_df = combined_df.drop_duplicates(subset=['trip_id'], keep='first') | |
| combined_df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Merged: {len(combined_df)} total trips") | |
| else: | |
| df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} trips") | |
| else: | |
| df.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} trips") | |
| sync_trips_to_hf() | |
| trips_restored = True | |
| except Exception as e: | |
| logger.error(f"Method 3 failed: {e}") | |
| if not trips_restored: | |
| logger.warning("⚠️ All methods to restore trips failed. Checking local file...") | |
| if os.path.exists(TRIPS_FILE): | |
| local_df = pd.read_csv(TRIPS_FILE) | |
| if not local_df.empty: | |
| logger.info(f"Found local trips file with {len(local_df)} trips, keeping local data") | |
| sync_trips_to_hf() | |
| else: | |
| logger.info("Local trips file is empty, creating new empty file") | |
| else: | |
| logger.info("No trips file found, creating new empty file") | |
| # Restore Payments | |
| logger.info("Restoring payments data...") | |
| payments_restored = False | |
| try: | |
| import requests | |
| import tempfile | |
| hf_token = HF_TOKEN | |
| headers = {"Authorization": f"Bearer {hf_token}"} if hf_token else {} | |
| api_url = "https://huggingface.co/api/datasets/yukee1992/mileage_tracker_yukee1992_payments" | |
| response = requests.get(api_url, headers=headers) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if 'siblings' in data: | |
| for sibling in data['siblings']: | |
| if sibling.get('rfilename', '').endswith('.parquet'): | |
| file_url = f"https://huggingface.co/datasets/yukee1992/mileage_tracker_yukee1992_payments/resolve/main/{sibling['rfilename']}" | |
| logger.info(f"Downloading payments: {file_url}") | |
| response = requests.get(file_url, headers=headers) | |
| if response.status_code == 200: | |
| with tempfile.NamedTemporaryFile(suffix='.parquet', delete=False) as tmp_file: | |
| tmp_file.write(response.content) | |
| tmp_path = tmp_file.name | |
| df = pd.read_parquet(tmp_path) | |
| os.unlink(tmp_path) | |
| if not df.empty: | |
| logger.info(f"✅ Loaded {len(df)} payments from HF parquet file") | |
| if os.path.exists(PAYMENTS_FILE): | |
| local_df = pd.read_csv(PAYMENTS_FILE) | |
| if not local_df.empty and len(local_df) > 0: | |
| combined_df = pd.concat([local_df, df], ignore_index=True) | |
| combined_df = combined_df.drop_duplicates(subset=['payment_id'], keep='first') | |
| combined_df.to_csv(PAYMENTS_FILE, index=False) | |
| logger.info(f"✅ Merged: {len(combined_df)} total payments") | |
| else: | |
| df.to_csv(PAYMENTS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} payments") | |
| else: | |
| df.to_csv(PAYMENTS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(df)} payments") | |
| sync_payments_to_hf() | |
| payments_restored = True | |
| break | |
| except Exception as e: | |
| logger.error(f"Failed to restore payments via parquet download: {e}") | |
| if not payments_restored: | |
| payments_df = load_from_hf_dataset(HF_PAYMENTS_DATASET) | |
| if payments_df is not None and not payments_df.empty: | |
| if os.path.exists(PAYMENTS_FILE): | |
| local_df = pd.read_csv(PAYMENTS_FILE) | |
| if local_df.empty or len(local_df) == 0: | |
| payments_df.to_csv(PAYMENTS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(payments_df)} payments from HF Dataset") | |
| else: | |
| payments_df.to_csv(PAYMENTS_FILE, index=False) | |
| logger.info(f"✅ Restored {len(payments_df)} payments from HF Dataset") | |
| # ============ USER MANAGEMENT FUNCTIONS ============ | |
| def load_users(): | |
| if os.path.exists(USERS_FILE): | |
| try: | |
| df = pd.read_csv(USERS_FILE) | |
| logger.info(f"Loaded {len(df)} users from CSV") | |
| required_columns = ['telegram_id', 'username', 'full_name', 'email', 'registered_date', 'is_active', | |
| 'paid_months', 'last_paid_month', 'last_payment_date', | |
| 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent', | |
| 'role'] | |
| for col in required_columns: | |
| if col not in df.columns: | |
| if col in ['is_active', 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent']: | |
| df[col] = False | |
| elif col in ['paid_months', 'last_paid_month', 'last_payment_date']: | |
| df[col] = '' | |
| elif col == 'role': | |
| df[col] = 'user' | |
| else: | |
| df[col] = '' | |
| df['telegram_id'] = df['telegram_id'].astype(str) | |
| string_columns = ['paid_months', 'last_paid_month', 'last_payment_date', | |
| 'username', 'full_name', 'email', 'role'] | |
| for col in string_columns: | |
| if col in df.columns: | |
| df[col] = df[col].astype(str) | |
| # Handle boolean columns - ensure they are actual booleans | |
| bool_columns = ['is_active', 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent'] | |
| for col in bool_columns: | |
| if col in df.columns: | |
| df[col] = df[col].apply(lambda x: str(x).lower() == 'true' if pd.notna(x) else False) | |
| for idx, row in df.iterrows(): | |
| paid_months_val = row.get('paid_months') | |
| if paid_months_val is None or pd.isna(paid_months_val) or paid_months_val == '': | |
| df.at[idx, 'paid_months'] = '[]' | |
| elif isinstance(paid_months_val, float): | |
| df.at[idx, 'paid_months'] = '[]' | |
| elif isinstance(paid_months_val, str): | |
| try: | |
| json.loads(paid_months_val) | |
| except: | |
| df.at[idx, 'paid_months'] = '[]' | |
| if 'role' in df.columns: | |
| df['role'] = df['role'].fillna('user') | |
| valid_roles = ['user', 'subscriber', 'admin'] | |
| df['role'] = df['role'].apply(lambda x: x if x in valid_roles else 'user') | |
| # Log user info for debugging | |
| for idx, row in df.iterrows(): | |
| logger.info(f"User {row['telegram_id']}: name='{row.get('full_name', '')}', email='{row.get('email', '')}'") | |
| return df | |
| except Exception as e: | |
| logger.error(f"Error loading users: {e}") | |
| return pd.DataFrame(columns=['telegram_id', 'username', 'full_name', 'email', 'registered_date', 'is_active', | |
| 'paid_months', 'last_paid_month', 'last_payment_date', | |
| 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent', | |
| 'role']) | |
| df = pd.DataFrame(columns=['telegram_id', 'username', 'full_name', 'email', 'registered_date', 'is_active', | |
| 'paid_months', 'last_paid_month', 'last_payment_date', | |
| 'reminder_1st_sent', 'reminder_15th_sent', 'reminder_last_sent', | |
| 'role']) | |
| df['paid_months'] = df['paid_months'].astype(str) | |
| df['last_paid_month'] = df['last_paid_month'].astype(str) | |
| df['last_payment_date'] = df['last_payment_date'].astype(str) | |
| df['role'] = df['role'].astype(str) | |
| return df | |
| def save_user(user_data): | |
| df = load_users() | |
| df_new = pd.DataFrame([user_data]) | |
| df_combined = pd.concat([df, df_new], ignore_index=True) | |
| df_combined.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ Saved user: {user_data['full_name']}") | |
| sync_users_to_hf() | |
| return len(df_combined) | |
| def get_user(telegram_id): | |
| df = load_users() | |
| if df.empty: | |
| return None | |
| user = df[df['telegram_id'].astype(str) == str(telegram_id)] | |
| if user.empty: | |
| return None | |
| return user.iloc[0].to_dict() | |
| def user_exists(telegram_id): | |
| df = load_users() | |
| if df.empty: | |
| return False | |
| return len(df[df['telegram_id'].astype(str) == str(telegram_id)]) > 0 | |
| def is_registered(telegram_id): | |
| """Check if user is registered with name and email""" | |
| user = get_user(telegram_id) | |
| if not user: | |
| return False | |
| full_name = user.get('full_name', '') | |
| email = user.get('email', '') | |
| logger.info(f"Checking registration for {telegram_id}: name='{full_name}', email='{email}'") | |
| # Check if user has provided name and email | |
| # Allow any name that's not empty, and any email that contains @ | |
| has_valid_name = full_name and full_name.strip() and full_name != 'User' and full_name != 'nan' | |
| has_valid_email = email and '@' in email and email.strip() and email != 'nan' | |
| logger.info(f"Registration check: has_valid_name={has_valid_name}, has_valid_email={has_valid_email}") | |
| return has_valid_name and has_valid_email | |
| # ============ TRIP MANAGEMENT FUNCTIONS ============ | |
| def load_trips(): | |
| if os.path.exists(TRIPS_FILE): | |
| df = pd.read_csv(TRIPS_FILE) | |
| logger.info(f"Loaded {len(df)} trips from CSV") | |
| required_cols = ['place_name', 'purpose'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| df[col] = '' | |
| logger.info(f"Added missing column '{col}' to trips DataFrame") | |
| if 'date' in df.columns: | |
| df['date'] = df['date'].astype(str) | |
| return df | |
| return pd.DataFrame(columns=['trip_id', 'user_id', 'user_name', 'place_name', 'timestamp', 'date', 'month', | |
| 'start_place', 'start_gps_lat', 'start_gps_lon', | |
| 'end_place', 'end_gps_lat', 'end_gps_lon', | |
| 'distance_km', 'status', 'purpose']) | |
| def save_trip(trip_data): | |
| df = load_trips() | |
| required_cols = ['place_name', 'purpose'] | |
| for col in required_cols: | |
| if col not in df.columns: | |
| df[col] = '' | |
| if 'date' in trip_data and trip_data['date']: | |
| if '-' in trip_data['date'] and len(trip_data['date'].split('-')) == 3: | |
| try: | |
| date_obj = datetime.strptime(trip_data['date'], "%Y-%m-%d") | |
| trip_data['date'] = date_obj.strftime("%d/%m/%Y") | |
| except: | |
| pass | |
| df_new = pd.DataFrame([trip_data]) | |
| df_combined = pd.concat([df, df_new], ignore_index=True) | |
| df_combined.to_csv(TRIPS_FILE, index=False) | |
| logger.info(f"✅ Saved trip: {trip_data['trip_id']}") | |
| sync_trips_to_hf() | |
| return len(df_combined) | |
| def get_user_trips(user_id, month_year=None): | |
| df = load_trips() | |
| if df.empty: | |
| return pd.DataFrame() | |
| df_user = df[df['user_id'].astype(str) == str(user_id)] | |
| if df_user.empty: | |
| return pd.DataFrame() | |
| if month_year: | |
| df_user = df_user.copy() | |
| df_user['month'] = pd.to_datetime(df_user['timestamp']).dt.strftime('%B %Y') | |
| df_user = df_user[df_user['month'] == month_year] | |
| return df_user | |
| def get_user_trips_by_month(user_id, month_num, year_num): | |
| """Get trips for a specific month and year for a user""" | |
| df = load_trips() | |
| if df.empty: | |
| return pd.DataFrame() | |
| df_user = df[df['user_id'].astype(str) == str(user_id)] | |
| if df_user.empty: | |
| return pd.DataFrame() | |
| df_user = df_user.copy() | |
| def parse_date(date_str): | |
| if pd.isna(date_str) or date_str == '': | |
| return None | |
| try: | |
| return datetime.strptime(str(date_str), "%d/%m/%Y") | |
| except: | |
| try: | |
| return datetime.strptime(str(date_str), "%Y-%m-%d") | |
| except: | |
| return None | |
| df_user['trip_date'] = df_user['date'].apply(parse_date) | |
| df_user = df_user[df_user['trip_date'].notna()] | |
| df_user = df_user[ | |
| (df_user['trip_date'].dt.month == int(month_num)) & | |
| (df_user['trip_date'].dt.year == int(year_num)) | |
| ] | |
| return df_user | |
| # ============ REPORT REQUEST FUNCTIONS ============ | |
| def load_requests(): | |
| if os.path.exists(REQUESTS_FILE): | |
| return pd.read_csv(REQUESTS_FILE) | |
| return pd.DataFrame(columns=['request_id', 'user_id', 'user_name', 'month', 'year', | |
| 'status', 'request_date', 'request_reason']) | |
| def save_request(request_data): | |
| df = load_requests() | |
| df_new = pd.DataFrame([request_data]) | |
| df_combined = pd.concat([df, df_new], ignore_index=True) | |
| df_combined.to_csv(REQUESTS_FILE, index=False) | |
| return len(df_combined) | |
| # ============ ROUTE CALCULATION FUNCTIONS ============ | |
| def get_straight_line_distance(lat1, lon1, lat2, lon2): | |
| R = 6371 | |
| lat1_rad = math.radians(lat1) | |
| lat2_rad = math.radians(lat2) | |
| dlat = math.radians(lat2 - lat1) | |
| dlon = math.radians(lon2 - lon1) | |
| a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2 | |
| c = 2 * math.asin(math.sqrt(a)) | |
| distance = R * c | |
| return round(distance, 2) | |
| def get_longest_route(lat1, lon1, lat2, lon2): | |
| """Calculate distance between two points with fallback to straight-line""" | |
| if abs(lat1 - lat2) < 0.0001 and abs(lon1 - lon2) < 0.0001: | |
| return 0.0 | |
| url = f"http://router.project-osrm.org/route/v1/driving/{lon1},{lat1};{lon2},{lat2}" | |
| params = { | |
| "overview": "false", | |
| "alternatives": "true", | |
| "steps": "false" | |
| } | |
| try: | |
| logger.info(f"Calculating route from ({lat1},{lon1}) to ({lat2},{lon2})") | |
| response = requests.get(url, params=params, timeout=30) | |
| data = response.json() | |
| if data.get("code") != "Ok" or "routes" not in data or len(data["routes"]) == 0: | |
| logger.warning("OSRM failed, using straight-line distance") | |
| distance = get_straight_line_distance(lat1, lon1, lat2, lon2) | |
| if distance == 0 and (abs(lat1 - lat2) > 0.0001 or abs(lon1 - lon2) > 0.0001): | |
| distance = 0.1 | |
| return round(distance, 2) | |
| max_distance = max([route["distance"] for route in data["routes"]]) / 1000 | |
| if max_distance == 0 and (abs(lat1 - lat2) > 0.0001 or abs(lon1 - lon2) > 0.0001): | |
| max_distance = 0.1 | |
| logger.info(f"Longest route: {max_distance:.2f} km") | |
| return round(max_distance, 2) | |
| except Exception as e: | |
| logger.error(f"Route error: {e}") | |
| distance = get_straight_line_distance(lat1, lon1, lat2, lon2) | |
| if distance == 0 and (abs(lat1 - lat2) > 0.0001 or abs(lon1 - lon2) > 0.0001): | |
| distance = 0.1 | |
| return round(distance, 2) | |
| # ============ GEOCODING FUNCTION ============ | |
| def geocode(place_name): | |
| try: | |
| place_name = place_name.strip() | |
| landmarks = { | |
| "klcc": "KLCC, Kuala Lumpur, Malaysia", | |
| "petronas": "Petronas Twin Towers, Kuala Lumpur, Malaysia", | |
| "sunway": "Sunway Pyramid, Selangor, Malaysia", | |
| "subang": "Subang Jaya, Selangor, Malaysia", | |
| "pyramid": "Sunway Pyramid, Selangor, Malaysia", | |
| "klia": "Kuala Lumpur International Airport, Sepang, Malaysia" | |
| } | |
| place_lower = place_name.lower() | |
| for key, value in landmarks.items(): | |
| if key in place_lower: | |
| search_term = value | |
| break | |
| else: | |
| if not any(msia in place_name.lower() for msia in ['malaysia', 'kl', 'kuala lumpur', 'selangor']): | |
| search_term = f"{place_name}, Selangor, Malaysia" | |
| else: | |
| search_term = place_name | |
| logger.info(f"Geocoding: {search_term}") | |
| location = geolocator.geocode(search_term, timeout=10) | |
| if location: | |
| logger.info(f"Found: {location.latitude}, {location.longitude}") | |
| return location.latitude, location.longitude, location.address | |
| return None, None, None | |
| except Exception as e: | |
| logger.error(f"Geocoding error: {e}") | |
| return None, None, None | |
| # ============ EXCEL REPORT GENERATION ============ | |
| def generate_excel_report(df, user_name, month_year): | |
| output = BytesIO() | |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: | |
| export_df = df[['date', 'start_place', 'end_place', 'distance_km', | |
| 'start_gps_lat', 'start_gps_lon', 'end_gps_lat', 'end_gps_lon']].copy() | |
| export_df.columns = ['Date', 'From', 'To', 'Distance (km)', | |
| 'Start GPS Lat', 'Start GPS Lon', 'End GPS Lat', 'End GPS Lon'] | |
| export_df.to_excel(writer, sheet_name='Trip Details', index=False) | |
| worksheet = writer.sheets['Trip Details'] | |
| for column in worksheet.columns: | |
| max_length = 0 | |
| column_letter = column[0].column_letter | |
| for cell in column: | |
| try: | |
| if len(str(cell.value)) > max_length: | |
| max_length = len(str(cell.value)) | |
| except: | |
| pass | |
| adjusted_width = min(max_length + 2, 30) | |
| worksheet.column_dimensions[column_letter].width = adjusted_width | |
| summary_data = { | |
| 'Metric': ['User Name', 'Report Period', 'Total Trips', 'Total Distance (km)', | |
| 'Average Distance (km)', 'Longest Trip (km)', 'Shortest Trip (km)', | |
| 'Report Generated On'], | |
| 'Value': [ | |
| user_name, month_year, len(df), | |
| round(df['distance_km'].sum(), 2), | |
| round(df['distance_km'].mean(), 2), | |
| round(df['distance_km'].max(), 2), | |
| round(df['distance_km'].min(), 2), | |
| datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| ] | |
| } | |
| summary_df = pd.DataFrame(summary_data) | |
| summary_df.to_excel(writer, sheet_name='Summary', index=False) | |
| if len(df['date'].unique()) > 1: | |
| daily_stats = df.groupby('date').agg({ | |
| 'distance_km': ['count', 'sum', 'mean'] | |
| }).round(2) | |
| daily_stats.columns = ['Trip Count', 'Total KM', 'Average KM'] | |
| daily_stats.to_excel(writer, sheet_name='Daily Stats') | |
| output.seek(0) | |
| return output.getvalue() | |
| # ============ TOYYIBPAY FUNCTIONS ============ | |
| async def create_toyyibpay_bill(telegram_id: int, user_name: str, user_email: str, month: str) -> dict: | |
| """Create a ToyyibPay bill with proper error handling using user's email""" | |
| display_month = datetime.strptime(month, "%Y-%m").strftime("%B %Y") | |
| if not TOYYIBPAY_SECRET_KEY or not TOYYIBPAY_CATEGORY: | |
| logger.error("ToyyibPay credentials not configured") | |
| return { | |
| "success": False, | |
| "error": "Payment system not configured. Please contact administrator." | |
| } | |
| # Use the user's email or fallback | |
| bill_email = user_email if user_email else f"user_{telegram_id}@mileage.tracker" | |
| async with aiohttp.ClientSession() as session: | |
| data = { | |
| "userSecretKey": TOYYIBPAY_SECRET_KEY, | |
| "categoryCode": TOYYIBPAY_CATEGORY, | |
| "billName": f"Mileage Tracker - {display_month}", | |
| "billDescription": f"Monthly subscription for {display_month}. Valid until end of month.", | |
| "billPriceSetting": 1, | |
| "billPayorInfo": 1, | |
| "billAmount": str(SUBSCRIPTION_AMOUNT), | |
| "billReturnUrl": "https://t.me/your_bot_username", | |
| "billCallbackUrl": "https://yukee1992-Milleage-tracker.hf.space/toyyibpay-webhook", | |
| "billExternalReferenceNo": str(telegram_id), | |
| "billTo": user_name, | |
| "billEmail": bill_email, # Use user's email | |
| "billPhone": "0123456789", | |
| "billSplitPayment": 0, | |
| "billPaymentChannel": "0", | |
| "billContentType": "application/json" | |
| } | |
| try: | |
| logger.info(f"Creating ToyyibPay bill for user {telegram_id}, month {month}, email {bill_email}") | |
| async with session.post( | |
| f"{TOYYIBPAY_API_URL}/createBill", | |
| data=data, | |
| timeout=30 | |
| ) as response: | |
| text = await response.text() | |
| logger.info(f"Raw response: {text}") | |
| try: | |
| result = json.loads(text) | |
| logger.info(f"Parsed response: {result}") | |
| if isinstance(result, list) and len(result) > 0 and 'BillCode' in result[0]: | |
| return { | |
| "success": True, | |
| "bill_code": result[0]["BillCode"], | |
| "url": f"https://toyyibpay.com/{result[0]['BillCode']}" | |
| } | |
| if isinstance(result, dict): | |
| if result.get('status') == 'error': | |
| return { | |
| "success": False, | |
| "error": f"Payment error: {result.get('msg', 'Unknown error')}" | |
| } | |
| if 'error' in result: | |
| return { | |
| "success": False, | |
| "error": f"Payment error: {result.get('error', 'Unknown error')}" | |
| } | |
| return { | |
| "success": False, | |
| "error": "Unexpected response format from payment service" | |
| } | |
| except json.JSONDecodeError as e: | |
| logger.error(f"Failed to parse JSON: {e}") | |
| if "error" in text.lower(): | |
| import re | |
| error_match = re.search(r'"msg":"([^"]+)"', text) | |
| if error_match: | |
| return { | |
| "success": False, | |
| "error": f"Payment error: {error_match.group(1)}" | |
| } | |
| return { | |
| "success": False, | |
| "error": "Payment service returned invalid response. Please try again." | |
| } | |
| except asyncio.TimeoutError: | |
| logger.error("ToyyibPay API timeout") | |
| return { | |
| "success": False, | |
| "error": "Payment service timeout. Please try again." | |
| } | |
| except Exception as e: | |
| logger.error(f"ToyyibPay error: {e}") | |
| return { | |
| "success": False, | |
| "error": f"Payment error: {str(e)}" | |
| } | |
| # ============ DAILY SUBSCRIPTION CHECK ============ | |
| async def daily_subscription_check(): | |
| logger.info("Running daily subscription check...") | |
| df = load_users() | |
| if df.empty: | |
| return | |
| current_month = get_current_month() | |
| updated = 0 | |
| for idx, row in df.iterrows(): | |
| telegram_id = row['telegram_id'] | |
| if is_admin(telegram_id): | |
| continue | |
| paid_months_raw = row.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if current_month not in paid_months: | |
| last_payment_date = row.get('last_payment_date') | |
| if last_payment_date: | |
| try: | |
| last_date = datetime.fromisoformat(last_payment_date) | |
| days_since = (datetime.now() - last_date).days | |
| if days_since > GRACE_PERIOD_DAYS: | |
| logger.info(f"User {telegram_id} subscription expired (no payment for {days_since} days)") | |
| updated += 1 | |
| except: | |
| pass | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| logger.info(f"Daily subscription check complete. Updated {updated} users.") | |
| async def subscription_cleanup_job(): | |
| while True: | |
| try: | |
| await daily_subscription_check() | |
| await asyncio.sleep(86400) | |
| except Exception as e: | |
| logger.error(f"Subscription cleanup error: {e}") | |
| await asyncio.sleep(3600) | |
| # ============ TELEGRAM MESSAGE HELPER ============ | |
| async def send_telegram_message(chat_id: int, text: str, parse_mode: str = None): | |
| """Send message via Telegram Bot API""" | |
| url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage" | |
| payload = {"chat_id": chat_id, "text": text} | |
| if parse_mode: | |
| payload["parse_mode"] = parse_mode | |
| try: | |
| async with aiohttp.ClientSession() as session: | |
| await session.post(url, json=payload) | |
| except Exception as e: | |
| logger.error(f"Failed to send message: {e}") | |
| # ============ FASTAPI ENDPOINTS ============ | |
| async def root(): | |
| users = load_users() | |
| trips = load_trips() | |
| payments = load_payments() | |
| current_month = get_current_month() | |
| active_subscribers = 0 | |
| for _, row in users.iterrows(): | |
| paid_months_raw = row.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if current_month in paid_months: | |
| active_subscribers += 1 | |
| return { | |
| "status": "active", | |
| "bot_name": "Mileage Tracker Bot", | |
| "total_users": len(users), | |
| "total_trips": len(trips), | |
| "total_payments": len(payments), | |
| "active_subscribers": active_subscribers, | |
| "subscription_amount": f"RM{SUBSCRIPTION_AMOUNT/100:.2f}", | |
| "grace_period_days": GRACE_PERIOD_DAYS, | |
| "hf_datasets": { | |
| "users": HF_USERS_DATASET, | |
| "trips": HF_TRIPS_DATASET, | |
| "payments": HF_PAYMENTS_DATASET | |
| } | |
| } | |
| async def download_report(filename: str): | |
| if os.path.exists(filename): | |
| return FileResponse( | |
| filename, | |
| media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
| filename=filename | |
| ) | |
| return {"error": "File not found"} | |
| async def view_data(): | |
| df = load_trips() | |
| if df.empty: | |
| return {"message": "No data yet"} | |
| df = df.replace([float('inf'), float('-inf')], 0) | |
| df = df.fillna("") | |
| return df.to_dict(orient="records") | |
| async def view_users(): | |
| df = load_users() | |
| if df.empty: | |
| return {"message": "No users yet"} | |
| df = df.fillna("") | |
| return df.to_dict(orient="records") | |
| async def view_payments(): | |
| df = load_payments() | |
| if df.empty: | |
| return {"message": "No payments yet"} | |
| df = df.fillna("") | |
| return df.to_dict(orient="records") | |
| # ============ DEBUG ENDPOINTS ============ | |
| async def admin_diagnose_sync(admin_key: str = None): | |
| """Diagnose sync issues""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| result = { | |
| "hf_token_set": bool(HF_TOKEN), | |
| "hf_token_preview": HF_TOKEN[:10] + "..." if HF_TOKEN else "NOT SET", | |
| "users_file_exists": os.path.exists(USERS_FILE), | |
| "users_file_size": 0, | |
| "users_count": 0, | |
| "sample_users": [], | |
| "sync_attempt_result": False, | |
| "error": None | |
| } | |
| try: | |
| if os.path.exists(USERS_FILE): | |
| import os | |
| result["users_file_size"] = os.path.getsize(USERS_FILE) | |
| df = pd.read_csv(USERS_FILE) | |
| result["users_count"] = len(df) | |
| result["sample_users"] = df[['telegram_id', 'role']].head(3).to_dict(orient='records') | |
| # Try to sync | |
| try: | |
| sync_result = sync_users_to_hf() | |
| result["sync_attempt_result"] = sync_result | |
| except Exception as e: | |
| result["error"] = str(e) | |
| import traceback | |
| result["traceback"] = traceback.format_exc() | |
| # Check HF dataset access | |
| try: | |
| import requests | |
| hf_response = requests.get( | |
| "https://huggingface.co/api/datasets/yukee1992/mileage_tracker_yukee1992_users", | |
| headers={"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} | |
| ) | |
| result["hf_dataset_status"] = hf_response.status_code | |
| if hf_response.status_code == 200: | |
| result["hf_dataset_exists"] = True | |
| data = hf_response.json() | |
| result["hf_dataset_info"] = { | |
| "private": data.get('private', False), | |
| "downloads": data.get('downloads', 0), | |
| "likes": data.get('likes', 0) | |
| } | |
| else: | |
| result["hf_dataset_exists"] = False | |
| result["hf_dataset_error"] = hf_response.text[:200] | |
| except Exception as e: | |
| result["hf_check_error"] = str(e) | |
| except Exception as e: | |
| result["error"] = str(e) | |
| import traceback | |
| result["traceback"] = traceback.format_exc() | |
| return result | |
| async def admin_test_sync(admin_key: str = None): | |
| """Test sync function with detailed output""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| result = { | |
| "hf_token": bool(HF_TOKEN), | |
| "hf_token_preview": HF_TOKEN[:10] + "..." if HF_TOKEN else "NOT SET", | |
| "users_file_exists": os.path.exists(USERS_FILE), | |
| "sync_attempt": False, | |
| "error": None, | |
| "traceback": None | |
| } | |
| if not HF_TOKEN: | |
| result["error"] = "HF_TOKEN is not set" | |
| return result | |
| if not os.path.exists(USERS_FILE): | |
| result["error"] = "Users file does not exist" | |
| return result | |
| try: | |
| # Read the CSV to check its content | |
| df = pd.read_csv(USERS_FILE) | |
| result["users_count"] = len(df) | |
| result["sample_roles"] = df[['telegram_id', 'role']].head(5).to_dict(orient='records') | |
| # Try to sync | |
| sync_result = sync_users_to_hf() | |
| result["sync_attempt"] = sync_result | |
| except Exception as e: | |
| result["error"] = str(e) | |
| import traceback | |
| result["traceback"] = traceback.format_exc() | |
| return result | |
| async def admin_debug_user(telegram_id: str, admin_key: str = None): | |
| """Debug endpoint to check user registration status""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| user = get_user(telegram_id) | |
| if not user: | |
| return {"status": "error", "message": "User not found"} | |
| full_name = user.get('full_name', '') | |
| email = user.get('email', '') | |
| registered = is_registered(telegram_id) | |
| return { | |
| "status": "success", | |
| "user": { | |
| "telegram_id": user.get('telegram_id'), | |
| "full_name": full_name, | |
| "email": email, | |
| "is_registered": registered, | |
| "name_valid": bool(full_name and full_name != 'User' and full_name.strip()), | |
| "email_valid": bool(email and '@' in email and email.strip()), | |
| "role": user.get('role', 'user') | |
| } | |
| } | |
| async def debug_dataset(dataset_name: str, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| try: | |
| from datasets import load_dataset | |
| full_dataset_name = f"yukee1992/{dataset_name}" | |
| logger.info(f"Debug loading: {full_dataset_name}") | |
| try: | |
| dataset = load_dataset( | |
| full_dataset_name, | |
| split="train", | |
| token=HF_TOKEN | |
| ) | |
| except: | |
| dataset = load_dataset( | |
| full_dataset_name, | |
| split="train" | |
| ) | |
| df = dataset.to_pandas() | |
| return { | |
| "status": "success", | |
| "dataset_name": dataset_name, | |
| "full_name": full_dataset_name, | |
| "rows": len(df), | |
| "columns": df.columns.tolist(), | |
| "sample_data": df.head(5).to_dict(orient="records") if not df.empty else "empty" | |
| } | |
| except Exception as e: | |
| return { | |
| "status": "error", | |
| "dataset_name": dataset_name, | |
| "full_name": f"yukee1992/{dataset_name}", | |
| "error": str(e) | |
| } | |
| # ============ RESTORE FROM HF DATASET ============ | |
| async def admin_restore_from_hf(admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| results = {} | |
| users_df = load_from_hf_dataset(HF_USERS_DATASET) | |
| if users_df is not None and not users_df.empty: | |
| users_df.to_csv(USERS_FILE, index=False) | |
| results["users"] = f"✅ Restored {len(users_df)} users" | |
| else: | |
| results["users"] = "❌ No users found" | |
| trips_df = load_from_hf_dataset(HF_TRIPS_DATASET) | |
| if trips_df is not None and not trips_df.empty: | |
| trips_df.to_csv(TRIPS_FILE, index=False) | |
| results["trips"] = f"✅ Restored {len(trips_df)} trips" | |
| else: | |
| results["trips"] = "❌ No trips found" | |
| return { | |
| "status": "success", | |
| "message": "Restore completed", | |
| "results": results | |
| } | |
| async def fix_paid_months(admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "success", "message": "No users to fix"} | |
| fixed_count = 0 | |
| for idx, row in df.iterrows(): | |
| paid_months_raw = row.get('paid_months') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| df.at[idx, 'paid_months'] = json.dumps(paid_months) | |
| fixed_count += 1 | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| return { | |
| "status": "success", | |
| "message": f"Fixed paid_months for {fixed_count} users", | |
| "fixed_count": fixed_count | |
| } | |
| async def admin_mark_paid(telegram_id: str, admin_key: str = None): | |
| """Manually mark a user as paid for current month""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| current_month = get_current_month() | |
| if has_paid_for_month(telegram_id, current_month): | |
| return { | |
| "status": "success", | |
| "message": f"User {telegram_id} already paid for {get_current_month_display()}" | |
| } | |
| if add_paid_month(telegram_id, current_month): | |
| payment_data = { | |
| "payment_id": str(uuid.uuid4())[:8], | |
| "user_id": str(telegram_id), | |
| "user_name": "User", | |
| "month": str(current_month.split("-")[1]), | |
| "year": str(current_month.split("-")[0]), | |
| "amount": float(SUBSCRIPTION_AMOUNT / 100), | |
| "payment_date": datetime.now().isoformat(), | |
| "transaction_id": "manual_" + str(uuid.uuid4())[:8], | |
| "status": "completed" | |
| } | |
| save_payment(payment_data) | |
| try: | |
| await send_telegram_message( | |
| int(telegram_id), | |
| f"✅ **Payment Confirmed!**\n\n" | |
| f"Your payment for {get_current_month_display()} has been confirmed.\n\n" | |
| f"✅ Your subscription is now active until the end of the month.\n" | |
| f"✅ You can now generate reports with `report {get_current_month_display()}`\n\n" | |
| f"Send `/status` to check your subscription.", | |
| parse_mode="Markdown" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to send confirmation message: {e}") | |
| return { | |
| "status": "success", | |
| "message": f"✅ User {telegram_id} marked as paid for {get_current_month_display()}" | |
| } | |
| else: | |
| return { | |
| "status": "error", | |
| "message": f"❌ Failed to mark user {telegram_id} as paid" | |
| } | |
| async def admin_remove_paid(telegram_id: str, admin_key: str = None): | |
| """Manually remove a user's paid status for the current month (Unsubscribe)""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| current_month = get_current_month() | |
| try: | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| telegram_id = str(telegram_id) | |
| df['telegram_id'] = df['telegram_id'].astype(str) | |
| if telegram_id not in df['telegram_id'].values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'] == telegram_id].index[0] | |
| paid_months_raw = df.at[idx, 'paid_months'] | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if current_month in paid_months: | |
| paid_months.remove(current_month) | |
| df.at[idx, 'paid_months'] = json.dumps(paid_months) | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| try: | |
| await send_telegram_message( | |
| int(telegram_id), | |
| f"📝 **Subscription Removed**\n\n" | |
| f"Your subscription for {get_current_month_display()} has been removed.\n" | |
| f"You will no longer be able to generate reports for this month.\n\n" | |
| f"Send `/subscribe` to renew your subscription.", | |
| parse_mode="Markdown" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to send notification: {e}") | |
| return { | |
| "status": "success", | |
| "message": f"✅ User {telegram_id} unsubscribed for {get_current_month_display()}" | |
| } | |
| else: | |
| return { | |
| "status": "success", | |
| "message": f"User {telegram_id} was not subscribed for {get_current_month_display()}" | |
| } | |
| except Exception as e: | |
| logger.error(f"Error removing paid status: {e}") | |
| return {"status": "error", "message": str(e)} | |
| async def admin_promote_subscriber(telegram_id: str, admin_key: str = None): | |
| """Promote a user to subscriber (paid user)""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| df.at[idx, 'role'] = 'subscriber' | |
| current_month = get_current_month() | |
| paid_months_raw = df.at[idx, 'paid_months'] | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if current_month not in paid_months: | |
| paid_months.append(current_month) | |
| df.at[idx, 'paid_months'] = json.dumps(paid_months) | |
| df.at[idx, 'last_paid_month'] = current_month | |
| df.at[idx, 'last_payment_date'] = datetime.now().isoformat() | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| return { | |
| "status": "success", | |
| "message": f"✅ User {telegram_id} is now a Subscriber (role: subscriber)", | |
| "role": "subscriber" | |
| } | |
| async def admin_demote_subscriber(telegram_id: str, admin_key: str = None): | |
| """Demote a subscriber back to regular user""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| df.at[idx, 'role'] = 'user' | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| return { | |
| "status": "success", | |
| "message": f"✅ User {telegram_id} demoted to User (role: user)", | |
| "role": "user" | |
| } | |
| async def admin_fix_roles(admin_key: str = None): | |
| """Fix roles for users who have paid months but are not subscribers""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| try: | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "success", "message": "No users found"} | |
| fixed_count = 0 | |
| for idx, row in df.iterrows(): | |
| telegram_id = row['telegram_id'] | |
| current_role = row.get('role', 'user') | |
| paid_months_raw = row.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| if paid_months and current_role not in ['admin', 'subscriber']: | |
| df.at[idx, 'role'] = 'subscriber' | |
| fixed_count += 1 | |
| logger.info(f"Fixed role for user {telegram_id} to 'subscriber' (had {len(paid_months)} paid months)") | |
| elif not paid_months and current_role == 'subscriber': | |
| df.at[idx, 'role'] = 'user' | |
| fixed_count += 1 | |
| logger.info(f"Demoted user {telegram_id} to 'user' (no paid months)") | |
| if fixed_count > 0: | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| logger.info(f"Fixed roles for {fixed_count} users") | |
| return { | |
| "status": "success", | |
| "message": f"Role fix completed. Fixed {fixed_count} users.", | |
| "fixed_count": fixed_count | |
| } | |
| except Exception as e: | |
| logger.error(f"Error fixing roles: {e}") | |
| return {"status": "error", "message": str(e)} | |
| async def admin_set_role(telegram_id: str, role: str, admin_key: str = None): | |
| """Directly set a user's role""" | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| valid_roles = ['user', 'subscriber', 'admin'] | |
| if role not in valid_roles: | |
| return {"status": "error", "message": f"Invalid role. Must be one of: {', '.join(valid_roles)}"} | |
| try: | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| # Update role | |
| df.at[idx, 'role'] = role | |
| if role == 'admin': | |
| df.at[idx, 'is_admin'] = True | |
| else: | |
| df.at[idx, 'is_admin'] = False | |
| # Save CSV | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ User {telegram_id} role set to '{role}' in CSV") | |
| # Force sync to HF | |
| sync_result = sync_users_to_hf() | |
| return { | |
| "status": "success" if sync_result else "warning", | |
| "message": f"User {telegram_id} role set to '{role}'", | |
| "role": role, | |
| "synced": sync_result, | |
| "sync_message": "Synced to HF" if sync_result else "CSV updated but sync to HF failed. Please check logs." | |
| } | |
| except Exception as e: | |
| logger.error(f"Error setting role: {e}") | |
| return {"status": "error", "message": str(e)} | |
| # ============ TELEGRAM BOT ENDPOINTS ============ | |
| async def register_user(data: dict): | |
| telegram_id = str(data.get("telegram_id")) | |
| username = data.get("username", "User") | |
| full_name = data.get("full_name", "") | |
| email = data.get("email", "") | |
| chat_id = data.get("chat_id") | |
| # Validate email | |
| if not email or '@' not in email or '.' not in email: | |
| return { | |
| "status": "error", | |
| "message": "❌ Please provide a valid email address.\n\n" | |
| "Format:\n" | |
| "/register John Doe john@example.com", | |
| "chat_id": chat_id | |
| } | |
| # Clean up the name - if it's 'User', 'nan', or empty, use the username | |
| if not full_name or full_name.strip() == '' or full_name == 'User' or full_name == 'nan': | |
| full_name = username if username and username != 'User' and username != 'nan' else 'User' | |
| logger.info(f"Registering user: {telegram_id}, name={full_name}, email={email}") | |
| if user_exists(telegram_id): | |
| # Update existing user | |
| user = get_user(telegram_id) | |
| df = load_users() | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| # Update name if provided and not empty | |
| if full_name and full_name != 'User' and full_name != 'nan': | |
| df.at[idx, 'full_name'] = full_name | |
| # Update email if provided and valid | |
| if email and email != 'nan': | |
| df.at[idx, 'email'] = email | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| logger.info(f"Updated user {telegram_id}: name={full_name}, email={email}") | |
| return { | |
| "status": "success", | |
| "message": f"✅ **Registration Updated!**\n\n" | |
| f"👤 Name: {full_name}\n" | |
| f"📧 Email: {email}\n\n" | |
| f"You are now registered!\n\n" | |
| f"Commands:\n" | |
| f"• `start [place]` - Begin a trip\n" | |
| f"• `end [place]` - Complete a trip\n" | |
| f"• `report [Month Year]` - Get monthly report\n" | |
| f"• `/stats` - Your statistics\n" | |
| f"• `/stats [Month Year]` - Monthly statistics\n" | |
| f"• `/subscribe` - Subscribe for RM{SUBSCRIPTION_AMOUNT/100:.2f}/month\n" | |
| f"• `/status` - Check subscription status", | |
| "chat_id": chat_id | |
| } | |
| # Create new user | |
| user_data = { | |
| "telegram_id": telegram_id, | |
| "username": username, | |
| "full_name": full_name if full_name and full_name != 'User' and full_name != 'nan' else username, | |
| "email": email, | |
| "registered_date": datetime.now().isoformat(), | |
| "is_active": True, | |
| "role": "user" | |
| } | |
| save_user(user_data) | |
| return { | |
| "status": "success", | |
| "message": f"✅ **Registration Complete!**\n\n" | |
| f"👤 Name: {user_data['full_name']}\n" | |
| f"📧 Email: {email}\n\n" | |
| f"You have been registered successfully!\n\n" | |
| f"Commands:\n" | |
| f"• `start [place]` - Begin a trip\n" | |
| f"• `end [place]` - Complete a trip\n" | |
| f"• `report [Month Year]` - Get monthly report\n" | |
| f"• `/stats` - Your statistics\n" | |
| f"• `/stats [Month Year]` - Monthly statistics\n" | |
| f"• `/subscribe` - Subscribe for RM{SUBSCRIPTION_AMOUNT/100:.2f}/month\n" | |
| f"• `/status` - Check subscription status", | |
| "chat_id": chat_id | |
| } | |
| async def start_trip(data: dict): | |
| user_id = str(data.get("user_id")) | |
| text = data.get("text", "") | |
| username = data.get("username", "User") | |
| chat_id = data.get("chat_id") | |
| logger.info(f"Start trip: user={user_id}, text={text}") | |
| # Check if user is registered | |
| if not is_registered(user_id): | |
| return { | |
| "status": "error", | |
| "message": "❌ **Please register first!**\n\n" | |
| "You need to register with your name and email before using the bot.\n\n" | |
| "**How to register:**\n" | |
| "Send: `/register Your Name your_email@example.com`\n\n" | |
| "Example:\n" | |
| "`/register Ahmad Ahmad@email.com`", | |
| "chat_id": chat_id | |
| } | |
| lines = text.strip().split('\n') | |
| trip_data = { | |
| "place_name": "", | |
| "from_place": "", | |
| "purpose": "" | |
| } | |
| for line in lines: | |
| line = line.strip() | |
| if line.lower().startswith("name:") or line.lower().startswith("place:") or line.lower().startswith("destination:"): | |
| if line.lower().startswith("name:"): | |
| trip_data["place_name"] = line[5:].strip() | |
| elif line.lower().startswith("place:"): | |
| trip_data["place_name"] = line[6:].strip() | |
| elif line.lower().startswith("destination:"): | |
| trip_data["place_name"] = line[12:].strip() | |
| elif line.lower().startswith("from:"): | |
| trip_data["from_place"] = line[5:].strip() | |
| elif line.lower().startswith("purpose:"): | |
| trip_data["purpose"] = line[8:].strip() | |
| if not trip_data["from_place"] and len(lines) > 0: | |
| first_line = lines[0].strip() | |
| if not any(first_line.lower().startswith(label) for label in ["name:", "place:", "destination:", "from:", "purpose:"]): | |
| trip_data["from_place"] = first_line | |
| if not trip_data["from_place"]: | |
| return { | |
| "status": "error", | |
| "message": "❌ Please specify the starting location.\n\n" | |
| "Format:\n" | |
| "start\n" | |
| "Name: Place Name\n" | |
| "From: KLCC\n" | |
| "Purpose: Meeting with client\n\n" | |
| "Or simply:\n" | |
| "start KLCC", | |
| "chat_id": chat_id | |
| } | |
| place_name = trip_data["place_name"] if trip_data["place_name"] else "Not specified" | |
| pending_actions[user_id] = "waiting_for_start_location" | |
| user_sessions[user_id] = { | |
| "start_place": trip_data["from_place"], | |
| "place_name": place_name, | |
| "trip_purpose": trip_data["purpose"] if trip_data["purpose"] else "Not specified", | |
| "status": "awaiting_start_location" | |
| } | |
| keyboard = { | |
| "keyboard": [[{"text": "📍 Share My Current Location", "request_location": True}]], | |
| "resize_keyboard": True, | |
| "one_time_keyboard": True | |
| } | |
| message = f"✅ **Trip Details Received!**\n\n" | |
| message += f"📍 Place: {user_sessions[user_id]['place_name']}\n" | |
| message += f"📍 From: {user_sessions[user_id]['start_place']}\n" | |
| message += f"📝 Purpose: {user_sessions[user_id]['trip_purpose']}\n\n" | |
| message += f"📍 **Please share your current location** for accurate GPS tracking.\n\nTap the button below." | |
| return { | |
| "status": "location_request", | |
| "message": message, | |
| "reply_markup": keyboard, | |
| "chat_id": chat_id | |
| } | |
| async def end_trip(data: dict): | |
| user_id = str(data.get("user_id")) | |
| place = data.get("place", "") | |
| username = data.get("username", "User") | |
| chat_id = data.get("chat_id") | |
| logger.info(f"End trip: user={user_id}, place={place}") | |
| # Check if user is registered | |
| if not is_registered(user_id): | |
| return { | |
| "status": "error", | |
| "message": "❌ **Please register first!**\n\n" | |
| "You need to register with your name and email before using the bot.\n\n" | |
| "**How to register:**\n" | |
| "Send: `/register Your Name your_email@example.com`\n\n" | |
| "Example:\n" | |
| "`/register Ahmad Ahmad@email.com`", | |
| "chat_id": chat_id | |
| } | |
| if user_id not in user_sessions or user_sessions[user_id].get("status") != "start_location_received": | |
| return { | |
| "status": "error", | |
| "message": "❌ No active trip found. Please send 'start [place]' first, share your location, then 'end [destination]'.", | |
| "chat_id": chat_id | |
| } | |
| user_sessions[user_id]["end_place"] = place | |
| user_sessions[user_id]["status"] = "awaiting_end_location" | |
| pending_actions[user_id] = "waiting_for_end_location" | |
| keyboard = { | |
| "keyboard": [[{"text": "📍 Share My Current Location", "request_location": True}]], | |
| "resize_keyboard": True, | |
| "one_time_keyboard": True | |
| } | |
| return { | |
| "status": "location_request", | |
| "message": f"✅ **Destination set:** {place}\n\n📍 **Please share your current location** to complete the trip.\n\nTap the button below.", | |
| "reply_markup": keyboard, | |
| "chat_id": chat_id | |
| } | |
| async def handle_location(data: dict): | |
| user_id = str(data.get("user_id")) | |
| latitude = data.get("latitude") | |
| longitude = data.get("longitude") | |
| username = data.get("username", "User") | |
| chat_id = data.get("chat_id") | |
| logger.info(f"Location received: user={user_id}, lat={latitude}, lon={longitude}") | |
| # Check if user is registered | |
| if not is_registered(user_id): | |
| return { | |
| "status": "error", | |
| "message": "❌ **Please register first!**\n\n" | |
| "You need to register with your name and email before using the bot.\n\n" | |
| "**How to register:**\n" | |
| "Send: `/register Your Name your_email@example.com`\n\n" | |
| "Example:\n" | |
| "`/register Ahmad Ahmad@email.com`", | |
| "chat_id": chat_id | |
| } | |
| if pending_actions.get(user_id) == "waiting_for_start_location": | |
| user_sessions[user_id]["start_gps_lat"] = latitude | |
| user_sessions[user_id]["start_gps_lon"] = longitude | |
| user_sessions[user_id]["status"] = "start_location_received" | |
| del pending_actions[user_id] | |
| remove_keyboard = {"remove_keyboard": True} | |
| return { | |
| "status": "success", | |
| "message": f"✅ **Start location saved!**\n\n" | |
| f"📍 Place: {user_sessions[user_id]['place_name']}\n" | |
| f"📍 From: {user_sessions[user_id]['start_place']}\n" | |
| f"📍 GPS: {latitude:.6f}, {longitude:.6f}\n\n" | |
| f"Now send: `end [destination]` to complete your trip.", | |
| "reply_markup": remove_keyboard, | |
| "chat_id": chat_id | |
| } | |
| elif pending_actions.get(user_id) == "waiting_for_end_location": | |
| end_place = user_sessions[user_id].get("end_place", "Destination") | |
| start_place = user_sessions[user_id]["start_place"] | |
| start_lat = user_sessions[user_id]["start_gps_lat"] | |
| start_lon = user_sessions[user_id]["start_gps_lon"] | |
| place_name = user_sessions[user_id].get("place_name", "Not specified") | |
| trip_purpose = user_sessions[user_id].get("trip_purpose", "Not specified") | |
| distance = get_longest_route(start_lat, start_lon, latitude, longitude) | |
| user = get_user(user_id) | |
| user_name = user['full_name'] if user else username | |
| trip_date = datetime.now().strftime("%d/%m/%Y") | |
| trip_month = datetime.now().strftime("%B %Y") | |
| trip_date_display = datetime.now().strftime("%d/%m/%Y") | |
| current_month_stats = get_user_current_month_stats_enhanced(user_id) | |
| trip_record = { | |
| "trip_id": str(uuid.uuid4())[:8], | |
| "user_id": user_id, | |
| "user_name": user_name, | |
| "place_name": place_name, | |
| "timestamp": datetime.now().isoformat(), | |
| "date": trip_date, | |
| "month": trip_month, | |
| "start_place": start_place, | |
| "start_gps_lat": start_lat, | |
| "start_gps_lon": start_lon, | |
| "end_place": end_place, | |
| "end_gps_lat": latitude, | |
| "end_gps_lon": longitude, | |
| "distance_km": distance, | |
| "status": "approved", | |
| "purpose": trip_purpose | |
| } | |
| total_all = save_trip(trip_record) | |
| logger.info(f"User {user_id} current month stats BEFORE this trip: {current_month_stats['total_trips']} trips, {current_month_stats['total_distance']} km") | |
| logger.info(f"User {user_id} just added trip #{total_all} with distance {distance} km") | |
| del user_sessions[user_id] | |
| del pending_actions[user_id] | |
| remove_keyboard = {"remove_keyboard": True} | |
| return { | |
| "status": "success", | |
| "message": f"✅ **Trip Completed!**\n\n" | |
| f"📅 Date: {trip_date_display}\n" | |
| f"👤 Staff: {user_name}\n" | |
| f"📍 Place: {place_name}\n" | |
| f"📝 Purpose: {trip_purpose}\n\n" | |
| f"📍 **From:** {start_place}\n" | |
| f"📍 **To:** {end_place}\n\n" | |
| f"📏 **This Trip:** {distance} km\n" | |
| f"━━━━━━━━━━━━━━━━━━━━\n" | |
| f"📊 **Current Month ({datetime.now().strftime('%B %Y')})**\n" | |
| f"📝 Trips: {current_month_stats['total_trips'] + 1} (including this one)\n" | |
| f"📏 Total: {current_month_stats['total_distance'] + distance} km\n\n" | |
| f"*Trip saved successfully!*", | |
| "reply_markup": remove_keyboard, | |
| "chat_id": chat_id | |
| } | |
| else: | |
| return { | |
| "status": "error", | |
| "message": "⚠️ No pending action. Please start a new trip with 'start [place]'", | |
| "chat_id": chat_id | |
| } | |
| async def request_report(data: dict): | |
| user_id = str(data.get("user_id")) | |
| month = data.get("month") | |
| year = data.get("year") | |
| chat_id = data.get("chat_id") | |
| logger.info(f"Report request from user: {user_id}, month: {month}, year: {year}") | |
| if not user_exists(user_id): | |
| return { | |
| "status": "error", | |
| "message": "❌ Please register first with `/register Your Name your_email@example.com`", | |
| "chat_id": chat_id | |
| } | |
| user = get_user(user_id) | |
| filter_text = f"{month} {year}" | |
| month_str = f"{year}-{datetime.strptime(month, '%B').month:02d}" | |
| role = user.get('role', 'user') | |
| if is_admin(user_id): | |
| logger.info(f"Admin {user_id} accessing report for {month_str}") | |
| elif role == 'subscriber': | |
| if not has_paid_for_month(user_id, month_str): | |
| return { | |
| "status": "error", | |
| "message": f"🔒 You haven't paid for {filter_text}.\n\n" | |
| f"Your subscription only allows reports for months you've paid for.\n" | |
| f"Send `/subscribe` to pay for the current month.", | |
| "chat_id": chat_id | |
| } | |
| else: | |
| return { | |
| "status": "error", | |
| "message": f"📝 **No Active Subscription**\n\n" | |
| f"You are a regular user. To generate reports, you need to subscribe.\n\n" | |
| f"💰 **Amount:** RM{SUBSCRIPTION_AMOUNT/100:.2f}/month\n" | |
| f"Send `/subscribe` to subscribe now.", | |
| "chat_id": chat_id | |
| } | |
| month_num = datetime.strptime(month, "%B").month | |
| year_num = int(year) | |
| df_user = get_user_trips_by_month(user_id, month_num, year_num) | |
| if df_user.empty: | |
| return { | |
| "status": "error", | |
| "message": f"📊 No trips found for {month} {year}.\n\nPlease make sure you have recorded trips for this period.", | |
| "chat_id": chat_id | |
| } | |
| excel_file = generate_excel_report(df_user, user['full_name'], filter_text) | |
| filename = f"mileage_report_{user_id}_{filter_text.replace(' ', '_')}.xlsx" | |
| with open(filename, "wb") as f: | |
| f.write(excel_file) | |
| space_url = "https://yukee1992-Milleage-tracker.hf.space" | |
| download_url = f"{space_url}/download-report/{filename}" | |
| return { | |
| "status": "success", | |
| "message": f"📊 **Mileage Report - {month} {year}**\n\n" | |
| f"👤 User: {user['full_name']}\n" | |
| f"👤 Role: {role.upper()}\n" | |
| f"━━━━━━━━━━━━━━━━━━━━\n" | |
| f"📝 Total Trips: **{len(df_user)}**\n" | |
| f"📏 Total Distance: **{df_user['distance_km'].sum():.2f} km**\n" | |
| f"⭐ Average Trip: **{df_user['distance_km'].mean():.2f} km**\n" | |
| f"🔝 Longest Trip: **{df_user['distance_km'].max():.2f} km**\n" | |
| f"━━━━━━━━━━━━━━━━━━━━\n\n" | |
| f"📎 **Download your report:**\n" | |
| f"{download_url}\n\n" | |
| f"💡 *Click the link above to download the Excel file*", | |
| "download_url": download_url, | |
| "filename": filename, | |
| "total_trips": len(df_user), | |
| "total_distance": round(df_user['distance_km'].sum(), 2), | |
| "chat_id": chat_id | |
| } | |
| # ============ ENHANCED STATS ENDPOINTS ============ | |
| async def get_stats(data: dict): | |
| user_id = str(data.get("user_id")) | |
| chat_id = data.get("chat_id") | |
| if not user_exists(user_id): | |
| return { | |
| "status": "success", | |
| "message": "📊 No trips yet. Start your first trip: `start KLCC`", | |
| "chat_id": chat_id | |
| } | |
| df_user = get_user_trips(user_id) | |
| if df_user.empty: | |
| return { | |
| "status": "success", | |
| "message": "📊 No trips yet. Start your first trip: `start KLCC`", | |
| "chat_id": chat_id | |
| } | |
| monthly_stats = df_user.groupby('month').agg({ | |
| 'distance_km': ['count', 'sum', 'mean'] | |
| }).round(2) | |
| monthly_stats.columns = ['Trips', 'Total Distance (km)', 'Avg Distance (km)'] | |
| last_12_months = [] | |
| current_date = datetime.now() | |
| for i in range(12): | |
| month_date = current_date.replace(day=1) - timedelta(days=i * 30) | |
| month_date = month_date.replace(day=1) | |
| month_name = month_date.strftime("%B %Y") | |
| last_12_months.append(month_name) | |
| last_12_months = last_12_months[::-1] | |
| monthly_stats_filtered = monthly_stats[monthly_stats.index.isin(last_12_months)] | |
| monthly_stats_filtered = monthly_stats_filtered.sort_index(key=lambda x: pd.to_datetime(x)) | |
| monthly_text = "" | |
| for month, row in monthly_stats_filtered.iterrows(): | |
| monthly_text += f"\n📅 {month}: {int(row['Trips'])} trips, {row['Total Distance (km)']} km" | |
| if not monthly_text: | |
| monthly_text = "\nNo trips in the last 12 months" | |
| return { | |
| "status": "success", | |
| "message": f"📊 **Monthly Breakdown (Last 12 Months)**{monthly_text}", | |
| "chat_id": chat_id | |
| } | |
| async def get_stats_by_month(data: dict): | |
| user_id = str(data.get("user_id")) | |
| month = data.get("month") | |
| year = data.get("year") | |
| chat_id = data.get("chat_id") | |
| logger.info(f"Monthly stats request from user: {user_id}, month: {month}, year: {year}") | |
| if not user_exists(user_id): | |
| return { | |
| "status": "error", | |
| "message": "❌ Please register first with `/register Your Name your_email@example.com`", | |
| "chat_id": chat_id | |
| } | |
| try: | |
| month_num = datetime.strptime(month, "%B").month | |
| year_num = int(year) | |
| except ValueError as e: | |
| logger.error(f"Error parsing date: {e}") | |
| return { | |
| "status": "error", | |
| "message": "❌ Invalid date format. Use: `/stats June 2026`\nExample: `/stats January 2025`", | |
| "chat_id": chat_id | |
| } | |
| df_user = get_user_trips_by_month(user_id, month_num, year_num) | |
| if df_user.empty: | |
| return { | |
| "status": "success", | |
| "message": f"📊 No trips found for {month} {year}.\n\nStart recording your trips!", | |
| "chat_id": chat_id | |
| } | |
| total_trips = len(df_user) | |
| total_distance = df_user['distance_km'].sum() | |
| avg_distance = df_user['distance_km'].mean() | |
| longest_trip = df_user['distance_km'].max() | |
| return { | |
| "status": "success", | |
| "message": f"📊 **Monthly Statistics - {month} {year}**\n\n" | |
| f"━━━━━━━━━━━━━━━━━━━━\n" | |
| f"📝 Total Trips: **{total_trips}**\n" | |
| f"📏 Total Distance: **{total_distance:.2f} km**\n" | |
| f"⭐ Average Trip: **{avg_distance:.2f} km**\n" | |
| f"🔝 Longest Trip: **{longest_trip:.2f} km**\n" | |
| f"━━━━━━━━━━━━━━━━━━━━", | |
| "chat_id": chat_id | |
| } | |
| async def help_command(data: dict = None): | |
| chat_id = data.get("chat_id") if data else None | |
| return { | |
| "status": "success", | |
| "message": "🤖 **Mileage Tracker Bot**\n\n" | |
| "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" | |
| "**📋 HOW TO USE**\n" | |
| "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" | |
| "**1. Register First:**\n" | |
| "• Type: `/register Your Name your_email@example.com`\n\n" | |
| "**2. Start a trip:**\n" | |
| "• Type `start` with details:\n" | |
| " start\n" | |
| " Name: Place Name\n" | |
| " From: KLCC\n" | |
| " Purpose: Meeting\n" | |
| "• Or simply: `start KLCC`\n" | |
| "• Tap the 📍 button to share your location\n\n" | |
| "**3. End a trip:**\n" | |
| "• Type `end Office`\n" | |
| "• Tap the 📍 button to share your location\n\n" | |
| "**4. Get report:**\n" | |
| "• Type `report June 2026`\n" | |
| "• Click the download link to save your Excel report\n\n" | |
| "**5. View stats:**\n" | |
| "• `/stats` - Monthly breakdown (last 12 months)\n" | |
| "• `/stats June 2026` - Monthly statistics\n\n" | |
| "**6. Subscription:**\n" | |
| f"• `/subscribe` - Pay RM{SUBSCRIPTION_AMOUNT/100:.2f} for monthly access\n" | |
| "• `/status` - Check subscription status\n\n" | |
| "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" | |
| "**📍 Commands:**\n" | |
| "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" | |
| "• `/register [Name] [Email]` - Register with name and email\n" | |
| "• `start` - Begin a trip (with Name, From, Purpose)\n" | |
| "• `end [place]` - Complete a trip\n" | |
| "• `report [Month Year]` - Get Excel report\n" | |
| "• `/stats` - Monthly breakdown (last 12 months)\n" | |
| "• `/stats [Month Year]` - Monthly statistics\n" | |
| "• `/subscribe` - Monthly subscription\n" | |
| "• `/status` - Subscription status\n" | |
| "• `/start` - Show this help", | |
| "chat_id": chat_id | |
| } | |
| async def error_handler(data: dict = None): | |
| chat_id = data.get("chat_id") if data else None | |
| return { | |
| "status": "error", | |
| "message": "⚠️ I didn't understand that command. Send /start for help.", | |
| "chat_id": chat_id | |
| } | |
| # ============ TOYYIBPAY WEBHOOK ============ | |
| async def toyyibpay_webhook(request: Request): | |
| """Handle ToyyibPay payment callback""" | |
| try: | |
| form_data = await request.form() | |
| data = dict(form_data) | |
| logger.info(f"ToyyibPay webhook received: {data}") | |
| bill_code = data.get("billcode") | |
| status = data.get("status") | |
| telegram_id = data.get("order_id") or data.get("external_reference_no") | |
| bill_amount = data.get("amount", "0") | |
| transaction_id = data.get("transaction_id") or data.get("refno") or bill_code | |
| logger.info(f"Processing: bill_code={bill_code}, status={status}, user={telegram_id}, amount={bill_amount}") | |
| if status == "1": | |
| if not telegram_id: | |
| logger.error("No user ID found in webhook data") | |
| return {"status": "error", "message": "Missing user ID"} | |
| telegram_id = str(telegram_id) | |
| current_month = get_current_month() | |
| logger.info(f"Payment success for user {telegram_id}, month {current_month}, bill {bill_code}") | |
| user = get_user(telegram_id) | |
| user_name = user.get('full_name', 'User') if user else 'User' | |
| if has_paid_for_month(telegram_id, current_month): | |
| logger.info(f"User {telegram_id} already paid for {current_month}") | |
| return {"status": "success", "message": "Already paid"} | |
| payment_data = { | |
| "payment_id": str(uuid.uuid4())[:8], | |
| "user_id": str(telegram_id), | |
| "user_name": str(user_name), | |
| "month": str(current_month.split("-")[1]), | |
| "year": str(current_month.split("-")[0]), | |
| "amount": float(bill_amount) if bill_amount else float(SUBSCRIPTION_AMOUNT / 100), | |
| "payment_date": datetime.now().isoformat(), | |
| "transaction_id": str(transaction_id), | |
| "status": "completed" | |
| } | |
| save_payment(payment_data) | |
| logger.info(f"Attempting to add paid month for user {telegram_id}, month {current_month}") | |
| if add_paid_month(telegram_id, current_month): | |
| logger.info(f"✅ User {telegram_id} marked as paid for {current_month}") | |
| try: | |
| await send_telegram_message( | |
| int(telegram_id), | |
| f"✅ **Payment Received!**\n\n" | |
| f"Thank you for your payment for {get_current_month_display()}!\n\n" | |
| f"✅ Your subscription is now active until the end of the month.\n" | |
| f"✅ You can now generate reports with `report {get_current_month_display()}`\n\n" | |
| f"Send `/status` to check your subscription.", | |
| parse_mode="Markdown" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to send confirmation message: {e}") | |
| return {"status": "success", "message": "Payment processed successfully"} | |
| else: | |
| logger.error(f"❌ Failed to add paid month for user {telegram_id}") | |
| return {"status": "error", "message": "Failed to update user status"} | |
| else: | |
| logger.info(f"Payment status not successful: {status}") | |
| return {"status": "error", "message": f"Payment status: {status}"} | |
| except Exception as e: | |
| logger.error(f"ToyyibPay webhook error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return {"status": "error", "message": str(e)} | |
| # ============ ENHANCED TELEGRAM WEBHOOK ============ | |
| async def telegram_webhook(request: Request): | |
| try: | |
| body = await request.json() | |
| if "message" not in body: | |
| return {"status": "ok", "message": "", "chat_id": None} | |
| message = body["message"] | |
| chat_id = message["chat"]["id"] | |
| user_id = str(message["from"]["id"]) | |
| text = message.get("text", "").strip() | |
| # Handle /register command | |
| if text.lower().startswith("/register"): | |
| parts = text.replace("/register", "").strip().split() | |
| if len(parts) < 2: | |
| reply = "❌ Please provide your name and email.\n\n" | |
| reply += "Format:\n" | |
| reply += "`/register Your Name your_email@example.com`\n\n" | |
| reply += "Example:\n" | |
| reply += "`/register Ahmad Ahmad@email.com`" | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| # Extract name and email | |
| # If there are more than 2 parts, the last part is email, the rest is name | |
| email = parts[-1] | |
| name_parts = parts[:-1] | |
| full_name = " ".join(name_parts) | |
| # Validate email | |
| if '@' not in email or '.' not in email: | |
| reply = "❌ Invalid email address. Please provide a valid email.\n\n" | |
| reply += "Format:\n" | |
| reply += "`/register Your Name your_email@example.com`" | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| register_data = { | |
| "telegram_id": user_id, | |
| "username": message["from"].get("username", "User"), | |
| "full_name": full_name, | |
| "email": email, | |
| "chat_id": chat_id | |
| } | |
| register_response = await register_user(register_data) | |
| return register_response | |
| elif text == "/subscribe": | |
| # Check if user is registered first | |
| if not is_registered(user_id): | |
| reply = "❌ **Please register first!**\n\n" | |
| reply += "You need to register with your name and email before subscribing.\n\n" | |
| reply += "**How to register:**\n" | |
| reply += "Send: `/register Your Name your_email@example.com`\n\n" | |
| reply += "Example:\n" | |
| reply += "`/register Ahmad Ahmad@email.com`" | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| if has_paid_for_month(user_id, get_current_month()): | |
| reply = f"✅ You are already subscribed for {get_current_month_display()}!\nSend /status to check." | |
| return { | |
| "status": "success", | |
| "message": reply, | |
| "chat_id": chat_id | |
| } | |
| user = get_user(user_id) | |
| user_name = user.get('full_name', 'User') if user else 'User' | |
| user_email = user.get('email', '') | |
| bill = await create_toyyibpay_bill(int(user_id), user_name, user_email, get_current_month()) | |
| if bill.get("success"): | |
| reply = (f"🔗 **Subscribe to Mileage Tracker**\n\n" | |
| f"📅 **Month:** {get_current_month_display()}\n" | |
| f"💰 **Amount:** RM{SUBSCRIPTION_AMOUNT/100:.2f}\n" | |
| f"📧 **Email:** {user_email}\n\n" | |
| f"Click here to pay: {bill['url']}\n\n" | |
| f"💡 *After payment, your subscription will be activated automatically.*") | |
| return { | |
| "status": "success", | |
| "message": reply, | |
| "chat_id": chat_id, | |
| "payment_url": bill['url'] | |
| } | |
| else: | |
| error_msg = bill.get('error', 'Please try again.') | |
| reply = f"❌ Error creating payment link: {error_msg}" | |
| if "Phone" in error_msg or "phone" in error_msg: | |
| reply += "\n\nℹ️ Please contact the administrator to fix the payment configuration." | |
| return { | |
| "status": "error", | |
| "message": reply, | |
| "chat_id": chat_id | |
| } | |
| elif text == "/status": | |
| user = get_user(user_id) | |
| role = user.get('role', 'user') if user else 'user' | |
| email = user.get('email', 'Not provided') if user else 'Not provided' | |
| if is_admin(user_id): | |
| reply = "👑 **Admin Account**\n\n" | |
| reply += "✅ Full access (free)\n" | |
| reply += "📅 All months available\n" | |
| reply += f"👤 Role: {role.upper()}\n" | |
| reply += f"📧 Email: {email}" | |
| elif has_paid_for_month(user_id, get_current_month()): | |
| last_payment = user.get('last_payment_date', 'Unknown') | |
| paid_months_raw = user.get('paid_months', '[]') | |
| paid_months = safe_parse_paid_months(paid_months_raw) | |
| reply = f"✅ **Active Subscription**\n\n" | |
| reply += f"📅 **Paid for:** {get_current_month_display()}\n" | |
| reply += f"📅 **Last payment:** {last_payment[:10] if last_payment != 'Unknown' else 'Unknown'}\n" | |
| reply += f"👤 **Role:** {role.upper()}\n" | |
| reply += f"📧 **Email:** {email}\n\n" | |
| if paid_months: | |
| reply += f"📋 **Payment History:**\n" | |
| for month in paid_months[-6:]: | |
| reply += f"• {datetime.strptime(month, '%Y-%m').strftime('%B %Y')}\n" | |
| reply += f"\nSend `/report {get_current_month_display()}` to get your report." | |
| elif role == 'subscriber': | |
| reply = f"📝 **Subscription Expired**\n\n" | |
| reply += f"👤 **Role:** {role.upper()}\n" | |
| reply += f"📧 **Email:** {email}\n" | |
| reply += f"💰 **Current month:** {get_current_month_display()}\n" | |
| reply += f"💵 **Amount:** RM{SUBSCRIPTION_AMOUNT/100:.2f}\n\n" | |
| reply += f"Send `/subscribe` to renew your subscription." | |
| else: | |
| reply = f"📝 **No Active Subscription**\n\n" | |
| reply += f"👤 **Role:** {role.upper()}\n" | |
| reply += f"📧 **Email:** {email}\n" | |
| reply += f"You are a regular user. To generate reports, you need to subscribe.\n\n" | |
| reply += f"💰 **Amount:** RM{SUBSCRIPTION_AMOUNT/100:.2f}/month\n" | |
| reply += f"Send `/subscribe` to subscribe now." | |
| return { | |
| "status": "success", | |
| "message": reply, | |
| "chat_id": chat_id | |
| } | |
| elif text.lower().startswith("report "): | |
| # Check if user is registered | |
| if not is_registered(user_id): | |
| reply = "❌ **Please register first!**\n\n" | |
| reply += "You need to register with your name and email before generating reports.\n\n" | |
| reply += "**How to register:**\n" | |
| reply += "Send: `/register Your Name your_email@example.com`\n\n" | |
| reply += "Example:\n" | |
| reply += "`/register Ahmad Ahmad@email.com`" | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| parts = text.replace("report", "").strip().split() | |
| if len(parts) < 2: | |
| reply = "Usage: `report June 2026`" | |
| return {"status": "success", "message": reply, "chat_id": chat_id} | |
| month_name = parts[0].capitalize() | |
| year = parts[1] | |
| display_month = f"{month_name} {year}" | |
| try: | |
| dt = datetime.strptime(display_month, "%B %Y") | |
| month_str = dt.strftime("%Y-%m") | |
| except: | |
| reply = "Invalid date format. Example: `report June 2026`" | |
| return {"status": "success", "message": reply, "chat_id": chat_id} | |
| report_data = {"user_id": user_id, "month": month_name, "year": year, "chat_id": chat_id} | |
| report_response = await request_report(report_data) | |
| if report_response.get("status") == "success": | |
| return {"status": "success", "message": report_response["message"], "chat_id": chat_id} | |
| else: | |
| reply = report_response.get("message", "Error generating report") | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| elif text.startswith("/stats"): | |
| # Check if user is registered | |
| if not is_registered(user_id): | |
| reply = "❌ **Please register first!**\n\n" | |
| reply += "You need to register with your name and email before viewing stats.\n\n" | |
| reply += "**How to register:**\n" | |
| reply += "Send: `/register Your Name your_email@example.com`\n\n" | |
| reply += "Example:\n" | |
| reply += "`/register Ahmad Ahmad@email.com`" | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| parts = text.replace("/stats", "").strip().split() | |
| if len(parts) >= 2: | |
| month = parts[0].capitalize() | |
| year = parts[1] | |
| try: | |
| datetime.strptime(f"{month} {year}", "%B %Y") | |
| stats_data = {"user_id": user_id, "month": month, "year": year, "chat_id": chat_id} | |
| stats_response = await get_stats_by_month(stats_data) | |
| return stats_response | |
| except ValueError: | |
| reply = "❌ Invalid date format. Use: `/stats June 2026`\nExample: `/stats January 2025`" | |
| return {"status": "error", "message": reply, "chat_id": chat_id} | |
| else: | |
| stats_data = {"user_id": user_id, "chat_id": chat_id} | |
| stats_response = await get_stats(stats_data) | |
| return stats_response | |
| elif text.lower().startswith("start"): | |
| start_data = { | |
| "user_id": user_id, | |
| "text": text, | |
| "username": message["from"].get("username", "User"), | |
| "chat_id": chat_id | |
| } | |
| start_response = await start_trip(start_data) | |
| return start_response | |
| else: | |
| if message.get("location"): | |
| return {"status": "not_found", "message": "", "chat_id": chat_id} | |
| # Check if it's a plain text that might be a start command without "start" | |
| # For example: "KLCC" could be interpreted as "start KLCC" | |
| if text and not text.startswith('/') and not text.startswith('end'): | |
| # Try to handle as a start command | |
| start_data = { | |
| "user_id": user_id, | |
| "text": f"start {text}", | |
| "username": message["from"].get("username", "User"), | |
| "chat_id": chat_id | |
| } | |
| start_response = await start_trip(start_data) | |
| if start_response.get("status") != "error": | |
| return start_response | |
| help_response = await help_command({"chat_id": chat_id}) | |
| return help_response | |
| except Exception as e: | |
| logger.error(f"Telegram webhook error: {e}") | |
| return {"status": "error", "message": str(e), "chat_id": None} | |
| # ============ ADMIN ENDPOINTS ============ | |
| async def migrate_subscription(admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if 'paid_months' not in df.columns: | |
| df['paid_months'] = '[]' | |
| if 'reminder_1st_sent' not in df.columns: | |
| df['reminder_1st_sent'] = False | |
| if 'reminder_15th_sent' not in df.columns: | |
| df['reminder_15th_sent'] = False | |
| if 'reminder_last_sent' not in df.columns: | |
| df['reminder_last_sent'] = False | |
| if 'last_paid_month' not in df.columns: | |
| df['last_paid_month'] = None | |
| if 'last_payment_date' not in df.columns: | |
| df['last_payment_date'] = None | |
| if 'role' not in df.columns: | |
| df['role'] = 'user' | |
| if ADMIN_TELEGRAM_ID in df['telegram_id'].astype(str).values: | |
| idx = df[df['telegram_id'].astype(str) == str(ADMIN_TELEGRAM_ID)].index[0] | |
| current_month = get_current_month() | |
| paid_months = safe_parse_paid_months(df.at[idx, 'paid_months']) | |
| if current_month not in paid_months: | |
| paid_months.append(current_month) | |
| df.at[idx, 'paid_months'] = json.dumps(paid_months) | |
| df.at[idx, 'role'] = 'admin' | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| return { | |
| "status": "success", | |
| "message": "Subscription columns added", | |
| "current_month": get_current_month() | |
| } | |
| async def admin_sync_all(admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized. Provide valid admin_key"} | |
| trips_result = sync_trips_to_hf() | |
| users_result = sync_users_to_hf() | |
| payments_result = sync_payments_to_hf() | |
| return { | |
| "status": "success", | |
| "trips_synced": trips_result, | |
| "users_synced": users_result, | |
| "payments_synced": payments_result, | |
| "message": "Sync completed - datasets now match local files" | |
| } | |
| # ============ ADMIN ENDPOINTS FOR DASHBOARD ============ | |
| async def admin_get_users(admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "success", "users": []} | |
| if 'role' not in df.columns: | |
| df['role'] = 'user' | |
| df = df.fillna("") | |
| return {"status": "success", "users": df.to_dict(orient="records")} | |
| async def admin_rate_status(admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| try: | |
| response = requests.get("https://huggingface.co/api/datasets", timeout=10) | |
| rate_limit = response.headers.get("RateLimit", "") | |
| limit = 1000 | |
| remaining = 1000 | |
| reset_seconds = 300 | |
| if rate_limit: | |
| parts = rate_limit.split(";") | |
| for part in parts: | |
| if part.startswith("r="): | |
| try: | |
| remaining = int(part.split("=")[1]) | |
| except: | |
| pass | |
| elif part.startswith("t="): | |
| try: | |
| reset_seconds = int(part.split("=")[1]) | |
| except: | |
| pass | |
| used = limit - remaining | |
| usage_percent = round((used / limit) * 100, 1) if limit > 0 else 0 | |
| return { | |
| "status": "success", | |
| "rate_limit": { | |
| "limit_per_5min": limit, | |
| "remaining": remaining, | |
| "used": used, | |
| "usage_percent": usage_percent, | |
| "reset_seconds": reset_seconds, | |
| "reset_seconds_formatted": f"{reset_seconds // 60}m {reset_seconds % 60}s" | |
| } | |
| } | |
| except Exception as e: | |
| return {"status": "error", "message": str(e)} | |
| async def admin_get_trip(trip_id: str): | |
| df = load_trips() | |
| if df.empty: | |
| return {"status": "error", "message": "No trips found"} | |
| trip = df[df['trip_id'] == trip_id] | |
| if trip.empty: | |
| return {"status": "error", "message": f"Trip {trip_id} not found"} | |
| trip = trip.replace([float('inf'), float('-inf')], 0) | |
| trip = trip.fillna("") | |
| return {"status": "success", "trip": trip.iloc[0].to_dict()} | |
| async def admin_delete_trip(trip_id: str, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized. Provide valid admin_key"} | |
| df = load_trips() | |
| if df.empty: | |
| return {"status": "error", "message": "No trips found"} | |
| if trip_id not in df['trip_id'].values: | |
| return {"status": "error", "message": f"Trip {trip_id} not found"} | |
| df = df[df['trip_id'] != trip_id] | |
| df.to_csv(TRIPS_FILE, index=False) | |
| sync_trips_to_hf() | |
| return {"status": "success", "message": f"Trip {trip_id} deleted successfully"} | |
| async def admin_update_trip(trip_id: str, data: dict, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized. Provide valid admin_key"} | |
| df = load_trips() | |
| if df.empty: | |
| return {"status": "error", "message": "No trips found"} | |
| if trip_id not in df['trip_id'].values: | |
| return {"status": "error", "message": f"Trip {trip_id} not found"} | |
| idx = df[df['trip_id'] == trip_id].index[0] | |
| if 'start_place' in data: | |
| df.at[idx, 'start_place'] = data['start_place'] | |
| if 'end_place' in data: | |
| df.at[idx, 'end_place'] = data['end_place'] | |
| if 'distance_km' in data: | |
| df.at[idx, 'distance_km'] = float(data['distance_km']) | |
| if 'date' in data: | |
| df.at[idx, 'date'] = data['date'] | |
| date_obj = datetime.strptime(data['date'], "%Y-%m-%d") | |
| df.at[idx, 'month'] = date_obj.strftime("%B %Y") | |
| df.to_csv(TRIPS_FILE, index=False) | |
| sync_trips_to_hf() | |
| updated_trip = df.loc[idx].to_dict() | |
| return {"status": "success", "message": f"Trip {trip_id} updated successfully", "trip": updated_trip} | |
| async def admin_add_trip(data: dict, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized. Provide valid admin_key"} | |
| trip_date = data.get("date", datetime.now().strftime("%Y-%m-%d")) | |
| date_obj = datetime.strptime(trip_date, "%Y-%m-%d") | |
| trip_data = { | |
| "trip_id": str(uuid.uuid4())[:8], | |
| "user_id": str(data.get("user_id", "admin")), | |
| "user_name": data.get("user_name", "Admin"), | |
| "timestamp": datetime.now().isoformat(), | |
| "date": trip_date, | |
| "month": date_obj.strftime("%B %Y"), | |
| "start_place": data.get("start_place", ""), | |
| "start_gps_lat": float(data.get("start_gps_lat", 0.0)), | |
| "start_gps_lon": float(data.get("start_gps_lon", 0.0)), | |
| "end_place": data.get("end_place", ""), | |
| "end_gps_lat": float(data.get("end_gps_lat", 0.0)), | |
| "end_gps_lon": float(data.get("end_gps_lon", 0.0)), | |
| "distance_km": float(data.get("distance_km", 0.0)), | |
| "status": "approved", | |
| "purpose": data.get("purpose", "") | |
| } | |
| total = save_trip(trip_data) | |
| return {"status": "success", "message": "Trip added successfully", "trip": trip_data, "total_trips": total} | |
| async def admin_activate_user(telegram_id: str, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| df.at[idx, 'is_active'] = True | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| return {"status": "success", "message": f"User {telegram_id} activated successfully"} | |
| async def admin_deactivate_user(telegram_id: str, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| df.at[idx, 'is_active'] = False | |
| df.to_csv(USERS_FILE, index=False) | |
| sync_users_to_hf() | |
| return {"status": "success", "message": f"User {telegram_id} deactivated successfully"} | |
| async def admin_promote_user(telegram_id: str, admin_key: str = None, requester_id: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| try: | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| df.at[idx, 'is_admin'] = True | |
| df.at[idx, 'role'] = 'admin' | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ User {telegram_id} promoted to admin in CSV") | |
| # Force sync to HF and get result | |
| sync_result = sync_users_to_hf() | |
| if sync_result: | |
| logger.info(f"✅ User {telegram_id} synced to HF Dataset") | |
| else: | |
| logger.warning(f"⚠️ User {telegram_id} promoted but sync to HF failed") | |
| return { | |
| "status": "success" if sync_result else "warning", | |
| "message": f"User {telegram_id} is now an Admin (role: admin)", | |
| "role": "admin", | |
| "synced": sync_result, | |
| "sync_message": "Synced to HF" if sync_result else "CSV updated but sync to HF failed. Please check logs." | |
| } | |
| except Exception as e: | |
| logger.error(f"Error promoting user: {e}") | |
| return {"status": "error", "message": str(e)} | |
| async def admin_demote_user(telegram_id: str, admin_key: str = None, requester_id: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| try: | |
| df = load_users() | |
| if df.empty: | |
| return {"status": "error", "message": "No users found"} | |
| if telegram_id not in df['telegram_id'].astype(str).values: | |
| return {"status": "error", "message": f"User {telegram_id} not found"} | |
| if str(telegram_id) == str(requester_id): | |
| return {"status": "error", "message": "You cannot demote yourself"} | |
| if str(telegram_id) == str(ADMIN_TELEGRAM_ID): | |
| return {"status": "error", "message": "Cannot demote the main admin"} | |
| idx = df[df['telegram_id'].astype(str) == str(telegram_id)].index[0] | |
| df.at[idx, 'is_admin'] = False | |
| df.at[idx, 'role'] = 'user' | |
| df.to_csv(USERS_FILE, index=False) | |
| logger.info(f"✅ User {telegram_id} demoted to user in CSV") | |
| # Force sync to HF and get result | |
| sync_result = sync_users_to_hf() | |
| if sync_result: | |
| logger.info(f"✅ User {telegram_id} synced to HF Dataset") | |
| else: | |
| logger.warning(f"⚠️ User {telegram_id} demoted but sync to HF failed") | |
| return { | |
| "status": "success" if sync_result else "warning", | |
| "message": f"User {telegram_id} demoted to User (role: user)", | |
| "role": "user", | |
| "synced": sync_result, | |
| "sync_message": "Synced to HF" if sync_result else "CSV updated but sync to HF failed. Please check logs." | |
| } | |
| except Exception as e: | |
| logger.error(f"Error demoting user: {e}") | |
| return {"status": "error", "message": str(e)} | |
| async def admin_user_stats(telegram_id: str, admin_key: str = None): | |
| if admin_key != ADMIN_KEY: | |
| return {"status": "error", "message": "Unauthorized"} | |
| df = load_trips() | |
| if df.empty: | |
| return {"status": "success", "stats": { | |
| "total_trips": 0, | |
| "total_distance": 0, | |
| "avg_distance": 0, | |
| "longest_trip": 0, | |
| "shortest_trip": 0, | |
| "monthly_breakdown": [] | |
| }} | |
| user_trips = df[df['user_id'].astype(str) == str(telegram_id)] | |
| if user_trips.empty: | |
| return {"status": "success", "stats": { | |
| "total_trips": 0, | |
| "total_distance": 0, | |
| "avg_distance": 0, | |
| "longest_trip": 0, | |
| "shortest_trip": 0, | |
| "monthly_breakdown": [] | |
| }} | |
| monthly = user_trips.groupby('month')['distance_km'].agg(['sum', 'count', 'mean']).round(2) | |
| monthly = monthly.reset_index() | |
| monthly.columns = ['month', 'total_distance', 'trip_count', 'avg_distance'] | |
| return { | |
| "status": "success", | |
| "stats": { | |
| "total_trips": len(user_trips), | |
| "total_distance": round(user_trips['distance_km'].sum(), 2), | |
| "avg_distance": round(user_trips['distance_km'].mean(), 2), | |
| "longest_trip": round(user_trips['distance_km'].max(), 2), | |
| "shortest_trip": round(user_trips['distance_km'].min(), 2), | |
| "monthly_breakdown": monthly.to_dict(orient='records') | |
| } | |
| } | |
| # ============ TELEGRAM MESSAGE HELPER ============ | |
| async def send_telegram_message(chat_id: int, text: str, parse_mode: str = None): | |
| url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage" | |
| payload = {"chat_id": chat_id, "text": text} | |
| if parse_mode: | |
| payload["parse_mode"] = parse_mode | |
| try: | |
| async with aiohttp.ClientSession() as session: | |
| await session.post(url, json=payload) | |
| except Exception as e: | |
| logger.error(f"Failed to send message: {e}") | |
| # ============ STARTUP EVENT ============ | |
| async def startup_event(): | |
| logger.info("🚀 Starting Mileage Tracker Bot...") | |
| restore_data_from_hf() | |
| asyncio.create_task(subscription_cleanup_job()) | |
| logger.info("✅ Startup complete") | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=7860) |