bottleneckcrm/backend/invoices/public_views.py

499 lines
18 KiB
Python

"""
Public views for client portal.
These views do not require authentication and are accessed via public tokens.
"""
import logging
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import transaction
from django.http import HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from common.models import Org
from common.modules import module_is_available
from common.org_time import activate_org_timezone
from common.portal_tokens import resolve_portal_org
from common.request_meta import client_ip, user_agent
from common.tasks import set_rls_context
from invoices.models import Estimate, Invoice, InvoiceTemplate
from invoices.pdf import (
generate_estimate_filename,
generate_estimate_pdf,
generate_invoice_filename,
generate_invoice_pdf,
)
logger = logging.getLogger(__name__)
def _resolve_org_context(token, resource_type):
"""Resolve the org from the URL token and set RLS context for this request.
Public endpoints reach the view with an empty ``app.current_org`` (the
middleware exempts them but sets no context), so under a correctly-configured
non-superuser DB role the resource query below would match zero rows and 404
every customer. Resolving the org from the unscoped token lookup and setting
the context first lets the query run under full RLS.
An unknown token leaves the context empty; the scoped query then returns
nothing and the caller 404s. The same answer a disabled link gives, so a
stranger learns nothing about whether a token is real.
The org's timezone is activated too. No user is signed in, so the
middleware found no org and left the server's UTC day active, and every
day check below (an estimate's expiry, the PDF's "(Expired)" and year)
would have run on it. `GetProfileAndOrg` deactivates it when the request
ends, so it cannot reach the next request on this worker.
"""
org_id = resolve_portal_org(token, resource_type)
if org_id:
set_rls_context(org_id)
activate_org_timezone(Org.objects.filter(id=org_id).first())
return org_id
class PublicInvoiceView(APIView):
"""
Public view for invoice - accessible via public token.
No authentication required.
"""
authentication_classes = []
permission_classes = []
def get(self, request, token):
"""Get invoice by public token"""
_resolve_org_context(token, "invoice")
invoice = Invoice.objects.filter(
public_token=token, public_link_enabled=True
).first()
if not invoice or not module_is_available(invoice.org, "invoices"):
return Response(
{"error": True, "message": "Invoice not found or link expired"},
status=status.HTTP_404_NOT_FOUND,
)
# Track view if first time. Re-read under a row lock and write only
# these fields, so a staff edit or payment that commits meanwhile
# survives and the Sent check reads the status as it is now.
if not invoice.viewed_at:
with transaction.atomic():
invoice = Invoice.objects.select_for_update().get(pk=invoice.pk)
if not invoice.viewed_at:
invoice.viewed_at = timezone.now()
if invoice.status == "Sent":
invoice.status = "Viewed"
invoice.save(update_fields=["viewed_at", "status", "updated_at"])
# Serialize invoice data for public view
data = {
"id": str(invoice.id),
"invoice_number": invoice.invoice_number,
"invoice_title": invoice.invoice_title,
"status": invoice.status,
"client_name": invoice.client_name,
"client_email": invoice.client_email,
"issue_date": invoice.issue_date,
"due_date": invoice.due_date,
"subtotal": str(invoice.subtotal),
"discount_type": invoice.discount_type,
"discount_value": str(invoice.discount_value),
"discount_amount": str(invoice.discount_amount),
"tax_rate": str(invoice.tax_rate),
"tax_amount": str(invoice.tax_amount),
"shipping_amount": str(invoice.shipping_amount),
"total_amount": str(invoice.total_amount),
"amount_paid": str(invoice.amount_paid),
"amount_due": str(invoice.amount_due),
"currency": invoice.currency,
"notes": invoice.notes,
"terms": invoice.terms,
"billing_address": {
"line": invoice.billing_address_line,
"city": invoice.billing_city,
"state": invoice.billing_state,
"postcode": invoice.billing_postcode,
"country": invoice.billing_country,
},
"line_items": [
{
"name": item.name or "",
"description": item.description,
"quantity": str(item.quantity),
"unit_price": str(item.unit_price),
# net_amount, after the line's own discount: the lines add
# up to "subtotal" above.
"amount": str(item.net_amount),
}
for item in invoice.line_items.all().order_by("order")
],
"payments": [
{
"amount": str(payment.amount),
"payment_date": payment.payment_date,
"payment_method": payment.payment_method,
}
for payment in invoice.payments.all().order_by("-payment_date")
],
"org": {
"name": invoice.org.name if invoice.org else "",
},
}
# Get default template for styling
template = invoice.template
if not template and invoice.org:
template = InvoiceTemplate.objects.filter(
org=invoice.org, is_default=True
).first()
if template:
data["template"] = {
"primary_color": template.primary_color,
"secondary_color": template.secondary_color,
"footer_text": template.footer_text or "",
}
else:
data["template"] = {
"primary_color": "#3B82F6",
"secondary_color": "#1E40AF",
"footer_text": "",
}
return Response(data)
class PublicInvoicePDFView(APIView):
"""
Public PDF download for invoice - accessible via public token.
No authentication required.
"""
authentication_classes = []
permission_classes = []
def get(self, request, token):
"""Download invoice PDF by public token"""
_resolve_org_context(token, "invoice")
invoice = Invoice.objects.filter(
public_token=token, public_link_enabled=True
).first()
if not invoice or not module_is_available(invoice.org, "invoices"):
return Response(
{"error": True, "message": "Invoice not found or link expired"},
status=status.HTTP_404_NOT_FOUND,
)
try:
pdf_content = generate_invoice_pdf(invoice, include_payments=True)
filename = generate_invoice_filename(invoice)
response = HttpResponse(pdf_content, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ImportError:
logger.exception("PDF generation library not available")
return Response(
{"error": True, "message": "PDF generation unavailable"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except Exception:
logger.exception("Failed to generate invoice PDF")
return Response(
{"error": True, "message": "Failed to generate PDF"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class PublicEstimateView(APIView):
"""
Public view for estimate - accessible via public token.
No authentication required.
"""
authentication_classes = []
permission_classes = []
def get(self, request, token):
"""Get estimate by public token"""
_resolve_org_context(token, "estimate")
estimate = Estimate.objects.filter(
public_token=token, public_link_enabled=True
).first()
if not estimate or not module_is_available(estimate.org, "invoices"):
return Response(
{"error": True, "message": "Estimate not found or link expired"},
status=status.HTTP_404_NOT_FOUND,
)
# Track view if first time, locked and narrow as on an invoice.
if not estimate.viewed_at:
with transaction.atomic():
estimate = Estimate.objects.select_for_update().get(pk=estimate.pk)
if not estimate.viewed_at:
estimate.viewed_at = timezone.now()
if estimate.status == "Sent":
estimate.status = "Viewed"
estimate.save(update_fields=["viewed_at", "status", "updated_at"])
# Serialize estimate data for public view
data = {
"id": str(estimate.id),
"estimate_number": estimate.estimate_number,
"title": estimate.title,
"status": estimate.status,
"client_name": estimate.client_name,
"client_email": estimate.client_email,
"issue_date": estimate.issue_date,
"expiry_date": estimate.expiry_date,
"subtotal": str(estimate.subtotal),
"discount_type": estimate.discount_type,
"discount_value": str(estimate.discount_value),
"discount_amount": str(estimate.discount_amount),
"tax_rate": str(estimate.tax_rate),
"tax_amount": str(estimate.tax_amount),
"total_amount": str(estimate.total_amount),
"currency": estimate.currency,
"notes": estimate.notes,
"terms": estimate.terms,
"client_address": {
"line": estimate.client_address_line,
"city": estimate.client_city,
"state": estimate.client_state,
"postcode": estimate.client_postcode,
"country": estimate.client_country,
},
"line_items": [
{
"name": item.name or "",
"description": item.description,
"quantity": str(item.quantity),
"unit_price": str(item.unit_price),
# net_amount, after the line's own discount: the lines add
# up to "subtotal" above.
"amount": str(item.net_amount),
}
for item in estimate.line_items.all().order_by("order")
],
"org": {
"name": estimate.org.name if estimate.org else "",
},
}
# Get default template for styling
if estimate.org:
template = InvoiceTemplate.objects.filter(
org=estimate.org, is_default=True
).first()
else:
template = None
if template:
data["template"] = {
"primary_color": template.primary_color,
"secondary_color": template.secondary_color,
"footer_text": template.footer_text or "",
}
else:
data["template"] = {
"primary_color": "#3B82F6",
"secondary_color": "#1E40AF",
"footer_text": "",
}
return Response(data)
class PublicEstimatePDFView(APIView):
"""
Public PDF download for estimate - accessible via public token.
No authentication required.
"""
authentication_classes = []
permission_classes = []
def get(self, request, token):
"""Download estimate PDF by public token"""
_resolve_org_context(token, "estimate")
estimate = Estimate.objects.filter(
public_token=token, public_link_enabled=True
).first()
if not estimate or not module_is_available(estimate.org, "invoices"):
return Response(
{"error": True, "message": "Estimate not found or link expired"},
status=status.HTTP_404_NOT_FOUND,
)
try:
pdf_content = generate_estimate_pdf(estimate)
filename = generate_estimate_filename(estimate)
response = HttpResponse(pdf_content, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ImportError:
logger.exception("PDF generation library not available")
return Response(
{"error": True, "message": "PDF generation unavailable"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except Exception:
logger.exception("Failed to generate estimate PDF")
return Response(
{"error": True, "message": "Failed to generate PDF"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class PublicEstimateAcceptView(APIView):
"""
Public endpoint to accept an estimate - accessible via public token.
No authentication required.
"""
authentication_classes = []
permission_classes = []
@method_decorator(transaction.atomic)
def post(self, request, token):
"""Accept estimate by public token.
Accepting a quote authorises its price, so this is enforced, not
cosmetic: the token holder must be within the estimate's validity
window and must identify themselves. Both checks live here because the
frontend is not a trust boundary, hiding the button past expiry, or
collecting a name the client could omit, protects nothing.
The row is locked from the read to the save, so the status and expiry
checks judge the estimate as it is when accepted, and a staff edit
that commits meanwhile is not written back over.
"""
_resolve_org_context(token, "estimate")
estimate = (
Estimate.objects.select_for_update()
.filter(public_token=token, public_link_enabled=True)
.first()
)
if not estimate or not module_is_available(estimate.org, "invoices"):
return Response(
{"error": True, "message": "Estimate not found or link expired"},
status=status.HTTP_404_NOT_FOUND,
)
if estimate.status not in ["Sent", "Viewed"]:
return Response(
{
"error": True,
"message": "Estimate cannot be accepted in current state",
},
status=status.HTTP_400_BAD_REQUEST,
)
# An expired quote is not acceptable at its original price. expiry_date
# is stored and printed on the estimate but was previously enforced
# nowhere; the check belongs on the server.
if estimate.is_expired:
return Response(
{
"error": True,
"message": (
f"This estimate expired on {estimate.expiry_date} and can "
"no longer be accepted. Please request an updated quote."
),
},
status=status.HTTP_400_BAD_REQUEST,
)
# Acceptance is an authorisation; record who gave it. Name and a valid
# email are required so the record is more than "whoever held the link".
name = (request.data.get("name") or "").strip()
email = (request.data.get("email") or "").strip()
if not name or not email:
return Response(
{
"error": True,
"message": "Your name and email are required to accept this estimate.",
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
validate_email(email)
except ValidationError:
return Response(
{"error": True, "message": "Please enter a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
estimate.status = "Accepted"
estimate.accepted_at = timezone.now()
estimate.accepted_by_name = name[:255]
estimate.accepted_by_email = email[:254]
estimate.accepted_ip = client_ip(request)
estimate.accepted_user_agent = user_agent(request)[:1024]
estimate.save(
update_fields=[
"status",
"accepted_at",
"accepted_by_name",
"accepted_by_email",
"accepted_ip",
"accepted_user_agent",
"updated_at",
]
)
return Response({"error": False, "message": "Estimate accepted successfully"})
class PublicEstimateDeclineView(APIView):
"""
Public endpoint to decline an estimate - accessible via public token.
No authentication required.
"""
authentication_classes = []
permission_classes = []
@method_decorator(transaction.atomic)
def post(self, request, token):
"""Decline estimate by public token, locked as accept is."""
_resolve_org_context(token, "estimate")
estimate = (
Estimate.objects.select_for_update()
.filter(public_token=token, public_link_enabled=True)
.first()
)
if not estimate or not module_is_available(estimate.org, "invoices"):
return Response(
{"error": True, "message": "Estimate not found or link expired"},
status=status.HTTP_404_NOT_FOUND,
)
if estimate.status not in ["Sent", "Viewed"]:
return Response(
{
"error": True,
"message": "Estimate cannot be declined in current state",
},
status=status.HTTP_400_BAD_REQUEST,
)
estimate.status = "Declined"
estimate.declined_at = timezone.now()
estimate.save(update_fields=["status", "declined_at", "updated_at"])
return Response({"error": False, "message": "Estimate declined"})