diff --git a/backend/admin.py b/backend/admin.py index b936979c..c0d7737c 100644 --- a/backend/admin.py +++ b/backend/admin.py @@ -21,6 +21,12 @@ VerificationCodes, APIKey, InvoiceOnetimeSchedule, + QuotaLimit, + QuotaOverrides, + QuotaUsage, + QuotaIncreaseRequest, + Receipt, + ReceiptDownloadToken, ) # from django.contrib.auth.models imp/ort User @@ -45,9 +51,21 @@ VerificationCodes, APIKey, InvoiceOnetimeSchedule, + QuotaOverrides, + QuotaUsage, + QuotaIncreaseRequest, + Receipt, + ReceiptDownloadToken, ] ) + +class QuotaLimitAdmin(admin.ModelAdmin): + readonly_fields = ["name", "slug"] + + +admin.site.register(QuotaLimit, QuotaLimitAdmin) + # admin.site.unregister(User) fields = list(UserAdmin.fieldsets) fields[0] = (None, {"fields": ("username", "password", "logged_in_as_team", "awaiting_email_verification")}) diff --git a/backend/api/base/modal.py b/backend/api/base/modal.py index 24ad3606..823ed93d 100644 --- a/backend/api/base/modal.py +++ b/backend/api/base/modal.py @@ -1,7 +1,7 @@ from django.http import HttpRequest, HttpResponseBadRequest from django.shortcuts import render -from backend.models import UserSettings, Invoice, Team +from backend.models import UserSettings, Invoice, Team, QuotaLimit # Still working on @@ -51,10 +51,20 @@ def open_modal(request: HttpRequest, modal_name, context_type=None, context_valu context["invoice"] = invoice except Invoice.DoesNotExist: ... + elif context_type == "quota": + try: + quota = QuotaLimit.objects.prefetch_related("quota_overrides").get(slug=context_value) + context["quota"] = quota + context["current_limit"] = quota.get_quota_limit(user=request.user, quota_limit=quota) + usage = quota.strict_get_quotas(user=request.user, quota_limit=quota) + context["quota_usage"] = usage.count() if usage != "Not Available" else "Not available" + print(context["quota_usage"]) + except QuotaLimit.DoesNotExist: + ... else: context[context_type] = context_value return render(request, template_name, context) - except Exception as e: + except ValueError as e: print(f"Something went wrong with loading modal {modal_name}. Error: {e}") return HttpResponseBadRequest("Something went wrong") diff --git a/backend/api/invoices/delete.py b/backend/api/invoices/delete.py index 0074dbcf..3cf644f8 100644 --- a/backend/api/invoices/delete.py +++ b/backend/api/invoices/delete.py @@ -5,7 +5,7 @@ from django.urls.exceptions import Resolver404 from django.views.decorators.http import require_http_methods -from backend.models import Invoice +from backend.models import Invoice, QuotaLimit @require_http_methods(["DELETE"]) @@ -20,16 +20,14 @@ def delete_invoice(request: HttpRequest): except Invoice.DoesNotExist: return JsonResponse({"message": "Invoice not found"}, status=404) - if not invoice.user.logged_in_as_team and invoice.user != request.user: + if not invoice.has_access(request.user): return JsonResponse({"message": "You do not have permission to delete this invoice"}, status=404) - if invoice.user.logged_in_as_team and invoice.organization != request.user.logged_in_as_team: - return JsonResponse({"message": "You do not have permission to delete this invoice"}, status=404) + QuotaLimit.delete_quota_usage("invoices-count", request.user, invoice.id, invoice.date_created) invoice.delete() if request.htmx: - print("should send msg") if not redirect: messages.success(request, "Invoice deleted") return render(request, "base/toasts.html") @@ -40,6 +38,6 @@ def delete_invoice(request: HttpRequest): response["HX-Location"] = redirect return response except Resolver404: - ... + return HttpResponseRedirect(reverse("dashboard")) return JsonResponse({"message": "Invoice successfully deleted"}, status=200) diff --git a/backend/api/quotas/fetch.py b/backend/api/quotas/fetch.py new file mode 100644 index 00000000..69aa93d8 --- /dev/null +++ b/backend/api/quotas/fetch.py @@ -0,0 +1,30 @@ +from django.db.models import Q +from django.http import HttpRequest +from django.shortcuts import render, redirect + +from backend.models import QuotaLimit + + +def fetch_all_quotas(request: HttpRequest, group: str): + context = {} + if not request.htmx: + return redirect("quotas") + + search_text = request.GET.get("search") + + results = QuotaLimit.objects.filter(slug__startswith=group).prefetch_related("quota_overrides", "quota_usage").order_by("-slug") + + if search_text: + results = results.filter(Q(name__icontains=search_text)) + + quotas = [ + { + "quota_limit": ql.get_quota_limit(request.user), + "period_usage": ql.get_period_usage(request.user), + "quota_object": ql, + } + for ql in results + ] + + context.update({"quotas": quotas}) + return render(request, "pages/quotas/_fetch_body.html", context) diff --git a/backend/api/quotas/requests.py b/backend/api/quotas/requests.py new file mode 100644 index 00000000..4d6fcda4 --- /dev/null +++ b/backend/api/quotas/requests.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass +from typing import Union + +from django.contrib import messages +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from backend.decorators import superuser_only +from backend.models import QuotaIncreaseRequest, QuotaLimit, QuotaUsage, QuotaOverrides +from backend.utils import quota_usage_check_under + + +def submit_request(request: HttpRequest, slug) -> HttpResponse: + if not request.htmx: + return redirect("quotas") + + new_value = request.POST.get("new_value") + reason = request.POST.get("reason") + + try: + quota_limit = QuotaLimit.objects.get(slug=slug) + except QuotaLimit.DoesNotExist: + return error(request, "Failed to get the quota limit type") + + usage_per_item = quota_usage_check_under(request, "quota_increase-request", extra_data=quota_limit.id, api=True, htmx=True) + usage_per_month = quota_usage_check_under( + request, "quota_increase-requests_per_month_per_quota", extra_data=quota_limit.id, api=True, htmx=True + ) + + if not isinstance(usage_per_item, bool): + return usage_per_item + + if not isinstance(usage_per_month, bool): + return usage_per_month + + current = quota_limit.get_quota_limit(request.user) + + validate = validate_request(new_value, reason, current) + + if isinstance(validate, Error): + return error(request, validate.message) + + quota_increase_request = QuotaIncreaseRequest.objects.create( + user=request.user, quota_limit=quota_limit, new_value=new_value, current_value=current + ) + + QuotaUsage.create_str(request.user, "quota_increase-request", quota_increase_request.id) + QuotaUsage.create_str(request.user, "quota_increase-requests_per_month_per_quota", quota_limit.id) + + messages.success(request, "Successfully submitted a quota increase request") + return render(request, "partials/messages_list.html") + + +@dataclass +class Error: + message: str + + +def error(request: HttpRequest, message: str) -> HttpResponse: + messages.error(request, message) + return render(request, "partials/messages_list.html") + + +def validate_request(new_value, reason, current) -> Union[True, Error]: + if not new_value: + return Error("Please enter a valid increase value") + + try: + new_value = int(new_value) + if new_value <= current: + raise ValueError + except ValueError: + return Error("Please enter a valid increase value that is above your current limit.") + + if len(reason) < 25: + return Error("Please enter a valid reason for the increase.") + + return True + + +@superuser_only +@require_http_methods(["DELETE", "POST"]) +def approve_request(request: HttpRequest, request_id) -> HttpResponse: + if not request.htmx: + return redirect("quotas") + try: + quota_request = QuotaIncreaseRequest.objects.get(id=request_id) + except QuotaIncreaseRequest.DoesNotExist: + return error(request, "Failed to get the quota increase request") + + QuotaOverrides.objects.filter(user=quota_request.user).delete() + + QuotaOverrides.objects.create( + user=quota_request.user, + value=quota_request.new_value, + quota_limit=quota_request.quota_limit, + ) + + quota_limit_for_increase = QuotaLimit.objects.get(slug="quota_increase-request") + QuotaUsage.objects.filter(user=quota_request.user, quota_limit=quota_limit_for_increase, extra_data=quota_request.id).delete() + quota_request.status = "approved" + quota_request.save() + + return HttpResponse(status=200) + + +@superuser_only +@require_http_methods(["DELETE", "POST"]) +def decline_request(request: HttpRequest, request_id) -> HttpResponse: + if not request.htmx: + return redirect("quotas") + try: + quota_request = QuotaIncreaseRequest.objects.get(id=request_id) + except QuotaIncreaseRequest.DoesNotExist: + return error(request, "Failed to get the quota increase request") + + quota_limit_for_increase = QuotaLimit.objects.get(slug="quota_increase-request") + QuotaUsage.objects.filter(user=quota_request.user, quota_limit=quota_limit_for_increase, extra_data=quota_request.id).delete() + quota_request.status = "decline" + quota_request.save() + + return HttpResponse(status=200) diff --git a/backend/api/quotas/urls.py b/backend/api/quotas/urls.py new file mode 100644 index 00000000..373da4f9 --- /dev/null +++ b/backend/api/quotas/urls.py @@ -0,0 +1,16 @@ +from django.urls import path + +from . import fetch, requests + +urlpatterns = [ + path( + "fetch//", + fetch.fetch_all_quotas, + name="fetch", + ), + path("submit_request//", requests.submit_request, name="submit_request"), + path("request//approve/", requests.approve_request, name="approve request"), + path("request//decline/", requests.decline_request, name="decline request"), +] + +app_name = "quotas" diff --git a/backend/api/receipts/delete.py b/backend/api/receipts/delete.py index 55ebe036..33a9a397 100644 --- a/backend/api/receipts/delete.py +++ b/backend/api/receipts/delete.py @@ -5,7 +5,6 @@ from django.views.decorators.http import require_http_methods from backend.models import Receipt -from django.db.models import Q @require_http_methods(["DELETE"]) @@ -20,6 +19,9 @@ def receipt_delete(request: HttpRequest, id: int): elif receipt.user != request.user: return JsonResponse(status=403, data={"message": "Forbidden"}) + # QuotaLimit.delete_quota_usage("receipts-count", request.user, receipt.id, receipt.date_uploaded) # Don't want to delete receipts + # from records because it does cost us PER receipt. So makes sense not to allow Upload, delete, upload .. etc + receipt.delete() messages.success(request, "Receipt deleted") return render( diff --git a/backend/api/receipts/new.py b/backend/api/receipts/new.py index 989c8694..6bc41208 100644 --- a/backend/api/receipts/new.py +++ b/backend/api/receipts/new.py @@ -1,14 +1,15 @@ from django.contrib import messages -from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponseBadRequest from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.models import Receipt +from backend.decorators import quota_usage_check +from backend.models import Receipt, QuotaUsage @require_http_methods(["POST"]) +@quota_usage_check("receipts-count", api=True, htmx=True) @login_required def receipt_create(request: HttpRequest): if not request.htmx: @@ -48,6 +49,7 @@ def receipt_create(request: HttpRequest): receipt.user = request.user receipt.save() + QuotaUsage.create_str(request.user, "receipts-count", receipt.id) # r = requests.post( # "https://ocr.asprise.com/api/receipt", # data={ diff --git a/backend/api/teams/create.py b/backend/api/teams/create.py index 75c74c79..9942dca1 100644 --- a/backend/api/teams/create.py +++ b/backend/api/teams/create.py @@ -1,12 +1,12 @@ from django.http import HttpRequest -from django.shortcuts import render from django.views.decorators.http import require_POST from backend.decorators import * -from backend.models import Team +from backend.models import Team, QuotaUsage @require_POST +@quota_usage_check("teams-count", api=True, htmx=True) def create_team(request: HttpRequest): name = request.POST.get("name") @@ -15,6 +15,10 @@ def create_team(request: HttpRequest): return render(request, "partials/messages_list.html") team = Team.objects.create(name=name, leader=request.user) + + QuotaUsage.create_str(request.user, "teams-count", team.id) + QuotaUsage.create_str(request.user, "teams-joined", team.id) + if not request.user.logged_in_as_team: request.user.logged_in_as_team = team request.user.save() diff --git a/backend/api/teams/invites.py b/backend/api/teams/invites.py index 75386025..4216ec34 100644 --- a/backend/api/teams/invites.py +++ b/backend/api/teams/invites.py @@ -1,6 +1,4 @@ -from django.http import HttpRequest, HttpResponse -from django.shortcuts import render -from django.urls import reverse +from django.http import HttpRequest from backend.decorators import * from backend.models import Notification, Team, TeamInvitation, User @@ -29,6 +27,15 @@ def check_team_invitation_is_valid(request, invitation: TeamInvitation, code=Non valid = False messages.error(request, "Invitation has expired") + try: + quota_limit = QuotaLimit.objects.get(slug="teams-user_count") + if invitation.team.members.count() >= quota_limit.get_quota_limit(invitation.team.leader): + valid = False + messages.error(request, "Unfortunately this team is currently full") + except QuotaLimit.DoesNotExist: + valid = False + messages.error(request, "Something went wrong with fetching the quota limit") + if not valid: delete_notification(request.user, code) return False @@ -41,9 +48,10 @@ def send_user_team_invite(request: HttpRequest): team_id = request.POST.get("team_id") team = Team.objects.filter(leader=request.user, id=team_id).first() - def return_error_notif(request: HttpRequest, message: str): + def return_error_notif(request: HttpRequest, message: str, autohide=None): messages.error(request, message) - resp = render(request, "partials/messages_list.html", status=200) + context = {"autohide": False} if autohide == False else {} + resp = render(request, "partials/messages_list.html", context=context, status=200) resp["HX-Trigger-After-Swap"] = "invite_user_error" return resp @@ -61,6 +69,18 @@ def return_error_notif(request: HttpRequest, message: str): if user.teams_joined.exists(): return return_error_notif(request, "User already is in this team") + try: + quota_limit = QuotaLimit.objects.get(slug="teams-user_count") + if team.members.count() >= quota_limit.get_quota_limit(team.leader): + return return_error_notif( + request, + "Unfortunately your team has reached the maximum members limit. Go to the service quotas " + "page to request a higher number or kick some users to make space.", + autohide=False, + ) + except QuotaLimit.DoesNotExist: + return return_error_notif(request, "Something went wrong with fetching the quota limit") + invitation = TeamInvitation.objects.create(team=team, user=user, invited_by=request.user) # if EMAIL_SERVER_ENABLED and EMAIL_FROM_ADDRESS: @@ -96,7 +116,7 @@ def return_error_notif(request: HttpRequest, message: str): def accept_team_invite(request: HttpRequest, code): - invitation: TeamInvitation = TeamInvitation.objects.filter(code=code).first() + invitation: TeamInvitation = TeamInvitation.objects.filter(code=code).prefetch_related("team").first() if not check_team_invitation_is_valid(request, invitation, code): messages.error(request, "Invalid invite - Maybe it has expired?") diff --git a/backend/api/teams/leave.py b/backend/api/teams/leave.py index 4a31295c..bf140ac0 100644 --- a/backend/api/teams/leave.py +++ b/backend/api/teams/leave.py @@ -1,3 +1,4 @@ +from django.contrib import messages from django.http import HttpRequest, HttpResponse from django.shortcuts import render diff --git a/backend/api/urls.py b/backend/api/urls.py index 6a2d03d7..3eef0cc6 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -10,6 +10,7 @@ path("settings/", include("backend.api.settings.urls")), path("products/", include("backend.api.products.urls")), path("currency_converter/", include("backend.api.currency_converter.urls")), + path("quotas/", include("backend.api.quotas.urls")), path("hc/", include("backend.api.healthcheck.urls")), ] diff --git a/backend/data/default_quota_limits.py b/backend/data/default_quota_limits.py new file mode 100644 index 00000000..0ed8171b --- /dev/null +++ b/backend/data/default_quota_limits.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from typing import List, Literal + + +@dataclass +class QuotaItem: + slug: str + name: str + description: str + default_value: int + adjustable: bool + period: Literal["forever", "per_month", "per_day", "per_client", "per_invoice", "per_team", "per_quota"] + + +@dataclass +class QuotaGroup: + name: str + items: List[QuotaItem] + + +default_quota_limits: List[QuotaGroup] = [ + QuotaGroup( + "invoices", + [ + QuotaItem( + slug="count", + name="Creations per month", + description="Amount of invoices created per month", + default_value=100, + period="per_month", + adjustable=True, + ), + QuotaItem( + slug="schedules", + name="Schedules per month", + description="Amount of invoice scheduled sends allowed per month", + default_value=100, + period="per_month", + adjustable=True, + ), + QuotaItem( + slug="access_codes", + name="Created access codes", + description="Amount of invoice access codes allowed per invoice", + default_value=3, + period="per_invoice", + adjustable=True, + ), + ], + ), + QuotaGroup( + "receipts", + [ + QuotaItem( + slug="count", + name="Created receipts", + description="Amount of receipts stored per month", + default_value=100, + period="per_month", + adjustable=True, + ) + ], + ), + QuotaGroup( + "clients", + [ + QuotaItem( + slug="count", + name="Created clients", + description="Amount of clients stored in total", + default_value=40, + period="forever", + adjustable=True, + ) + ], + ), + QuotaGroup( + "teams", + [ + QuotaItem( + slug="count", + name="Created teams", + description="Amount of teams created in total", + default_value=3, + period="forever", + adjustable=True, + ), + QuotaItem( + slug="joined", + name="Joined teams", + description="Amount of teams that you have joined in total", + default_value=5, + period="forever", + adjustable=True, + ), + QuotaItem( + slug="user_count", + name="Users per team", + description="Amount of users per team", + default_value=10, + period="per_team", + adjustable=True, + ), + ], + ), + QuotaGroup( + "quota_increase", + [ + QuotaItem( + slug="request", + name="Quota Increase Request", + description="Amount of increase requests allowed per quota", + default_value=1, + period="per_quota", + adjustable=False, + ), + QuotaItem( + slug="requests_per_month_per_quota", + name="Quota Increase Requests per month", + description="Amount of increase requests allowed per month per quota", + period="per_quota", + default_value=1, + adjustable=False, + ), + ], + ), +] diff --git a/backend/decorators.py b/backend/decorators.py index 0072731b..17e45ab8 100644 --- a/backend/decorators.py +++ b/backend/decorators.py @@ -1,9 +1,12 @@ from functools import wraps +from typing import Optional from django.contrib import messages from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, render +from django.urls import reverse +from backend.models import QuotaLimit from backend.utils import get_feature_status @@ -54,7 +57,40 @@ def wrapper(request, *args, **kwargs): elif api: return HttpResponse(status=403, content="This feature is currently disabled.") messages.error(request, "This feature is currently disabled.") - return HttpResponseRedirect(request.META.get("HTTP_REFERER")) + referer = request.META.get("HTTP_REFERER") + current_url = request.build_absolute_uri() + if referer != current_url: + return HttpResponseRedirect(referer) + return HttpResponseRedirect(reverse("dashboard")) + + return wrapper + + return decorator + + +def quota_usage_check(limit: str | QuotaLimit, extra_data: Optional[str | int] = None, api=False, htmx=False): + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + try: + quota_limit = QuotaLimit.objects.get(slug=limit) if isinstance(limit, str) else limit + except QuotaLimit.DoesNotExist: + return view_func(request, *args, **kwargs) + + if not quota_limit.strict_goes_above_limit(request.user, extra=extra_data): + return view_func(request, *args, **kwargs) + + if api and htmx: + messages.error(request, f"You have reached the quota limit for this service '{quota_limit.slug}'") + return render(request, "partials/messages_list.html", {"autohide": False}) + elif api: + return HttpResponse(status=403, content=f"You have reached the quota limit for this service '{quota_limit.slug}'") + messages.error(request, f"You have reached the quota limit for this service '{quota_limit.slug}'") + referer = request.META.get("HTTP_REFERER") + current_url = request.build_absolute_uri() + if referer != current_url: + return HttpResponseRedirect(referer) + return HttpResponseRedirect(reverse("dashboard")) return wrapper diff --git a/backend/migrations/0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more.py b/backend/migrations/0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more.py new file mode 100644 index 00000000..ca011aa9 --- /dev/null +++ b/backend/migrations/0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more.py @@ -0,0 +1,111 @@ +# Generated by Django 5.0.3 on 2024-04-01 17:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend", "0027_invoice_currency"), + ] + + operations = [ + migrations.CreateModel( + name="QuotaLimit", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.CharField(editable=False, max_length=100, unique=True)), + ("name", models.CharField(editable=False, max_length=100)), + ("description", models.TextField(blank=True, max_length=500, null=True)), + ("value", models.IntegerField()), + ("updated_at", models.DateTimeField(auto_now=True)), + ("adjustable", models.BooleanField(default=True)), + ( + "limit_type", + models.CharField( + choices=[ + ("per_month", "Per Month"), + ("per_day", "Per Day"), + ("per_client", "Per Client"), + ("per_invoice", "Per Invoice"), + ("per_team", "Per Team"), + ("per_quota", "Per Quota"), + ("forever", "Forever"), + ], + default="per_month", + max_length=20, + ), + ), + ], + options={ + "verbose_name": "Quota Limit", + "verbose_name_plural": "Quota Limits", + }, + ), + migrations.CreateModel( + name="QuotaIncreaseRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("new_value", models.IntegerField()), + ("current_value", models.IntegerField()), + ("updated_at", models.DateTimeField(auto_now=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "status", + models.CharField( + choices=[("pending", "Pending"), ("approved", "Approved"), ("rejected", "Rejected")], + default="pending", + max_length=20, + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "quota_limit", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="quota_increase_requests", to="backend.quotalimit" + ), + ), + ], + options={ + "verbose_name": "Quota Increase Request", + "verbose_name_plural": "Quota Increase Requests", + }, + ), + migrations.CreateModel( + name="QuotaOverrides", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("value", models.IntegerField()), + ("updated_at", models.DateTimeField(auto_now=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "quota_limit", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="quota_overrides", to="backend.quotalimit"), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "Quota Override", + "verbose_name_plural": "Quota Overrides", + }, + ), + migrations.CreateModel( + name="QuotaUsage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("extra_data", models.IntegerField(blank=True, null=True)), + ( + "quota_limit", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="quota_usage", to="backend.quotalimit"), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "Quota Usage", + "verbose_name_plural": "Quota Usage", + }, + ), + ] diff --git a/backend/models.py b/backend/models.py index db6aec5f..bd482b82 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,11 +1,13 @@ +from datetime import datetime from decimal import Decimal +from typing import Optional, NoReturn, Union, Literal from uuid import uuid4 from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import UserManager, AbstractUser, AnonymousUser from django.core.validators import MaxValueValidator from django.db import models -from django.db.models import Count +from django.db.models import Count, QuerySet from django.utils import timezone from django.utils.crypto import get_random_string from shortuuid.django_fields import ShortUUIDField @@ -552,9 +554,186 @@ class FeatureFlags(models.Model): value = models.BooleanField(default=False) updated_at = models.DateTimeField(auto_now=True) + class Meta: + verbose_name = "Feature Flag" + verbose_name_plural = "Feature Flags" + def __str__(self): return self.name + +class QuotaLimit(models.Model): + class LimitTypes(models.TextChoices): + PER_MONTH = "per_month" + PER_DAY = "per_day" + PER_CLIENT = "per_client" + PER_INVOICE = "per_invoice" + PER_TEAM = "per_team" + PER_QUOTA = "per_quota" + FOREVER = "forever" + + slug = models.CharField(max_length=100, unique=True, editable=False) + name = models.CharField(max_length=100, editable=False) + description = models.TextField(max_length=500, null=True, blank=True) + value = models.IntegerField() + updated_at = models.DateTimeField(auto_now=True) + adjustable = models.BooleanField(default=True) + limit_type = models.CharField(max_length=20, choices=LimitTypes.choices, default=LimitTypes.PER_MONTH) + class Meta: - verbose_name = "Feature Flag" - verbose_name_plural = "Feature Flags" + verbose_name = "Quota Limit" + verbose_name_plural = "Quota Limits" + + def __str__(self): + return self.name + + def get_quota_limit(self, user: User, quota_limit: Optional["QuotaLimit"] = None): + try: + if quota_limit: + user_quota_override = quota_limit + else: + user_quota_override = self.quota_overrides.get(user=user) + return user_quota_override.value + except QuotaOverrides.DoesNotExist: + return self.value + + def get_period_usage(self, user: User): + if self.limit_type == "forever": + return self.quota_usage.filter(user=user, quota_limit=self).count() + elif self.limit_type == "per_month": + return self.quota_usage.filter(user=user, quota_limit=self, created_at__month=datetime.now().month).count() + elif self.limit_type == "per_day": + return self.quota_usage.filter(user=user, quota_limit=self, created_at__day=datetime.now().day).count() + else: + return "Not available" + + def strict_goes_above_limit(self, user: User, extra: Optional[str | int] = None) -> bool: + current = self.strict_get_quotas(user, extra) + current = current.count() if current != "Not Available" else None + return current >= self.get_quota_limit(user) if current else False + + def strict_get_quotas( + self, user: User, extra: Optional[str | int] = None, quota_limit: Optional["QuotaLimit"] = None + ) -> Union['QuerySet["QuotaUsage"]', Literal["Not Available"]]: + """ + Gets all usages of a quota + :return: QuerySet of quota usages OR "Not Available" if utilisation isn't available (e.g. per invoice you can't get in total) + """ + current = None + quota_limit = quota_limit.quota_usage if quota_limit else QuotaUsage.objects.filter(user=user, quota_limit=self) + + if self.limit_type == "forever": + current = self.quota_usage.filter(user=user, quota_limit=self) + elif self.limit_type == "per_month": + current_month = timezone.now().month + current_year = timezone.now().year + current = quota_limit.filter(created_at__year=current_year, created_at__month=current_month) + elif self.limit_type == "per_day": + current_day = timezone.now().day + current_month = timezone.now().month + current_year = timezone.now().year + current = quota_limit.filter(created_at__year=current_year, created_at__month=current_month, created_at__day=current_day) + elif self.limit_type in ["per_client", "per_invoice", "per_team", "per_receipt", "per_quota"] and extra: + current = quota_limit.filter(extra_data=extra) + else: + return "Not Available" + return current + + @classmethod + def delete_quota_usage(cls, quota_limit: Union[str, "QuotaLimit"], user: User, extra, timestamp=None) -> NoReturn: + quota_limit = cls.objects.get(slug=quota_limit) if isinstance(quota_limit, str) else quota_limit + + all_usages = quota_limit.strict_get_quotas(user, extra) + closest_obj = None + + if all_usages.count() > 1 and timestamp: + earliest: QuotaUsage = all_usages.filter(created_at__gte=timestamp).order_by("created_at").first() + latest: QuotaUsage = all_usages.filter(created_at__lte=timestamp).order_by("created_at").last() + + if earliest and latest: + time_until_soonest_obj = abs(earliest.created_at - timestamp) + time_since_most_recent_obj = abs(latest.created_at - timestamp) + if time_until_soonest_obj < time_since_most_recent_obj: + closest_obj = earliest + else: + closest_obj = latest + + if earliest and latest and closest_obj: + closest_obj.delete() + elif all_usages.count() > 1: + earliest = all_usages.order_by("created_at").first() + if earliest: + earliest.delete() + else: + first = all_usages.first() + if first: + first.delete() + + +class QuotaOverrides(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + quota_limit = models.ForeignKey(QuotaLimit, on_delete=models.CASCADE, related_name="quota_overrides") + value = models.IntegerField() + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Quota Override" + verbose_name_plural = "Quota Overrides" + + def __str__(self): + return f"{self.user}" + + +class QuotaUsage(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + quota_limit = models.ForeignKey(QuotaLimit, on_delete=models.CASCADE, related_name="quota_usage") + created_at = models.DateTimeField(auto_now_add=True) + extra_data = models.IntegerField(null=True, blank=True) # id of Limit Type + + class Meta: + verbose_name = "Quota Usage" + verbose_name_plural = "Quota Usage" + + def __str__(self): + return f"{self.user} quota usage for {self.quota_limit_id}" + + @classmethod + def create_str(cls, user: User, limit: str | QuotaLimit, extra_data: Optional[str | int] = None): + try: + quota_limit = limit if isinstance(limit, QuotaLimit) else QuotaLimit.objects.get(slug=limit) + except QuotaLimit.DoesNotExist: + return "Not Found" + + return cls.objects.create(user=user, quota_limit=quota_limit, extra_data=extra_data) + + @classmethod + def get_usage(self, user: User, limit: str | QuotaLimit): + try: + ql: QuotaLimit = QuotaLimit.objects.get(slug=limit) if isinstance(limit, str) else limit + except QuotaLimit.DoesNotExist: + return "Not Found" + + return self.objects.filter(user=user, quota_limit=ql).count() + + +class QuotaIncreaseRequest(models.Model): + class StatusTypes(models.TextChoices): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + quota_limit = models.ForeignKey(QuotaLimit, on_delete=models.CASCADE, related_name="quota_increase_requests") + new_value = models.IntegerField() + current_value = models.IntegerField() + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + status = models.CharField(max_length=20, choices=StatusTypes.choices, default=StatusTypes.PENDING) + + class Meta: + verbose_name = "Quota Increase Request" + verbose_name_plural = "Quota Increase Requests" + + def __str__(self): + return f"{self.user}" diff --git a/backend/signals.py b/backend/signals.py index 2dc8d5b4..14af938a 100644 --- a/backend/signals.py +++ b/backend/signals.py @@ -1,14 +1,18 @@ +import logging + from django.core.cache import cache from django.core.cache.backends.redis import RedisCacheClient +from backend.data.default_quota_limits import default_quota_limits + cache: RedisCacheClient = cache from django.core.files.storage import default_storage -from django.db.models.signals import pre_save, post_delete, post_save, post_migrate +from django.db.models.signals import pre_save, post_delete, post_save, post_migrate, pre_delete from django.dispatch import receiver from django.urls import reverse import settings.settings -from backend.models import UserSettings, Receipt, User, FeatureFlags, VerificationCodes +from backend.models import UserSettings, Receipt, User, FeatureFlags, VerificationCodes, QuotaLimit from settings.helpers import ARE_EMAILS_ENABLED, send_email @@ -34,8 +38,8 @@ def set_profile_picture_to_none(sender, instance, **kwargs): instance.profile_picture.delete(save=False) -@receiver(post_delete, sender=Receipt) -def set_profile_picture_to_none(sender, instance, **kwargs): +@receiver(pre_delete, sender=Receipt) +def delete_old_receipts(sender, instance, **kwargs): # Check if the file exists in the storage if instance.image and default_storage.exists(instance.image.name): instance.image.delete(save=False) @@ -76,6 +80,42 @@ def insert_initial_data(**kwargs): flag.value = feature.get("default") flag.save() + for group in default_quota_limits: + for item in group.items: + existing = QuotaLimit.objects.filter(slug=f"{group.name}-{item.slug}").first() + if existing: + name, value, adjustable, description, limit_type = ( + existing.name, + existing.value, + existing.adjustable, + existing.description, + existing.limit_type, + ) + existing.name = item.name + existing.value = item.default_value + existing.adjustable = item.adjustable + existing.description = item.description + existing.limit_type = item.period + if ( + item.name != name + or item.default_value != value + or item.adjustable != adjustable + or item.description != description + or item.period != limit_type + ): + logging.info(f"Updated QuotaLimit {item.name}") + existing.save() + else: + QuotaLimit.objects.create( + name=item.name, + slug=f"{group.name}-{item.slug}", + value=item.default_value, + adjustable=item.adjustable, + description=item.description, + limit_type=item.period, + ) + logging.info(f"Added QuotaLimit {item.name}") + post_migrate.connect(insert_initial_data) @@ -96,12 +136,12 @@ def send_welcome_email(sender, instance: User, created, **kwargs): if created: email_message = f""" Welcome to MyFinances{f", {instance.first_name}" if instance.first_name else ""}! - + We're happy to have you join us. We are still in development and are still working on the core mechanics. - If you find any bugs with our software, create a bug report on our + If you find any bugs with our software, create a bug report on our Github Issues (https://github.com/TreyWW/MyFinances/issues/new?assignees=&labels=bug&projects=&template=bug-report.md&title=%5BBUG%5D+) and we'll try to help debug the issue or squash the bug. - + Thank you for using MyFinances. """ if ARE_EMAILS_ENABLED: @@ -114,7 +154,7 @@ def send_welcome_email(sender, instance: User, created, **kwargs): email_message += f""" To start with, you must first **verify this email** so that we can link your account to this email. Click the link below to activate your account, no details are required, once pressed you're all set! - + Verify Link: {magic_link_url} """ diff --git a/backend/urls.py b/backend/urls.py index 3af88996..d72e7593 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -14,6 +14,7 @@ from backend.views.core.currency_converter import dashboard as cc_dashboard from backend.views.core.invoices.overview import manage_invoice from backend.views.core.other.index import index, dashboard +from backend.views.core.quotas.view import quotas_page, quotas_list, view_quota_increase_requests url( r"^frontend/static/(?P.*)$", @@ -27,6 +28,9 @@ path("dashboard/", dashboard, name="dashboard"), path("dashboard/settings/", settings_v.view.settings_page, name="user settings"), path("dashboard/invoices/", include("backend.views.core.invoices.urls")), + path("dashboard/quotas/", quotas_page, name="quotas"), + path("dashboard/quotas//", quotas_list, name="quotas group"), + path("dashboard/admin/quota_requests/", view_quota_increase_requests, name="admin quota increase requests"), path( "dashboard/invoice//", manage_invoice, diff --git a/backend/utils.py b/backend/utils.py index 7bfa2238..ff24ff0a 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -1,9 +1,15 @@ +from typing import Optional + +from django.contrib import messages from django.core.cache import cache from django.core.cache.backends.redis import RedisCacheClient +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse cache: RedisCacheClient = cache -from backend.models import FeatureFlags +from backend.models import FeatureFlags, QuotaLimit def get_feature_status(feature, should_use_cache=True): @@ -20,3 +26,27 @@ def get_feature_status(feature, should_use_cache=True): return value.value else: return False + + +def quota_usage_check_under( + request, limit: str | QuotaLimit, extra_data: Optional[str | int] = None, api=False, htmx=False +) -> bool | HttpResponse | HttpResponseRedirect: + try: + quota_limit = QuotaLimit.objects.get(slug=limit) if isinstance(limit, str) else limit + except QuotaLimit.DoesNotExist: + return True + + if not quota_limit.strict_goes_above_limit(request.user, extra=extra_data): + return True + + if api and htmx: + messages.error(request, f"You have reached the quota limit for this service '{quota_limit.slug}'") + return render(request, "partials/messages_list.html", {"autohide": False}) + elif api: + return HttpResponse(status=403, content=f"You have reached the quota limit for this service '{quota_limit.slug}'") + messages.error(request, f"You have reached the quota limit for this service '{quota_limit.slug}'") + referer = request.META.get("HTTP_REFERER") + current_url = request.build_absolute_uri() + if referer != current_url: + return HttpResponseRedirect(referer) + return HttpResponseRedirect(reverse("dashboard")) diff --git a/backend/views/core/invoices/create.py b/backend/views/core/invoices/create.py index de92b81c..419503eb 100644 --- a/backend/views/core/invoices/create.py +++ b/backend/views/core/invoices/create.py @@ -5,10 +5,15 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.models import Invoice, InvoiceItem, Client, InvoiceProduct +from backend.decorators import quota_usage_check +from backend.models import Invoice, InvoiceItem, Client, InvoiceProduct, QuotaUsage +from backend.utils import quota_usage_check_under def invoice_page_get(request: HttpRequest): + check_usage = quota_usage_check_under(request, "invoices-count") + if not isinstance(check_usage, bool): + return check_usage context = { "clients": Client.objects.filter(user=request.user), "existing_products": InvoiceProduct.objects.filter(user=request.user), @@ -16,6 +21,7 @@ def invoice_page_get(request: HttpRequest): return render(request, "pages/invoices/create/create.html", context) +@quota_usage_check("invoices-count") def invoice_page_post(request: HttpRequest): invoice_items = [ InvoiceItem.objects.create(name=row[0], description=row[1], hours=row[2], price_per_hour=row[3]) @@ -80,6 +86,8 @@ def invoice_page_post(request: HttpRequest): invoice.save() invoice.items.set(invoice_items) + QuotaUsage.create_str(request.user, "invoices-count", invoice.id) + return redirect("invoices:dashboard") diff --git a/backend/views/core/invoices/manage_access.py b/backend/views/core/invoices/manage_access.py index 518f84ba..f6854309 100644 --- a/backend/views/core/invoices/manage_access.py +++ b/backend/views/core/invoices/manage_access.py @@ -2,7 +2,8 @@ from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, render -from backend.models import Invoice, InvoiceURL +from backend.decorators import quota_usage_check +from backend.models import Invoice, InvoiceURL, QuotaUsage, QuotaLimit def manage_access(request: HttpRequest, invoice_id): @@ -21,6 +22,7 @@ def manage_access(request: HttpRequest, invoice_id): ) +@quota_usage_check("invoices-access_codes", 1, api=True, htmx=True) def create_code(request: HttpRequest, invoice_id): if not request.htmx: return redirect("invoices:dashboard") @@ -36,6 +38,9 @@ def create_code(request: HttpRequest, invoice_id): code = InvoiceURL.objects.create(invoice=invoice, created_by=request.user) messages.success(request, "Successfully created code") + + QuotaUsage.create_str(request.user, "invoices-access_codes", invoice_id) + return render( request, "pages/invoices/manage_access/_table_row.html", @@ -49,13 +54,16 @@ def delete_code(request: HttpRequest, code): try: code_obj = InvoiceURL.objects.get(uuid=code) - invoice = Invoice.objects.get(id=code_obj.invoice.id, user=request.user) + invoice = Invoice.objects.get(id=code_obj.invoice.id) + if not invoice.has_access(request.user): + raise Invoice.DoesNotExist except (Invoice.DoesNotExist, InvoiceURL.DoesNotExist): return redirect("invoices:dashboard") + QuotaLimit.delete_quota_usage("invoices-access_codes", request.user, invoice.id, code_obj.created_on) + code_obj.delete() - # return HttpResponse("", status=200) messages.success(request, "Successfully deleted code") return render( request, diff --git a/backend/views/core/quotas/view.py b/backend/views/core/quotas/view.py new file mode 100644 index 00000000..6ad4d2cd --- /dev/null +++ b/backend/views/core/quotas/view.py @@ -0,0 +1,19 @@ +from django.http import HttpResponse, HttpRequest +from django.shortcuts import render + +from backend.decorators import superuser_only +from backend.models import QuotaIncreaseRequest + + +def quotas_page(request: HttpRequest) -> HttpResponse: + return render(request, "pages/quotas/dashboard.html") + + +def quotas_list(request: HttpRequest, group: str) -> HttpResponse: + return render(request, "pages/quotas/list.html", {"group": group}) + + +@superuser_only +def view_quota_increase_requests(request: HttpRequest) -> HttpResponse: + requests = QuotaIncreaseRequest.objects.filter(status="pending").order_by("-created_at") + return render(request, "pages/quotas/view_requests.html", {"requests": requests}) diff --git a/backend/views/core/settings/teams.py b/backend/views/core/settings/teams.py index 5f18b1e0..ad7f90c5 100644 --- a/backend/views/core/settings/teams.py +++ b/backend/views/core/settings/teams.py @@ -1,5 +1,3 @@ -from typing import Optional - from django.db.models import When, Case, BooleanField from django.http import HttpRequest from django.shortcuts import render diff --git a/components/+messages_list.html b/components/+messages_list.html index 844415ca..96766ec2 100644 --- a/components/+messages_list.html +++ b/components/+messages_list.html @@ -12,13 +12,15 @@ {{ message }} {% endautoescape %} - + }, 7000); + + {% endif %} {% endfor %} {% if with_js %} diff --git a/frontend/templates/base/+left_drawer.html b/frontend/templates/base/+left_drawer.html index af37b6a8..94b2b051 100644 --- a/frontend/templates/base/+left_drawer.html +++ b/frontend/templates/base/+left_drawer.html @@ -39,5 +39,12 @@ {% if request.resolver_match.url_name == i_url %}class="active"{% endif %}>Currency Converter {% endwith %} +
  • + {% with i_url="quotas" %} + Service Quotas + + {% endwith %} +
  • diff --git a/frontend/templates/base/toast.html b/frontend/templates/base/toast.html new file mode 100644 index 00000000..bb637a64 --- /dev/null +++ b/frontend/templates/base/toast.html @@ -0,0 +1 @@ +{% component "messages_list" %} diff --git a/frontend/templates/modals/view_quota_limit_info.html b/frontend/templates/modals/view_quota_limit_info.html new file mode 100644 index 00000000..c4631152 --- /dev/null +++ b/frontend/templates/modals/view_quota_limit_info.html @@ -0,0 +1,78 @@ +{% component_block "modal" start_open="true" %} +{% fill "id" %}modal_view_quota_limit_info_{{ quota.id }}{% endfill %} +{% fill "title" %} +

    {{ quota.name }} - ({{ quota.slug }})

    +{% endfill %} +{% fill "content" %} + +{% endfill %} +{% endcomponent_block %} diff --git a/frontend/templates/pages/quotas/_fetch_body.html b/frontend/templates/pages/quotas/_fetch_body.html new file mode 100644 index 00000000..4aff9499 --- /dev/null +++ b/frontend/templates/pages/quotas/_fetch_body.html @@ -0,0 +1,30 @@ + + {% for quota in quotas %} + + + + + {{ quota.period_usage }} + {{ quota.quota_limit }} + {{ quota.quota_object.value }} + + {% if quota.quota_object.adjustable %} + Account Level + {% else %} + Not adjustable + {% endif %} + + + {% empty %} + No Quotas Found + {% endfor %} + +{% component "messages_list" %} diff --git a/frontend/templates/pages/quotas/dashboard.html b/frontend/templates/pages/quotas/dashboard.html new file mode 100644 index 00000000..f5827ea2 --- /dev/null +++ b/frontend/templates/pages/quotas/dashboard.html @@ -0,0 +1,22 @@ +{% extends 'base/base.html' %} +{% block content %} +
    +

    Service Quotas

    +
    + {% if request.user.is_superuser %} + View Requests + {% endif %} + Invoices + Clients + Teams + Receipts + Quotas +
    +
    +{% endblock content %} diff --git a/frontend/templates/pages/quotas/list.html b/frontend/templates/pages/quotas/list.html new file mode 100644 index 00000000..3b908332 --- /dev/null +++ b/frontend/templates/pages/quotas/list.html @@ -0,0 +1,32 @@ +{% extends 'base/base.html' %} +{% block content %} +
    +
    + +

    Service Quotas

    +
    +
    +
    + + + + + + + + + + + + {% include 'components/table/skeleton_rows.html' with rows=3 cols=5 %} + +
    Quota NameCurrent usageApplied account quota valueDefault quota valueAdjustability
    +
    +
    +{% endblock content %} diff --git a/frontend/templates/pages/quotas/view_requests.html b/frontend/templates/pages/quotas/view_requests.html new file mode 100644 index 00000000..23745ed7 --- /dev/null +++ b/frontend/templates/pages/quotas/view_requests.html @@ -0,0 +1,45 @@ +{% extends 'base/base.html' %} +{% block content %} +
    +

    View Requests

    + + + + + + + + + + + + + + {% for request in requests %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    IDQuota ItemCurrent ValueNew ValueUser IDDateActions
    {{ request.id }}{{ request.quota_limit.name }}{{ request.current_value }}{{ request.new_value }}{{ request.user.id }}{{ request.created_at }} + + +
    There are currently no submitted requests
    +
    +{% endblock content %} diff --git a/frontend/templates/partials/messages_list.html b/frontend/templates/partials/messages_list.html index bb637a64..b943764b 100644 --- a/frontend/templates/partials/messages_list.html +++ b/frontend/templates/partials/messages_list.html @@ -1 +1,5 @@ -{% component "messages_list" %} +{% if autohide != False %} + {% component "messages_list" %} +{% else %} + {% component "messages_list" autohide=False %} +{% endif %}