Refactor code for improved readability and consistency

- Cleaned up whitespace and formatting in various files including http.py, language.py, logger.py, safe_exec.py, and SQL migration scripts.
- Consolidated import statements and removed unnecessary blank lines.
- Updated logging configuration for better clarity.
- Enhanced the safe execution code with improved error handling and logging.
- Removed commented-out code and unnecessary variables in backfill_zero_trades.py and other scripts.
- Added a pyproject.toml for Ruff and Vulture configuration.
- Introduced requirements-dev.txt for development dependencies.
- Removed commented-out stock entries in init.sql for cleaner migration scripts.
This commit is contained in:
dienakdz
2026-04-09 14:30:51 +07:00
parent 103055b3df
commit 87f2845483
157 changed files with 19026 additions and 17773 deletions
+152 -147
View File
@@ -3,22 +3,24 @@ User Service - Multi-user management
Handles user CRUD operations, password hashing, and role management.
"""
import hashlib
import re
import time
import os
from typing import Optional, Dict, Any, List
import re
from typing import Any, Dict, List, Optional
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
# IANA timezone id subset check (e.g. Asia/Shanghai, America/New_York)
_TIMEZONE_ID_RE = re.compile(r'^[A-Za-z0-9_/+\-.]+$')
_TIMEZONE_ID_RE = re.compile(r"^[A-Za-z0-9_/+\-.]+$")
# Try to import bcrypt for secure password hashing
try:
import bcrypt
HAS_BCRYPT = True
except ImportError:
HAS_BCRYPT = False
@@ -27,50 +29,60 @@ except ImportError:
class UserService:
"""User management service"""
# Available roles (ordered by privilege level)
ROLES = ['viewer', 'user', 'manager', 'admin']
ROLES = ["viewer", "user", "manager", "admin"]
# Role permissions mapping
ROLE_PERMISSIONS = {
'viewer': ['dashboard', 'view'],
'user': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'],
'manager': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings'],
'admin': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings', 'user_manage', 'credentials'],
"viewer": ["dashboard", "view"],
"user": ["dashboard", "view", "indicator", "backtest", "strategy", "portfolio"],
"manager": ["dashboard", "view", "indicator", "backtest", "strategy", "portfolio", "settings"],
"admin": [
"dashboard",
"view",
"indicator",
"backtest",
"strategy",
"portfolio",
"settings",
"user_manage",
"credentials",
],
}
def hash_password(self, password: str) -> str:
"""Hash password using bcrypt (preferred) or SHA256 (fallback)"""
if HAS_BCRYPT:
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
else:
# Fallback to SHA256 with salt
salt = os.urandom(16).hex()
hashed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest()
hashed = hashlib.sha256((password + salt).encode("utf-8")).hexdigest()
return f"sha256${salt}${hashed}"
def verify_password(self, password: str, password_hash: str) -> bool:
"""Verify password against hash"""
if password_hash.startswith('$2b$') or password_hash.startswith('$2a$'):
if password_hash.startswith("$2b$") or password_hash.startswith("$2a$"):
# bcrypt hash
if HAS_BCRYPT:
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except Exception:
return False
return False
elif password_hash.startswith('sha256$'):
elif password_hash.startswith("sha256$"):
# SHA256 fallback hash
parts = password_hash.split('$')
parts = password_hash.split("$")
if len(parts) != 3:
return False
salt = parts[1]
stored_hash = parts[2]
computed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest()
computed = hashlib.sha256((password + salt).encode("utf-8")).hexdigest()
return computed == stored_hash
return False
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Get user by ID"""
try:
@@ -82,7 +94,7 @@ class UserService:
credits, vip_expires_at, timezone, last_login_at, created_at, updated_at
FROM qd_users WHERE id = ?
""",
(user_id,)
(user_id,),
)
row = cur.fetchone()
cur.close()
@@ -90,7 +102,7 @@ class UserService:
except Exception as e:
logger.error(f"get_user_by_id failed: {e}")
return None
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user by username (includes password_hash for auth)"""
try:
@@ -102,7 +114,7 @@ class UserService:
status, role, timezone, last_login_at, created_at, updated_at
FROM qd_users WHERE username = ?
""",
(username,)
(username,),
)
row = cur.fetchone()
cur.close()
@@ -110,7 +122,7 @@ class UserService:
except Exception as e:
logger.error(f"get_user_by_username failed: {e}")
return None
def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
"""Get user by email (includes password_hash for auth)"""
if not email:
@@ -124,7 +136,7 @@ class UserService:
status, role, timezone, last_login_at, created_at, updated_at
FROM qd_users WHERE LOWER(email) = LOWER(?)
""",
(email,)
(email,),
)
row = cur.fetchone()
cur.close()
@@ -132,7 +144,7 @@ class UserService:
except Exception as e:
logger.error(f"get_user_by_email failed: {e}")
return None
def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
"""
Authenticate user with username/email and password.
@@ -141,38 +153,35 @@ class UserService:
"""
# Try username first
user = self.get_user_by_username(username)
# If not found, try email (supports both username and email login)
if not user:
user = self.get_user_by_email(username)
if not user:
return None
if user.get('status') != 'active':
if user.get("status") != "active":
logger.warning(f"Login attempt for disabled user: {username}")
return None
password_hash = user.get('password_hash', '')
password_hash = user.get("password_hash", "")
# Check if user has no password (code-login user)
if not password_hash or password_hash.strip() == '':
if not password_hash or password_hash.strip() == "":
logger.info(f"Password login attempted for code-login user: {username}")
# Return a special marker to indicate no password set
# This allows the caller to provide a more specific error message
return {'_no_password': True, **user}
return {"_no_password": True, **user}
if not self.verify_password(password, password_hash):
return None
# Update last login time
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
(user['id'],)
)
cur.execute("UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (user["id"],))
db.commit()
affected = cur.rowcount
cur.close()
@@ -182,45 +191,42 @@ class UserService:
logger.info(f"Updated last_login_at for user_id={user['id']}")
except Exception as e:
logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}")
# Remove password_hash from return value
user.pop('password_hash', None)
user.pop("password_hash", None)
return user
def get_token_version(self, user_id: int) -> int:
"""
Get the user's current token version number.
Args:
user_id: user ID
Returns:
Current token version number, default is 1
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
)
cur.execute("SELECT token_version FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
if row:
return int(row.get('token_version') or 1)
return int(row.get("token_version") or 1)
return 1
except Exception as e:
logger.error(f"get_token_version failed: {e}")
return 1
def increment_token_version(self, user_id: int) -> int:
"""
Increment the user's token version number and invalidate the old token.
Used to implement single client login (kick out other devices).
Args:
user_id: user ID
Returns:
New token version number
"""
@@ -230,33 +236,30 @@ class UserService:
# Increment token_version
cur.execute(
"""
UPDATE qd_users
UPDATE qd_users
SET token_version = COALESCE(token_version, 0) + 1, updated_at = NOW()
WHERE id = ?
""",
(user_id,)
(user_id,),
)
db.commit()
# Get new token_version
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
)
cur.execute("SELECT token_version FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
new_version = int(row.get('token_version') or 1) if row else 1
new_version = int(row.get("token_version") or 1) if row else 1
logger.info(f"Incremented token_version for user_id={user_id} to {new_version}")
return new_version
except Exception as e:
logger.error(f"increment_token_version failed: {e}")
return 1
def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]:
"""
Create a new user.
Args:
data: dict with user fields, OR use keyword arguments:
username: str (required),
@@ -267,7 +270,7 @@ class UserService:
status: str (optional, default 'active'),
email_verified: bool (optional, default False),
referred_by: int (optional, referrer user ID)
Returns:
New user ID or None if failed
"""
@@ -276,99 +279,99 @@ class UserService:
data = kwargs
else:
data = {**data, **kwargs}
username = (data.get('username') or '').strip()
password = data.get('password') # Can be None for code-login users
username = (data.get("username") or "").strip()
password = data.get("password") # Can be None for code-login users
if not username:
raise ValueError("Username is required")
if len(username) < 3 or len(username) > 50:
raise ValueError("Username must be 3-50 characters")
# Password validation only if provided
if password and len(password) < 6:
raise ValueError("Password must be at least 6 characters")
# Check if username already exists
existing = self.get_user_by_username(username)
if existing:
raise ValueError("Username already exists")
# Hash password or use empty string for code-login users
password_hash = self.hash_password(password) if password else ''
email = (data.get('email') or '').strip() or None
nickname = (data.get('nickname') or '').strip() or username
role = data.get('role', 'user')
status = data.get('status', 'active')
email_verified = data.get('email_verified', False)
referred_by = data.get('referred_by') # Referrer user ID
password_hash = self.hash_password(password) if password else ""
email = (data.get("email") or "").strip() or None
nickname = (data.get("nickname") or "").strip() or username
role = data.get("role", "user")
status = data.get("status", "active")
email_verified = data.get("email_verified", False)
referred_by = data.get("referred_by") # Referrer user ID
if role not in self.ROLES:
role = 'user'
role = "user"
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_users
INSERT INTO qd_users
(username, password_hash, email, nickname, role, status, email_verified, referred_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(username, password_hash, email, nickname, role, status, email_verified, referred_by)
(username, password_hash, email, nickname, role, status, email_verified, referred_by),
)
db.commit()
user_id = cur.lastrowid
cur.close()
# For PostgreSQL, get the ID differently
if user_id is None:
cur = db.cursor()
cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,))
row = cur.fetchone()
user_id = row['id'] if row else None
user_id = row["id"] if row else None
cur.close()
logger.info(f"Created user: {username} (id={user_id}, referred_by={referred_by})")
return user_id
except Exception as e:
logger.error(f"create_user failed: {e}")
raise
def update_user(self, user_id: int, data: Dict[str, Any]) -> bool:
"""
Update user information.
Args:
user_id: User ID
data: Fields to update (email, nickname, avatar, role, status)
"""
allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status', 'timezone']
allowed_fields = ["email", "nickname", "avatar", "role", "status", "timezone"]
updates = []
values = []
for field in allowed_fields:
if field in data:
value = data[field]
if field == 'role' and value not in self.ROLES:
if field == "role" and value not in self.ROLES:
continue
if field == 'timezone':
s = '' if value is None else str(value).strip()
if field == "timezone":
s = "" if value is None else str(value).strip()
if s and (len(s) > 64 or not _TIMEZONE_ID_RE.match(s)):
continue
updates.append('timezone = ?')
updates.append("timezone = ?")
values.append(s)
continue
updates.append(f"{field} = ?")
values.append(value)
if not updates:
return False
updates.append("updated_at = NOW()")
values.append(user_id)
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -380,49 +383,48 @@ class UserService:
except Exception as e:
logger.error(f"update_user failed: {e}")
return False
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
"""Change user password (requires old password verification, except for users with no password)"""
user = self.get_user_by_id(user_id)
if not user:
return False
# Get full user with password_hash
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
if not row:
return False
password_hash = row.get('password_hash', '')
password_hash = row.get("password_hash", "")
# If user has no password (code-login user), allow setting password without old password
if not password_hash or password_hash.strip() == '':
if not password_hash or password_hash.strip() == "":
logger.info(f"Setting initial password for code-login user: {user_id}")
return self.reset_password(user_id, new_password)
# For users with existing password, verify old password
if not self.verify_password(old_password, password_hash):
return False
return self.reset_password(user_id, new_password)
def reset_password(self, user_id: int, new_password: str) -> bool:
"""Reset user password (admin operation, no old password required)"""
if len(new_password) < 6:
raise ValueError("Password must be at least 6 characters")
password_hash = self.hash_password(new_password)
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?",
(password_hash, user_id)
"UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?", (password_hash, user_id)
)
db.commit()
cur.close()
@@ -430,11 +432,11 @@ class UserService:
except Exception as e:
logger.error(f"reset_password failed: {e}")
return False
def update_password(self, user_id: int, new_password: str) -> bool:
"""Alias for reset_password - update user password without old password verification"""
return self.reset_password(user_id, new_password)
def delete_user(self, user_id: int) -> bool:
"""Delete a user"""
try:
@@ -447,15 +449,15 @@ class UserService:
except Exception as e:
logger.error(f"delete_user failed: {e}")
return False
def list_users(self, page: int = 1, page_size: int = 20, search: str = None) -> Dict[str, Any]:
"""List all users with pagination and optional search"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# Build WHERE clause for search
where_clause = ""
params = []
@@ -463,12 +465,12 @@ class UserService:
search_term = f"%{search.strip()}%"
where_clause = "WHERE username LIKE ? OR email LIKE ? OR nickname LIKE ?"
params = [search_term, search_term, search_term]
# Get total count
count_sql = f"SELECT COUNT(*) as count FROM qd_users {where_clause}"
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['count']
total = cur.fetchone()["count"]
# Get users
query_sql = f"""
SELECT id, username, email, nickname, avatar, status, role,
@@ -481,22 +483,22 @@ class UserService:
cur.execute(query_sql, tuple(params + [page_size, offset]))
users = cur.fetchall()
cur.close()
return {
'items': users,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size
"items": users,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size,
}
except Exception as e:
logger.error(f"list_users failed: {e}")
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0}
def get_user_permissions(self, role: str) -> List[str]:
"""Get permissions for a role"""
return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS['viewer'])
return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS["viewer"])
def ensure_admin_exists(self):
"""
Ensure at least one admin user exists.
@@ -506,24 +508,26 @@ class UserService:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT COUNT(*) as count FROM qd_users")
count = cur.fetchone()['count']
count = cur.fetchone()["count"]
cur.close()
if count == 0:
# Create admin using env credentials
admin_user = os.getenv('ADMIN_USER', 'admin')
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com')
admin_user = os.getenv("ADMIN_USER", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com")
self.create_user({
'username': admin_user,
'password': admin_password,
'email': admin_email,
'nickname': 'Administrator',
'role': 'admin',
'status': 'active',
'email_verified': True # Admin email is pre-verified
})
self.create_user(
{
"username": admin_user,
"password": admin_password,
"email": admin_email,
"nickname": "Administrator",
"role": "admin",
"status": "active",
"email_verified": True, # Admin email is pre-verified
}
)
logger.info(f"Created admin user: {admin_user} ({admin_email})")
except Exception as e:
logger.error(f"ensure_admin_exists failed: {e}")
@@ -532,6 +536,7 @@ class UserService:
# Global singleton
_user_service = None
def get_user_service() -> UserService:
"""Get UserService singleton"""
global _user_service