Files
TimeTracker/app/routes/projects_refactored_example.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

200 lines
6.9 KiB
Python

"""
REFERENCE ONLY — This module is not registered as an active blueprint.
Example refactored projects route using service layer and fixing N+1 queries.
It demonstrates the intended architecture pattern (service layer, eager loading).
The active routes live in app/routes/projects.py. Do not register this blueprint;
use it as reference when refactoring or when adding new project routes.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload
from app import db
from app.models import Client, Project, TimeEntry, UserFavoriteProject
from app.repositories import ClientRepository, ProjectRepository, TimeEntryRepository
from app.services import ProjectService
from app.utils.permissions import admin_or_permission_required
projects_bp = Blueprint("projects", __name__)
@projects_bp.route("/projects")
@login_required
def list_projects():
"""
List all projects - REFACTORED VERSION
This version fixes N+1 queries by using joinedload to eagerly load
related data (clients) in a single query.
"""
from app import track_page_view
track_page_view("projects_list")
page = request.args.get("page", 1, type=int)
status = request.args.get("status", "active")
client_name = request.args.get("client", "").strip()
search = request.args.get("search", "").strip()
favorites_only = request.args.get("favorites", "").lower() == "true"
# Use repository with eager loading to fix N+1 queries
project_repo = ProjectRepository()
query = project_repo.query().options(joinedload(Project.client_obj)) # Eagerly load client to avoid N+1
# Filter by favorites if requested
if favorites_only:
query = query.join(
UserFavoriteProject,
db.and_(UserFavoriteProject.project_id == Project.id, UserFavoriteProject.user_id == current_user.id),
)
# Filter by status
if status == "active":
query = query.filter(Project.status == "active")
elif status == "archived":
query = query.filter(Project.status == "archived")
elif status == "inactive":
query = query.filter(Project.status == "inactive")
# Filter by client
if client_name:
query = query.join(Client).filter(Client.name == client_name)
# Search filter
if search:
like = f"%{search}%"
query = query.filter(db.or_(Project.name.ilike(like), Project.description.ilike(like)))
# Paginate with eager loading
projects_pagination = query.order_by(Project.name).paginate(page=page, per_page=20, error_out=False)
# Get user's favorite project IDs (single query)
favorite_project_ids = {
fav.project_id for fav in UserFavoriteProject.query.filter_by(user_id=current_user.id).all()
}
# Get clients for filter dropdown (single query)
client_repo = ClientRepository()
clients = client_repo.get_active_clients()
client_list = [c.name for c in clients]
return render_template(
"projects/list.html",
projects=projects_pagination.items,
status=status,
clients=client_list,
favorite_project_ids=favorite_project_ids,
favorites_only=favorites_only,
pagination=projects_pagination,
)
@projects_bp.route("/projects/<int:project_id>")
@login_required
def view_project(project_id):
"""
View project details - REFACTORED VERSION
This version uses the service layer and fixes N+1 queries.
"""
from sqlalchemy.orm import joinedload
from app.models import Comment, KanbanColumn, ProjectCost, Task
from app.repositories import TimeEntryRepository
# Use repository to get project with relations
project_repo = ProjectRepository()
project = project_repo.get_with_stats(project_id)
if not project:
flash(_("Project not found"), "error")
return redirect(url_for("projects.list_projects"))
# Get time entries with eager loading (fixes N+1)
time_entry_repo = TimeEntryRepository()
page = request.args.get("page", 1, type=int)
entries_query = (
time_entry_repo.query()
.filter(TimeEntry.project_id == project_id, TimeEntry.end_time.isnot(None))
.options(joinedload(TimeEntry.user), joinedload(TimeEntry.task)) # Eagerly load user # Eagerly load task
.order_by(TimeEntry.start_time.desc())
)
entries_pagination = entries_query.paginate(page=page, per_page=50, error_out=False)
# Get tasks with eager loading
tasks = (
Task.query.filter_by(project_id=project_id)
.options(joinedload(Task.assignee)) # If Task has assignee relationship
.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc())
.all()
)
# Get user totals (this might need optimization too)
user_totals = project.get_user_totals()
# Get comments with eager loading
comments = (
Comment.query.filter_by(project_id=project_id)
.options(joinedload(Comment.author)) # Eagerly load author
.order_by(Comment.created_at.desc())
.all()
)
# Get recent project costs
recent_costs = (
ProjectCost.query.filter_by(project_id=project_id).order_by(ProjectCost.cost_date.desc()).limit(5).all()
)
# Get kanban columns
kanban_columns = KanbanColumn.get_active_columns(project_id=project_id) if KanbanColumn else []
return render_template(
"projects/view.html",
project=project,
entries=entries_pagination.items,
entries_pagination=entries_pagination,
tasks=tasks,
user_totals=user_totals,
comments=comments,
recent_costs=recent_costs,
kanban_columns=kanban_columns,
)
@projects_bp.route("/projects/create", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("create_projects")
def create_project():
"""
Create a new project - REFACTORED VERSION using service layer
"""
if request.method == "POST":
# Use service layer for business logic
project_service = ProjectService()
result = project_service.create_project(
name=request.form.get("name", "").strip(),
client_id=request.form.get("client_id", type=int),
description=request.form.get("description", "").strip() or None,
billable=request.form.get("billable") == "on",
hourly_rate=request.form.get("hourly_rate", type=float),
created_by=current_user.id,
)
if result["success"]:
flash(_("Project created successfully"), "success")
return redirect(url_for("projects.view_project", project_id=result["project"].id))
else:
flash(_(result["message"]), "error")
# GET request - show form
client_repo = ClientRepository()
clients = client_repo.get_active_clients()
return render_template("projects/create.html", clients=clients)