diff --git a/backend/api/invoices/edit.py b/backend/api/invoices/edit.py index c278aae2..05a6b27f 100644 --- a/backend/api/invoices/edit.py +++ b/backend/api/invoices/edit.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import NoReturn from django.contrib import messages from django.http import HttpRequest, JsonResponse, HttpResponse @@ -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"), @@ -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() diff --git a/backend/api/invoices/fetch.py b/backend/api/invoices/fetch.py index a0baff63..6e326555 100644 --- a/backend/api/invoices/fetch.py +++ b/backend/api/invoices/fetch.py @@ -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"]) @@ -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") @@ -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) diff --git a/backend/api/public/endpoints/Invoices/add.py b/backend/api/public/endpoints/Invoices/add_service.py similarity index 100% rename from backend/api/public/endpoints/Invoices/add.py rename to backend/api/public/endpoints/Invoices/add_service.py diff --git a/backend/api/public/endpoints/Invoices/delete.py b/backend/api/public/endpoints/Invoices/delete.py new file mode 100644 index 00000000..f170ce1d --- /dev/null +++ b/backend/api/public/endpoints/Invoices/delete.py @@ -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) diff --git a/backend/api/public/endpoints/Invoices/edit.py b/backend/api/public/endpoints/Invoices/edit.py new file mode 100644 index 00000000..53f23fa0 --- /dev/null +++ b/backend/api/public/endpoints/Invoices/edit.py @@ -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 {invoice_status} to {dps} + 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 {invoice_status}"}, 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) diff --git a/backend/api/public/endpoints/Invoices/fetch.py b/backend/api/public/endpoints/Invoices/fetch.py new file mode 100644 index 00000000..dbabab47 --- /dev/null +++ b/backend/api/public/endpoints/Invoices/fetch.py @@ -0,0 +1,95 @@ +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from backend.api.public.authentication import BearerAuthentication +from backend.api.public.serializers.invoices import InvoiceSerializer +from backend.models import Invoice +from backend.service.invoices.fetch import get_context + + +@swagger_auto_schema( + method="get", + operation_description="Fetch all invoices", + operation_id="invoices_list", + manual_parameters=[ + openapi.Parameter( + "sort", + openapi.IN_QUERY, + description="Field you want to order by to. Sort options: 'date_due', 'id', 'payment_status'. Default by " "'id'.", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "sort_direction", + openapi.IN_QUERY, + description="Order by descending or ascending. False for descending and True for ascending. Default is " "ascending.", + type=openapi.TYPE_BOOLEAN, + ), + openapi.Parameter( + "filter_type", + openapi.IN_QUERY, + description="Select filter type by which results will be filtered. Filter types are 'payment_status' and " + "'amount'. By default there is no filter types applied.", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "filter", + openapi.IN_QUERY, + description="Select filter by which results will be filtered. Filters for 'payment_status' are 'paid', " + "'pending', 'overdue' and for 'amount' are '20+', '50+', '100+'. By default there is no " + "filter applied.", + type=openapi.TYPE_STRING, + ), + ], + responses={ + 200: openapi.Response( + description="List of invoices", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "success": openapi.Schema(type=openapi.TYPE_BOOLEAN), + "invoices": openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Schema(type=openapi.TYPE_OBJECT)), + }, + ), + ) + }, +) +@api_view(["GET"]) +# @authentication_classes([BearerAuthentication]) +# @permission_classes([IsAuthenticated]) +def fetch_all_invoices_endpoint(request): + if request.user.is_authenticated: + if hasattr(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) + else: + invoices = Invoice.objects.none() + + sort_by = request.data.get("sort") + sort_direction = request.data.get("sort_direction", "") + action_filter_type = request.data.get("filter_type") + action_filter_by = request.data.get("filter") + + # TODO: Decide on how to handle this part or to get rid of it in API + previous_filters = { + "payment_status": { + "paid": True if request.data.get("payment_status_paid") else False, + "pending": True if request.data.get("payment_status_pending") else False, + "overdue": True if request.data.get("payment_status_overdue") else False, + }, + "amount": { + "20+": True if request.data.get("amount_20+") else False, + "50+": True if request.data.get("amount_50+") else False, + "100+": True if request.data.get("amount_100+") else False, + }, + } + + _, invoices = get_context(invoices, sort_by, sort_direction, action_filter_type, action_filter_by, previous_filters) + + serializer = InvoiceSerializer(invoices, many=True) + + return Response({"success": True, "invoices": serializer.data}, status=status.HTTP_200_OK) diff --git a/backend/api/public/endpoints/Invoices/set_destination.py b/backend/api/public/endpoints/Invoices/set_destination.py new file mode 100644 index 00000000..74f541bf --- /dev/null +++ b/backend/api/public/endpoints/Invoices/set_destination.py @@ -0,0 +1,38 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status + +from backend.models import Client +from backend.api.public.serializers.clients import ClientSerializer + + +to_get = ["name", "address", "city", "country", "company", "is_representative"] + + +@api_view(["POST"]) +def set_destination_from_endpoint(request): + context: dict = {"swapping": True} + + data = request.data + context.update({key: data.get(key, "") for key in to_get}) + + return Response(context, status=status.HTTP_200_OK) + + +@api_view(["POST"]) +def set_destination_to_endpoint(request): + context: dict = {"swapping": True} + + data = request.data + context.update({key: data.get(key, "") for key in to_get}) + + use_existing = True if request.data.get("use_existing") == "true" else False + selected_client = request.data.get("selected_client") if use_existing else None + + if selected_client: + try: + client = Client.objects.get(user=request.user, id=selected_client) + context["existing_client"] = ClientSerializer(client).data + except Client.DoesNotExist: + return Response({"detail": "Client not found"}, status=status.HTTP_404_NOT_FOUND) + return Response(context, status=status.HTTP_200_OK) diff --git a/backend/api/public/endpoints/Invoices/urls.py b/backend/api/public/endpoints/Invoices/urls.py index d8b27c07..2ad9a11a 100644 --- a/backend/api/public/endpoints/Invoices/urls.py +++ b/backend/api/public/endpoints/Invoices/urls.py @@ -1,12 +1,12 @@ from django.urls import path -from . import add, create +from . import add_service, create, set_destination, delete, edit, fetch urlpatterns = [ path( "add_service", - add.add_service_endpoint, + add_service.add_service_endpoint, name="services add", ), path( @@ -14,6 +14,29 @@ create.create_invoice_endpoint, name="create", ), + path( + "set_destination/to/", + set_destination.set_destination_to_endpoint, + name="set_destination to", + ), + path( + "set_destination/from/", + set_destination.set_destination_from_endpoint, + name="set_destination from", + ), + path( + "delete/", + delete.delete_invoice_endpoint, + name="delete", + ), + path( + "edit/", + edit.edit_invoice_endpoint, + name="edit", + ), + path("edit//set_status//", edit.change_status_endpoint, name="edit status"), + path("edit//discount/", edit.edit_discount_endpoint, name="edit discount"), + path("fetch/", fetch.fetch_all_invoices_endpoint, name="fetch"), ] app_name = "invoices" diff --git a/backend/api/public/endpoints/clients/list.py b/backend/api/public/endpoints/clients/list.py index 412f5bf1..bfd7be45 100644 --- a/backend/api/public/endpoints/clients/list.py +++ b/backend/api/public/endpoints/clients/list.py @@ -41,7 +41,7 @@ def list_clients_endpoint(request): # paginator = PageNumberPagination() # paginator.page_size = 5 - search_text = request.GET.get("search") + search_text = request.data.get("search") clients: QuerySet[Client] = fetch_clients(request, search_text=search_text) diff --git a/backend/api/public/serializers/invoices.py b/backend/api/public/serializers/invoices.py index 10432936..faeb2603 100644 --- a/backend/api/public/serializers/invoices.py +++ b/backend/api/public/serializers/invoices.py @@ -15,34 +15,7 @@ class InvoiceSerializer(serializers.ModelSerializer): class Meta: model = Invoice - fields = [ - "date_due", - "date_issued", - "currency", - "client_id", - "client_name", - "client_company", - "client_address", - "client_city", - "client_county", - "client_country", - "client_is_representative", - "self_name", - "self_company", - "self_address", - "self_city", - "self_county", - "self_country", - "notes", - "invoice_number", - "vat_number", - "logo", - "reference", - "sort_code", - "account_number", - "account_holder_name", - "items", - ] + fields = "__all__" # def create(self, validated_data): # items_data = validated_data.pop('items') diff --git a/backend/service/invoices/fetch.py b/backend/service/invoices/fetch.py new file mode 100644 index 00000000..a119e17c --- /dev/null +++ b/backend/service/invoices/fetch.py @@ -0,0 +1,109 @@ +from django.db.models import Prefetch, ExpressionWrapper, F, FloatField, Sum, Case, When, Q, Value, CharField +from django.utils import timezone + +from backend.models import Invoice, InvoiceItem + + +def get_context(invoices, sort_by, sort_direction=True, action_filter_type=None, action_filter_by=None, previous_filters=None): + context: dict = {} + + 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: + context["sort"] = "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 + + return context, invoices