Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invoices: Public API #425

Merged
merged 3 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions backend/api/invoices/edit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import NoReturn

from django.contrib import messages
from django.http import HttpRequest, JsonResponse, HttpResponse
Expand Down Expand Up @@ -29,7 +28,7 @@ def edit_invoice(request: HtmxHttpRequest):
)

attributes_to_updates = {
"date_due": datetime.strptime(request.POST.get("date_due", ""), "%Y-%m-%d").date(),
"date_due": request.POST.get("date_due"),
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
Expand All @@ -53,7 +52,14 @@ def edit_invoice(request: HtmxHttpRequest):
}

for column_name, new_value in attributes_to_updates.items():
setattr(invoice, column_name, new_value)
if new_value is not None:
if column_name == "date_due":
try:
new_value = datetime.strptime(new_value, "%Y-%m-%d").date()
except ValueError:
messages.error(request, "Invalid date format for date_due")
return render(request, "base/toasts.html")
setattr(invoice, column_name, new_value)

invoice.save()

Expand Down
126 changes: 7 additions & 119 deletions backend/api/invoices/fetch.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
from django.db.models import (
Q,
Case,
When,
F,
FloatField,
ExpressionWrapper,
CharField,
Value,
Sum,
Prefetch,
)
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods

from backend.models import Invoice, InvoiceItem
from backend.models import Invoice
from backend.types.htmx import HtmxHttpRequest
from backend.service.invoices.fetch import get_context


@require_http_methods(["GET"])
Expand All @@ -24,7 +12,10 @@ def fetch_all_invoices(request: HtmxHttpRequest):
if not request.htmx:
return redirect("invoices:dashboard")

context: dict = {}
if request.user.logged_in_as_team:
invoices = Invoice.objects.filter(organization=request.user.logged_in_as_team)
else:
invoices = Invoice.objects.filter(user=request.user)

# Get filter and sort parameters from the request
sort_by = request.GET.get("sort")
Expand All @@ -46,110 +37,7 @@ def fetch_all_invoices(request: HtmxHttpRequest):
},
}

# Fetch invoices for the user, prefetch related items, and select specific fields
if request.user.logged_in_as_team:
invoices = Invoice.objects.filter(organization=request.user.logged_in_as_team)
else:
invoices = Invoice.objects.filter(user=request.user)

invoices = (
invoices.prefetch_related(
Prefetch(
"items",
queryset=InvoiceItem.objects.annotate(
subtotal=ExpressionWrapper(
F("hours") * F("price_per_hour"),
output_field=FloatField(),
),
),
),
)
.select_related("client_to", "client_to__user")
.only("invoice_id", "id", "payment_status", "date_due", "client_to", "client_name")
.annotate(
subtotal=Sum(F("items__hours") * F("items__price_per_hour")),
amount=Case(
When(vat_number=True, then=F("subtotal") * 1.2),
default=F("subtotal"),
output_field=FloatField(),
),
)
.distinct() # just an extra precaution
)

# Initialize context variables
context["selected_filters"] = []
context["all_filters"] = {item: [i for i, _ in dictio.items()] for item, dictio in previous_filters.items()}

# Initialize OR conditions for filters using Q objects
or_conditions = Q()

# Iterate through previous filters to build OR conditions
for filter_type, filter_by_list in previous_filters.items():
or_conditions_filter = Q() # Initialize OR conditions for each filter type
for filter_by, status in filter_by_list.items():
# Determine if the filter was selected in the previous request
was_previous_selection = True if status else False
# Determine if the filter is selected in the current request
has_just_been_selected = True if action_filter_by == filter_by and action_filter_type == filter_type else False

# Check if the filter status has changed
if (was_previous_selection and not has_just_been_selected) or (not was_previous_selection and has_just_been_selected):
# Construct filter condition dynamically based on filter_type
if "+" in filter_by:
numeric_part = float(filter_by.split("+")[0])
filter_condition: dict[str, str | float] = {f"{filter_type}__gte": numeric_part}
else:
filter_condition = {f"{filter_type}": filter_by}
or_conditions_filter |= Q(**filter_condition)
context["selected_filters"].append(filter_by)

# Combine OR conditions for each filter type with AND
or_conditions &= or_conditions_filter

# check/update payment status to make sure it is correct before invoices are filtered and displayed
invoices.update(
payment_status=Case(
When(
date_due__lt=timezone.now().date(),
payment_status="pending",
then=Value("overdue"),
),
When(
date_due__gt=timezone.now().date(),
payment_status="overdue",
then=Value("pending"),
),
default=F("payment_status"),
output_field=CharField(),
)
)

# Apply OR conditions to the invoices queryset
invoices = invoices.filter(or_conditions)

# Validate and sanitize the sort_by parameter
all_sort_options = ["date_due", "id", "payment_status"]
context["all_sort_options"] = all_sort_options

# Apply sorting to the invoices queryset
if sort_by not in all_sort_options:
sort_by = "id"
elif sort_by in all_sort_options:
# True is for reverse order
# first time set direction is none
if sort_direction is not None and sort_direction.lower() == "true" or sort_direction == "":
context["sort"] = f"-{sort_by}"
context["sort_direction"] = False
invoices = invoices.order_by(f"-{sort_by}")
else:
# sort_direction is False
context["sort"] = sort_by
context["sort_direction"] = True
invoices = invoices.order_by(sort_by)

# Add invoices to the context
context["invoices"] = invoices
context, _ = get_context(invoices, sort_by, sort_direction, action_filter_type, action_filter_by, previous_filters)

# Render the HTMX response
return render(request, "pages/invoices/dashboard/_fetch_body.html", context)
26 changes: 26 additions & 0 deletions backend/api/public/endpoints/Invoices/delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.http import QueryDict
from django.urls import resolve, Resolver404
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response

from backend.models import Invoice, QuotaLimit


@api_view(["DELETE"])
def delete_invoice_endpoint(request):
delete_items = QueryDict(request.body)

try:
invoice = Invoice.objects.get(id=delete_items.get("invoice", ""))
except Invoice.DoesNotExist:
return Response({"error": "Invoice Not Found"}, status=status.HTTP_404_NOT_FOUND)

if not invoice.has_access(request.user):
return Response({"error": "You do not have permission to delete this invoice"}, status=status.HTTP_403_FORBIDDEN)

QuotaLimit.delete_quota_usage("invoices-count", request.user, invoice.id, invoice.date_created)

invoice.delete()

return Response({"message": "Invoice successfully deleted"}, status=status.HTTP_200_OK)
134 changes: 134 additions & 0 deletions backend/api/public/endpoints/Invoices/edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
from datetime import datetime
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response

from backend.models import Invoice


@api_view(["POST"])
def edit_invoice_endpoint(request):
invoice_id = request.data.get("invoice_id", "")
if not invoice_id:
return Response({"error": "Invoice ID is required"}, status=status.HTTP_400_BAD_REQUEST)

try:
invoice = Invoice.objects.get(id=invoice_id)
except Invoice.DoesNotExist:
return Response({"error": "Invoice Not Found"}, status=status.HTTP_404_NOT_FOUND)

if request.user.logged_in_as_team and request.user.logged_in_as_team != invoice.organization:
return Response(
{"error": "You do not have permission to edit this invoice"},
status=status.HTTP_403_FORBIDDEN,
)
elif request.user != invoice.user:
return Response(
{"error": "You do not have permission to edit this invoice"},
status=status.HTTP_403_FORBIDDEN,
)

attributes_to_updates = {
"date_due": request.POST.get("date_due"),
"date_issued": request.POST.get("date_issued"),
"client_name": request.POST.get("to_name"),
"client_company": request.POST.get("to_company"),
"client_address": request.POST.get("to_address"),
"client_city": request.POST.get("to_city"),
"client_county": request.POST.get("to_county"),
"client_country": request.POST.get("to_country"),
"self_name": request.POST.get("from_name"),
"self_company": request.POST.get("from_company"),
"self_address": request.POST.get("from_address"),
"self_city": request.POST.get("from_city"),
"self_county": request.POST.get("from_county"),
"self_country": request.POST.get("from_country"),
"notes": request.POST.get("notes"),
"invoice_number": request.POST.get("invoice_number"),
"vat_number": request.POST.get("vat_number"),
"reference": request.POST.get("reference"),
"sort_code": request.POST.get("sort_code"),
"account_number": request.POST.get("account_number"),
"account_holder_name": request.POST.get("account_holder_name"),
}

for column_name, new_value in attributes_to_updates.items():
if new_value is not None:
if column_name == "date_due":
try:
new_value = datetime.strptime(new_value, "%Y-%m-%d").date()
except ValueError:
return Response({"error": "Invalid date format for date_due"}, status=status.HTTP_400_BAD_REQUEST)
setattr(invoice, column_name, new_value)

invoice.save()

return Response({"message": "Invoice successfully edited"}, status=status.HTTP_200_OK)


@api_view(["POST"])
def change_status_endpoint(request, invoice_id: int, invoice_status: str):
invoice_status = invoice_status.lower() if invoice_status else ""

try:
invoice = Invoice.objects.get(id=invoice_id)
except Invoice.DoesNotExist:
return Response({"error": "Invoice Not Found"}, status=status.HTTP_404_NOT_FOUND)

if request.user.logged_in_as_team and request.user.logged_in_as_team != invoice.organization or request.user != invoice.user:
return Response({"error": "You do not have permission to edit this invoice"}, status=status.HTTP_403_FORBIDDEN)

if invoice_status not in ["paid", "overdue", "pending"]:
return Response({"error": "Invalid status. Please choose from: pending, paid, overdue"}, status=status.HTTP_400_BAD_REQUEST)

if invoice.payment_invoice_status == invoice_status:
return Response({"error": f"Invoice status is already {invoice_status}"}, status=status.HTTP_400_BAD_REQUEST)

invoice.payment_status = invoice_status
invoice.save()

dps = invoice.dynamic_payment_status
if (invoice_status == "overdue" and dps == "pending") or (invoice_status == "pending" and dps == "overdue"):
message = f"""
The invoice status was automatically changed from <strong>{invoice_status}</strong> to <strong>{dps}</strong>
as the invoice dates override the manual status.
"""
return Response({"error": message}, status=status.HTTP_400_BAD_REQUEST)

return Response({"message": f"Invoice status been changed to <strong>{invoice_status}</strong>"}, status=status.HTTP_200_OK)


@api_view(["POST"])
def edit_discount_endpoint(request, invoice_id: str):
discount_type = "percentage" if request.data.get("discount_type") == "on" else "amount"
discount_amount_str: str = request.data.get("discount_amount", "")
percentage_amount_str: str = request.data.get("percentage_amount", "")

try:
invoice: Invoice = Invoice.objects.get(id=invoice_id)
except Invoice.DoesNotExist:
return Response({"error": "Invoice not found"}, status=status.HTTP_404_NOT_FOUND)

if not invoice.has_access(request.user):
return Response({"error": "You don't have permission to make changes to this invoice."}, status=status.HTTP_403_FORBIDDEN)

if discount_type == "percentage":
try:
percentage_amount = int(percentage_amount_str)
if percentage_amount < 0 or percentage_amount > 100:
raise ValueError
except ValueError:
return Response({"error": "Please enter a valid percentage amount (between 0 and 100)"}, status=status.HTTP_400_BAD_REQUEST)
invoice.discount_percentage = percentage_amount
else:
try:
discount_amount = int(discount_amount_str)
if discount_amount < 0:
raise ValueError
except ValueError:
return Response({"error": "Please enter a valid discount amount"}, status=status.HTTP_400_BAD_REQUEST)
invoice.discount_amount = discount_amount

invoice.save()

return Response({"message": "Discount was applied successfully"}, status=status.HTTP_200_OK)
Loading
Loading