Milleage-tracker / main.py
yukee1992's picture
Update main.py
8b7cf48 verified
Raw
History Blame Contribute Delete
137 kB
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 ============
@app.get("/")
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
}
}
@app.get("/download-report/{filename}")
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"}
@app.get("/view-data")
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")
@app.get("/view-users")
async def view_users():
df = load_users()
if df.empty:
return {"message": "No users yet"}
df = df.fillna("")
return df.to_dict(orient="records")
@app.get("/view-payments")
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 ============
@app.get("/admin/diagnose-sync")
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
@app.get("/admin/test-sync")
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
@app.get("/admin/debug-user/{telegram_id}")
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')
}
}
@app.get("/debug-dataset/{dataset_name}")
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 ============
@app.post("/admin/restore-from-hf")
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
}
@app.post("/admin/fix-paid-months")
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
}
@app.post("/admin/mark-paid/{telegram_id}")
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"
}
@app.post("/admin/remove-paid/{telegram_id}")
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)}
@app.post("/admin/user/promote-subscriber/{telegram_id}")
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"
}
@app.post("/admin/user/demote-subscriber/{telegram_id}")
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"
}
@app.post("/admin/fix-roles")
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)}
@app.post("/admin/set-role/{telegram_id}")
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 ============
@app.post("/register")
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
}
@app.post("/start")
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
}
@app.post("/end")
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
}
@app.post("/location")
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
}
@app.post("/request-report")
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 ============
@app.post("/stats")
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
}
@app.post("/stats-month")
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
}
@app.post("/help")
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
}
@app.post("/error")
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 ============
@app.post("/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 ============
@app.post("/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 ============
@app.post("/admin/migrate-subscription")
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()
}
@app.post("/admin/sync-all")
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 ============
@app.get("/admin/users")
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")}
@app.get("/admin/rate-status")
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)}
@app.get("/admin/trip/{trip_id}")
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()}
@app.delete("/admin/delete-trip/{trip_id}")
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"}
@app.put("/admin/update-trip/{trip_id}")
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}
@app.post("/admin/add-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}
@app.post("/admin/user/activate/{telegram_id}")
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"}
@app.post("/admin/user/deactivate/{telegram_id}")
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"}
@app.post("/admin/user/promote/{telegram_id}")
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)}
@app.post("/admin/user/demote/{telegram_id}")
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)}
@app.get("/admin/user-stats/{telegram_id}")
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 ============
@app.on_event("startup")
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)