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:
Dries Peeters
2025-12-13 19:06:19 +01:00
parent 2230d5b909
commit 8324636e2b
6 changed files with 159 additions and 34 deletions
+59 -16
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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,
+54 -3
View File
@@ -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()
+15 -5
View File
@@ -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')
+1 -1
View File
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='4.5.0',
version='4.5.1',
packages=find_packages(),
include_package_data=True,
install_requires=[