mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 06:40:53 -05:00
b4486a627f
- 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
415 lines
17 KiB
Python
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"))
|