mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-03 19:00:13 -05:00
perf: optimize task listing and improve version management
- Bump version to 4.5.1 - Refactor version retrieval: make get_version_from_setup() public and add multiple path fallbacks for better reliability in production and development environments - Optimize task listing performance: * Replace joinedload with selectinload to avoid cartesian product issues * Implement optimized pagination that avoids expensive count queries when possible * Move AJAX request check earlier to skip unnecessary filter data loading * Add query limits to filter dropdowns (projects: 500, users: 200) * Optimize permission checks by checking is_admin first (no DB query) - Update API info endpoint and context processor to use centralized version retrieval - Maintain backward compatibility with _get_version_from_setup alias
This commit is contained in:
@@ -29,7 +29,7 @@ SENTRY_TRACES_RATE_DEFAULT = "0.1"
|
||||
TELE_ENABLED_DEFAULT = "false" # Disabled by default for privacy
|
||||
|
||||
|
||||
def _get_version_from_setup():
|
||||
def get_version_from_setup():
|
||||
"""
|
||||
Get the application version from setup.py.
|
||||
|
||||
@@ -37,34 +37,77 @@ def _get_version_from_setup():
|
||||
This function reads setup.py at runtime to get the current version.
|
||||
All other code should reference this function, not define versions themselves.
|
||||
|
||||
This function tries multiple paths to find setup.py to work correctly
|
||||
in both production and development modes.
|
||||
|
||||
Returns:
|
||||
str: Application version (e.g., "3.1.0") or "unknown" if setup.py can't be read
|
||||
str: Application version (e.g., "4.5.0") or "unknown" if setup.py can't be read
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
# Try multiple possible paths to setup.py
|
||||
possible_paths = []
|
||||
|
||||
# Path 1: Relative to this file (app/config/analytics_defaults.py -> setup.py)
|
||||
try:
|
||||
# Get path to setup.py (root of project)
|
||||
setup_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "setup.py")
|
||||
|
||||
# Read setup.py
|
||||
with open(setup_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract version using regex
|
||||
# Matches: version='X.Y.Z' or version="X.Y.Z"
|
||||
version_match = re.search(r'version\s*=\s*[\'"]([^\'"]+)[\'"]', content)
|
||||
|
||||
if version_match:
|
||||
return version_match.group(1)
|
||||
base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
possible_paths.append(os.path.join(base_path, "setup.py"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Path 2: Current working directory
|
||||
try:
|
||||
possible_paths.append(os.path.join(os.getcwd(), "setup.py"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Path 3: From environment variable (if set)
|
||||
try:
|
||||
project_root = os.getenv("PROJECT_ROOT") or os.getenv("APP_ROOT")
|
||||
if project_root:
|
||||
possible_paths.append(os.path.join(project_root, "setup.py"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Path 4: Try to find setup.py by walking up from current file
|
||||
try:
|
||||
current = os.path.dirname(__file__)
|
||||
for _ in range(5): # Max 5 levels up
|
||||
current = os.path.dirname(current)
|
||||
setup_path = os.path.join(current, "setup.py")
|
||||
if os.path.exists(setup_path):
|
||||
possible_paths.append(setup_path)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try each path until we find setup.py
|
||||
for setup_path in possible_paths:
|
||||
try:
|
||||
if os.path.exists(setup_path):
|
||||
# Read setup.py
|
||||
with open(setup_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract version using regex
|
||||
# Matches: version='X.Y.Z' or version="X.Y.Z"
|
||||
version_match = re.search(r'version\s*=\s*[\'"]([^\'"]+)[\'"]', content)
|
||||
|
||||
if version_match:
|
||||
return version_match.group(1)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Fallback version if setup.py can't be read
|
||||
# This is the ONLY place besides setup.py where version is defined
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Keep the old function name for backward compatibility
|
||||
_get_version_from_setup = get_version_from_setup
|
||||
|
||||
|
||||
def get_analytics_config():
|
||||
"""
|
||||
Get analytics configuration.
|
||||
@@ -92,7 +135,7 @@ def get_analytics_config():
|
||||
sentry_dsn = SENTRY_DSN_DEFAULT if not is_placeholder(SENTRY_DSN_DEFAULT) else ""
|
||||
|
||||
# App version - read from setup.py at runtime
|
||||
app_version = _get_version_from_setup()
|
||||
app_version = get_version_from_setup()
|
||||
|
||||
# Note: Environment variables are NOT checked for keys to prevent override
|
||||
# Users control telemetry via the opt-in/opt-out toggle in admin dashboard
|
||||
|
||||
@@ -133,10 +133,17 @@ def api_info():
|
||||
documentation_url:
|
||||
type: string
|
||||
"""
|
||||
# Get app version from setup.py (single source of truth)
|
||||
from app.config.analytics_defaults import get_version_from_setup
|
||||
app_version = get_version_from_setup()
|
||||
if app_version == "unknown":
|
||||
# Fallback to config or default
|
||||
app_version = current_app.config.get("APP_VERSION", "1.0.0")
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"api_version": "v1",
|
||||
"app_version": current_app.config.get("APP_VERSION", "1.0.0"),
|
||||
"app_version": app_version,
|
||||
"documentation_url": "/api/docs",
|
||||
"authentication": "API Token (Bearer or X-API-Key header)",
|
||||
"endpoints": {
|
||||
|
||||
+22
-8
@@ -32,8 +32,18 @@ def list_tasks():
|
||||
overdue_param = request.args.get("overdue", "").strip().lower()
|
||||
overdue = overdue_param in ["1", "true", "on", "yes"]
|
||||
|
||||
# Check if this is an AJAX request first (before loading filter data)
|
||||
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||
|
||||
# Use service layer to get tasks (prevents N+1 queries)
|
||||
task_service = TaskService()
|
||||
|
||||
# Optimize permission check - check is_admin first (no DB query needed)
|
||||
has_view_all_tasks = current_user.is_admin
|
||||
if not has_view_all_tasks:
|
||||
# Only check permission if not admin (roles are already loaded via lazy="joined")
|
||||
has_view_all_tasks = current_user.has_permission("view_all_tasks")
|
||||
|
||||
result = task_service.list_tasks(
|
||||
status=status if status else None,
|
||||
priority=priority if priority else None,
|
||||
@@ -43,19 +53,13 @@ def list_tasks():
|
||||
overdue=overdue,
|
||||
user_id=current_user.id,
|
||||
is_admin=current_user.is_admin,
|
||||
has_view_all_tasks=current_user.is_admin or current_user.has_permission("view_all_tasks"),
|
||||
has_view_all_tasks=has_view_all_tasks,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
# Get filter options (these could also be cached)
|
||||
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
||||
users = User.query.order_by(User.username).all()
|
||||
# Get kanban columns (already queries fresh from database)
|
||||
kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else []
|
||||
|
||||
# Check if this is an AJAX request
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
if is_ajax:
|
||||
# Return only the tasks list HTML for AJAX requests
|
||||
response = make_response(render_template(
|
||||
"tasks/_tasks_list.html",
|
||||
@@ -71,6 +75,16 @@ def list_tasks():
|
||||
response.headers["Content-Type"] = "text/html; charset=utf-8"
|
||||
return response
|
||||
|
||||
# Get filter options - only load for full page loads, not AJAX requests
|
||||
# These are used for filter dropdowns in the template
|
||||
# Use reasonable limits to avoid loading too many records
|
||||
projects = Project.query.filter_by(status="active").order_by(Project.name).limit(500).all()
|
||||
users = User.query.filter_by(is_active=True).order_by(User.username).limit(200).all()
|
||||
|
||||
# Kanban columns are already loaded in TaskService, but we need them for the template
|
||||
# This is a lightweight query, so it's acceptable
|
||||
kanban_columns = KanbanColumn.get_active_columns(project_id=None) if KanbanColumn else []
|
||||
|
||||
# Pre-calculate task counts by status for summary cards (avoid template iteration)
|
||||
task_counts = {
|
||||
'todo': 0,
|
||||
|
||||
@@ -204,7 +204,13 @@ class TaskService:
|
||||
|
||||
step_start = time.time()
|
||||
# Eagerly load relations to prevent N+1
|
||||
query = query.options(joinedload(Task.project), joinedload(Task.assigned_user), joinedload(Task.creator))
|
||||
# Use selectinload for better performance with many tasks (avoids cartesian product)
|
||||
from sqlalchemy.orm import selectinload
|
||||
query = query.options(
|
||||
selectinload(Task.project),
|
||||
selectinload(Task.assigned_user),
|
||||
selectinload(Task.creator)
|
||||
)
|
||||
logger.debug(f"[TaskService.list_tasks] Step 2: Eager loading setup took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
@@ -241,8 +247,53 @@ class TaskService:
|
||||
logger.debug(f"[TaskService.list_tasks] Step 4: Ordering query took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
# Paginate (always use pagination for performance)
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
# Optimize pagination: fetch one extra item to check for next page without full count
|
||||
offset = (page - 1) * per_page
|
||||
tasks_with_extra = query.limit(per_page + 1).offset(offset).all()
|
||||
|
||||
# Check if there's a next page
|
||||
has_next = len(tasks_with_extra) > per_page
|
||||
tasks = tasks_with_extra[:per_page] # Remove extra item if present
|
||||
|
||||
# For count, use a simpler query without joins (much faster)
|
||||
# Only count if we're on first page or we detected a next page
|
||||
if page == 1 or has_next:
|
||||
count_start = time.time()
|
||||
count_query = self.task_repo.query()
|
||||
# Apply same filters but without eager loading (faster)
|
||||
if status:
|
||||
count_query = count_query.filter(Task.status == status)
|
||||
if priority:
|
||||
count_query = count_query.filter(Task.priority == priority)
|
||||
if project_id:
|
||||
count_query = count_query.filter(Task.project_id == project_id)
|
||||
if assigned_to:
|
||||
count_query = count_query.filter(Task.assigned_to == assigned_to)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
count_query = count_query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
|
||||
if overdue:
|
||||
today_local = now_in_app_timezone().date()
|
||||
count_query = count_query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"]))
|
||||
if not has_view_all_tasks and user_id:
|
||||
count_query = count_query.filter(db.or_(Task.assigned_to == user_id, Task.created_by == user_id))
|
||||
total = count_query.count()
|
||||
logger.debug(f"[TaskService.list_tasks] Count query took {(time.time() - count_start) * 1000:.2f}ms")
|
||||
else:
|
||||
# Estimate: we know there's no next page, so total is at most current page items
|
||||
total = (page - 1) * per_page + len(tasks)
|
||||
|
||||
# Create pagination-like object compatible with Flask-SQLAlchemy pagination
|
||||
from types import SimpleNamespace
|
||||
pagination = SimpleNamespace()
|
||||
pagination.items = tasks
|
||||
pagination.page = page
|
||||
pagination.per_page = per_page
|
||||
pagination.total = total
|
||||
pagination.pages = (total + per_page - 1) // per_page if total else 1
|
||||
pagination.has_next = has_next
|
||||
pagination.has_prev = page > 1
|
||||
|
||||
logger.debug(f"[TaskService.list_tasks] Step 5: Pagination query execution took {(time.time() - step_start) * 1000:.2f}ms (total: {pagination.total} tasks, page: {page}, per_page: {per_page})")
|
||||
|
||||
step_start = time.time()
|
||||
|
||||
@@ -74,18 +74,28 @@ def register_context_processors(app):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine app version from environment or config
|
||||
# Determine app version from setup.py (single source of truth)
|
||||
try:
|
||||
from app.config.analytics_defaults import get_version_from_setup
|
||||
import os
|
||||
from app.config import Config
|
||||
|
||||
env_version = os.getenv("APP_VERSION")
|
||||
# If running in GitHub Actions build, prefer tag-like versions
|
||||
version_value = env_version or getattr(Config, "APP_VERSION", None) or "dev-0"
|
||||
# Get version from setup.py
|
||||
version_value = get_version_from_setup()
|
||||
|
||||
# If version is "unknown", fall back to environment variable for dev mode
|
||||
if version_value == "unknown":
|
||||
env_version = os.getenv("APP_VERSION")
|
||||
if env_version:
|
||||
version_value = env_version
|
||||
else:
|
||||
# Last resort: use "dev-0" for development
|
||||
version_value = "dev-0"
|
||||
|
||||
# Strip any leading 'v' prefix to avoid double 'v' in template (e.g., vv3.5.0)
|
||||
if version_value and version_value.startswith("v"):
|
||||
version_value = version_value[1:]
|
||||
except Exception:
|
||||
# Fallback if anything goes wrong
|
||||
version_value = "dev-0"
|
||||
|
||||
# Current locale code (e.g., 'en', 'de')
|
||||
|
||||
Reference in New Issue
Block a user