Files
TimeTracker/app/routes/offers.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

415 lines
17 KiB
Python

from datetime import datetime
from decimal import Decimal, InvalidOperation
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from app import db, log_event, track_event
from app.models import Client, Invoice, Project, Quote, QuoteItem
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required, permission_required
quotes_bp = Blueprint("quotes", __name__)
@quotes_bp.route("/quotes")
@login_required
def list_quotes():
"""List all quotes"""
status = request.args.get("status", "all")
search = request.args.get("search", "").strip()
query = Quote.query
if status != "all":
query = query.filter_by(status=status)
if search:
like = f"%{search}%"
query = query.filter(
db.or_(Quote.title.ilike(like), Quote.quote_number.ilike(like), Quote.description.ilike(like))
)
quotes = query.order_by(Quote.created_at.desc()).all()
return render_template("quotes/list.html", quotes=quotes, status=status, search=search)
@quotes_bp.route("/quotes/create", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("create_quotes")
def create_quote():
"""Create a new quote"""
if request.method == "POST":
client_id = request.form.get("client_id", "").strip()
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
total_amount = request.form.get("total_amount", "").strip()
hourly_rate = request.form.get("hourly_rate", "").strip()
estimated_hours = request.form.get("estimated_hours", "").strip()
tax_rate = request.form.get("tax_rate", "0").strip()
currency_code = request.form.get("currency_code", "EUR").strip()
valid_until = request.form.get("valid_until", "").strip()
notes = request.form.get("notes", "").strip()
terms = request.form.get("terms", "").strip()
try:
current_app.logger.info(
"POST /quotes/create user=%s title=%s client_id=%s",
current_user.username,
title or "<empty>",
client_id or "<empty>",
)
except Exception:
pass
# Validate required fields
if not title or not client_id:
flash(_("Quote title and client are required"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
# Get client and validate
client = Client.query.get(client_id)
if not client:
flash(_("Selected client not found"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
# Validate amounts
try:
total_amount = Decimal(total_amount) if total_amount else None
if total_amount is not None and total_amount < 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid total amount format"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
try:
hourly_rate = Decimal(hourly_rate) if hourly_rate else None
if hourly_rate is not None and hourly_rate < 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid hourly rate format"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
try:
estimated_hours = float(estimated_hours) if estimated_hours else None
if estimated_hours is not None and estimated_hours < 0:
raise ValueError
except ValueError:
flash(_("Invalid estimated hours format"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
try:
tax_rate = Decimal(tax_rate) if tax_rate else Decimal("0")
if tax_rate < 0 or tax_rate > 100:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid tax rate format"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
# Parse valid_until date
valid_until_date = None
if valid_until:
try:
valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date()
except ValueError:
flash(_("Invalid date format for valid until"), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
# Generate quote number
quote_number = Quote.generate_quote_number()
# Create quote
quote = Quote(
quote_number=quote_number,
client_id=client_id,
title=title,
created_by=current_user.id,
description=description,
tax_rate=tax_rate,
currency_code=currency_code,
valid_until=valid_until_date,
notes=notes,
terms=terms,
)
db.session.add(quote)
db.session.flush() # Get quote ID for items
# Process line items if provided
item_descriptions = request.form.getlist("item_description[]")
item_quantities = request.form.getlist("item_quantity[]")
item_prices = request.form.getlist("item_price[]")
item_units = request.form.getlist("item_unit[]")
for desc, qty, price, unit in zip(item_descriptions, item_quantities, item_prices, item_units):
if desc.strip():
try:
item = QuoteItem(
quote_id=quote.id,
description=desc.strip(),
quantity=Decimal(qty) if qty else Decimal("1"),
unit_price=Decimal(price) if price else Decimal("0"),
unit=unit.strip() if unit else None,
)
db.session.add(item)
except (ValueError, InvalidOperation):
pass # Skip invalid items
quote.calculate_totals()
if not safe_commit("create_quote", {"title": title, "client_id": client_id}):
flash(_("Could not create quote due to a database error. Please check server logs."), "error")
return render_template("quotes/create.html", clients=Client.get_active_clients())
# Log event
log_event("quote.created", user_id=current_user.id, quote_id=quote.id, quote_title=title, client_id=client_id)
track_event(
current_user.id, "quote.created", {"quote_id": quote.id, "quote_title": title, "client_id": client_id}
)
flash(_("Quote created successfully"), "success")
return redirect(url_for("quotes.view_quote", quote_id=quote.id))
return render_template("quotes/create.html", clients=Client.get_active_clients())
@quotes_bp.route("/quotes/<int:quote_id>")
@login_required
def view_quote(quote_id):
"""View quote details"""
quote = Quote.query.get_or_404(quote_id)
return render_template("quotes/view.html", quote=quote)
@quotes_bp.route("/quotes/<int:quote_id>/edit", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("edit_quotes")
def edit_quote(quote_id):
"""Edit an quote"""
quote = Quote.query.get_or_404(quote_id)
# Only allow editing draft quotes
if quote.status != "draft":
flash(_("Only draft quotes can be edited"), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
if request.method == "POST":
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
total_amount = request.form.get("total_amount", "").strip()
hourly_rate = request.form.get("hourly_rate", "").strip()
estimated_hours = request.form.get("estimated_hours", "").strip()
tax_rate = request.form.get("tax_rate", "0").strip()
currency_code = request.form.get("currency_code", "EUR").strip()
valid_until = request.form.get("valid_until", "").strip()
notes = request.form.get("notes", "").strip()
terms = request.form.get("terms", "").strip()
# Validate amounts
try:
total_amount = Decimal(total_amount) if total_amount else None
if total_amount is not None and total_amount < 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid total amount format"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
try:
hourly_rate = Decimal(hourly_rate) if hourly_rate else None
if hourly_rate is not None and hourly_rate < 0:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid hourly rate format"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
try:
estimated_hours = float(estimated_hours) if estimated_hours else None
if estimated_hours is not None and estimated_hours < 0:
raise ValueError
except ValueError:
flash(_("Invalid estimated hours format"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
try:
tax_rate = Decimal(tax_rate) if tax_rate else Decimal("0")
if tax_rate < 0 or tax_rate > 100:
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid tax rate format"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
# Parse valid_until date
valid_until_date = None
if valid_until:
try:
valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date()
except ValueError:
flash(_("Invalid date format for valid until"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
# Update quote
quote.title = title
quote.description = description.strip() if description else None
quote.total_amount = total_amount
quote.hourly_rate = hourly_rate
quote.estimated_hours = estimated_hours
quote.tax_rate = tax_rate
quote.currency_code = currency_code
quote.valid_until = valid_until_date
quote.notes = notes.strip() if notes else None
quote.terms = terms.strip() if terms else None
if not safe_commit("edit_quote", {"quote_id": quote_id}):
flash(_("Could not update quote due to a database error. Please check server logs."), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
log_event("quote.updated", user_id=current_user.id, quote_id=quote.id, quote_title=title)
track_event(current_user.id, "quote.updated", {"quote_id": quote.id, "quote_title": title})
flash(_("Quote updated successfully"), "success")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
@quotes_bp.route("/quotes/<int:quote_id>/send", methods=["POST"])
@login_required
@admin_or_permission_required("edit_quotes")
def send_quote(quote_id):
"""Send an quote to the client"""
quote = Quote.query.get_or_404(quote_id)
if quote.status != "draft":
flash(_("Only draft quotes can be sent"), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
quote.send()
if not safe_commit("send_quote", {"quote_id": quote_id}):
flash(_("Could not send quote due to a database error. Please check server logs."), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
log_event("quote.sent", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title)
track_event(current_user.id, "quote.sent", {"quote_id": quote.id, "quote_title": quote.title})
flash(_("Quote sent successfully"), "success")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
@quotes_bp.route("/quotes/<int:quote_id>/accept", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("accept_quotes")
def accept_quote(quote_id):
"""Accept an quote and create a project"""
quote = Quote.query.get_or_404(quote_id)
if not quote.can_be_accepted:
flash(_("This quote cannot be accepted"), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
if request.method == "POST":
# Create project from quote
project_name = request.form.get("project_name", quote.title).strip()
if not project_name:
project_name = quote.title
# Use quote's budget as project budget
budget_amount = quote.total_amount
# Create project
project = Project(
name=project_name,
client_id=quote.client_id,
description=quote.description,
billable=True,
hourly_rate=quote.hourly_rate,
budget_amount=budget_amount,
quote_id=quote.id,
status="active",
)
db.session.add(project)
# Accept the quote
try:
db.session.flush() # Get project ID
quote.accept(current_user.id, project.id)
except ValueError as e:
flash(_("Could not accept quote: %(error)s", error=str(e)), "error")
db.session.rollback()
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
if not safe_commit("accept_quote", {"quote_id": quote_id, "project_id": project.id}):
flash(_("Could not accept quote due to a database error. Please check server logs."), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
log_event(
"quote.accepted", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title, project_id=project.id
)
track_event(
current_user.id,
"quote.accepted",
{"quote_id": quote.id, "quote_title": quote.title, "project_id": project.id},
)
flash(_("Quote accepted and project created successfully"), "success")
return redirect(url_for("projects.view_project", project_id=project.id))
return render_template("quotes/accept.html", quote=quote)
@quotes_bp.route("/quotes/<int:quote_id>/reject", methods=["POST"])
@login_required
@admin_or_permission_required("edit_quotes")
def reject_quote(quote_id):
"""Reject an quote"""
quote = Quote.query.get_or_404(quote_id)
if quote.status not in ["sent", "draft"]:
flash(_("This quote cannot be rejected"), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
try:
quote.reject()
except ValueError as e:
flash(_("Could not reject quote: %(error)s", error=str(e)), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
if not safe_commit("reject_quote", {"quote_id": quote_id}):
flash(_("Could not reject quote due to a database error. Please check server logs."), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
log_event("quote.rejected", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title)
track_event(current_user.id, "quote.rejected", {"quote_id": quote.id, "quote_title": quote.title})
flash(_("Quote rejected"), "success")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
@quotes_bp.route("/quotes/<int:quote_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("delete_quotes")
def delete_quote(quote_id):
"""Delete an quote"""
quote = Quote.query.get_or_404(quote_id)
# Only allow deleting draft or rejected quotes
if quote.status not in ["draft", "rejected"]:
flash(_("Only draft or rejected quotes can be deleted"), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
quote_title = quote.title
db.session.delete(quote)
if not safe_commit("delete_quote", {"quote_id": quote_id}):
flash(_("Could not delete quote due to a database error. Please check server logs."), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
log_event("quote.deleted", user_id=current_user.id, quote_id=quote_id, quote_title=quote_title)
track_event(current_user.id, "quote.deleted", {"quote_id": quote_id, "quote_title": quote_title})
flash(_("Quote deleted successfully"), "success")
return redirect(url_for("quotes.list_quotes"))