diff --git a/.github/workflows/run_mypy.yml b/.github/workflows/run_mypy.yml.disabled similarity index 100% rename from .github/workflows/run_mypy.yml rename to .github/workflows/run_mypy.yml.disabled diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1facc0ac5..796c571b5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,16 +35,33 @@ jobs: - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: poetry install --no-interaction --no-root - - name: Install latest setuptools - run: | - source .venv/bin/activate - pip install setuptools - - name: Install latest Django + - name: Debug Python Environment + env: + SECRET_KEY: "some!random!secret!key!use!online!generator!to!get" + URL: "127.0.0.1" + PROXY_IP: "localhost" + BRANCH: "debug" + DEBUG: "true" + DATABASE_TYPE: "sqlite3" + SITE_URL: "http://myfinances.example.com" + SITE_NAME: "myfinances" run: | source .venv/bin/activate - pip install Django + python -c "import pkgutil; print([module.name for module in pkgutil.iter_modules()])" + python -c "import core; print(core.__file__)" + python -c "import billing; print(billing.__file__)" - name: Install dependencies and build frontend + env: + SECRET_KEY: "some!random!secret!key!use!online!generator!to!get" + URL: "127.0.0.1" + PROXY_IP: "localhost" + BRANCH: "debug" + DEBUG: "true" + DATABASE_TYPE: "sqlite3" + SITE_URL: "http://myfinances.example.com" + SITE_NAME: "myfinances" run: | + source .venv/bin/activate npm ci npm run tailwind-build npm run webpack-build diff --git a/.gitignore b/.gitignore index f3647efb0..3c089d5c2 100644 --- a/.gitignore +++ b/.gitignore @@ -166,4 +166,4 @@ Pulumi.*.yaml.bak # Closed Source features -./billing +../core/billing diff --git a/backend/admin.py b/backend/admin.py index f60925484..cd6793d8a 100644 --- a/backend/admin.py +++ b/backend/admin.py @@ -1,29 +1,5 @@ from django.contrib import admin -from django.contrib.auth.admin import UserAdmin -from backend.core.api.public import APIAuthToken -from backend.core.models import ( - PasswordSecret, - AuditLog, - LoginLog, - Error, - TracebackError, - UserSettings, - Notification, - Organization, - TeamInvitation, - TeamMemberPermission, - User, - FeatureFlags, - VerificationCodes, - QuotaLimit, - QuotaOverrides, - QuotaUsage, - QuotaIncreaseRequest, - EmailSendStatus, - FileStorageFile, - MultiFileUpload, -) from backend.finance.models import ( Invoice, InvoiceURL, @@ -33,49 +9,25 @@ InvoiceProduct, Receipt, ReceiptDownloadToken, + FinanceDefaultValues, ) -from backend.clients.models import Client, DefaultValues - -from settings.settings import BILLING_ENABLED - # from django.contrib.auth.models imp/ort User # admin.register(Invoice) admin.site.register( [ - UserSettings, - Client, Invoice, InvoiceURL, InvoiceItem, - PasswordSecret, - AuditLog, - LoginLog, - Error, - TracebackError, - Notification, - Organization, - TeamInvitation, - TeamMemberPermission, InvoiceProduct, - FeatureFlags, - VerificationCodes, Receipt, ReceiptDownloadToken, InvoiceReminder, - APIAuthToken, InvoiceRecurringProfile, - FileStorageFile, - MultiFileUpload, - DefaultValues, + FinanceDefaultValues, ] ) -if BILLING_ENABLED: - from billing.models import PlanFeature, PlanFeatureGroup, SubscriptionPlan, UserSubscription - - admin.site.register([PlanFeature, PlanFeatureGroup, SubscriptionPlan, UserSubscription]) - class QuotaLimitAdmin(admin.ModelAdmin): readonly_fields = ["name", "slug"] @@ -96,38 +48,14 @@ def get_queryset(self, request): return super().get_queryset(request).select_related("quota_limit", "user") -class EmailSendStatusAdmin(admin.ModelAdmin): - readonly_fields = ["aws_message_id"] - - class InvoiceURLAdmin(admin.ModelAdmin): readonly_fields = ["expires"] -admin.site.register(QuotaLimit, QuotaLimitAdmin) -admin.site.register(QuotaUsage, QuotaUsageAdmin) -admin.site.register(QuotaOverrides, QuotaOverridesAdmin) -admin.site.register(QuotaIncreaseRequest, QuotaIncreaseRequestAdmin) -admin.site.register(EmailSendStatus, EmailSendStatusAdmin) - -# admin.site.unregister(User) -fields = list(UserAdmin.fieldsets) # type: ignore[arg-type] -fields[0] = ( - None, - { - "fields": ( - "username", - "password", - "logged_in_as_team", - "awaiting_email_verification", - "stripe_customer_id", - "entitlements", - "require_change_password", - ) - }, -) -UserAdmin.fieldsets = tuple(fields) -admin.site.register(User, UserAdmin) +# admin.site.register(QuotaLimit, QuotaLimitAdmin) +# admin.site.register(QuotaUsage, QuotaUsageAdmin) +# admin.site.register(QuotaOverrides, QuotaOverridesAdmin) +# admin.site.register(QuotaIncreaseRequest, QuotaIncreaseRequestAdmin) admin.site.site_header = "MyFinances Admin" admin.site.index_title = "MyFinances" diff --git a/backend/core/__init__.py b/backend/api/__init__.py similarity index 100% rename from backend/core/__init__.py rename to backend/api/__init__.py diff --git a/backend/core/api/__init__.py b/backend/api/emails/__init__.py similarity index 100% rename from backend/core/api/__init__.py rename to backend/api/emails/__init__.py diff --git a/backend/core/api/emails/fetch.py b/backend/api/emails/fetch.py similarity index 93% rename from backend/core/api/emails/fetch.py rename to backend/api/emails/fetch.py index 87c062c72..dfc12b391 100644 --- a/backend/core/api/emails/fetch.py +++ b/backend/api/emails/fetch.py @@ -4,9 +4,9 @@ from django.shortcuts import render, redirect from django_ratelimit.core import is_ratelimited -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.models import EmailSendStatus -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @web_require_scopes("emails:read", True, True) diff --git a/backend/core/api/emails/send.py b/backend/api/emails/send.py similarity index 79% rename from backend/core/api/emails/send.py rename to backend/api/emails/send.py index 317a59ad0..968d3e578 100644 --- a/backend/core/api/emails/send.py +++ b/backend/api/emails/send.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from string import Template +from core.data.default_email_templates import email_footer from django.contrib import messages from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -15,20 +16,20 @@ from django.views.decorators.http import require_POST from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef -from backend.core.data.default_email_templates import email_footer -from backend.decorators import feature_flag_check, web_require_scopes -from backend.decorators import htmx_only +from core.decorators import feature_flag_check, web_require_scopes +from core.decorators import htmx_only from backend.models import Client from backend.models import EmailSendStatus -from backend.models import QuotaLimit -from backend.models import QuotaUsage -from backend.core.types.emails import ( + +# from backend.models import QuotaLimit +# from backend.models import QuotaUsage +from core.types.emails import ( BulkEmailEmailItem, ) -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest from settings.helpers import send_email, send_templated_bulk_email, get_var -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @dataclass @@ -81,7 +82,7 @@ def _send_bulk_email_view(request: WebRequest) -> HttpResponse: if validated_bulk: messages.error(request, validated_bulk) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") message += email_footer() message_single_line_html = message.replace("\r\n", "
").replace("\n", "
") @@ -123,7 +124,7 @@ def _send_bulk_email_view(request: WebRequest) -> HttpResponse: } ) messages.success(request, f"Successfully emailed {len(email_list)} people.") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") EMAIL_SENT = send_templated_bulk_email( email_list=email_list, @@ -137,7 +138,7 @@ def _send_bulk_email_view(request: WebRequest) -> HttpResponse: if EMAIL_SENT.failed: messages.error(request, EMAIL_SENT.error) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") # todo - fix @@ -174,20 +175,20 @@ def _send_bulk_email_view(request: WebRequest) -> HttpResponse: messages.success(request, f"Successfully emailed {len(email_list)} people.") - try: - quota_limits = QuotaLimit.objects.filter(slug__in=["emails-single-count", "emails-bulk-count"]) - - QuotaUsage.objects.bulk_create( - [ - QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-single-count"), extra_data=status.id) - for status in SEND_STATUS_OBJECTS - ] - + [QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-bulk-count"))] - ) - except QuotaLimit.DoesNotExist: - ... + # try: + # quota_limits = QuotaLimit.objects.filter(slug__in=["emails-single-count", "emails-bulk-count"]) + # + # QuotaUsage.objects.bulk_create( + # [ + # QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-single-count"), extra_data=status.id) + # for status in SEND_STATUS_OBJECTS + # ] + # + [QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-bulk-count"))] + # ) + # except QuotaLimit.DoesNotExist: + # ... - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") def _send_single_email_view(request: WebRequest) -> HttpResponse: @@ -204,7 +205,7 @@ def _send_single_email_view(request: WebRequest) -> HttpResponse: if validated_single: messages.error(request, validated_single) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") message += email_footer() message_single_line_html = message.replace("\r\n", "
").replace("\n", "
") @@ -246,9 +247,9 @@ def _send_single_email_view(request: WebRequest) -> HttpResponse: status_object.save() - QuotaUsage.create_str(request.user, "emails-single-count", status_object.id) + # QuotaUsage.create_str(request.user, "emails-single-count", status_object.id) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") def validate_bulk_inputs(*, request, emails, clients, message, subject) -> str | None: @@ -284,20 +285,20 @@ def validate_bulk_quotas(*, request: HtmxHttpRequest, emails: list) -> str | Non email_count = len(emails) slugs = ["emails-bulk-count", "emails-bulk-max_sends"] - quota_limits: QuerySet[QuotaLimit] = QuotaLimit.objects.prefetch_related("quota_overrides", "quota_usage").filter(slug__in=slugs) + # quota_limits: QuerySet[QuotaLimit] = QuotaLimit.objects.prefetch_related("quota_overrides", "quota_usage").filter(slug__in=slugs) # quota_limits.get(). - above_bulk_sends_limit: bool = quota_limits.get(slug="emails-bulk-count").strict_goes_above_limit(request.user) - if above_bulk_sends_limit: - return "You have exceeded the quota limit for bulk email sends per month" + # above_bulk_sends_limit: bool = quota_limits.get(slug="emails-bulk-count").strict_goes_above_limit(request.user) + # if above_bulk_sends_limit: + # return "You have exceeded the quota limit for bulk email sends per month" - max_email_count = quota_limits.get(slug="emails-bulk-max_sends").get_quota_limit(user=request.user) + # max_email_count = quota_limits.get(slug="emails-bulk-max_sends").get_quota_limit(user=request.user) - if email_count > max_email_count: - return "You have exceeded the quota limit for the number of emails allowed per bulk send" - else: - return None + # if email_count > max_email_count: + # return "You have exceeded the quota limit for the number of emails allowed per bulk send" + # else: + # return None def validate_client_email(email, client) -> str | None: @@ -348,27 +349,28 @@ def validate_email_subject(subject: str) -> str | None: max_count = 64 if len(subject) < min_count: - return "The minimum character count is 16 for a subject" + return f"The minimum character count is {min_count} for a subject" if len(subject) > max_count: - return "The maximum character count is 64 characters for a subject" - - alpha_count = len(re.findall("[a-zA-Z ]", subject)) - non_alpha_count = len(subject) - alpha_count + return f"The maximum character count is {max_count} characters for a subject" - if non_alpha_count > 0 and alpha_count / non_alpha_count < 10: - return "The subject should have at least 10 letters per 'symbol'" + # alpha_count = len(re.findall("[a-zA-Z ]", subject)) + # non_alpha_count = len(subject) - alpha_count + # if non_alpha_count > 0 and alpha_count / non_alpha_count < 10: + # return "The subject should have at least 10 letters per 'symbol'" + # return None def validate_email_content(message: str, request: HtmxHttpRequest) -> str | None: min_count = 64 - max_count = QuotaLimit.objects.get(slug="emails-email_character_count").get_quota_limit(user=request.user) + max_count = 1000 + # max_count = QuotaLimit.objects.get(slug="emails-email_character_count").get_quota_limit(user=request.user) if len(message) < min_count: - return "The minimum character count is 64 for an email" + return f"The minimum character count is {min_count} for an email" if len(message) > max_count: - return "The maximum character count is 1000 characters for an email" + return f"The maximum character count is {max_count} characters for an email" return None diff --git a/backend/core/api/emails/status.py b/backend/api/emails/status.py similarity index 91% rename from backend/core/api/emails/status.py rename to backend/api/emails/status.py index 8b6dd95e3..6df99aa32 100644 --- a/backend/core/api/emails/status.py +++ b/backend/api/emails/status.py @@ -8,9 +8,9 @@ from django_ratelimit.core import is_ratelimited from mypy_boto3_sesv2.type_defs import GetMessageInsightsResponseTypeDef, InsightsEventTypeDef -from backend.decorators import htmx_only, feature_flag_check, web_require_scopes +from core.decorators import htmx_only, feature_flag_check, web_require_scopes from backend.models import EmailSendStatus -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest from settings.helpers import EMAIL_CLIENT @@ -26,13 +26,13 @@ def get_status_view(request: HtmxHttpRequest, status_id: str) -> HttpResponse: EMAIL_STATUS = EmailSendStatus.objects.get(user=request.user, id=status_id) except EmailSendStatus.DoesNotExist: messages.error(request, "Status not found") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") message_insight = get_message_insights(message_id=EMAIL_STATUS.aws_message_id) # type: ignore[arg-type] if isinstance(message_insight, str): messages.error(request, message_insight) - return render(request, "base/toast.html", {"autohide": False}) + return render(request, "core/base/toast.html", {"autohide": False}) important_info = get_important_info_from_response(message_insight) @@ -41,7 +41,7 @@ def get_status_view(request: HtmxHttpRequest, status_id: str) -> HttpResponse: EMAIL_STATUS.save() messages.success(request, f"Status updated to {important_info['status']}") - return render(request, "base/toast.html", {"autohide": False}) + return render(request, "core/base/toast.html", {"autohide": False}) @require_POST @@ -52,7 +52,7 @@ def refresh_all_statuses_view(request: HtmxHttpRequest) -> HttpResponse: request, group="email-refresh_all_statuses", key="user", rate="1/m", increment=True ): messages.error(request, "Woah, slow down! Refreshing the statuses takes a while, give us a break!") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") if request.user.logged_in_as_team: ALL_STATUSES = EmailSendStatus.objects.filter(organization=request.user.logged_in_as_team) else: diff --git a/backend/core/api/emails/urls.py b/backend/api/emails/urls.py similarity index 100% rename from backend/core/api/emails/urls.py rename to backend/api/emails/urls.py diff --git a/backend/core/api/base/__init__.py b/backend/api/public/__init__.py similarity index 100% rename from backend/core/api/base/__init__.py rename to backend/api/public/__init__.py diff --git a/backend/core/api/emails/__init__.py b/backend/api/public/endpoints/__init__.py similarity index 100% rename from backend/core/api/emails/__init__.py rename to backend/api/public/endpoints/__init__.py diff --git a/backend/core/api/healthcheck/__init__.py b/backend/api/public/endpoints/clients/__init__.py similarity index 100% rename from backend/core/api/healthcheck/__init__.py rename to backend/api/public/endpoints/clients/__init__.py diff --git a/backend/core/api/public/endpoints/clients/create.py b/backend/api/public/endpoints/clients/create.py similarity index 86% rename from backend/core/api/public/endpoints/clients/create.py rename to backend/api/public/endpoints/clients/create.py index 7899eb3db..d62246925 100644 --- a/backend/core/api/public/endpoints/clients/create.py +++ b/backend/api/public/endpoints/clients/create.py @@ -4,11 +4,11 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.helpers.response import APIResponse -from backend.core.api.public.serializers.clients import ClientSerializer -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest +from core.api.public.decorators import require_scopes +from core.api.public.helpers.response import APIResponse +from backend.api.public.serializers.clients import ClientSerializer +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest @swagger_auto_schema( @@ -47,7 +47,6 @@ @api_view(["POST"]) @require_scopes(["clients:write"]) def client_create_endpoint(request: APIRequest): - serializer = ClientSerializer(data=request.data) if not serializer.is_valid(): diff --git a/backend/core/api/public/endpoints/clients/delete.py b/backend/api/public/endpoints/clients/delete.py similarity index 87% rename from backend/core/api/public/endpoints/clients/delete.py rename to backend/api/public/endpoints/clients/delete.py index 398f77f1a..5073464b7 100644 --- a/backend/core/api/public/endpoints/clients/delete.py +++ b/backend/api/public/endpoints/clients/delete.py @@ -4,12 +4,12 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import api_view -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest +from core.api.public.decorators import require_scopes +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest -from backend.core.service.clients.delete import delete_client, DeleteClientServiceResponse -from backend.core.api.public.helpers.response import APIResponse +from backend.finance.service.clients.delete import delete_client, DeleteClientServiceResponse +from core.api.public.helpers.response import APIResponse @swagger_auto_schema( diff --git a/backend/core/api/public/endpoints/clients/list.py b/backend/api/public/endpoints/clients/list.py similarity index 78% rename from backend/core/api/public/endpoints/clients/list.py rename to backend/api/public/endpoints/clients/list.py index 29c59f969..f36172c89 100644 --- a/backend/core/api/public/endpoints/clients/list.py +++ b/backend/api/public/endpoints/clients/list.py @@ -4,14 +4,12 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.helpers.response import APIResponse -from backend.core.api.public.serializers.clients import ClientSerializer -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest -from backend.core.service.clients.get import fetch_clients, FetchClientServiceResponse - -from backend.models import Organization +from core.api.public.decorators import require_scopes +from core.api.public.helpers.response import APIResponse +from backend.api.public.serializers.clients import ClientSerializer +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest +from backend.finance.service.clients.get import fetch_clients, FetchClientServiceResponse @swagger_auto_schema( diff --git a/backend/core/api/public/endpoints/clients/urls.py b/backend/api/public/endpoints/clients/urls.py similarity index 100% rename from backend/core/api/public/endpoints/clients/urls.py rename to backend/api/public/endpoints/clients/urls.py diff --git a/backend/core/api/landing_page/__init__.py b/backend/api/public/endpoints/invoices/__init__.py similarity index 100% rename from backend/core/api/landing_page/__init__.py rename to backend/api/public/endpoints/invoices/__init__.py diff --git a/backend/core/api/public/endpoints/Invoices/create.py b/backend/api/public/endpoints/invoices/create.py similarity index 92% rename from backend/core/api/public/endpoints/Invoices/create.py rename to backend/api/public/endpoints/invoices/create.py index 3382c93c4..a00f28e44 100644 --- a/backend/core/api/public/endpoints/Invoices/create.py +++ b/backend/api/public/endpoints/invoices/create.py @@ -4,12 +4,12 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.clients.models import Client -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.helpers.response import APIResponse -from backend.core.api.public.serializers.invoices import InvoiceSerializer -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest +from backend.models import Client +from core.api.public.decorators import require_scopes +from core.api.public.helpers.response import APIResponse +from backend.api.public.serializers.invoices import InvoiceSerializer +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest from backend.finance.models import InvoiceProduct diff --git a/backend/core/api/public/endpoints/Invoices/delete.py b/backend/api/public/endpoints/invoices/delete.py similarity index 70% rename from backend/core/api/public/endpoints/Invoices/delete.py rename to backend/api/public/endpoints/invoices/delete.py index 6f7ed1770..c26256b33 100644 --- a/backend/core/api/public/endpoints/Invoices/delete.py +++ b/backend/api/public/endpoints/invoices/delete.py @@ -2,11 +2,11 @@ from rest_framework import status from rest_framework.decorators import api_view -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.types import APIRequest -from backend.core.api.public.helpers.response import APIResponse +from core.api.public.decorators import require_scopes +from core.api.public.types import APIRequest +from core.api.public.helpers.response import APIResponse -from backend.models import Invoice, QuotaLimit +from backend.models import Invoice # , QuotaLimit @api_view(["DELETE"]) @@ -22,7 +22,7 @@ def delete_invoice_endpoint(request: APIRequest): if not invoice.has_access(request.user): return APIResponse(False, {"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) + # QuotaLimit.delete_quota_usage("invoices-count", request.user, invoice.id, invoice.date_created) invoice.delete() diff --git a/backend/core/api/public/endpoints/Invoices/download_pdf.py b/backend/api/public/endpoints/invoices/download_pdf.py similarity index 87% rename from backend/core/api/public/endpoints/Invoices/download_pdf.py rename to backend/api/public/endpoints/invoices/download_pdf.py index d49c18224..b16273868 100644 --- a/backend/core/api/public/endpoints/Invoices/download_pdf.py +++ b/backend/api/public/endpoints/invoices/download_pdf.py @@ -7,13 +7,13 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.helpers.deprecate import deprecated -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest +from core.api.public.decorators import require_scopes +from core.api.public.helpers.deprecate import deprecated +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest from backend.finance.models import Invoice -from backend.core.service.invoices.single.create_pdf import generate_pdf -from backend.core.api.public.helpers.response import APIResponse +from backend.finance.service.invoices.single.create_pdf import generate_pdf +from core.api.public.helpers.response import APIResponse @swagger_auto_schema( diff --git a/backend/core/api/public/endpoints/Invoices/edit.py b/backend/api/public/endpoints/invoices/edit.py similarity index 96% rename from backend/core/api/public/endpoints/Invoices/edit.py rename to backend/api/public/endpoints/invoices/edit.py index 12232404e..a2349ddba 100644 --- a/backend/core/api/public/endpoints/Invoices/edit.py +++ b/backend/api/public/endpoints/invoices/edit.py @@ -4,9 +4,9 @@ from rest_framework import status from rest_framework.decorators import api_view -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.types import APIRequest -from backend.core.api.public.helpers.response import APIResponse +from core.api.public.decorators import require_scopes +from core.api.public.types import APIRequest +from core.api.public.helpers.response import APIResponse from backend.finance.models import Invoice diff --git a/backend/core/api/public/endpoints/Invoices/get.py b/backend/api/public/endpoints/invoices/get.py similarity index 86% rename from backend/core/api/public/endpoints/Invoices/get.py rename to backend/api/public/endpoints/invoices/get.py index 044be292e..50b885b53 100644 --- a/backend/core/api/public/endpoints/Invoices/get.py +++ b/backend/api/public/endpoints/invoices/get.py @@ -4,11 +4,11 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.serializers.invoices import InvoiceSerializer -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest -from backend.core.api.public.helpers.response import APIResponse +from core.api.public.decorators import require_scopes +from backend.api.public.serializers.invoices import InvoiceSerializer +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest +from core.api.public.helpers.response import APIResponse from backend.finance.models import Invoice diff --git a/backend/core/api/public/endpoints/Invoices/list.py b/backend/api/public/endpoints/invoices/list.py similarity index 88% rename from backend/core/api/public/endpoints/Invoices/list.py rename to backend/api/public/endpoints/invoices/list.py index adb81033b..fc9e19282 100644 --- a/backend/core/api/public/endpoints/Invoices/list.py +++ b/backend/api/public/endpoints/invoices/list.py @@ -7,14 +7,14 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.core.api.public.decorators import require_scopes -from backend.core.api.public.helpers.response import APIResponse -from backend.core.api.public.serializers.invoices import InvoiceSerializer -from backend.core.api.public.swagger_ui import TEAM_PARAMETER -from backend.core.api.public.types import APIRequest +from core.api.public.decorators import require_scopes +from core.api.public.helpers.response import APIResponse +from backend.api.public.serializers.invoices import InvoiceSerializer +from core.api.public.swagger_ui import TEAM_PARAMETER +from core.api.public.types import APIRequest from backend.finance.models import Invoice -from backend.core.service.invoices.common.fetch import get_context +from backend.finance.service.invoices.common.fetch import get_context @swagger_auto_schema( diff --git a/backend/core/api/public/endpoints/Invoices/urls.py b/backend/api/public/endpoints/invoices/urls.py similarity index 100% rename from backend/core/api/public/endpoints/Invoices/urls.py rename to backend/api/public/endpoints/invoices/urls.py diff --git a/backend/core/api/maintenance/__init__.py b/backend/api/public/serializers/__init__.py similarity index 100% rename from backend/core/api/maintenance/__init__.py rename to backend/api/public/serializers/__init__.py diff --git a/backend/core/api/public/serializers/clients.py b/backend/api/public/serializers/clients.py similarity index 100% rename from backend/core/api/public/serializers/clients.py rename to backend/api/public/serializers/clients.py diff --git a/backend/core/api/public/serializers/invoices.py b/backend/api/public/serializers/invoices.py similarity index 100% rename from backend/core/api/public/serializers/invoices.py rename to backend/api/public/serializers/invoices.py diff --git a/backend/api/public/urls.py b/backend/api/public/urls.py new file mode 100644 index 000000000..ffbd61db1 --- /dev/null +++ b/backend/api/public/urls.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from django.conf.urls import include +from django.urls import path, re_path + +urlpatterns = [ + path("clients/", include("backend.api.public.endpoints.clients.urls")), + path("invoices/", include("backend.api.public.endpoints.invoices.urls")), +] + +app_name = "public" diff --git a/backend/core/api/public/endpoints/Invoices/__init__.py b/backend/api/settings/__init__.py similarity index 100% rename from backend/core/api/public/endpoints/Invoices/__init__.py rename to backend/api/settings/__init__.py diff --git a/backend/core/api/settings/defaults.py b/backend/api/settings/defaults.py similarity index 85% rename from backend/core/api/settings/defaults.py rename to backend/api/settings/defaults.py index 7d8859256..12f186703 100644 --- a/backend/core/api/settings/defaults.py +++ b/backend/api/settings/defaults.py @@ -4,11 +4,12 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.clients.models import Client -from backend.core.service.clients.validate import validate_client -from backend.core.service.defaults.get import get_account_defaults -from backend.core.service.defaults.update import change_client_defaults -from backend.core.types.requests import WebRequest +from backend.models import Client +from backend.finance.service.clients.validate import validate_client +from backend.finance.service.defaults.get import get_account_defaults +from backend.finance.service.defaults.update import change_client_defaults + +from core.types.requests import WebRequest # @require_http_methods(["GET", "PUT"]) @@ -61,10 +62,10 @@ def change_client_defaults_endpoint(request: WebRequest, client_id: int | None = if response.failed: messages.error(request, response.error) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") messages.success(request, "Successfully updated client defaults") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") @require_http_methods(["DELETE"]) @@ -84,11 +85,11 @@ def remove_client_default_logo_endpoint(request: WebRequest, client_id: int | No if not defaults.default_invoice_logo: messages.error(request, "No default logo to remove") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") defaults.default_invoice_logo.delete() messages.success(request, "Successfully updated client defaults") - resp = render(request, "base/toast.html") + resp = render(request, "core/base/toast.html") resp["HX-Refresh"] = "true" return resp diff --git a/backend/core/api/settings/email_templates.py b/backend/api/settings/email_templates.py similarity index 73% rename from backend/core/api/settings/email_templates.py rename to backend/api/settings/email_templates.py index 97458738d..77fc0ff9b 100644 --- a/backend/core/api/settings/email_templates.py +++ b/backend/api/settings/email_templates.py @@ -2,9 +2,9 @@ from django.shortcuts import render from django.views.decorators.http import require_POST -from backend.core.service.defaults.get import get_account_defaults -from backend.decorators import web_require_scopes -from backend.core.types.requests import WebRequest +from backend.finance.service.defaults.get import get_account_defaults +from core.decorators import web_require_scopes +from core.types.requests import WebRequest @require_POST @@ -15,11 +15,11 @@ def save_email_template(request: WebRequest, template: str): if template not in ["invoice_created", "invoice_overdue", "invoice_cancelled"]: messages.error(request, f"Invalid template: {template}") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") if content is None: messages.error(request, f"Missing content for template: {template}") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") acc_defaults = get_account_defaults(request.actor) @@ -29,4 +29,4 @@ def save_email_template(request: WebRequest, template: str): messages.success(request, f"Email template '{template}' saved successfully") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") diff --git a/backend/core/api/public/endpoints/__init__.py b/backend/api/settings/service/__init__.py similarity index 100% rename from backend/core/api/public/endpoints/__init__.py rename to backend/api/settings/service/__init__.py diff --git a/backend/core/service/settings/view.py b/backend/api/settings/service/view.py similarity index 92% rename from backend/core/service/settings/view.py rename to backend/api/settings/service/view.py index 433a88b1b..de4666df2 100644 --- a/backend/core/service/settings/view.py +++ b/backend/api/settings/service/view.py @@ -1,9 +1,10 @@ from django.db.models import QuerySet -from backend.core.api.public import APIAuthToken +from core.api.public import APIAuthToken + +from backend.finance.service.defaults.get import get_account_defaults from backend.models import UserSettings -from backend.core.service.defaults.get import get_account_defaults -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest def validate_page(page: str | None) -> bool: diff --git a/backend/core/api/settings/urls.py b/backend/api/settings/urls.py similarity index 54% rename from backend/core/api/settings/urls.py rename to backend/api/settings/urls.py index 56e54a7aa..bba9aadf5 100644 --- a/backend/core/api/settings/urls.py +++ b/backend/api/settings/urls.py @@ -1,24 +1,9 @@ from django.urls import path -from . import change_name, profile_picture, preferences -from .api_keys import generate_api_key_endpoint, revoke_api_key_endpoint from .defaults import handle_client_defaults_endpoints, remove_client_default_logo_endpoint from .email_templates import save_email_template urlpatterns = [ - path( - "account_preferences/", - preferences.update_account_preferences, - name="account_preferences", - ), - path( - "change_name/", - change_name.change_account_name, - name="change_name", - ), - path("profile_picture/", profile_picture.change_profile_picture_endpoint, name="update profile picture"), - path("api_keys/generate/", generate_api_key_endpoint, name="api_keys generate"), - path("api_keys/revoke//", revoke_api_key_endpoint, name="api_keys revoke"), path("client_defaults//", handle_client_defaults_endpoints, name="client_defaults"), path("client_defaults/", handle_client_defaults_endpoints, name="client_defaults without client"), path("client_defaults/remove_default_logo/", remove_client_default_logo_endpoint, name="client_defaults remove logo without client"), diff --git a/backend/api/urls.py b/backend/api/urls.py new file mode 100644 index 000000000..af263e4b1 --- /dev/null +++ b/backend/api/urls.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from django.urls import include +from django.urls import path + +from backend.api.settings.defaults import handle_client_defaults_endpoints, remove_client_default_logo_endpoint + +urlpatterns = [ + path("clients/", include("backend.clients.api.urls", namespace="clients")), + path("", include("backend.finance.api.urls", namespace="finance")), + path( + "", + include("backend.api.settings.urls", namespace="settings"), + # path( + # "settings/", + # include( + # ( + # [ + # path("client_defaults//", handle_client_defaults_endpoints, name="client_defaults"), + # path("client_defaults/", handle_client_defaults_endpoints, name="client_defaults without client"), + # path( + # "client_defaults/remove_default_logo/", + # remove_client_default_logo_endpoint, + # name="client_defaults remove logo without client", + # ), + # path( + # "client_defaults/remove_default_logo/", + # remove_client_default_logo_endpoint, + # name="client_defaults remove logo", + # ), + # ], + # "settings", + # ), + # namespace="settings", + # ), + ), + path("public/", include("backend.api.public.urls")), + path("emails/", include("backend.api.emails.urls")), +] + +app_name = "api" diff --git a/backend/apps.py b/backend/apps.py index 7af63d87f..9d8843a97 100644 --- a/backend/apps.py +++ b/backend/apps.py @@ -1,3 +1,5 @@ +import importlib + from django.apps import AppConfig @@ -6,9 +8,11 @@ class BackendConfig(AppConfig): def ready(self): from .finance import signals - from .core import signals + from .clients import clients + + importlib.import_module("backend.modals") # from .clients import signals # from .storage import signals - # from .events import signals + # from .events import signalsupload_receipt pass diff --git a/backend/auth_backends.py b/backend/auth_backends.py deleted file mode 100644 index e9f1e6fa9..000000000 --- a/backend/auth_backends.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.backends import ModelBackend - - -class EmailInsteadOfUsernameBackend(ModelBackend): - def authenticate(self, request, username=None, password=None, **kwargs): - UserModel = get_user_model() - - if username is None or password is None: - return - - try: - user = UserModel.objects.get(email=username) - except UserModel.DoesNotExist: - # Run the default password hasher once to reduce the timing - # difference between an existing and a nonexistent user (#20760). - UserModel().set_password(password) - return None - else: - if user.check_password(password): - return user - return None diff --git a/backend/core/api/public/endpoints/clients/__init__.py b/backend/boto3/__init__.py similarity index 100% rename from backend/core/api/public/endpoints/clients/__init__.py rename to backend/boto3/__init__.py diff --git a/backend/core/api/public/endpoints/webhooks/__init__.py b/backend/boto3/async_tasks/__init__.py similarity index 100% rename from backend/core/api/public/endpoints/webhooks/__init__.py rename to backend/boto3/async_tasks/__init__.py diff --git a/backend/core/service/asyn_tasks/tasks.py b/backend/boto3/async_tasks/tasks.py similarity index 97% rename from backend/core/service/asyn_tasks/tasks.py rename to backend/boto3/async_tasks/tasks.py index c8eed4815..4ef68d883 100644 --- a/backend/core/service/asyn_tasks/tasks.py +++ b/backend/boto3/async_tasks/tasks.py @@ -14,7 +14,7 @@ def __init__(self, queue_url=None): self.region_name = os.environ.get("AWS_REGION_NAME") self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") self.aws_secret_access_key = os.environ.get("AWS_ACCESS_KEY") - self.WEBHOOK_URL = os.environ.get("SITE_URL", default="http://127.0.0.1:8000") + reverse("api:public:webhooks:receive_global") + self.WEBHOOK_URL = os.environ.get("SITE_URL", default="http://127.0.0.1:8000") + reverse("core:api:public:webhooks:receive_global") if self.queue_url: self.sqs_client = boto3.client( diff --git a/backend/core/service/boto3/handler.py b/backend/boto3/handler.py similarity index 100% rename from backend/core/service/boto3/handler.py rename to backend/boto3/handler.py diff --git a/backend/core/api/public/helpers/__init__.py b/backend/boto3/scheduler/__init__.py similarity index 100% rename from backend/core/api/public/helpers/__init__.py rename to backend/boto3/scheduler/__init__.py diff --git a/backend/core/service/boto3/scheduler/create_schedule.py b/backend/boto3/scheduler/create_schedule.py similarity index 95% rename from backend/core/service/boto3/scheduler/create_schedule.py rename to backend/boto3/scheduler/create_schedule.py index 2ce7aa153..33ff32503 100644 --- a/backend/core/service/boto3/scheduler/create_schedule.py +++ b/backend/boto3/scheduler/create_schedule.py @@ -6,8 +6,8 @@ from django.urls import reverse from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.boto3.handler import BOTO3_HANDLER -from backend.core.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse +from backend.boto3.handler import BOTO3_HANDLER +from backend.finance.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse from settings.helpers import get_var logger = logging.getLogger(__name__) diff --git a/backend/core/service/boto3/scheduler/delete_schedule.py b/backend/boto3/scheduler/delete_schedule.py similarity index 83% rename from backend/core/service/boto3/scheduler/delete_schedule.py rename to backend/boto3/scheduler/delete_schedule.py index 5e232f6ab..3bc640a71 100644 --- a/backend/core/service/boto3/scheduler/delete_schedule.py +++ b/backend/boto3/scheduler/delete_schedule.py @@ -2,16 +2,12 @@ import json import logging from typing import Type -from uuid import uuid4 from django.apps import apps from django.core.exceptions import ObjectDoesNotExist -from django.urls import reverse -from backend.finance.models import InvoiceRecurringProfile, BotoSchedule, InvoiceReminder -from backend.core.service.boto3.handler import BOTO3_HANDLER -from backend.core.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse -from settings.helpers import get_var +from backend.finance.models import BotoSchedule +from backend.boto3.handler import BOTO3_HANDLER logger = logging.getLogger(__name__) diff --git a/backend/core/service/boto3/scheduler/get.py b/backend/boto3/scheduler/get.py similarity index 89% rename from backend/core/service/boto3/scheduler/get.py rename to backend/boto3/scheduler/get.py index 446d4b5e7..b7961b650 100644 --- a/backend/core/service/boto3/scheduler/get.py +++ b/backend/boto3/scheduler/get.py @@ -2,8 +2,8 @@ from mypy_boto3_scheduler.type_defs import GetScheduleOutputTypeDef -from backend.core.service.boto3.handler import BOTO3_HANDLER -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.boto3.handler import BOTO3_HANDLER +from core.utils.dataclasses import BaseServiceResponse logger = logging.getLogger(__name__) diff --git a/backend/core/service/boto3/scheduler/pause.py b/backend/boto3/scheduler/pause.py similarity index 87% rename from backend/core/service/boto3/scheduler/pause.py rename to backend/boto3/scheduler/pause.py index b60c2fb57..b90e40865 100644 --- a/backend/core/service/boto3/scheduler/pause.py +++ b/backend/boto3/scheduler/pause.py @@ -2,9 +2,9 @@ from mypy_boto3_scheduler.type_defs import UpdateScheduleOutputTypeDef -from backend.core.service.boto3.handler import BOTO3_HANDLER -from backend.core.service.boto3.scheduler.get import get_boto_schedule -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.boto3.handler import BOTO3_HANDLER +from backend.boto3.scheduler.get import get_boto_schedule +from core.utils.dataclasses import BaseServiceResponse logger = logging.getLogger(__name__) diff --git a/backend/core/service/boto3/scheduler/update_schedule.py b/backend/boto3/scheduler/update_schedule.py similarity index 95% rename from backend/core/service/boto3/scheduler/update_schedule.py rename to backend/boto3/scheduler/update_schedule.py index fe0a8e8bb..c87acbc89 100644 --- a/backend/core/service/boto3/scheduler/update_schedule.py +++ b/backend/boto3/scheduler/update_schedule.py @@ -3,9 +3,9 @@ from uuid import UUID from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.boto3.handler import BOTO3_HANDLER -from backend.core.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.core.service.boto3.scheduler.get import get_boto_schedule +from backend.boto3.handler import BOTO3_HANDLER +from backend.boto3.scheduler.create_schedule import create_boto_schedule +from backend.boto3.scheduler.get import get_boto_schedule from backend.core.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse logger = logging.getLogger(__name__) diff --git a/backend/core/api/public/serializers/__init__.py b/backend/boto3/service/__init__.py similarity index 100% rename from backend/core/api/public/serializers/__init__.py rename to backend/boto3/service/__init__.py diff --git a/backend/clients/api/delete.py b/backend/clients/api/delete.py index 2dc5ee734..8cb9d4a8c 100644 --- a/backend/clients/api/delete.py +++ b/backend/clients/api/delete.py @@ -2,9 +2,9 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes -from backend.core.service.clients.delete import delete_client, DeleteClientServiceResponse -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes +from backend.finance.service.clients.delete import delete_client, DeleteClientServiceResponse +from core.types.requests import WebRequest @require_http_methods(["DELETE"]) @@ -16,4 +16,4 @@ def client_delete(request: WebRequest, id: int): messages.error(request, response.error) else: messages.success(request, f"Successfully deleted client #{id}") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") diff --git a/backend/clients/api/fetch.py b/backend/clients/api/fetch.py index abf4a7435..d5ebef01a 100644 --- a/backend/clients/api/fetch.py +++ b/backend/clients/api/fetch.py @@ -1,11 +1,11 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes -from backend.clients.models import Client -from backend.core.service.clients.get import fetch_clients, FetchClientServiceResponse -from backend.core.types.htmx import HtmxHttpRequest -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes +from backend.models import Client +from backend.finance.service.clients.get import fetch_clients, FetchClientServiceResponse +from core.types.htmx import HtmxHttpRequest +from core.types.requests import WebRequest @require_http_methods(["GET"]) diff --git a/backend/clients/clients.py b/backend/clients/clients.py index adf7cbb68..231981f46 100644 --- a/backend/clients/clients.py +++ b/backend/clients/clients.py @@ -3,7 +3,7 @@ from django.dispatch import receiver from django.db.models.signals import post_save -from backend.clients.models import Client, DefaultValues +from backend.models import Client, FinanceDefaultValues logger = logging.getLogger(__name__) @@ -16,11 +16,11 @@ def create_client_defaults(sender: Type[Client], instance: Client, created, **kw logger.info(f"Creating client defaults for client #{instance.id}") if instance.user: - account_defaults, _ = DefaultValues.objects.get_or_create(user=instance.owner, client=None) + account_defaults, _ = FinanceDefaultValues.objects.get_or_create(user=instance.owner, client=None) else: - account_defaults, _ = DefaultValues.objects.get_or_create(organization=instance.owner, client=None) + account_defaults, _ = FinanceDefaultValues.objects.get_or_create(organization=instance.owner, client=None) - defaults = DefaultValues.objects.create(client=instance, owner=instance.owner) # type: ignore[misc] + defaults = FinanceDefaultValues.objects.create(client=instance, owner=instance.owner) # type: ignore[misc] defaults.invoice_date_value = account_defaults.invoice_date_value defaults.invoice_date_type = account_defaults.invoice_date_type diff --git a/backend/clients/models.py b/backend/clients/models.py index fad6da28f..391471bce 100644 --- a/backend/clients/models.py +++ b/backend/clients/models.py @@ -2,106 +2,78 @@ from datetime import date, timedelta from django.db import models -from backend.core.data.default_email_templates import ( +from backend.data.default_email_templates import ( recurring_invoices_invoice_created_default_email_template, recurring_invoices_invoice_overdue_default_email_template, recurring_invoices_invoice_cancelled_default_email_template, ) -from backend.core.models import OwnerBase, User, UserSettings, _private_storage - - -class Client(OwnerBase): - active = models.BooleanField(default=True) - name = models.CharField(max_length=64) - phone_number = models.CharField(max_length=100, blank=True, null=True) - email = models.EmailField(blank=True, null=True) - email_verified = models.BooleanField(default=False) - company = models.CharField(max_length=100, blank=True, null=True) - contact_method = models.CharField(max_length=100, blank=True, null=True) - is_representative = models.BooleanField(default=False) - - address = models.TextField(max_length=100, blank=True, null=True) - city = models.CharField(max_length=100, blank=True, null=True) - country = models.CharField(max_length=100, blank=True, null=True) - - def __str__(self): - return self.name - - def has_access(self, user: User) -> bool: - if not user.is_authenticated: - return False - - if user.logged_in_as_team: - return self.organization == user.logged_in_as_team - else: - return self.user == user - - -class DefaultValues(OwnerBase): - class InvoiceDueDateType(models.TextChoices): - days_after = "days_after" # days after issue - date_following = "date_following" # date of following month - date_current = "date_current" # date of current month - - class InvoiceDateType(models.TextChoices): - day_of_month = "day_of_month" - days_after = "days_after" - - client = models.OneToOneField(Client, on_delete=models.CASCADE, related_name="default_values", null=True, blank=True) - - currency = models.CharField( - max_length=3, - default="GBP", - choices=[(code, info["name"]) for code, info in UserSettings.CURRENCIES.items()], - ) - - invoice_due_date_value = models.PositiveSmallIntegerField(default=7, null=False, blank=False) - invoice_due_date_type = models.CharField(max_length=20, choices=InvoiceDueDateType.choices, default=InvoiceDueDateType.days_after) - - invoice_date_value = models.PositiveSmallIntegerField(default=15, null=False, blank=False) - invoice_date_type = models.CharField(max_length=20, choices=InvoiceDateType.choices, default=InvoiceDateType.day_of_month) - - invoice_from_name = models.CharField(max_length=100, null=True, blank=True) - invoice_from_company = models.CharField(max_length=100, null=True, blank=True) - invoice_from_address = models.CharField(max_length=100, null=True, blank=True) - invoice_from_city = models.CharField(max_length=100, null=True, blank=True) - invoice_from_county = models.CharField(max_length=100, null=True, blank=True) - invoice_from_country = models.CharField(max_length=100, null=True, blank=True) - invoice_from_email = models.CharField(max_length=100, null=True, blank=True) - - invoice_account_number = models.CharField(max_length=100, null=True, blank=True) - invoice_sort_code = models.CharField(max_length=100, null=True, blank=True) - invoice_account_holder_name = models.CharField(max_length=100, null=True, blank=True) - - email_template_recurring_invoices_invoice_created = models.TextField(default=recurring_invoices_invoice_created_default_email_template) - email_template_recurring_invoices_invoice_overdue = models.TextField(default=recurring_invoices_invoice_overdue_default_email_template) - email_template_recurring_invoices_invoice_cancelled = models.TextField( - default=recurring_invoices_invoice_cancelled_default_email_template - ) - - def get_issue_and_due_dates(self, issue_date: date | str | None = None) -> tuple[str, str]: - due: date - issue: date - - if isinstance(issue_date, str): - issue = date.fromisoformat(issue_date) or date.today() - else: - issue = issue_date or date.today() - - match self.invoice_due_date_type: - case self.InvoiceDueDateType.days_after: - due = issue + timedelta(days=self.invoice_due_date_value) - case self.InvoiceDueDateType.date_following: - due = date(issue.year, issue.month + 1, self.invoice_due_date_value) - case self.InvoiceDueDateType.date_current: - due = date(issue.year, issue.month, self.invoice_due_date_value) - case _: - raise ValueError("Invalid invoice due date type") - return date.isoformat(issue), date.isoformat(due) - - default_invoice_logo = models.ImageField( - upload_to="invoice_logos/", - storage=_private_storage, - blank=True, - null=True, - ) +from backend.models import _private_storage, DefaultValuesBase + +# class FinanceDefaultValues(DefaultValuesBase): +# class InvoiceDueDateType(models.TextChoices): +# days_after = "days_after" +# date_following = "date_following" +# date_current = "date_current" +# +# class InvoiceDateType(models.TextChoices): +# day_of_month = "day_of_month" +# days_after = "days_after" +# +# invoice_due_date_value = models.PositiveSmallIntegerField(default=7, null=False, blank=False) +# invoice_due_date_type = models.CharField( +# max_length=20, +# choices=InvoiceDueDateType.choices, +# default=InvoiceDueDateType.days_after, +# ) +# +# invoice_date_value = models.PositiveSmallIntegerField(default=15, null=False, blank=False) +# invoice_date_type = models.CharField( +# max_length=20, +# choices=InvoiceDateType.choices, +# default=InvoiceDateType.day_of_month, +# ) +# +# invoice_from_name = models.CharField(max_length=100, null=True, blank=True) +# invoice_from_company = models.CharField(max_length=100, null=True, blank=True) +# invoice_from_address = models.CharField(max_length=100, null=True, blank=True) +# invoice_from_city = models.CharField(max_length=100, null=True, blank=True) +# invoice_from_county = models.CharField(max_length=100, null=True, blank=True) +# invoice_from_country = models.CharField(max_length=100, null=True, blank=True) +# invoice_from_email = models.CharField(max_length=100, null=True, blank=True) +# +# invoice_account_number = models.CharField(max_length=100, null=True, blank=True) +# invoice_sort_code = models.CharField(max_length=100, null=True, blank=True) +# invoice_account_holder_name = models.CharField(max_length=100, null=True, blank=True) +# +# email_template_recurring_invoices_invoice_created = models.TextField(default=recurring_invoices_invoice_created_default_email_template) +# email_template_recurring_invoices_invoice_overdue = models.TextField(default=recurring_invoices_invoice_overdue_default_email_template) +# email_template_recurring_invoices_invoice_cancelled = models.TextField( +# default=recurring_invoices_invoice_cancelled_default_email_template +# ) +# +# default_invoice_logo = models.ImageField( +# upload_to="invoice_logos/", +# storage=_private_storage, +# blank=True, +# null=True, +# ) +# +# def get_issue_and_due_dates(self, issue_date: date | str | None = None) -> tuple[str, str]: +# due: date +# issue: date +# +# if isinstance(issue_date, str): +# issue = date.fromisoformat(issue_date) or date.today() +# else: +# issue = issue_date or date.today() +# +# match self.invoice_due_date_type: +# case self.InvoiceDueDateType.days_after: +# due = issue + timedelta(days=self.invoice_due_date_value) +# case self.InvoiceDueDateType.date_following: +# due = date(issue.year, issue.month + 1, self.invoice_due_date_value) +# case self.InvoiceDueDateType.date_current: +# due = date(issue.year, issue.month, self.invoice_due_date_value) +# case _: +# raise ValueError("Invalid invoice due date type") +# return date.isoformat(issue), date.isoformat(due) diff --git a/backend/clients/views/create.py b/backend/clients/views/create.py index 97674d4a8..8a2dec93b 100644 --- a/backend/clients/views/create.py +++ b/backend/clients/views/create.py @@ -1,9 +1,9 @@ from django.contrib import messages from django.shortcuts import render, redirect -from backend.decorators import web_require_scopes -from backend.core.service.clients.create import create_client, CreateClientServiceResponse -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes +from backend.finance.service.clients.create import create_client, CreateClientServiceResponse +from core.types.requests import WebRequest @web_require_scopes("clients:write", False, False, "clients:dashboard") diff --git a/backend/clients/views/dashboard.py b/backend/clients/views/dashboard.py index 362dde0ab..ab1ffd18b 100644 --- a/backend/clients/views/dashboard.py +++ b/backend/clients/views/dashboard.py @@ -1,7 +1,7 @@ from django.shortcuts import render -from backend.decorators import web_require_scopes -from backend.core.types.htmx import HtmxHttpRequest +from core.decorators import web_require_scopes +from core.types.htmx import HtmxHttpRequest @web_require_scopes("clients:read", False, False, "dashboard") diff --git a/backend/clients/views/detail.py b/backend/clients/views/detail.py index e120442d9..574bce923 100644 --- a/backend/clients/views/detail.py +++ b/backend/clients/views/detail.py @@ -4,11 +4,11 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes -from backend.core.service.clients.delete import delete_client, DeleteClientServiceResponse -from backend.core.service.clients.validate import validate_client -from backend.core.types.requests import WebRequest -from backend.clients.models import Client +from core.decorators import web_require_scopes +from backend.finance.service.clients.delete import delete_client, DeleteClientServiceResponse +from backend.finance.service.clients.validate import validate_client +from core.types.requests import WebRequest +from backend.models import Client @require_http_methods(["GET"]) diff --git a/backend/context_processors.py b/backend/context_processors.py index 6b81f2090..5922925aa 100644 --- a/backend/context_processors.py +++ b/backend/context_processors.py @@ -1,73 +1,12 @@ -from typing import List, Optional, Dict, Any - -from django.http import HttpRequest -from django.urls import reverse - -import calendar - -from backend.core.service.base.breadcrumbs import get_breadcrumbs - -from settings.helpers import get_var +from typing import Dict, Any +from core.types.requests import WebRequest from backend import __version__ -from settings.settings import BASE_DIR, DEBUG - -## Context processors need to be put in SETTINGS TEMPLATES to be recognized -def navbar(request): - # cached_navbar_items = cache.get("navbar_items") - # if cached_navbar_items is None: - # navbar_items = load_navbar_items() - # - # # Cache the sidebar items for a certain time (e.g., 3600 seconds = 1 hr) - # cache.set("navbar_items", navbar_items, 60 * 60 * 3) # 3 hrs - # else: - # navbar_items = cached_navbar_items - # context = {"navbar_items": navbar_items} - return {} - - -def extras(request: HttpRequest): - # import_method can be one of: "webpack", "public_cdn", "custom_cdn" +def extras(request: WebRequest): data: Dict[str, Any] = {} - import pathlib - - def get_git_revision(base_path): - if not DEBUG: - return "prod" - - git_dir = pathlib.Path(base_path) / ".git" - - # check file exists - - if not git_dir.exists() or not git_dir.is_dir() or not (git_dir / "HEAD").exists(): - return "commit not found" - - with (git_dir / "HEAD").open("r") as head: - ref = head.readline().split(" ")[-1].strip() - - if not (git_dir / ref).exists(): - return "commit not found" - - with (git_dir / ref).open("r") as git_hash: - return git_hash.readline().strip() - - data["version"] = __version__ - data["git_branch"] = get_var("BRANCH") - data["git_version"] = get_git_revision(BASE_DIR) - data["import_method"] = get_var("IMPORT_METHOD", default="webpack") - data["analytics"] = get_var("ANALYTICS_STRING") - data["calendar_util"] = calendar - data["day_names_sunday_first"] = [calendar.day_name[(i + 6) % 7] for i in range(7)] - data["day_names_monday_first"] = [day for day in calendar.day_name] - - if hasattr(request, "htmx") and request.htmx.boosted: - data["base"] = "base/htmx.html" + data["finances_version"] = __version__ return data - - -def breadcrumbs(request: HttpRequest): - return get_breadcrumbs(request=request) diff --git a/backend/core/api/base/breadcrumbs.py b/backend/core/api/base/breadcrumbs.py deleted file mode 100644 index 7dfbdbfee..000000000 --- a/backend/core/api/base/breadcrumbs.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.shortcuts import render - -from backend.core.types.requests import WebRequest -from backend.core.service.base.breadcrumbs import get_breadcrumbs - - -def update_breadcrumbs_endpoint(request: WebRequest): - url = request.GET.get("url") - - breadcrumb_dict: dict = get_breadcrumbs(url=url) - return render( - request, - "base/breadcrumbs.html", - { - "breadcrumb": breadcrumb_dict.get("breadcrumb"), - "swapping": True, - # "swap": True - }, - ) diff --git a/backend/core/api/base/modal.py b/backend/core/api/base/modal.py deleted file mode 100644 index 0a6225ad9..000000000 --- a/backend/core/api/base/modal.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -from django.contrib import messages -from django.http import HttpResponseBadRequest -from django.shortcuts import render - -from backend.core.api.public import APIAuthToken -from backend.core.api.public.permissions import SCOPE_DESCRIPTIONS - -from backend.clients.models import Client -from backend.finance.models import InvoiceURL, Invoice, Receipt -from backend.models import QuotaLimit, Organization, UserSettings -from backend.core.types.requests import WebRequest -from backend.core.utils.feature_flags import get_feature_status -from backend.core.service.defaults.get import get_account_defaults - - -def open_modal(request: WebRequest, modal_name, context_type=None, context_value=None): - try: - context = {} - template_name = f"modals/{modal_name}.html" - if context_type and context_value: - if context_type == "profile_picture": - try: - context["users_profile_picture"] = request.user.user_profile.profile_picture_url - except UserSettings.DoesNotExist: - pass - elif context_type == "accept_invite_with_code": - context["code"] = context_value - elif context_type == "leave_team": - if request.user.teams_joined.filter(id=context_value).exists(): - context["team"] = Organization.objects.filter(id=context_value).first() - elif context_type == "edit_receipt": - try: - receipt = Receipt.objects.get(pk=context_value) - except Receipt.DoesNotExist: - return render(request, template_name, context) - receipt_date = receipt.date.strftime("%Y-%m-%d") if receipt.date else "" - context = { - "modal_id": f"modal_{receipt.id}_receipts_upload", - "receipt_id": context_value, - "receipt_name": receipt.name, - "receipt_date": receipt_date, - "merchant_store_name": receipt.merchant_store, - "purchase_category": receipt.purchase_category, - "total_price": receipt.total_price, - "has_receipt_image": True if receipt.image else False, - "edit_flag": True, - } - elif context_type == "upload_receipt": - context["modal_id"] = f"modal_receipts_upload" - elif context_type == "edit_invoice_to": - invoice = context_value - try: - invoice = Invoice.filter_by_owner(request.actor).get(id=invoice) - except Invoice.DoesNotExist: - return render(request, template_name, context) - - if invoice.client_to: - context["to_name"] = invoice.client_to.name - context["to_company"] = invoice.client_to.company - context["to_email"] = invoice.client_to.email - context["to_address"] = invoice.client_to.address - context["existing_client_id"] = ( - invoice.client_to.id - ) # context["to_city"] = invoice.client_to.city # context["to_county"] = invoice.client_to.county # context["to_country"] = invoice.client_to.country - else: - context["to_name"] = invoice.client_name - context["to_company"] = invoice.client_company - context["to_email"] = invoice.client_email - context["is_representative"] = invoice.client_is_representative - context["to_address"] = ( - invoice.client_address - ) # context["to_city"] = invoice.client_city # context["to_county"] = invoice.client_county # context["to_country"] = invoice.client_country - elif context_type == "edit_invoice_from": - invoice = context_value - try: - invoice = Invoice.filter_by_owner(request.actor).get(id=invoice) - except Invoice.DoesNotExist: - return render(request, template_name, context) - - context["from_name"] = invoice.self_name - context["from_company"] = invoice.self_company - context["from_address"] = invoice.self_address - context["from_city"] = invoice.self_city - context["from_county"] = invoice.self_county - context["from_country"] = invoice.self_country - elif context_type == "create_invoice_from": - defaults = get_account_defaults(request.actor) - - context["from_name"] = getattr(defaults, f"invoice_from_name") - context["from_company"] = getattr(defaults, f"invoice_from_company") - context["from_address"] = getattr(defaults, f"invoice_from_address") - context["from_city"] = getattr(defaults, f"invoice_from_city") - context["from_county"] = getattr(defaults, f"invoice_from_county") - context["from_country"] = getattr(defaults, f"invoice_from_country") - elif context_type == "invoice": - try: - invoice = Invoice.objects.get(id=context_value) - if invoice.has_access(request.user): - 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: - ... - elif context_type == "invoice_reminder": - try: - invoice = ( - Invoice.objects.only("id", "client_email", "client_to__email").select_related("client_to").get(id=context_value) - ) - except Invoice.DoesNotExist: - return render(request, template_name, context) - - if invoice.has_access(request.user): - context["invoice"] = invoice - else: - messages.error(request, "You don't have access to this invoice") - return render(request, "base/toasts.html") - - # above_quota_usage = False # quota_usage_check_under(request, "invoices-schedules", api=True, htmx=True) - - # if not isinstance(above_quota_usage, bool): # context["above_quota_usage"] = True - - else: - context[context_type] = context_value - - if modal_name == "send_single_email" or modal_name == "send_bulk_email": - if not get_feature_status("areUserEmailsAllowed"): - messages.error(request, "Emails are disabled") - return render(request, "base/toast.html") - context["content_min_length"] = 64 - quota = QuotaLimit.objects.prefetch_related("quota_overrides").get(slug="emails-email_character_count") - context["content_max_length"] = quota.get_quota_limit(user=request.user, quota_limit=quota) - context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True) - - if context_type == "invoice_code_send": - invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=context_value).prefetch_related("invoice").first() - - if not invoice_url or not invoice_url.invoice.has_access(request.user): - messages.error(request, "You don't have access to this invoice") - return render(request, "base/toast.html", {"autohide": False}) - - context["invoice"] = invoice_url.invoice - context["selected_clients"] = [ - invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email - for value in [ - invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email - ] - if value is not None - ] - - context["email_list"] = list(context["email_list"]) + context["selected_clients"] - - elif modal_name == "invoices_to_destination": - if existing_client := request.GET.get("client"): - context["existing_client_id"] = existing_client - elif modal_name in ["generate_api_key", "edit_team_member_permissions", "team_create_user"]: - # example - # "clients": { - # "description": "Access customer details", - # "options": ["read", "write"] - # }, - context["permissions"] = [ - {"name": group, "description": perms["description"], "options": perms["options"]} - for group, perms in SCOPE_DESCRIPTIONS.items() - ] - context["APIAuthToken_types"] = APIAuthToken.AdministratorServiceTypes - - if modal_name == "edit_team_member_permissions": - team = request.user.logged_in_as_team - if team: - for_user = team.members.filter(id=context_value).first() - for_user_perms = team.permissions.filter(user=for_user).first() - if for_user: - context["editing_user"] = for_user - context["user_current_scopes"] = for_user_perms.scopes if for_user_perms else [] - - return render(request, template_name, context) - 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/core/api/base/notifications.py b/backend/core/api/base/notifications.py deleted file mode 100644 index 9880b1210..000000000 --- a/backend/core/api/base/notifications.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render - -from backend.models import Notification -from backend.core.types.htmx import HtmxHttpRequest - - -def get_notification_html(request: HtmxHttpRequest): - user_notifications = Notification.objects.filter(user=request.user).order_by("-date") - count = user_notifications.count() - - if count > 5: - user_notifications = user_notifications[:5] - - return render( - request, - "base/topbar/_notification_dropdown_items.html", - {"notifications": user_notifications, "notif_count": count}, - ) - - -def get_notification_count_html(request: HtmxHttpRequest): - user_notifications = Notification.objects.filter(user=request.user).count() - return HttpResponse(f"{user_notifications}") - - -def delete_notification(request: HtmxHttpRequest, id: int): - notif = Notification.objects.filter(id=id, user=request.user).first() - - if notif is None or notif.user != request.user: - if request.htmx: - messages.error(request, "Notification not found") - return render(request, "base/toasts.html") - return HttpResponse(status=404, content="Notification not found") - - notif.delete() - - response = HttpResponse(status=200) - response["HX-Trigger"] = "refresh_notification_count" - return response diff --git a/backend/core/api/base/urls.py b/backend/core/api/base/urls.py deleted file mode 100644 index 539b0c64d..000000000 --- a/backend/core/api/base/urls.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.urls import path -from . import modal, notifications, breadcrumbs - -urlpatterns = [ - path( - "modals//retrieve", - modal.open_modal, - name="modal retrieve", - ), - path( - "modals//retrieve//", - modal.open_modal, - name="modal retrieve with context", - ), - path( - "notifications/get", - notifications.get_notification_html, - name="notifications get", - ), - path("notifications/get_count", notifications.get_notification_count_html, name="notifications get count"), - path( - "notifications/delete/", - notifications.delete_notification, - name="notifications delete", - ), - path("breadcrumbs/refetch/", breadcrumbs.update_breadcrumbs_endpoint, name="breadcrumbs refetch"), -] - -app_name = "base" diff --git a/backend/core/api/healthcheck/healthcheck.py b/backend/core/api/healthcheck/healthcheck.py deleted file mode 100644 index 4c1862d30..000000000 --- a/backend/core/api/healthcheck/healthcheck.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db import connection, OperationalError -from django.http import HttpRequest, HttpResponse -from login_required import login_not_required - - -@login_not_required -def ping(request: HttpRequest) -> HttpResponse: - return HttpResponse("pong") - - -@login_not_required -def healthcheck(request: HttpRequest) -> HttpResponse: - try: - connection.ensure_connection() - return HttpResponse(status=200, content="All operations are up and running!") - except OperationalError: - return HttpResponse(status=503, content="Service Unavailable") diff --git a/backend/core/api/healthcheck/urls.py b/backend/core/api/healthcheck/urls.py deleted file mode 100644 index 7d271225f..000000000 --- a/backend/core/api/healthcheck/urls.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.urls import path - -from . import healthcheck - -urlpatterns = [ - path( - "ping/", - healthcheck.ping, - name="ping", - ), - path( - "healthcheck/", - healthcheck.healthcheck, - name="healthcheck", - ), -] - -app_name = "healthcheck" diff --git a/backend/core/api/landing_page/email_waitlist.py b/backend/core/api/landing_page/email_waitlist.py deleted file mode 100644 index 25fedcd4b..000000000 --- a/backend/core/api/landing_page/email_waitlist.py +++ /dev/null @@ -1,49 +0,0 @@ -from textwrap import dedent - -from login_required import login_not_required - -from backend.core.service import BOTO3_HANDLER -from backend.core.types.requests import WebRequest - -from django.http import HttpResponse - -from settings.helpers import send_email - - -@login_not_required -def join_waitlist_endpoint(request: WebRequest): - email_address = request.POST.get("email", "") - name = request.POST.get("name", "") - - if not email_address: - return HttpResponse(status=400) - - if not BOTO3_HANDLER.initiated: - return HttpResponse(status=500) - - BOTO3_HANDLER.dynamodb_client.put_item(TableName="myfinances-emails", Item={"email": {"S": email_address}, "name": {"S": name}}) - - content = """ -
- Successfully registered! Expect some discounts and updates as we progress in our journey :) -
- """ - - send_email( - destination=email_address, - subject="Welcome aboard", - content=dedent( - f""" - Thank you for joining our waitlist! - - We're excited to have you on board and will be in touch with more updates as we progress in our journey. - - Stay tuned for discounts, updates and personal direct emails from our founder! - - Best regards, - The MyFinances Team - """ - ).strip(), - ) - - return HttpResponse(status=200, content=dedent(content).strip()) diff --git a/backend/core/api/landing_page/urls.py b/backend/core/api/landing_page/urls.py deleted file mode 100644 index 309fdf768..000000000 --- a/backend/core/api/landing_page/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path -from . import email_waitlist - -urlpatterns = [ - path("join_waitlist/", email_waitlist.join_waitlist_endpoint, name="join_waitlist"), -] - -app_name = "landing_page" diff --git a/backend/core/api/maintenance/now.py b/backend/core/api/maintenance/now.py deleted file mode 100644 index 11777c2d0..000000000 --- a/backend/core/api/maintenance/now.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_POST -from login_required import login_not_required - -from backend.core.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key - -from backend.core.service.maintenance.expire.run import expire_and_cleanup_objects - -import logging - -from backend.core.types.requests import WebRequest - -logger = logging.getLogger(__name__) - - -@require_POST -@csrf_exempt -@login_not_required -def handle_maintenance_now_endpoint(request: WebRequest): - logger.info("Received routine cleanup handler. Now authenticating...") - api_auth_response = authenticate_api_key(request) - - if api_auth_response.failed: - logger.info(f"Maintenance auth failed: {api_auth_response.error}") - return JsonResponse({"message": api_auth_response.error, "success": False}, status=api_auth_response.status_code or 400) - - cleanup_str = expire_and_cleanup_objects() - logger.info(cleanup_str) - return JsonResponse({"message": cleanup_str, "success": True}, status=200) diff --git a/backend/core/api/maintenance/urls.py b/backend/core/api/maintenance/urls.py deleted file mode 100644 index c1b4d2e98..000000000 --- a/backend/core/api/maintenance/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import now - -urlpatterns = [ - path("cleanup/", now.handle_maintenance_now_endpoint, name="cleanup"), -] - -app_name = "maintenance" diff --git a/backend/core/api/public/__init__.py b/backend/core/api/public/__init__.py deleted file mode 100644 index 496d78000..000000000 --- a/backend/core/api/public/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .models import APIAuthToken diff --git a/backend/core/api/public/authentication.py b/backend/core/api/public/authentication.py deleted file mode 100644 index 77b52d59d..000000000 --- a/backend/core/api/public/authentication.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import Type - -from rest_framework.authentication import TokenAuthentication, get_authorization_header -from rest_framework.exceptions import AuthenticationFailed -from django.utils.translation import gettext_lazy as _ -from backend.core.api.public.models import APIAuthToken -from backend.models import User, Organization - -from rest_framework import exceptions - - -class CustomBearerAuthentication(TokenAuthentication): - keyword = "Bearer" - - def get_model(self) -> Type[APIAuthToken]: - return APIAuthToken - - def authenticate(self, request): - auth = get_authorization_header(request).split() - - if not auth or auth[0].lower() != self.keyword.lower().encode(): - return None - - if len(auth) == 1: - msg = _("Invalid token header. No credentials provided.") - raise exceptions.AuthenticationFailed(msg) - elif len(auth) > 2: - msg = _("Invalid token header. Token string should not contain spaces.") - raise exceptions.AuthenticationFailed(msg) - - try: - token = auth[1].decode() - except UnicodeError: - msg = _("Invalid token header. Token string should not contain invalid characters.") - raise exceptions.AuthenticationFailed(msg) - - user_or_org, token = self.authenticate_credentials(token) - - request.actor = user_or_org - - if isinstance(user_or_org, Organization): - request.team = user_or_org - request.team_id = user_or_org.id - else: - request.team = None - request.team_id = None - - return (user_or_org, token) - - def authenticate_credentials(self, raw_key) -> tuple[User | Organization | None, APIAuthToken]: - model = self.get_model() - - try: - token = model.objects.get(hashed_key=model.hash_raw_key(raw_key), active=True) - except model.DoesNotExist: - raise AuthenticationFailed(_("Invalid token.")) - - if token.has_expired: - raise AuthenticationFailed(_("Token has expired.")) - - user_or_org = token.user or token.organization - - if user_or_org is None: - raise AuthenticationFailed(_("Associated user or organization not found.")) - - return user_or_org, token diff --git a/backend/core/api/public/decorators.py b/backend/core/api/public/decorators.py deleted file mode 100644 index 71360540c..000000000 --- a/backend/core/api/public/decorators.py +++ /dev/null @@ -1,47 +0,0 @@ -from functools import wraps - -from rest_framework.exceptions import PermissionDenied -from rest_framework.generics import get_object_or_404 -from rest_framework import status - -from backend.models import TeamMemberPermission, Organization, Client -from backend.core.api.public.helpers.response import APIResponse - -import logging - -logger = logging.getLogger(__name__) - - -def require_scopes(scopes): - def decorator(view_func): - @wraps(view_func) - def _wrapped_view(request, *args, **kwargs): - token = request.auth - if not token: - logger.info( - f"Authentication credentials were not provided in api request |" f" {request.META.get('REMOTE_ADDR', 'Unknown IP')}" - ) - return APIResponse(False, {"detail": "Authentication credentials were not provided."}, status=status.HTTP_401_UNAUTHORIZED) - - if request.team_id and not request.team: - return APIResponse(False, {"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND) - - if request.team: - # Check for team permissions based on team_id and scopes - if not request.team.is_owner(token.user) and not request.team.is_logged_in_as_team(request): - team_permissions = TeamMemberPermission.objects.filter(team=request.team, user=token.user).first() - if not team_permissions or not all(scope in team_permissions.scopes for scope in scopes): - return APIResponse(False, {"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN) - - # Check for global API Key permissions based on token scopes - if not all(scope in token.scopes for scope in scopes): - return APIResponse(False, {"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN) - - token.update_last_used() - - return view_func(request, *args, **kwargs) - - _wrapped_view.required_scopes = scopes - return _wrapped_view - - return decorator diff --git a/backend/core/api/public/endpoints/system_health.py b/backend/core/api/public/endpoints/system_health.py deleted file mode 100644 index 391ae4fcc..000000000 --- a/backend/core/api/public/endpoints/system_health.py +++ /dev/null @@ -1,63 +0,0 @@ -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from django.db import connection, OperationalError -from django.core.cache import cache - -from rest_framework.decorators import api_view, permission_classes - -from backend.core.api.public.permissions import IsSuperuser -from backend.core.api.public.helpers.response import APIResponse - - -@swagger_auto_schema( - method="get", - operation_description="Check the system's health by verifying database and external API connections.", - responses={ - 200: openapi.Response( - description="System health check result", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "problems": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "id": openapi.Schema(type=openapi.TYPE_STRING, description="Problem ID"), - "message": openapi.Schema(type=openapi.TYPE_STRING, description="Problem message"), - }, - ), - ), - "healthy": openapi.Schema(type=openapi.TYPE_BOOLEAN, description="Indicates overall system health"), - }, - ), - examples={ - "application/json": { - "problems": [ - {"id": "database", "message": "database failed to connect"}, - ], - "healthy": False, - } - }, - ) - }, -) -@api_view(["GET"]) -@permission_classes([IsSuperuser]) -def system_health_endpoint(request): - if not request.user or not request.user.is_superuser: - return APIResponse(False, "User is not permitted to view internal information", status=403) - - problems = [] - - try: - connection.ensure_connection() - except OperationalError: - problems.append({"id": "database", "message": "database failed to connect"}) - - try: - cache._cache.get_client().ping() - except ConnectionError: - problems.append({"id": "redis", "message": "redis failed to connect"}) - - return APIResponse({"problems": problems, "healthy": not bool(problems)}) diff --git a/backend/core/api/public/endpoints/webhooks/urls.py b/backend/core/api/public/endpoints/webhooks/urls.py deleted file mode 100644 index f4209f0a0..000000000 --- a/backend/core/api/public/endpoints/webhooks/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path -from .webhook_task_queue_handler import webhook_task_queue_handler_view_endpoint - -urlpatterns = [ - path( - "receive/global/", - webhook_task_queue_handler_view_endpoint, - name="receive_global", - ) -] - -app_name = "webhooks" diff --git a/backend/core/api/public/endpoints/webhooks/webhook_task_queue_handler.py b/backend/core/api/public/endpoints/webhooks/webhook_task_queue_handler.py deleted file mode 100644 index 256f302ce..000000000 --- a/backend/core/api/public/endpoints/webhooks/webhook_task_queue_handler.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging -from backend.core.api.public import APIAuthToken -from rest_framework.decorators import api_view - -from backend.core.service.asyn_tasks.tasks import Task -from backend.core.api.public.helpers.response import APIResponse - - -@api_view(["POST"]) -def webhook_task_queue_handler_view_endpoint(request): - token: APIAuthToken | None = request.auth - - if not token: - return APIResponse(False, {"status": "error", "message": "No token found"}, status=500) - - if not token.administrator_service_type == token.AdministratorServiceTypes.AWS_WEBHOOK_CALLBACK: - return APIResponse(False, {"status": "error", "message": "Invalid API key for this service"}, status=500) - - try: - data: dict = request.data - func_name: str = data.get("func_name") - args: list = data.get("args", []) - kwargs: dict = data.get("kwargs", {}) - - print(f"Function Name: {func_name}") - print(f"Arguments: {args}") - print(f"Keyword Arguments: {kwargs}") - - # Validate function name - if not func_name: - raise ValueError("Function name is required.") - - # Create an instance of Task - task_helper = Task() - - # Attempt to execute the function - result = task_helper.execute_now(func_name, *args, **kwargs) - - # Handle the result (e.g., store it or log it) - print(f"Webhook executed: {func_name} with result: {result}") - - return APIResponse(True, {"status": "success", "result": result}) - - except Exception as e: - logging.error(f"Error executing webhook task: {str(e)}") - return APIResponse(False, {"status": "error", "message": "An internal error has occurred."}, status=500) diff --git a/backend/core/api/public/helpers/deprecate.py b/backend/core/api/public/helpers/deprecate.py deleted file mode 100644 index 54c4ee325..000000000 --- a/backend/core/api/public/helpers/deprecate.py +++ /dev/null @@ -1,46 +0,0 @@ -import datetime -import functools -import logging - -from rest_framework.response import Response - -logger = logging.getLogger(__name__) - - -# add type hints for these deprecation dates - - -def deprecated(deprecation_date: datetime.datetime | None = None, end_of_life_date: datetime.datetime | None = None): - """ - Returns a decorator which informs requester that the decorated endpoint has been deprecated. - """ - - def decorator_deprecated(func): - """Amend the request with information that the endpoint has been deprecated and when it will be removed""" - - @functools.wraps(func) - def wrapper_deprecated(*args, **kwargs): - # do something before handling the request, could e.g. issue a django signal - logger.warning("Deprecated endpoint %s called", func.__name__) - - if end_of_life_date and datetime.datetime.now() > end_of_life_date: - return Response( - {"success": False, "message": "This endpoint is no longer available"}, - status=410, - headers={"X-Deprecated": "", "X-Deprecation-Date": deprecation_date, "X-End-Of-Life-Date": end_of_life_date}, - ) - - response: Response = func(*args, **kwargs) - - # amend the response with deprecation information - if isinstance(response, Response): - response.headers["X-Deprecated"] = "" - if deprecation_date: - response.headers["X-Deprecation-Date"] = deprecation_date - if end_of_life_date: - response.headers["X-End-Of-Life-Date"] = deprecation_date - return response - - return wrapper_deprecated - - return decorator_deprecated diff --git a/backend/core/api/public/helpers/response.py b/backend/core/api/public/helpers/response.py deleted file mode 100644 index f0ae92470..000000000 --- a/backend/core/api/public/helpers/response.py +++ /dev/null @@ -1,19 +0,0 @@ -from rest_framework.response import Response - - -def APIResponse(success: bool = True, data: str | dict | None = None, meta=None, status: int = 0, **kwargs) -> Response: - """ - - Returns a rest_framework Response object, but prefills meta (success etc) aswell as the data with KWARGS. - - """ - meta = meta or {} - if not status and success: - status = 201 - elif not status: - status = 400 - - if success: - return Response({"meta": {"success": True, **meta}, "data": {**kwargs} | data if isinstance(data, dict) else {}}, status=status) - else: - return Response({"meta": {"success": False}, "error": data}, status=status) diff --git a/backend/core/api/public/middleware.py b/backend/core/api/public/middleware.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/api/public/models.py b/backend/core/api/public/models.py deleted file mode 100644 index 96476bac2..000000000 --- a/backend/core/api/public/models.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.db import models -from django.contrib.auth.hashers import check_password, make_password -import binascii -import os -from django.utils import timezone - -from backend.core.models import OwnerBase, ExpiresBase - - -class APIAuthToken(OwnerBase, ExpiresBase): - id = models.AutoField(primary_key=True) - - hashed_key = models.CharField("Key", max_length=128, unique=True) - - name = models.CharField("Key Name", max_length=64) - description = models.TextField("Description", blank=True, null=True) - created = models.DateTimeField("Created", auto_now_add=True) - last_used = models.DateTimeField("Last Used", null=True, blank=True) - # expires = models.DateTimeField("Expires", null=True, blank=True, help_text="Leave blank for no expiry") - # expired = models.BooleanField("Expired", default=False, help_text="If the key has expired") - # active = models.BooleanField("Active", default=True, help_text="If the key is active") - scopes = models.JSONField("Scopes", default=list, help_text="List of permitted scopes") - - class AdministratorServiceTypes(models.TextChoices): - AWS_WEBHOOK_CALLBACK = "aws_webhook_callback", "AWS Webhook Callback" - AWS_API_DESTINATION = "aws_api_destination", "AWS API Destination" - - administrator_service_type = models.CharField("Administrator Service Type", max_length=64, blank=True, null=True) - - class Meta: - verbose_name = "API Key" - verbose_name_plural = "API Keys" - - def __str__(self): - return self.name - - def update_last_used(self): - self.last_used = timezone.now() - self.save() - return True - - # def save(self, *args, **kwargs): - # return super().save(*args, **kwargs) - - def generate_key(self) -> str: - """ - :returns: raw_key - """ - - raw = binascii.hexlify(os.urandom(20)).decode() - self.hashed_key = self.hash_raw_key(raw) - - return raw - - @classmethod - def hash_raw_key(cls, raw_key: str): - return make_password(raw_key, salt="api_tokens", hasher="default") - - def verify(self, key) -> bool: - return check_password(key, self.hashed_key) - - def deactivate(self): - self.active = False - self.save() - return self - - def has_scope(self, scope): - return scope in self.scopes diff --git a/backend/core/api/public/permissions.py b/backend/core/api/public/permissions.py deleted file mode 100644 index 9893a41cc..000000000 --- a/backend/core/api/public/permissions.py +++ /dev/null @@ -1,66 +0,0 @@ -from rest_framework.permissions import BasePermission -from rest_framework.request import Request -from django.conf import settings - -SCOPES = { - "clients:read", - "clients:write", - "invoices:read", - "invoices:write", - "receipts:read", - "receipts:write", - "clients:read", - "clients:write", - "emails:read", - "emails:send", - "profile:read", - "profile:write", - "api_keys:read", - "api_keys:write", - "team_permissions:read", - "team_permissions:write", - "team:invite", - "team:kick", - "account_defaults:write", - "email_templates:read", - "email_templates:write", -} - -SCOPES_TREE = { - "clients:read": {"clients:read"}, - "clients:write": {"clients:read", "clients:write"}, - "invoices:read": {"invoices:read"}, - "invoices:write": {"invoices:read", "invoices:write"}, - "profile:read": {"profile:read"}, - "profile:write": {"profile:read", "profile:write"}, - "api_keys:read": {"api_keys:read"}, - "api_keys:write": {"api_keys:read", "api_keys:write"}, - "team_permissions:read": {"team_permissions:read"}, - "team_permissions:write": {"team_permissions:read", "team_permissions:write"}, - "team:invite": {"team:invite"}, - "team:kick": {"team:kick", "team:invite"}, - "email_templates:read": {"email_templates:read"}, - "email_templates:write": {"email_templates:read", "email_templates:write"}, - "account_defaults:write": {"account_defaults:write"}, -} - -SCOPE_DESCRIPTIONS = { - "clients": {"description": "Access customer details", "options": {"read": "Read only", "write": "Read and write"}}, - "invoices": {"description": "Access invoices", "options": {"read": "Read only", "write": "Read and write"}}, - "profile": {"description": "Access profile details", "options": {"read": "Read only", "write": "Read and write"}}, - "api_keys": {"description": "Access API keys", "options": {"read": "Read only", "write": "Read and write"}}, - "team_permissions": {"description": "Access team permissions", "options": {"read": "Read only", "write": "Read and write"}}, - "team": {"description": "Invite team members", "options": {"invite": "Invite members"}}, - "email_templates": {"description": "Access email templates", "options": {"read": "Read only", "write": "Read and write"}}, - "account_defaults": {"description": "Modify account defaults", "options": {"write": "Read and write"}}, -} - -if settings.BILLING_ENABLED: - SCOPES.add("billing:manage") - SCOPES_TREE["billing:manage"] = {"billing:manage"} - SCOPE_DESCRIPTIONS["billing"] = {"description": "Access billing details + stripe", "options": {"manage": "Manage billing"}} - - -class IsSuperuser(BasePermission): - def has_permission(self, request: Request, view: object) -> bool: - return bool(request.user and request.user.is_superuser) diff --git a/backend/core/api/public/swagger_ui.py b/backend/core/api/public/swagger_ui.py deleted file mode 100644 index e1b2f331a..000000000 --- a/backend/core/api/public/swagger_ui.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.urls import path -from drf_yasg import openapi -from drf_yasg.views import get_schema_view -from rest_framework import permissions - -INFO = openapi.Info( - title="MyFinances Public API", - default_version="v0.0.1", - description="", - terms_of_service="", - contact=openapi.Contact(email="support@strelix.org"), - license=openapi.License(name="AGPL v3"), -) - -schema_view = get_schema_view( - INFO, - public=True, - permission_classes=[permissions.AllowAny], -) - - -def get_swagger_ui(): - return schema_view - - -def get_swagger_endpoints(debug): - return ( - [ - path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), - ] - + [ - path("swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json"), - path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), - ] - if debug - else [] - ) - - -TEAM_PARAMETER = openapi.Parameter( - "team_id", openapi.IN_QUERY, description="id of the team you want to do this action under", type=openapi.TYPE_STRING, required=False -) diff --git a/backend/core/api/public/types.py b/backend/core/api/public/types.py deleted file mode 100644 index 05d937c70..000000000 --- a/backend/core/api/public/types.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework.request import Request - -from backend.core.api.public import APIAuthToken -from backend.models import User, Organization - - -class APIRequest(Request): - user: User - auth: APIAuthToken - api_token: APIAuthToken - team: Organization | None - team_id: int | None diff --git a/backend/core/api/public/urls.py b/backend/core/api/public/urls.py deleted file mode 100644 index 2ab400e50..000000000 --- a/backend/core/api/public/urls.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from django.conf.urls import include -from django.urls import path, re_path -from rest_framework.authentication import TokenAuthentication - -from .endpoints.system_health import system_health_endpoint - -INTERNAL_URLS = [path("health/", system_health_endpoint, name="public-system-health")] - -urlpatterns = [ - path("internal/", include(INTERNAL_URLS)), - path("clients/", include("backend.core.api.public.endpoints.clients.urls")), - path("invoices/", include("backend.core.api.public.endpoints.Invoices.urls")), - path("webhooks/", include("backend.core.api.public.endpoints.webhooks.urls")), -] - -app_name = "public" diff --git a/backend/core/api/quotas/fetch.py b/backend/core/api/quotas/fetch.py deleted file mode 100644 index f2520a030..000000000 --- a/backend/core/api/quotas/fetch.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.db.models import Q -from django.shortcuts import render, redirect - -from backend.models import QuotaLimit -from backend.core.types.htmx import HtmxHttpRequest - - -def fetch_all_quotas(request: HtmxHttpRequest, 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/core/api/quotas/requests.py b/backend/core/api/quotas/requests.py deleted file mode 100644 index a2c239895..000000000 --- a/backend/core/api/quotas/requests.py +++ /dev/null @@ -1,136 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from django.contrib import messages -from django.http import 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.core.types.htmx import HtmxHttpRequest - - -# from backend.utils.quota_limit_ops import quota_usage_check_under - - -def submit_request(request: HtmxHttpRequest, 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, reason=reason - ) - - 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, "base/toast.html") - - -@dataclass -class Error: - message: str - - -def error(request: HtmxHttpRequest, message: str) -> HttpResponse: - messages.error(request, message) - return render(request, "partials/messages_list.html") - - -def validate_request(new_value, reason, current) -> Union[bool, 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: HtmxHttpRequest, 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") - - try: - quota_override_existing = QuotaOverrides.objects.get(user=quota_request.user, quota_limit=quota_request.quota_limit) - quota_override_existing.value = quota_request.new_value - quota_override_existing.save() - except QuotaOverrides.DoesNotExist: - 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() - - try: - QuotaUsage.objects.get( - quota_limit=QuotaLimit.objects.get(slug="quota_increase-requests_per_month_per_quota"), extra_data=quota_request.quota_limit_id - ).delete() - except QuotaUsage.DoesNotExist: - ... - - return HttpResponse(status=200) - - -@superuser_only -@require_http_methods(["DELETE", "POST"]) -def decline_request(request: HtmxHttpRequest, 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/core/api/quotas/urls.py b/backend/core/api/quotas/urls.py deleted file mode 100644 index 373da4f99..000000000 --- a/backend/core/api/quotas/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -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/core/api/settings/api_keys.py b/backend/core/api/settings/api_keys.py deleted file mode 100644 index ca2891a2d..000000000 --- a/backend/core/api/settings/api_keys.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render -from django.views.decorators.http import require_http_methods - -from backend.core.api.public import APIAuthToken -from backend.core.service.api_keys.delete import delete_api_key -from backend.core.service.api_keys.generate import generate_public_api_key -from backend.core.service.api_keys.get import get_api_key_by_id -from backend.core.service.permissions.scopes import get_permissions_from_request - -from backend.core.types.requests import WebRequest -from backend.decorators import web_require_scopes - - -@require_http_methods(["POST"]) -@web_require_scopes("api_keys:write") -def generate_api_key_endpoint(request: WebRequest) -> HttpResponse: - name = request.POST.get("name") - expires = request.POST.get("expires") - description = request.POST.get("description") - administrator_toggle = True if request.POST.get("administrator") == "on" else False - administrator_type = request.POST.get("administrator_type") - - permissions: list = get_permissions_from_request(request) - - key_obj, key_response = generate_public_api_key( - request, - request.user.logged_in_as_team or request.user, - name, - permissions, - expires=expires, - description=description, - administrator_toggle=administrator_toggle, - administrator_type=administrator_type, - ) - - if not key_obj: - messages.error(request, key_response) - return render(request, "base/toast.html") - - messages.success(request, "API key generated successfully") - - http_response = render( - request, - "pages/settings/settings/api_key_generated_response.html", - { - "raw_key": key_response, - "name": name, - }, - ) - - http_response.headers["HX-Reswap"] = "beforebegin" - http_response.headers["HX-Retarget"] = 'div[data-hx-container="api_keys"]' - - return http_response - - -@require_http_methods(["DELETE"]) -def revoke_api_key_endpoint(request: WebRequest, key_id: str) -> HttpResponse: - key: APIAuthToken | None = get_api_key_by_id(request.user.logged_in_as_team or request.user, key_id) - - delete_key_response = delete_api_key(request, request.user.logged_in_as_team or request.user, key=key) - - if isinstance(delete_key_response, str): - messages.error(request, "This key does not exist") - else: - messages.success(request, "Successfully revoked the API Key") - return render(request, "base/toast.html") diff --git a/backend/core/api/settings/change_name.py b/backend/core/api/settings/change_name.py deleted file mode 100644 index 03afc3d88..000000000 --- a/backend/core/api/settings/change_name.py +++ /dev/null @@ -1,40 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render -from django.views.decorators.http import require_http_methods - -from backend.core.types.htmx import HtmxHttpRequest - - -@require_http_methods(["POST"]) -def change_account_name(request: HtmxHttpRequest): - if not request.htmx: - return HttpResponse("Invalid Request", status=405) - - htmx_return = "base/toasts.html" - - first_name = request.POST.get("first_name") - last_name = request.POST.get("last_name") - - if not first_name and not last_name: - messages.error(request, "Please enter a valid firstname or lastname.") - return render(request, htmx_return) - - if request.user.first_name == first_name and request.user.last_name == last_name: - messages.warning(request, "You already have this name.") - return render(request, htmx_return) - - if first_name: - request.user.first_name = first_name - - if last_name: - request.user.last_name = last_name - - request.user.save() - - messages.success( - request, - f"Successfully changed your name to {request.user.get_full_name()}", - ) - - return render(request, htmx_return) diff --git a/backend/core/api/settings/preferences.py b/backend/core/api/settings/preferences.py deleted file mode 100644 index a67e28b93..000000000 --- a/backend/core/api/settings/preferences.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render -from django.views.decorators.http import require_http_methods - -from backend.models import UserSettings - - -@require_http_methods(["POST"]) -def update_account_preferences(request): - currency = request.POST.get("currency", None) - try: - usersettings = request.user.user_profile - except UserSettings.DoesNotExist: - usersettings = UserSettings.objects.create(user=request.user) - - htmx_return = "base/toasts.html" - - if not request.htmx and not currency: - return HttpResponse("Invalid Request", status=400) - elif not currency or currency not in usersettings.CURRENCIES: - messages.error(request, "Invalid Currency") - return render(request, htmx_return) - - usersettings.currency = currency - - updated_features: bool = False - - for choice, _ in usersettings.CoreFeatures.choices: - selected: str | None = request.POST.get(f"selected_{choice}", None) - - if choice in usersettings.disabled_features: # currently disabled - if selected: # enabled - updated_features = True - usersettings.disabled_features.remove(choice) - else: - if not selected: # disabled - updated_features = True - usersettings.disabled_features.append(choice) - - usersettings.save(update_fields=["disabled_features", "currency"]) - messages.success(request, "Successfully updated preferences") - - if updated_features: - response = HttpResponse("Success") - response["HX-Refresh"] = "true" - return response - return render(request, htmx_return) diff --git a/backend/core/api/settings/profile_picture.py b/backend/core/api/settings/profile_picture.py deleted file mode 100644 index 27dd71121..000000000 --- a/backend/core/api/settings/profile_picture.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect, render -from django.views.decorators.http import require_http_methods - -from backend.core.service.settings.update import update_profile_picture, UpdateProfilePictureServiceResponse -from backend.core.service.settings.view import get_user_profile -from backend.core.types.requests import WebRequest - - -@require_http_methods(["POST"]) -def change_profile_picture_endpoint(request: WebRequest): - if not request.htmx: - messages.error(request, "Invalid request") - return redirect("settings:dashboard with page", page="profile") - - user_profile = get_user_profile(request) - - update_response: UpdateProfilePictureServiceResponse = update_profile_picture(request.FILES.get("profile_picture_image"), user_profile) - - if update_response.success: - messages.success(request, update_response.response) - else: - messages.error(request, update_response.error) - - return render( - request, - "pages/settings/settings/_post_profile_pic.html", - {"users_profile_picture": user_profile.profile_picture_url}, - ) diff --git a/backend/core/api/teams/create.py b/backend/core/api/teams/create.py deleted file mode 100644 index f01172589..000000000 --- a/backend/core/api/teams/create.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.contrib import messages -from django.shortcuts import render -from django.views.decorators.http import require_POST - -from backend.decorators import has_entitlements -from backend.models import Organization, QuotaUsage -from backend.core.types.htmx import HtmxHttpRequest - - -@require_POST -@has_entitlements("organizations") -# @quota_usage_check("teams-count", api=True, htmx=True) -def create_team(request: HtmxHttpRequest): - name = request.POST.get("name") - - if not name: - messages.error(request, "A team name field must be filled.") - return render(request, "partials/messages_list.html") - - if Organization.objects.filter(name=name).exists(): - messages.error(request, "A team with this name already exists.") - return render(request, "partials/messages_list.html") - - team = Organization.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() - - messages.success(request, f"Successfully created team {name} with the ID of #{team.id}") - response = render(request, "partials/messages_list.html") - response["HX-Refresh"] = "true" - return response diff --git a/backend/core/api/teams/create_user.py b/backend/core/api/teams/create_user.py deleted file mode 100644 index 23c311b93..000000000 --- a/backend/core/api/teams/create_user.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.contrib import messages -from django.shortcuts import render - -from backend.decorators import web_require_scopes -from backend.models import Organization -from backend.core.service.permissions.scopes import get_permissions_from_request -from backend.core.service.teams.create_user import create_user_service -from backend.core.types.requests import WebRequest - - -@web_require_scopes("team:invite", True, True) -def create_user_endpoint(request: WebRequest): - team_id = request.POST.get("team_id", "") - - team: Organization | None = Organization.objects.filter(id=team_id).first() - - if not team: - messages.error(request, "This team does not exist") - return render(request, "base/toast.html") - - if not team.is_owner(request.user): - messages.error(request, "Only the team owner can create users") - return render(request, "base/toast.html") - - first_name = request.POST.get("first_name", "") - last_name = request.POST.get("last_name", "") - email = request.POST.get("email", "") - permissions: list = get_permissions_from_request(request) - - created_user = create_user_service(request, email, team, first_name, last_name, permissions) - - if created_user.failed: - messages.error(request, created_user.error) - return render(request, "base/toast.html") - else: - messages.success(request, f"The account for {first_name} was created successfully. They have been emailed instructions.") - return render(request, "base/toast.html") diff --git a/backend/core/api/teams/edit_permissions.py b/backend/core/api/teams/edit_permissions.py deleted file mode 100644 index 532a57a06..000000000 --- a/backend/core/api/teams/edit_permissions.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render -from django.views.decorators.http import require_http_methods - -from backend.decorators import web_require_scopes -from backend.models import User -from backend.core.service.permissions.scopes import get_permissions_from_request -from backend.core.service.teams.permissions import edit_member_permissions -from backend.core.types.requests import WebRequest - - -@require_http_methods(["POST"]) -@web_require_scopes("team_permissions:write") -def edit_user_permissions_endpoint(request: WebRequest) -> HttpResponse: - permissions: list = get_permissions_from_request(request) - user_id = request.POST.get("user_id") - - receiver: User | None = User.objects.filter(id=user_id).first() - - if not receiver: - messages.error(request, "Invalid user") - return render(request, "base/toast.html") - - edit_response = edit_member_permissions(receiver, request.user.logged_in_as_team, permissions) - - if edit_response.success: - messages.success(request, "User permissions saved successfully") - else: - messages.error(request, edit_response.error) - return render(request, "base/toast.html") diff --git a/backend/core/api/teams/invites.py b/backend/core/api/teams/invites.py deleted file mode 100644 index 7555fa4f0..000000000 --- a/backend/core/api/teams/invites.py +++ /dev/null @@ -1,216 +0,0 @@ -from textwrap import dedent - -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render -from django.urls import reverse - -from backend.core.models import QuotaLimit -from backend.decorators import web_require_scopes -from backend.models import Notification, Organization, TeamInvitation, User -from backend.core.types.htmx import HtmxHttpRequest -from settings.helpers import send_email - - -def delete_notification(user: User, code: TeamInvitation): - notification = Notification.objects.filter( - user=user, - message="New Organization Invite", - action="modal", - action_value="accept_invite", - extra_type="accept_invite_with_code", - extra_value=code, - ).first() - - if notification: - notification.delete() - - -def check_team_invitation_is_valid(request, invitation: TeamInvitation, code=None): - valid: bool = True - - if not invitation.is_active(): - 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 - - return True - - -@web_require_scopes("team:invite", True, True) -def send_user_team_invite(request: HtmxHttpRequest): - user_email = request.POST.get("email") - team_id = request.POST.get("team_id", "") - team: Organization | None = Organization.objects.filter(leader=request.user, id=team_id).first() - - def return_error_notif(request: HtmxHttpRequest, message: str, autohide=None): - messages.error(request, message) - context = {"autohide": False} if autohide is False else {} - resp = render(request, "partials/messages_list.html", context=context, status=200) - resp["HX-Trigger-After-Swap"] = "invite_user_error" - return resp - - if not user_email: - return return_error_notif(request, "Please enter a valid user email") - - if not team: - return return_error_notif(request, "You are not the leader of this team") - - user: User | None = User.objects.filter(email=user_email).first() - - if not user: - return return_error_notif(request, 'User not found. Either ask them to create an account or press "Create User"') - - if user.teams_joined.filter(pk=team_id).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) - - Notification.objects.create( - user=user, - message=f"New Organization Invite", - action="modal", - action_value="accept_invite", - extra_type="accept_invite_with_code", - extra_value=invitation.code, - ) - - send_email( - destination=user.email, - subject="New Organization Invite", - content=dedent( - f""" - Hi {user.first_name or "User"}, - - {request.user.first_name or f"User {request.user.email}"} has invited you to join the organization \"{team.name}\" (#{team.id}) - - - Click the url below to accept the invite! - {request.build_absolute_uri(reverse("api:teams:join accept", kwargs={"code": invitation.code}))} - - Didn't give permission to be added to this organization? You can safely ignore the email, no actions can be done on - behalf of you without your action. - """ - ), - ) - - messages.success(request, "Invitation successfully sent") - response = HttpResponse(status=200) - response["HX-Refresh"] = "true" - return response - - -def accept_team_invite(request: HtmxHttpRequest, code): - invitation: TeamInvitation | None = TeamInvitation.objects.filter(code=code).prefetch_related("team").first() - - if not invitation: - messages.error(request, "Invalid Invite Code") - # Force break early to avoid "no invitation" on invitation.code - delete_notification(request.user, code) - return render(request, "partials/messages_list.html") - - if not check_team_invitation_is_valid(request, invitation, code): - messages.error(request, "Invalid invite - Maybe it has expired?") - return render(request, "partials/messages_list.html") - - if request.user.teams_joined.filter(pk=invitation.team_id).exists(): - messages.error(request, "You are already in this team") - response = render(request, "partials/messages_list.html", status=200) - response["HX-Trigger-After-Swap"] = "accept_invite_error" - return response - - invitation.team.members.add(request.user) - - notification = Notification.objects.filter( - user=request.user, - action="modal", - action_value="accept_invite", - extra_type="accept_invite_with_code", - extra_value=code, - ).first() - - if notification: - notification.delete() - - Notification.objects.create( - user=request.user, - message=f"You have now joined the team {invitation.team.name}", - action="normal", - ) - - Notification.objects.create( - user=invitation.invited_by, - message=f"{request.user.username} has joined your team", - action="normal", - ) - - invitation.delete() - - messages.success(request, f"You have successfully joined the team {invitation.team.name}") - response = HttpResponse(status=200) - response["HX-Refresh"] = "true" - return response - # return render(request, "partials/messages_list.html") - - -def decline_team_invite(request: HtmxHttpRequest, code): - invitation: TeamInvitation | None = TeamInvitation.objects.filter(code=code).first() - confirmation_text = request.POST.get("confirmation_text") - - if not invitation: - messages.error(request, "Invalid Invite Code") - # Force break early to avoid "no invitation" on invitation.code - delete_notification(request.user, code) - return render(request, "partials/messages_list.html") - - if not check_team_invitation_is_valid(request, invitation, code): - return render(request, "partials/messages_list.html") - - # if confirmation_text != "i confirm i want to decline " + invitation.team.name: - # messages.error(request, "Invalid confirmation text") - # return redirect("teams:dashboard join", code=code) # kwargs={"code": code}) - - invitation.team.members.remove(request.user) - - Notification.objects.create( - user=request.user, - message=f"You have declined the team invitation", - action="normal", - ) - - Notification.objects.create( - user=invitation.invited_by, - message=f"{request.user.username} has declined the team invitation", - action="normal", - ) - - delete_notification(request.user, code) - - invitation.delete() - messages.success(request, "You have successfully declined the team invitation") - - return render(request, "partials/messages_list.html") diff --git a/backend/core/api/teams/kick.py b/backend/core/api/teams/kick.py deleted file mode 100644 index e15d26ae8..000000000 --- a/backend/core/api/teams/kick.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.contrib import messages -from django.http import HttpRequest -from django.shortcuts import redirect - -from backend.decorators import web_require_scopes -from backend.models import User, Organization - - -@web_require_scopes("team:kick", True, True) -def kick_user(request: HttpRequest, user_id): - user: User | None = User.objects.filter(id=user_id).first() - confirmation_text = request.POST.get("confirmation_text") - if not user: - messages.error(request, "User not found") - return redirect("teams:dashboard") - - if confirmation_text != f"i confirm i want to kick {user.username}": - messages.error(request, "Invalid confirmation") - return redirect("teams:dashboard") - - team: Organization | None = user.teams_joined.first() - if not team: - messages.error(request, "User is not apart of your team") - return redirect("teams:dashboard") - - if team.leader != request.user: - messages.error(request, "You don't have the required permissions to kick this user") - return redirect("teams:dashboard") - - team.members.remove(user) - messages.success(request, f"Successfully kicked {user.username}") - - return redirect("teams:dashboard") diff --git a/backend/core/api/teams/leave.py b/backend/core/api/teams/leave.py deleted file mode 100644 index f5a717d97..000000000 --- a/backend/core/api/teams/leave.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render - -from backend.models import Organization -from backend.core.types.htmx import HtmxHttpRequest - - -def return_error_notif(request: HtmxHttpRequest, message: str): - messages.error(request, message) - resp = render(request, "partials/messages_list.html", status=200) - resp["HX-Trigger-After-Swap"] = "leave_team_error" - return resp - - -def leave_team_confirmed(request: HtmxHttpRequest, team_id): - team: Organization | None = Organization.objects.filter(id=team_id).first() - - if not team: - return return_error_notif(request, "Team not found") - - if team.leader == request.user: # may be changed in the future. If no members allow delete - return return_error_notif(request, "You cannot leave your own team") - - if request.user.teams_joined.filter(id=team_id).exists(): - team.members.remove(request.user) - messages.success(request, f"You have successfully left the team {team.name}") - response = HttpResponse(status=200) - response["HX-Refresh"] = "true" - return response - else: - return return_error_notif(request, "You are not a member of this team") diff --git a/backend/core/api/teams/switch_team.py b/backend/core/api/teams/switch_team.py deleted file mode 100644 index f1a12eda3..000000000 --- a/backend/core/api/teams/switch_team.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render - -from backend.models import Organization -from backend.core.types.htmx import HtmxHttpRequest - - -def switch_team(request: HtmxHttpRequest, team_id: str | int | None = None): - if not team_id: - team_id = request.POST.get("join_team", None) - - if not team_id: - if not request.user.logged_in_as_team: - messages.warning(request, "You are not logged into an organization") - else: - messages.success(request, "You are now logged into your personal account") - - request.user.logged_in_as_team = None - request.user.save() - response = HttpResponse(status=200) - response["HX-Refresh"] = "true" - return response - - team: Organization | None = Organization.objects.filter(id=team_id).first() - - if not team: - messages.error(request, "Team not found") - return render(request, "partials/messages_list.html") - - if request.user.logged_in_as_team == team: - messages.error(request, "You are already logged in for this team") - return render(request, "partials/messages_list.html") - - if not request.user.teams_leader_of.filter(id=team_id).exists() and not request.user.teams_joined.filter(id=team_id).exists(): - messages.error(request, "You are not a member of this team") - return render(request, "partials/messages_list.html") - - messages.success(request, f"Now signing into the organization '{team.name}'") - request.user.logged_in_as_team = team - request.user.save() - - response = HttpResponse(status=200) - response["HX-Refresh"] = "true" - return response - # return render(request, "components/+logged_in_for.html") - - -def get_dropdown(request: HtmxHttpRequest): - if not request.htmx: - return HttpResponse("Invalid Request", status=405) - - return render(request, "base/topbar/_organizations_list.html") diff --git a/backend/core/api/teams/urls.py b/backend/core/api/teams/urls.py deleted file mode 100644 index ef6988400..000000000 --- a/backend/core/api/teams/urls.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.urls import path - -from . import kick, switch_team, invites, leave, create, edit_permissions -from .create_user import create_user_endpoint - -urlpatterns = [ - path("edit_permissions/", edit_permissions.edit_user_permissions_endpoint, name="edit_permissions"), - path( - "kick/", - kick.kick_user, - name="kick", - ), - path( - "switch_team//", - switch_team.switch_team, - name="switch_team", - ), - path( - "switch_team/", - switch_team.switch_team, - name="switch_team input", - ), - path( - "create_user/", - create_user_endpoint, - name="create_user", - ), - # INVITES # - path( - "invite/", - invites.send_user_team_invite, - name="invite", - ), - path( - "join//accept/", - invites.accept_team_invite, - name="join accept", - ), - path( - "join//decline/", - invites.decline_team_invite, - name="join decline", - ), - # LEAVE TEAM # - path( - "leave//confirm/", - leave.leave_team_confirmed, - name="leave confirm", - ), - path( - "create/", - create.create_team, - name="create", - ), - path("get_dropdown/", switch_team.get_dropdown, name="get_dropdown"), -] - -app_name = "teams" diff --git a/backend/core/api/urls.py b/backend/core/api/urls.py deleted file mode 100644 index 1249767a7..000000000 --- a/backend/core/api/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from django.urls import include -from django.urls import path - -urlpatterns = [ - path("base/", include("backend.core.api.base.urls")), - path("teams/", include("backend.core.api.teams.urls")), - path("settings/", include("backend.core.api.settings.urls")), - path("quotas/", include("backend.core.api.quotas.urls")), - path("clients/", include("backend.clients.api.urls")), - path("emails/", include("backend.core.api.emails.urls")), - path("maintenance/", include("backend.core.api.maintenance.urls")), - path("landing_page/", include("backend.core.api.landing_page.urls")), - path("public/", include("backend.core.api.public.urls")), - path("", include("backend.finance.api.urls")), -] - -app_name = "api" diff --git a/backend/core/data/default_feature_flags.py b/backend/core/data/default_feature_flags.py deleted file mode 100644 index fa5905367..000000000 --- a/backend/core/data/default_feature_flags.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class FeatureFlag: - name: str - description: str - default: bool - - -default_feature_flags: list[FeatureFlag] = [ - FeatureFlag(name="areSignupsEnabled", description="Are new account creations allowed", default=True), - FeatureFlag( - name="isInvoiceSchedulingEnabled", - description="Invoice Scheduling allows for clients to create invoice schedules that send and invoice at a specific date.", - default=False, - ), - FeatureFlag(name="areUserEmailsAllowed", description="Are users allowed to send emails from YOUR DOMAIN to customers", default=False), - FeatureFlag( - name="areInvoiceRemindersEnabled", - description="Invoice Reminders allow for clients to be reminded to pay an invoice.", - default=False, - ), -] diff --git a/backend/core/data/default_quota_limits.py b/backend/core/data/default_quota_limits.py deleted file mode 100644 index 2e1791ffa..000000000 --- a/backend/core/data/default_quota_limits.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - - -@dataclass -class QuotaItem: - slug: str - name: str - description: str - default_value: int - adjustable: bool - period: Literal[ - "forever", - "per_month", - "per_minute", - "per_hour", - "per_day", - "per_client", - "per_invoice", - "per_team", - "per_quota", - "per_bulk_send", - "per_email", - ] - - -@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, - ), - ], - ), - QuotaGroup( - "emails", - [ - QuotaItem( - slug="single-count", - name="Single Email Sends", - description="Amount of single email sends allowed per month", - period="per_month", - default_value=10, - adjustable=True, - ), - QuotaItem( - slug="bulk-count", - name="Bulk Email Sends", - description="Amount of 'Bulk Emails' allowed to be sent per month", - period="per_month", - default_value=1, - adjustable=True, - ), - QuotaItem( - slug="bulk-max_sends", - name="Bulk Email Maximum Emails", - description="Maximum amount of emails allowed to be sent per 'Bulk' request", - period="per_bulk_send", - default_value=10, - adjustable=True, - ), - QuotaItem( - slug="email_character_count", - name="Maximum Character Count", - description="Maximum amount of characters allowed in an email", - period="per_email", - default_value=1000, - adjustable=True, - ), - QuotaItem( - slug="complaints", - name="Complaints allowed", - description="Maximum amount of complaints allowed before your account will be blocked from sending emails", - period="forever", - default_value=2, - adjustable=True, - ), - ], - ), -] diff --git a/backend/core/management/commands/auto.py b/backend/core/management/commands/auto.py deleted file mode 100644 index ff7659659..000000000 --- a/backend/core/management/commands/auto.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.core.management.base import BaseCommand -from backend.core.service.maintenance.expire.run import expire_and_cleanup_objects - - -class Command(BaseCommand): - """ - Runs automation scripts to make sure objects are up to date, expired objects are deleted, etc. - """ - - def handle(self, *args, **kwargs): - self.stdout.write("Running expire + cleanup script...") - self.stdout.write(expire_and_cleanup_objects()) diff --git a/backend/core/management/commands/contributors.json b/backend/core/management/commands/contributors.json deleted file mode 100644 index 2699ea5a7..000000000 --- a/backend/core/management/commands/contributors.json +++ /dev/null @@ -1,266 +0,0 @@ -[ - { - "name": "Trey", - "username": "TreyWW", - "role": "Project Lead", - "tags": [ - "👑", - "🖥" - ] - }, - { - "name": "Slawek Bierwiaczonek", - "username": "Domejko", - "role": "Development & CI & Bug Fixing", - "tags": [ - "🧪", - "🖥", - "🐞" - ] - }, - { - "name": "Sergey G", - "username": "introkun", - "role": "Development & CI", - "tags": [ - "♻", - "🧪", - "🖥", - "🎨" - ] - }, - { - "name": "Jacob", - "username": "Z3nKrypt", - "role": "Documentation", - "tags": [ - "📖" - ] - }, - { - "name": "Tom", - "username": "tomkinane", - "role": "Design", - "tags": [ - "🎨" - ] - }, - { - "name": "SharonAliyas5573", - "username": "SharonAliyas5573", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "romana-la", - "username": "romana-la", - "role": "Documentation", - "tags": [ - "📖" - ] - }, - { - "name": "flyingdev", - "username": "flyingdev", - "role": "CI", - "tags": [ - "🧪" - ] - }, - { - "name": "chavi362", - "username": "chavi362", - "role": "Documentation", - "tags": [ - "📖" - ] - }, - { - "name": "bermr", - "username": "bermr", - "role": "CI", - "tags": [ - "🧪" - ] - }, - { - "name": "PhilipZara", - "username": "PhilipZara", - "role": "Design", - "tags": [ - "🎨" - ] - }, - { - "name": "Tianrui-Luo", - "username": "Tianrui-Luo", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "HarryHuCodes", - "username": "HarryHuCodes", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "Nuova", - "username": "Nuovaxu", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "HessTaha", - "username": "HessTaha", - "role": "CI-CD", - "tags": [ - "🐳" - ] - }, - { - "name": "wnm210", - "username": "wnm210", - "role": "Design", - "tags": [ - "🎨" - ] - }, - { - "name": "Matt", - "username": "matthewjuarez1", - "role": "Full Stack", - "tags": [ - "🖥", - "🎨" - ] - }, - { - "name": "SBMOYO", - "username": "SBMOYO", - "role": "CI", - "tags": [ - "🧪" - ] - }, - { - "name": "Kevin Liu", - "username": "kliu6151", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "Jehad Altoutou", - "username": "HappyLife2", - "role": "Design", - "tags": [ - "🎨" - ] - }, - { - "name": "Samuel P", - "username": "spalominor", - "role": "Design", - "tags": [ - "🎨" - ] - }, - { - "name": "Sabari Ragavendra CK", - "username": "CKsabari2001", - "role": "Layout", - "tags": [ - "🎨" - ] - }, - { - "name": "atulanand25", - "username": "atulanand25", - "role": "Full Stack", - "tags": [ - "🎨", - "🖥", - "🐞" - ] - }, - { - "name": "ryansurf", - "username": "ryansurf", - "role": "Bug Fixes", - "tags": [ - "🐞" - ] - }, - { - "name": "David", - "username": "blocage", - "role": "Refactoring", - "tags": "♻" - }, - { - "name": "Guillermo", - "username": "glizondo", - "role": "Bug Fixes", - "tags": [ - "🐞" - ] - }, - { - "name": "Marvin Lopez", - "username": "marvinl803", - "role": "Full Stack", - "tags": [ - "🎨", - "🖥" - ] - }, - { - "name": "Artem Kolpakov", - "username": "artkolpakov", - "role": "Bug Fixes", - "tags": [ - "🐞" - ] - }, - { - "name": "Yadu", - "username": "Yadu-M", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "Vatsal", - "username": "vatsaaaal", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "Hussein", - "username": "hussein-mamane", - "role": "Development", - "tags": [ - "🖥" - ] - }, - { - "name": "pvvramakrishnarao234", - "username": "pvvramakrishnarao234", - "role": "Development", - "tags": [ - "🖥" - ] - } -] diff --git a/backend/core/management/commands/contributors.py b/backend/core/management/commands/contributors.py deleted file mode 100644 index 8c2915eb6..000000000 --- a/backend/core/management/commands/contributors.py +++ /dev/null @@ -1,295 +0,0 @@ -import json -import os -from typing import TypedDict - -from django.core.management.base import BaseCommand -from django.utils.termcolors import colorize - - -class ContributorsItem(TypedDict): - name: str - username: str - role: str - tags: list[str] - - -class Command(BaseCommand): - """ - Adds contributors HTML table to README.md file. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.script_dir = os.path.dirname(os.path.abspath(__file__)) - self.contributors_json_path = os.path.join(self.script_dir, "contributors.json") - self.readme_path = "README.md" - - def add_arguments(self, parser): - parser.add_argument("action", type=str, help="sync, list, add, edit") - - parser.add_argument("--sort", type=str, help="Sort by: name, username or role") - parser.add_argument("--limit", type=int, default=20, help="Limit results by amount") - - parser.add_argument("name", type=str, nargs="?", help="users human/readable name") - parser.add_argument("--name", type=str, help="users human/readable name") - parser.add_argument("username", type=str, nargs="?", help="github username") - parser.add_argument("--username", type=str, help="github username") - parser.add_argument("role", type=str, nargs="?", help="role in team") - parser.add_argument("--role", type=str, help="role in team") - parser.add_argument("tags", type=str, nargs="*", help="comma separated list of tags") - parser.add_argument("--tags", type=str, help="comma separated list of tags") - - def handle(self, *args, **kwargs): - action = kwargs["action"] - - if action == "sync": - self.sync_contributors() - elif action == "list": - self.list_contributors(*args, **kwargs) - elif action == "add": - self.add_contributor(*args, **kwargs) - elif action == "help": - self.stdout.write( - colorize( - """ -Please provide valid action. -- sync -- list --sort --limit -- add - to use multi-word usernames or names, surround with quotes - tags can be space separated - to change the order use --, e.g. --name bob - """, - fg="red", - opts=("bold",), - ) - ) - else: - self.stdout.write(colorize("Please provide valid action. \n - sync \n - list \n - add \n - help", fg="red", opts=("bold",))) - - def add_contributor(self, *args, **kwargs): - name: str = kwargs.get("name") - username: str = kwargs.get("username") - role: str = kwargs.get("role") - tags: list[str] = kwargs.get("tags") - - if not name or not username or not role or not tags: - return self.stdout.write(colorize("Please provide name, username, role and tags", fg="red", opts=("bold",))) - - if not all([t in ["👑", "🖥", "🎨", "📖", "🐳", "♻", "🐞"] for t in tags]): - return self.stdout.write( - colorize(f"Please provide valid tags. Valid tags are: 👑, 🖥, 🎨, 📖, 🐳, ♻, 🐞", fg="red", opts=("bold",)) - ) - - contributors_data: list[ContributorsItem] = self._read_contributor_file() - - for user in contributors_data: - if user["username"] == username: - return self.stdout.write(colorize("User already exists", fg="red", opts=("bold",))) - - contributor_obj = ContributorsItem(name=name, username=username, role=role, tags=tags) - - if contributors_data: - contributors_data.append(contributor_obj) - else: - return self.stdout.write( - colorize("contributors.json file not found. Please make sure the file exists.", fg="red", opts=("bold",)) - ) - - self._save_contributors_file(contributors_data) - - def list_contributors(self, *args, **kwargs): - contributors_data: list[ContributorsItem] | None = self._read_contributor_file() - - if not contributors_data: - return - - if kwargs.get("sort") == "name": - contributors_data = sorted(contributors_data, key=lambda d: d.get("name", "")) - elif kwargs.get("sort") == "username": - contributors_data = sorted(contributors_data, key=lambda d: d.get("username", "")) - elif kwargs.get("sort") == "role": - contributors_data = sorted(contributors_data, key=lambda d: d.get("role", "")) - - if limit := kwargs.get("limit"): - bef_count = len(contributors_data) - contributors_data = contributors_data[:limit] - aft_count = len(contributors_data) - - max_w_name = max(len(d.get("name", "")) + 4 for d in contributors_data) - max_w_username = max(len(d.get("username", "")) + 4 for d in contributors_data) - max_w_role = max(len(d.get("role", "")) + 4 for d in contributors_data) - - row_str = "{:<{max_w_name}} {:<{max_w_username}} {:<{max_w_role}} {:<10}" - - header = row_str.format( - "Name", "Username", "Role", "Tags", max_w_name=max_w_name, max_w_username=max_w_username, max_w_role=max_w_role - ) - - self.stdout.write(header) - - for user in contributors_data: - row = row_str.format( - user.get("name"), - user.get("username"), - user.get("role"), - " ".join(user.get("tags")), - max_w_name=max_w_name, - max_w_username=max_w_username, - max_w_role=max_w_role, - ) - self.stdout.write(row) - - if limit: - # noinspection PyUnboundLocalVariable - self.stdout.write(f"\nShowing {aft_count} of {bef_count} contributors\n") - - def sync_contributors(self): - contributors_data = self._read_contributor_file() - - if not contributors_data: - return - - # Path to contributors.json file in the same directory as the script - - # HTML template for each contributor entry - contributor_template = """ - - - -
- - {name} - -
-
- {tags} - - """ - - # Function to generate HTML for a contributor - def generate_contributor_html(contributor): - tags_html = "".join( - [ - f'{tag_icon}' - for tag_icon, tag_link, tag_title in [ - ( - "👑", - f'https://github.com/TreyWW/MyFinances/pulls?q=user%3A{contributor["username"]}', - "Project Lead", - ), - ( - "🖥", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Backend", - ), - ( - "📖", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Documentation", - ), - ( - "🎨", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Frontend", - ), - ( - "🐞", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Bug Fixes", - ), - ( - "🧪", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Added Tests", - ), - ( - "🐳", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Docker", - ), - ( - "♻", - f'https://github.com/TreyWW/MyFinances/pulls?q=is%3Apr+author%3A{contributor["username"]}', - "Refactored Files", - ), - ] - if tag_icon in contributor.get("tags", []) - ] - ) - return contributor_template.format( - username=contributor["username"], - name=contributor["name"], - role=contributor.get("role"), - tags=tags_html, - ) - - # Generate HTML for contributors - contributors_html = "" - users_per_row = 5 - for index, contributor in enumerate(contributors_data, start=1): - if (index - 1) % users_per_row == 0 and index > 1: - contributors_html += "" - contributors_html += f"{generate_contributor_html(contributor)}" - - # Wrap the entire content in a table row - contributors_html = f"{contributors_html}" - - try: - # Read the README.md file - with open(self.readme_path, encoding="utf-8") as readme_file: - readme_content = readme_file.read() - except FileNotFoundError: - self.stderr.write(self.style.ERROR("README.md file not found. Please make sure the file exists.")) - return - - # Insert the generated HTML between the comments - start_comment = "" - end_comment = "" - start_index = readme_content.find(start_comment) + len(start_comment) - end_index = readme_content.find(end_comment) - - new_readme_content = readme_content[:start_index] + "\n\n" + contributors_html + "
\n" + readme_content[end_index:] - - self._save_readme_file(new_readme_content) - - self.stdout.write(self.style.SUCCESS("HTML table inserted into README.md successfully.")) - - def _read_contributor_file(self) -> list[ContributorsItem] | None: - try: - # Load JSON data from contributors.json file - with open(self.contributors_json_path, encoding="utf-8") as json_file: - contributors_json = json_file.read() - return json.loads(contributors_json) - except FileNotFoundError: - self.stderr.write(self.style.ERROR("contributors.json file not found. Please make sure the file exists.")) - return None - except json.JSONDecodeError: - self.stderr.write(self.style.ERROR("Error decoding JSON data from contributors.json file. Please check the file contents.")) - return None - - def _read_readme_file(self) -> str | None: - try: - # Read the README.md file - with open(self.readme_path, encoding="utf-8") as readme_file: - return readme_file.read() - except FileNotFoundError: - self.stderr.write(self.style.ERROR("README.md file not found. Please make sure the file exists.")) - return None - - def _save_contributors_file(self, contributors_data: list[ContributorsItem]): - try: - # Save JSON data to contributors.json file - with open(self.contributors_json_path, "w", encoding="utf-8") as json_file: - json.dump(contributors_data, json_file, indent=4, ensure_ascii=False) - except FileNotFoundError: - self.stderr.write(self.style.ERROR("contributors.json file not found. Please make sure the file exists.")) - return - except json.JSONDecodeError: - self.stderr.write(self.style.ERROR("Error encoding JSON data to contributors.json file. Please check the file contents.")) - return - - def _save_readme_file(self, new_readme_content): - with open(self.readme_path, "w", encoding="utf-8") as readme_file: - readme_file.write(new_readme_content) diff --git a/backend/core/management/commands/feature_flags.py b/backend/core/management/commands/feature_flags.py deleted file mode 100644 index 3c68e2e4b..000000000 --- a/backend/core/management/commands/feature_flags.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.core.management.base import BaseCommand -from django.db.models.functions import Length -from django.utils.termcolors import colorize -from backend.models import FeatureFlags - - -class Command(BaseCommand): - help = "Manage the feature flag statuses" - - def add_arguments(self, parser): - parser.add_argument("action", type=str, help="enable, disable or list") - parser.add_argument("flag", type=str, nargs="?", help="feature flag name") - # parser.add_argument("-f", type=str, dest="flag", help="feature flag name") - - # - def handle(self, *args, **kwargs): - if kwargs["action"] == "list": - flags = FeatureFlags.objects.annotate(name_len=Length("name"), description_len=Length("description")) - width = flags.order_by("-name_len").first().name_len + 4 - description_width = flags.order_by("-description_len").first().description_len + 4 - - header = "{:<{width}} {:<10} {:<{description_width}} {:<20}".format( - "Name", "Enabled", "Description", "Last updated", width=width, description_width=description_width - ) - self.stdout.write("Feature flags:") - self.stdout.write(header) - - for flag in FeatureFlags.objects.all(): - value = "✔" if flag.value else "❌" - - formatted_date = flag.updated_at.strftime("%Y-%m-%d %H:%M:%S") - row = "{:<{width}} {:<10} {:<{description_width}} {:<20}".format( - flag.name, value, flag.description or "No description", formatted_date, width=width, description_width=description_width - ) - self.stdout.write(row) - return - - if not kwargs["flag"]: - self.stdout.write( - colorize("Please provide a feature flag name with `feature_flags enable|disable `", fg="red", opts=("bold",)) - ) - return - - try: - flag = FeatureFlags.objects.get(name=kwargs["flag"]) - - if kwargs["action"] == "enable": - flag.enable() - self.stdout.write(f"[👍] Feature flag {kwargs['flag']} has been enabled") - elif kwargs["action"] == "disable": - flag.disable() - self.stdout.write(f"[👍] Feature flag {kwargs['flag']} has been disabled") - except FeatureFlags.DoesNotExist: - self.stdout.write(colorize("Feature flag with the name of `{kwargs['flag']}` does not exist", fg="red", opts=("bold",))) - return diff --git a/backend/core/management/commands/generate_aws_scheduler_apikey.py b/backend/core/management/commands/generate_aws_scheduler_apikey.py deleted file mode 100644 index c502797ec..000000000 --- a/backend/core/management/commands/generate_aws_scheduler_apikey.py +++ /dev/null @@ -1,32 +0,0 @@ -import uuid -from django.core.management.base import BaseCommand -from backend.core.api.public import APIAuthToken - - -class Command(BaseCommand): - """ - Generates an API key for the AWS EventBridge API. - """ - - def handle(self, *args, **kwargs): - token = APIAuthToken(service=APIAuthToken.AdministratorServiceTypes.AWS_API_DESTINATION, name=str(uuid.uuid4())) - raw_key: str = token.generate_key() - token.save() - - self.stdout.write( - f""" - NOTE: Keep this key secret. It is used to authenticate your API requests with the AWS EventBridge API. - - Your API Key: {raw_key} - - To use this API Key for development you can use: - - pulumi config set api_destination-api_key {raw_key} - pulumi up - - If you would like to use it for production use: - pulumi stack select production - pulumi config set api_destination-api_key {raw_key} - pulumi up - """ - ) diff --git a/backend/core/management/commands/lint.py b/backend/core/management/commands/lint.py deleted file mode 100644 index cb401e7bf..000000000 --- a/backend/core/management/commands/lint.py +++ /dev/null @@ -1,32 +0,0 @@ -import subprocess - -from django.core.management import BaseCommand -from django.utils.termcolors import colorize - - -class Command(BaseCommand): - help = "Run linters" - requires_system_checks = [] - requires_migrations_checks = False - - def add_arguments(self, parser): - parser.add_argument("action", type=str, nargs="?", help="djlint or black") # parser.add_argument("-f", type=str, dest="flag", - - def handle(self, *args, **kwargs): - if kwargs["action"] == "djlint": - djlint() - elif kwargs["action"] == "black": - black() - else: - self.stdout.write(colorize("Linting with: BLACK FORMATTER", fg="green", opts=("bold",))) - black() - self.stdout.write(colorize("Linting with: DJLINT", fg="green", opts=("bold",))) - djlint() - - -def djlint(): - subprocess.run(["djlint", "./frontend/templates/", "--reformat"]) - - -def black(): - subprocess.run(["black", "./"]) diff --git a/backend/core/management/commands/navbar_refresh.py b/backend/core/management/commands/navbar_refresh.py deleted file mode 100644 index 40757f193..000000000 --- a/backend/core/management/commands/navbar_refresh.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.core.management.base import BaseCommand -from django.core.cache import cache - - -class Command(BaseCommand): - """ - Deletes the "navbar_items" cache and prints a message to the standard output. - """ - - def handle(self, *args, **kwargs): - cache.delete("navbar_items") - self.stdout.write("Cleared cache\n") diff --git a/backend/core/management/commands/test_urls.py b/backend/core/management/commands/test_urls.py deleted file mode 100644 index 51c1dcc14..000000000 --- a/backend/core/management/commands/test_urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.core.management.base import BaseCommand -from django.core.management import call_command - - -class Command(BaseCommand): - help = "Runs URL verification tests." - - def handle(self, *args, **options): - call_command("test", "backend.tests.urls.verify_urls") diff --git a/backend/core/management/commands/test_views.py b/backend/core/management/commands/test_views.py deleted file mode 100644 index 97b4a0847..000000000 --- a/backend/core/management/commands/test_views.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -from django.core.management.base import BaseCommand -from django.core.management import call_command - - -class Command(BaseCommand): - help = "Runs verification tests for view files in backend/tests/views." - - def add_arguments(self, parser): - parser.add_argument( - "test_label", - nargs="?", - type=str, - help="Test label for a specific view file.", - ) - - def handle(self, *args, **options): - test_dir = "backend/tests/views" - test_label = options["test_label"] - - if test_label: - self.run_test("backend.tests.views." + test_label) - else: - for root, dirs, files in os.walk(test_dir): - for file in files: - if file.endswith(".py") and file != "__init__.py": - test_module = file.replace(".py", "") - test_label = f"MAIN.tests.views.{test_module}" - self.run_test(test_label) - - def run_test(self, test_label): - self.stdout.write(self.style.SUCCESS(f"Running tests for {test_label}")) - call_command("test", test_label) diff --git a/backend/core/models.py b/backend/core/models.py deleted file mode 100644 index 4f5d71867..000000000 --- a/backend/core/models.py +++ /dev/null @@ -1,723 +0,0 @@ -from __future__ import annotations - -import itertools -import typing -from datetime import datetime, timedelta -from typing import Literal, Union -from uuid import uuid4 - -from django.contrib.auth.hashers import make_password -from django.contrib.auth.models import AbstractUser, UserManager -from django.core.files.storage import storages, FileSystemStorage -from django.db import models -from django.db.models import Count, QuerySet -from django.utils import timezone -from django.utils.crypto import get_random_string -from storages.backends.s3 import S3Storage - - -def _public_storage(): - return storages["public_media"] - - -def _private_storage() -> FileSystemStorage | S3Storage: - return storages["private_media"] - - -def RandomCode(length=6): - return get_random_string(length=length).upper() - - -def RandomAPICode(length=89): - return get_random_string(length=length).lower() - - -def upload_to_user_separate_folder(instance, filename, optional_actor=None) -> str: - instance_name = instance._meta.verbose_name.replace(" ", "-") - - print(instance, filename) - - if optional_actor: - if isinstance(optional_actor, User): - return f"{instance_name}/users/{optional_actor.id}/{filename}" - elif isinstance(optional_actor, Organization): - return f"{instance_name}/orgs/{optional_actor.id}/{filename}" - return f"{instance_name}/global/{filename}" - - if hasattr(instance, "user") and hasattr(instance.user, "id"): - return f"{instance_name}/users/{instance.user.id}/{filename}" - elif hasattr(instance, "organization") and hasattr(instance.organization, "id"): - return f"{instance_name}/orgs/{instance.organization.id}/{filename}" - return f"{instance_name}/global/{filename}" - - -def USER_OR_ORGANIZATION_CONSTRAINT(): - return models.CheckConstraint( - name=f"%(app_label)s_%(class)s_check_user_or_organization", - check=(models.Q(user__isnull=True, organization__isnull=False) | models.Q(user__isnull=False, organization__isnull=True)), - ) - - -M = typing.TypeVar("M", bound=models.Model) - - -class CustomUserManager(UserManager): - def get_queryset(self): - return ( - super() - .get_queryset() - .select_related("user_profile", "logged_in_as_team") - .annotate(notification_count=(Count("user_notifications"))) - ) - - -class User(AbstractUser): - objects: CustomUserManager = CustomUserManager() # type: ignore - - logged_in_as_team = models.ForeignKey("Organization", on_delete=models.SET_NULL, null=True, blank=True) - stripe_customer_id = models.CharField(max_length=255, null=True, blank=True) - entitlements = models.JSONField(null=True, blank=True, default=list) # list of strings e.g. ["invoices"] - awaiting_email_verification = models.BooleanField(default=True) - require_change_password = models.BooleanField(default=False) # does user need to change password upon next login - - class Role(models.TextChoices): - # NAME DJANGO ADMIN NAME - DEV = "DEV", "Developer" - STAFF = "STAFF", "Staff" - USER = "USER", "User" - TESTER = "TESTER", "Tester" - - role = models.CharField(max_length=10, choices=Role.choices, default=Role.USER) - - @property - def name(self): - return self.first_name - - @property - def teams_apart_of(self): - return set(itertools.chain(self.teams_joined.all(), self.teams_leader_of.all())) - - @property - def is_org(self): - return False - - -def add_3hrs_from_now(): - return timezone.now() + timezone.timedelta(hours=3) - - -class ActiveManager(models.Manager): - """Manager to return only active objects.""" - - def get_queryset(self): - return super().get_queryset().filter(active=True) - - -class ExpiredManager(models.Manager): - """Manager to return only expired (inactive) objects.""" - - def get_queryset(self): - now = timezone.now() - return super().get_queryset().filter(expires__isnull=False, expires__lte=now) - - -class ExpiresBase(models.Model): - """Base model for handling expiration logic.""" - - expires = models.DateTimeField("Expires", null=True, blank=True, help_text="When the item will expire") - active = models.BooleanField(default=True) - - # Default manager that returns only active items - objects = ActiveManager() - - # Custom manager to get expired/inactive objects - expired_objects = ExpiredManager() - - # Fallback All objects - all_objects = models.Manager() - - def deactivate(self) -> None: - """Manually deactivate the object.""" - self.active = False - self.save() - - def delete_if_expired_for(self, days: int = 14) -> bool: - """Delete the object if it has been expired for a certain number of days.""" - if self.expires and self.expires <= timezone.now() - timedelta(days=days): - self.delete() - return True - return False - - @property - def remaining_active_time(self): - """Return the remaining time until expiration, or None if already expired or no expiration set.""" - if not self.has_expired: - return self.expires - timezone.now() - return None - - @property - def has_expired(self): - return self.expires and self.expires <= timezone.now() - - def is_active(self): - return self.active - - class Meta: - abstract = True - - -class VerificationCodes(ExpiresBase): - class ServiceTypes(models.TextChoices): - CREATE_ACCOUNT = "create_account", "Create Account" - RESET_PASSWORD = "reset_password", "Reset Password" - - uuid = models.UUIDField(default=uuid4, editable=False, unique=True) # This is the public identifier - token = models.TextField(default=RandomCode, editable=False) # This is the private token (should be hashed) - - user = models.ForeignKey(User, on_delete=models.CASCADE) - created = models.DateTimeField(auto_now_add=True) - service = models.CharField(max_length=14, choices=ServiceTypes.choices) - - def __str__(self): - return self.user.username - - def hash_token(self): - self.token = make_password(self.token) - self.save() - return True - - class Meta: - verbose_name = "Verification Code" - verbose_name_plural = "Verification Codes" - - -class UserSettings(models.Model): - class CoreFeatures(models.TextChoices): - INVOICES = "invoices", "Invoices" - RECEIPTS = "receipts", "Receipts" - EMAIL_SENDING = "email_sending", "Email Sending" - MONTHLY_REPORTS = "monthly_reports", "Monthly Reports" - - CURRENCIES = { - "GBP": {"name": "British Pound Sterling", "symbol": "£"}, - "EUR": {"name": "Euro", "symbol": "€"}, - "USD": {"name": "United States Dollar", "symbol": "$"}, - "JPY": {"name": "Japanese Yen", "symbol": "¥"}, - "INR": {"name": "Indian Rupee", "symbol": "₹"}, - "AUD": {"name": "Australian Dollar", "symbol": "$"}, - "CAD": {"name": "Canadian Dollar", "symbol": "$"}, - } - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="user_profile") - dark_mode = models.BooleanField(default=True) - currency = models.CharField( - max_length=3, - default="GBP", - choices=[(code, info["name"]) for code, info in CURRENCIES.items()], - ) - profile_picture = models.ImageField( - upload_to="profile_pictures/", - storage=_public_storage, - blank=True, - null=True, - ) - - disabled_features = models.JSONField(default=list) - - @property - def profile_picture_url(self): - if self.profile_picture and hasattr(self.profile_picture, "url"): - return self.profile_picture.url - return "" - - def get_currency_symbol(self): - return self.CURRENCIES.get(self.currency, {}).get("symbol", "$") - - def has_feature(self, feature: str) -> bool: - return feature not in self.disabled_features - - def __str__(self): - return self.user.username - - class Meta: - verbose_name = "User Settings" - verbose_name_plural = "User Settings" - - -class Organization(models.Model): - name = models.CharField(max_length=100, unique=True) - leader = models.ForeignKey(User, on_delete=models.CASCADE, related_name="teams_leader_of") - members = models.ManyToManyField(User, related_name="teams_joined") - - stripe_customer_id = models.CharField(max_length=255, null=True, blank=True) - entitlements = models.JSONField(null=True, blank=True, default=list) # list of strings e.g. ["invoices"] - - def is_owner(self, user: User) -> bool: - return self.leader == user - - def is_logged_in_as_team(self, request) -> bool: - if isinstance(request.auth, User): - return False - - if request.auth and request.auth.organization_id == self.id: - return True - return False - - @property - def is_authenticated(self): - return True - - @property - def is_org(self): - return True - - -class TeamMemberPermission(models.Model): - team = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="permissions") - user = models.OneToOneField("backend.User", on_delete=models.CASCADE, related_name="team_permissions") - scopes = models.JSONField("Scopes", default=list, help_text="List of permitted scopes") - - class Meta: - unique_together = ("team", "user") - - -class TeamInvitation(ExpiresBase): - code = models.CharField(max_length=10) - team = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="team_invitations") - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_invitations") - invited_by = models.ForeignKey(User, on_delete=models.CASCADE) - - def is_active(self): - return self.active - - def set_expires(self): - self.expires = timezone.now() + timezone.timedelta(days=14) - - def save(self, *args, **kwargs): - if not self.code: - self.code = RandomCode(10) - self.set_expires() - super().save() - - def __str__(self): - return self.team.name - - class Meta: - verbose_name = "Team Invitation" - verbose_name_plural = "Team Invitations" - - -class OwnerBaseManager(models.Manager): - def create(self, **kwargs): - # Handle the 'owner' argument dynamically in `create()` - owner = kwargs.pop("owner", None) - if isinstance(owner, User): - kwargs["user"] = owner - kwargs["organization"] = None - elif isinstance(owner, Organization): - kwargs["organization"] = owner - kwargs["user"] = None - return super().create(**kwargs) - - def filter(self, *args, **kwargs): - # Handle the 'owner' argument dynamically in `filter()` - owner = kwargs.pop("owner", None) - if isinstance(owner, User): - kwargs["user"] = owner - elif isinstance(owner, Organization): - kwargs["organization"] = owner - return super().filter(*args, **kwargs) - - -class OwnerBase(models.Model): - user = models.ForeignKey("backend.User", on_delete=models.CASCADE, null=True, blank=True) - organization = models.ForeignKey("backend.Organization", on_delete=models.CASCADE, null=True, blank=True) - - objects = OwnerBaseManager() - - class Meta: - abstract = True - constraints = [ - USER_OR_ORGANIZATION_CONSTRAINT(), - ] - - @property - def owner(self) -> User | Organization: - """ - Property to dynamically get the owner (either User or Team) - """ - if hasattr(self, "user") and self.user: - return self.user - elif hasattr(self, "team") and self.team: - return self.team - return self.organization # type: ignore[return-value] - # all responses WILL have either a user or org so this will handle all - - @owner.setter - def owner(self, value: User | Organization) -> None: - if isinstance(value, User): - self.user = value - self.organization = None - elif isinstance(value, Organization): - self.user = None - self.organization = value - else: - raise ValueError("Owner must be either a User or a Organization") - - def save(self, *args, **kwargs): - if hasattr(self, "owner") and not self.user and not self.organization: - if isinstance(self.owner, User): - self.user = self.owner - elif isinstance(self.owner, Organization): - self.organization = self.owner - super().save(*args, **kwargs) - - @classmethod - def filter_by_owner(cls: typing.Type[M], owner: Union[User, Organization]) -> QuerySet[M]: - """ - Class method to filter objects by owner (either User or Organization) - """ - if isinstance(owner, User): - return cls.objects.filter(user=owner) # type: ignore[attr-defined] - elif isinstance(owner, Organization): - return cls.objects.filter(organization=owner) # type: ignore[attr-defined] - else: - raise ValueError("Owner must be either a User or an Organization") - - @property - def is_team(self): - return isinstance(self.owner, Organization) - - -class PasswordSecret(ExpiresBase): - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="password_secrets") - secret = models.TextField(max_length=300) - - -class Notification(models.Model): - action_choices = [ - ("normal", "Normal"), - ("modal", "Modal"), - ("redirect", "Redirect"), - ] - - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_notifications") - message = models.CharField(max_length=100) - action = models.CharField(max_length=10, choices=action_choices, default="normal") - action_value = models.CharField(max_length=100, null=True, blank=True) - extra_type = models.CharField(max_length=100, null=True, blank=True) - extra_value = models.CharField(max_length=100, null=True, blank=True) - date = models.DateTimeField(auto_now_add=True) - - -class AuditLog(OwnerBase): - action = models.CharField(max_length=300) - date = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints: list = [] - - def __str__(self): - return f"{self.action} - {self.date}" - - -class LoginLog(models.Model): - class ServiceTypes(models.TextChoices): - MANUAL = "manual" - MAGIC_LINK = "magic_link" - - user = models.ForeignKey(User, on_delete=models.CASCADE) - service = models.CharField(max_length=14, choices=ServiceTypes.choices, default="manual") - date = models.DateTimeField(auto_now_add=True) - - -class Error(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - error = models.CharField(max_length=250, null=True) - error_code = models.CharField(max_length=100, null=True) - error_colour = models.CharField(max_length=25, default="danger") - date = models.DateTimeField(auto_now=True) - - def __str__(self): - return str(self.user_id) - - -class TracebackError(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) - error = models.CharField(max_length=5000, null=True) - date = models.DateTimeField(auto_now=True) - - def __str__(self): - return str(self.error) - - -class FeatureFlags(models.Model): - name = models.CharField(max_length=100, editable=False, unique=True) - description = models.TextField(max_length=500, null=True, blank=True, editable=False) - 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 - - def enable(self): - self.value = True - self.save() - - def disable(self): - self.value = False - self.save() - - -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 = "Quota Limit" - verbose_name_plural = "Quota Limits" - - def __str__(self): - return self.name - - def get_quota_limit(self, user: User, quota_limit: QuotaLimit | None = None): - user_quota_override: QuotaOverrides | QuotaLimit - 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: str | int | None = None, add: int = 0) -> bool: - current: Union[int, None, QuerySet[QuotaUsage], Literal["Not Available"]] - - current = self.strict_get_quotas(user, extra) - current = current.count() if current != "Not Available" else None - return current + add >= self.get_quota_limit(user) if current else False - - def strict_get_quotas( - self, user: User, extra: str | int | None = None, quota_limit: QuotaLimit | None = None - ) -> 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 - if quota_limit is not None: - quota_lim = quota_limit.quota_usage - else: - quota_lim = QuotaUsage.objects.filter(user=user, quota_limit=self) # type: ignore[assignment] - - 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_lim.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_lim.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_lim.filter(extra_data=extra) - else: - return "Not Available" - return current - - @classmethod - @typing.no_type_check - def delete_quota_usage(cls, quota_limit: str | QuotaLimit, user: User, extra, timestamp=None): - 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 | None = all_usages.filter(created_at__gte=timestamp).order_by("created_at").first() - latest: QuotaUsage | None = 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(OwnerBase): - 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(OwnerBase): - 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: str | int | None = None): - try: - quota_limit = limit if isinstance(limit, QuotaLimit) else QuotaLimit.objects.get(slug=limit) - except QuotaLimit.DoesNotExist: - return "Not Found" - - Notification.objects.create( - user=user, - action="redirect", - action_value=f"/dashboard/quotas/{quota_limit.slug.split('-')[0]}/", - message=f"You have reached the limit for {quota_limit.name}", - ) - - 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(OwnerBase): - class StatusTypes(models.TextChoices): - PENDING = "pending" - APPROVED = "approved" - REJECTED = "rejected" - - requester = models.ForeignKey(User, on_delete=models.CASCADE, related_name="quota_increase_requests") - - quota_limit = models.ForeignKey(QuotaLimit, on_delete=models.CASCADE, related_name="quota_increase_requests") - reason = models.CharField(max_length=1000) - 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.owner}" - - -class EmailSendStatus(OwnerBase): - STATUS_CHOICES = [ - (status, status.title()) - for status in [ - "send", - "reject", - "bounce", - "complaint", - "delivery", - "open", - "click", - "rendering_failure", - "delivery_delay", - "subscription", - "failed_to_send", - "pending", - ] - ] - - sent_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="emails_sent") - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - updated_status_at = models.DateTimeField(auto_now_add=True) - - recipient = models.TextField() - aws_message_id = models.CharField(max_length=100, null=True, blank=True, editable=False) - status = models.CharField(max_length=20, choices=STATUS_CHOICES) - - class Meta: - constraints = [USER_OR_ORGANIZATION_CONSTRAINT()] - - -class FileStorageFile(OwnerBase): - file = models.FileField(upload_to=upload_to_user_separate_folder, storage=_private_storage) - file_uri_path = models.CharField(max_length=500) # relative path not including user folder/media - last_edited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name="files_edited") - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - __original_file = None - __original_file_uri_path = None - - def __init__(self, *args, **kwargs): - super(FileStorageFile, self).__init__(*args, **kwargs) - self.__original_file = self.file - self.__original_file_uri_path = self.file_uri_path - - -class MultiFileUpload(OwnerBase): - files = models.ManyToManyField(FileStorageFile, related_name="multi_file_uploads") - started_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - finished_at = models.DateTimeField(null=True, blank=True, editable=False) - uuid = models.UUIDField(default=uuid4, editable=False, unique=True) - - def is_finished(self): - return self.finished_at is not None diff --git a/backend/core/service/__init__.py b/backend/core/service/__init__.py deleted file mode 100644 index 36dfed184..000000000 --- a/backend/core/service/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from backend.core.service.boto3.handler import BOTO3_HANDLER diff --git a/backend/core/service/api_keys/delete.py b/backend/core/service/api_keys/delete.py deleted file mode 100644 index 0b172694a..000000000 --- a/backend/core/service/api_keys/delete.py +++ /dev/null @@ -1,18 +0,0 @@ -from backend.models import User, Organization -from backend.core.service.api_keys.get import get_api_key_by_name -from backend.core.api.public import APIAuthToken - - -def delete_api_key(request, owner: User | Organization, key: str | None | APIAuthToken) -> bool | str: - if isinstance(owner, Organization) and "api_keys:write" not in owner.permissions.get(user=request.user).scopes: - return "No permission to delete key" - - if not isinstance(key, APIAuthToken): - key: APIAuthToken | None = get_api_key_by_name(owner, key) # type: ignore[no-redef, arg-type] - - if not key: - return "Key not found" - - key.deactivate() # type: ignore[union-attr] - - return True diff --git a/backend/core/service/api_keys/generate.py b/backend/core/service/api_keys/generate.py deleted file mode 100644 index 81f38a542..000000000 --- a/backend/core/service/api_keys/generate.py +++ /dev/null @@ -1,103 +0,0 @@ -from django.core.exceptions import ValidationError - -from backend.core.api.public import APIAuthToken -from backend.models import User, Organization -from backend.core.service.permissions.scopes import validate_scopes - - -def generate_public_api_key( - request, - owner: User | Organization, - api_key_name: str | None, - permissions: list, - *, - expires=None, - description=None, - administrator_toggle: bool = False, - administrator_type: str | None = None, -) -> tuple[APIAuthToken | None, str]: - if not validate_name(api_key_name): - return None, "Invalid key name" - - if not validate_description(description): - return None, "Invalid description" - - if api_key_exists_under_name(owner, api_key_name): - return None, "A key with this name already exists in your account" - - if validate_scopes(permissions).failed: # or not has_permission_to_create(request, owner): - return None, "Invalid permissions" - - administrator_service_type = None - - if request.user.is_superuser: - if administrator_toggle: - if administrator_type not in [option[0] for option in APIAuthToken.AdministratorServiceTypes.choices]: - return None, "Invalid administration type" - administrator_service_type = administrator_type - - token = APIAuthToken( - name=api_key_name, - description=description, - expires=expires, - scopes=permissions, - administrator_service_type=administrator_service_type, - ) # type: ignore[arg-type, misc] - - raw_key: str = token.generate_key() - - if isinstance(owner, Organization): - token.organization = owner - else: - token.user = owner - - try: - token.full_clean() - except ValidationError as validation_errors: - field, error_list = next(iter(validation_errors.error_dict.items())) - - field = "Permissions" if field == "scopes" else field.title() - - if isinstance(error_list[0], ValidationError): - error_message = error_list[0].messages[0] - else: - error_message = error_list[0] - - return None, f"{field}: {error_message}" - - token.save() - - return token, raw_key - - -def validate_name(name: str | None) -> bool: - """ - Require name not already exist under account - """ - if not name: - return False - return len(name) <= 64 - - -def validate_description(description: str | None) -> bool: - """ - Accept any description - Reject description longer than 255 characters - """ - return not description or len(description) <= 255 - - -def api_key_exists_under_name(owner: User | Organization, name: str | None) -> bool: - """ - Check if API key exists under a given name - """ - return APIAuthToken.filter_by_owner(owner).filter(name=name, active=True).exists() - - -def has_permission_to_create(request, owner: User | Organization) -> bool: - if isinstance(owner, User): - return True - - if owner.permissions.filter(user=request.user).exists() and "api_keys:write" in owner.permissions.get(user=request.user).scopes: - return True - return False diff --git a/backend/core/service/api_keys/get.py b/backend/core/service/api_keys/get.py deleted file mode 100644 index 7d2439962..000000000 --- a/backend/core/service/api_keys/get.py +++ /dev/null @@ -1,10 +0,0 @@ -from backend.core.api.public import APIAuthToken -from backend.models import User, Organization - - -def get_api_key_by_name(owner: User | Organization, key_name: str) -> APIAuthToken | None: - return APIAuthToken.filter_by_owner(owner).filter(name=key_name, active=True).first() - - -def get_api_key_by_id(owner: User | Organization, key_id: str | int) -> APIAuthToken | None: - return APIAuthToken.filter_by_owner(owner).filter(id=key_id, active=True).first() diff --git a/backend/core/service/base/breadcrumbs.py b/backend/core/service/base/breadcrumbs.py deleted file mode 100644 index 6e0739ad6..000000000 --- a/backend/core/service/base/breadcrumbs.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Optional, Any - -from django.http import HttpRequest -from django.urls import resolve, reverse -from django.urls.exceptions import NoReverseMatch - -ALL_ITEMS: dict[str, tuple[str, Optional[str], Optional[str]]] = { - "dashboard": ("Dashboard", "dashboard", "house"), - "finance:invoices:dashboard": ("Invoices", "finance:invoices:single:dashboard", "file-invoice"), - "finance:invoices:single:dashboard": ("Single", "finance:invoices:single:dashboard", "file-invoice"), - "finance:invoices:single:create": ("Create (single)", "finance:invoices:single:create", None), - "finance:invoices:recurring:dashboard": ("Recurring", "finance:invoices:recurring:dashboard", "refresh"), - "finance:invoices:recurring:create": ("Create (recurring)", "finance:invoices:recurring:create", None), - "finance:invoices:single:edit": ("Edit", None, "pencil"), - "finance:invoices:single:overview": ("Invoice", None, None), - "receipts dashboard": ("Receipts", "receipts dashboard", "file-invoice"), - "teams:dashboard": ("Teams", "teams:dashboard", "users"), - "settings:dashboard": ("Settings", "settings:dashboard", "gear"), - "clients:dashboard": ("Clients", "clients:dashboard", "users"), - "clients:create": ("Create", "clients:create", None), - "reports:dashboard": ("Monthly Reports", "reports:dashboard", "chart-line"), -} - -ALL_BREADCRUMBS: dict[str, str | tuple] = { - "dashboard": "dashboard", - "teams:dashboard": ("dashboard", "teams:dashboard"), - "receipts dashboard": ("dashboard", "receipts dashboard"), - "finance:invoices:single:dashboard": ("dashboard", "finance:invoices:dashboard", "finance:invoices:single:dashboard"), - "finance:invoices:single:create": ("dashboard", "finance:invoices:dashboard", "finance:invoices:single:create"), - "finance:invoices:recurring:dashboard": ("dashboard", "finance:invoices:dashboard", "finance:invoices:recurring:dashboard"), - "finance:invoices:recurring:create": ("dashboard", "finance:invoices:dashboard", "finance:invoices:recurring:create"), - "finance:invoices:single:edit": ("dashboard", "finance:invoices:dashboard", "finance:invoices:single:edit"), - "finance:invoices:single:overview": ("dashboard", "finance:invoices:dashboard", "finance:invoices:single:overview"), - "clients:dashboard": ("dashboard", "clients:dashboard"), - "clients:create": ("dashboard", "clients:dashboard", "clients:create"), - "settings:dashboard": ("dashboard", "settings:dashboard"), - "reports:dashboard": ("dashboard", "reports:dashboard"), -} - - -def get_item(name: str, url_name: Optional[str] = None, icon: Optional[str] = None, kwargs: dict = {}, *, request=None) -> dict: - """ - Create a breadcrumb item dictionary. - Parameters: - - name (str): The name of the breadcrumb item. - - url_name (str): The URL name used for generating the URL using Django's reverse function. - - icon (Optional[str]): The icon associated with the breadcrumb item (default is None). - Returns: - Dict[str, Any]: A dictionary representing the breadcrumb item. - """ - - if request: - rev_kwargs = {kwarg: request.resolver_match.kwargs.get(kwarg) for url, kwarg in kwargs.items() if url == url_name if kwargs} - else: - rev_kwargs = {} - return { - "name": name, - "url": reverse(url_name, kwargs=rev_kwargs if rev_kwargs else {}) if url_name else "", - "icon": icon, - } - - -def generate_breadcrumbs(*breadcrumb_list: str, request=None) -> list[dict[Any, Any] | None]: - """ - Generate a list of breadcrumb items based on the provided list of breadcrumb names. - Parameters: - - breadcrumb_list (str): Variable number of strings representing the names of the breadcrumbs. - Returns: - List[Dict[str, Any]]: A list of dictionaries representing the breadcrumb items. - """ - return [ - get_item(*ALL_ITEMS.get(breadcrumb, (None, None, None)), request=request) - for breadcrumb in breadcrumb_list - if breadcrumb in ALL_ITEMS - ] - - -def get_breadcrumbs(*, request: HttpRequest | None = None, url: str | None = None): - current_url_name: str | Any = request.resolver_match.view_name if request and request.resolver_match else None # type: ignore[ union-attr] - if url: - try: - current_url_name = resolve(url).view_name - except NoReverseMatch: - return {"breadcrumb": []} - return {"breadcrumb": generate_breadcrumbs(*ALL_BREADCRUMBS.get(current_url_name, []), request=request)} diff --git a/backend/core/service/defaults/get.py b/backend/core/service/defaults/get.py deleted file mode 100644 index 813c47675..000000000 --- a/backend/core/service/defaults/get.py +++ /dev/null @@ -1,12 +0,0 @@ -from backend.models import User, Organization -from backend.clients.models import DefaultValues, Client - - -def get_account_defaults(actor: User | Organization, client: Client | None = None) -> DefaultValues: - if not client: - account_defaults = DefaultValues.filter_by_owner(owner=actor).filter(client__isnull=True).first() - - if account_defaults: - return account_defaults - return DefaultValues.objects.create(owner=actor, client=None) # type: ignore[misc] - return DefaultValues.filter_by_owner(owner=actor).get(client=client) diff --git a/backend/core/service/invoices/recurring/schedules/__init__.py b/backend/core/service/invoices/recurring/schedules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/invoices/recurring/validate/__init__.py b/backend/core/service/invoices/recurring/validate/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/invoices/recurring/webhooks/__init__.py b/backend/core/service/invoices/recurring/webhooks/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/invoices/recurring/webhooks/webhook_apikey_auth.py b/backend/core/service/invoices/recurring/webhooks/webhook_apikey_auth.py deleted file mode 100644 index 360a47efa..000000000 --- a/backend/core/service/invoices/recurring/webhooks/webhook_apikey_auth.py +++ /dev/null @@ -1,33 +0,0 @@ -from backend.core.api.public import APIAuthToken -from backend.core.types.requests import WebRequest -from backend.core.utils.dataclasses import BaseServiceResponse - - -class APIAuthenticationServiceResponse(BaseServiceResponse[None]): - response: None = None - _status_code: int - - -def authenticate_api_key(request: WebRequest) -> APIAuthenticationServiceResponse: - auth_header = request.headers.get("Authorization") - - if not (auth_header and auth_header.startswith("Bearer ")): - return APIAuthenticationServiceResponse(error_message="Unauthorized", status_code=401) - - token_key = auth_header.split(" ")[1] - - try: - token = APIAuthToken.objects.get( - hashed_key=APIAuthToken.hash_raw_key(token_key), - active=True, - administrator_service_type=APIAuthToken.AdministratorServiceTypes.AWS_WEBHOOK_CALLBACK, - ) - - if token.has_expired: - return APIAuthenticationServiceResponse(error_message="Token expired", status_code=400) - except APIAuthToken.DoesNotExist: - return APIAuthenticationServiceResponse(error_message="Token not found", status_code=400) - - token.update_last_used() - - return APIAuthenticationServiceResponse(True, None, status_code=200) diff --git a/backend/core/service/invoices/single/__init__.py b/backend/core/service/invoices/single/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/invoices/single/create/__init__.py b/backend/core/service/invoices/single/create/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/maintenance/__init__.py b/backend/core/service/maintenance/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/maintenance/expire/__init__.py b/backend/core/service/maintenance/expire/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/maintenance/expire/run.py b/backend/core/service/maintenance/expire/run.py deleted file mode 100644 index 04c71b416..000000000 --- a/backend/core/service/maintenance/expire/run.py +++ /dev/null @@ -1,38 +0,0 @@ -from datetime import timedelta -from typing import Type - -from django.db import models -from django.db.models import QuerySet - -from backend.models import TeamInvitation, InvoiceURL, PasswordSecret - -from django.utils import timezone - -""" -Every model MUST have the field "expires" as: - -expires = models.DateTimeField(null=True, blank=True) -""" - - -def expire_and_cleanup_objects() -> str: - deactivated_items: int = 0 - deleted_items: int = 0 - - model_list: list[Type[models.Model]] = [TeamInvitation, InvoiceURL, PasswordSecret] - - now = timezone.now() - - for model in model_list: - # Delete objects that have been inactive and expired for more than 14 days - over_14_days_expired = model.all_objects.filter(expires__lte=now - timedelta(days=14)) # type: ignore[attr-defined] - deleted_items += over_14_days_expired.count() - over_14_days_expired.delete() - - # Deactivate expired items that got missed - to_deactivate: QuerySet[models.Model] = model.all_objects.filter(expires__lte=now, active=True) # type: ignore[attr-defined] - - deactivated_items += to_deactivate.count() - to_deactivate.update(active=False) - - return f"Deactivated {deactivated_items} objects and deleted {deleted_items} objects." diff --git a/backend/core/service/permissions/__init__.py b/backend/core/service/permissions/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/permissions/scopes.py b/backend/core/service/permissions/scopes.py deleted file mode 100644 index f9e4212d3..000000000 --- a/backend/core/service/permissions/scopes.py +++ /dev/null @@ -1,34 +0,0 @@ -from backend.core.api.public.permissions import SCOPE_DESCRIPTIONS, SCOPES -from backend.core.types.requests import WebRequest -from backend.core.utils.dataclasses import BaseServiceResponse - - -class PermissionScopesServiceResponse(BaseServiceResponse[None]): - response: None = None - - -def get_permissions_from_request(request: WebRequest) -> list: - scopes = [ - f"{group}:{perm}" - for group, items in SCOPE_DESCRIPTIONS.items() - if (perm := request.POST.get(f"permission_{group}")) in items["options"] - ] - - scopes.extend(f"{group}:read" for group, items in SCOPE_DESCRIPTIONS.items() if request.POST.get(f"permission_{group}") == "write") - - return scopes - - -def validate_scopes(permissions: list[str]) -> PermissionScopesServiceResponse: - """ - Validate permissions are valid - """ - if not permissions: - return PermissionScopesServiceResponse(True) - - invalid_permissions: list[str] = [permission for permission in permissions if permission not in SCOPES] - - if invalid_permissions: - return PermissionScopesServiceResponse(False, error_message=f"Invalid permissions: {', '.join(invalid_permissions)}") - - return PermissionScopesServiceResponse(True) diff --git a/backend/core/service/reports/__init__.py b/backend/core/service/reports/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/settings/__init__.py b/backend/core/service/settings/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/settings/update.py b/backend/core/service/settings/update.py deleted file mode 100644 index ce7fda38c..000000000 --- a/backend/core/service/settings/update.py +++ /dev/null @@ -1,36 +0,0 @@ -from backend.models import UserSettings -from PIL import Image - -from backend.core.utils.dataclasses import BaseServiceResponse - - -class UpdateProfilePictureServiceResponse(BaseServiceResponse[str]): ... - - -def update_profile_picture(profile_picture, user_profile: UserSettings) -> UpdateProfilePictureServiceResponse: - if not profile_picture: - return UpdateProfilePictureServiceResponse(error_message="Invalid or unsupported image file") - - try: - # Max file size is 10MB (Change the first number to determine the size in MB) - max_file_size = 10 * 1024 * 1024 - - if profile_picture.size is None: - return UpdateProfilePictureServiceResponse(error_message="File size not found") - - if profile_picture.size > max_file_size: - return UpdateProfilePictureServiceResponse(error_message="File size should be up to 10MB") - - img = Image.open(profile_picture) - img.verify() - - if img.format is None or img.format.lower() not in ["jpeg", "png", "jpg"]: - return UpdateProfilePictureServiceResponse( - error_message="Unsupported image format. We support only JPEG, JPG, PNG, if you have a good extension, your file just got renamed." - ) - - user_profile.profile_picture = profile_picture - user_profile.save() - return UpdateProfilePictureServiceResponse(True, "Successfully updated profile picture") - except (FileNotFoundError, Image.UnidentifiedImageError): - return UpdateProfilePictureServiceResponse(error_message="Invalid or unsupported image file") diff --git a/backend/core/service/settings/view/email_templates.py b/backend/core/service/settings/view/email_templates.py new file mode 100644 index 000000000..22d5639a6 --- /dev/null +++ b/backend/core/service/settings/view/email_templates.py @@ -0,0 +1,20 @@ +from core.types.requests import WebRequest + +from backend.finance.service.defaults.get import get_account_defaults + + +def email_templates_context(request: WebRequest, context: dict) -> None: + acc_defaults = get_account_defaults(request.actor) + context.update( + { + "account_defaults": acc_defaults, + "email_templates": { + "recurring_invoices": { + "invoice_created": acc_defaults.email_template_recurring_invoices_invoice_created, + "invoice_overdue": acc_defaults.email_template_recurring_invoices_invoice_overdue, + "invoice_cancelled": acc_defaults.email_template_recurring_invoices_invoice_cancelled, + } + }, + } + ) + # print(context.get("email_templates")) diff --git a/backend/core/service/teams/__init__.py b/backend/core/service/teams/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/teams/create_user.py b/backend/core/service/teams/create_user.py deleted file mode 100644 index 72e40518d..000000000 --- a/backend/core/service/teams/create_user.py +++ /dev/null @@ -1,65 +0,0 @@ -from textwrap import dedent - -from django.urls import reverse -from django.utils.crypto import get_random_string - -from backend.core.models import User, Organization, TeamMemberPermission -from backend.core.utils.dataclasses import BaseServiceResponse -from settings.helpers import send_email - - -class CreateUserServiceResponse(BaseServiceResponse[User]): ... - - -def create_user_service( - request, email: str, team: Organization, first_name: str, last_name: str, permissions: list[str] -) -> CreateUserServiceResponse: - - if not first_name: - return CreateUserServiceResponse(error_message="Please enter a valid first name") - - if not email: - return CreateUserServiceResponse(error_message="Please enter a valid user email") - - if User.objects.filter(email=email).exists(): - return CreateUserServiceResponse(error_message="This user already exists, invite them instead!") - - temporary_password = get_random_string(length=8) - - user: User = User.objects.create_user(email=email, first_name=first_name, last_name=last_name, username=email) - user.set_password(temporary_password) - user.awaiting_email_verification = False - user.require_change_password = True - user.save() - - send_email( - destination=email, - subject="You have been invited to join an organization", - content=dedent( - f""" - Hi {user.first_name or "User"}, - - You have been invited by {request.user.email} to join the organization '{team.name}'. - - Your account email is: {email} - Your temporary password is: {temporary_password} - - We suggest that you change your password as soon as you login, however no other user including the organization have - access to this password. - - Upon login, you will be added to the \"{team.name}\" organization. However, if required, you may leave at any point. - - Login to your new account using this link: - {request.build_absolute_uri(reverse("auth:login"))} - - Didn't give permission to be added to this organization? You can safely ignore the email, no actions can be done on - behalf of you without your permission. - """ - ), - ) - - team.members.add(user) - - TeamMemberPermission.objects.create(user=user, team=team, scopes=permissions) - - return CreateUserServiceResponse(True, response=user) diff --git a/backend/core/service/teams/fetch.py b/backend/core/service/teams/fetch.py deleted file mode 100644 index fa4bd177d..000000000 --- a/backend/core/service/teams/fetch.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.db.models import QuerySet - -from backend.models import Organization -from backend.core.types.requests import WebRequest - - -def get_all_users_teams(request: WebRequest) -> QuerySet[Organization]: - return request.user.teams_joined.all() | request.user.teams_leader_of.all() diff --git a/backend/core/service/teams/permissions.py b/backend/core/service/teams/permissions.py deleted file mode 100644 index afc515195..000000000 --- a/backend/core/service/teams/permissions.py +++ /dev/null @@ -1,47 +0,0 @@ -from backend.models import User, Organization, TeamMemberPermission -from backend.core.service.permissions.scopes import validate_scopes -from backend.core.utils.dataclasses import BaseServiceResponse - - -class EditMemberPermissionsServiceResponse(BaseServiceResponse[None]): - response: None = None - - -def edit_member_permissions(receiver: User, team: Organization | None, permissions: list) -> EditMemberPermissionsServiceResponse: - if not validate_receiver(receiver, team): - return EditMemberPermissionsServiceResponse(error_message="Invalid key name") - - if (scopes_response := validate_scopes(permissions)).failed: - return EditMemberPermissionsServiceResponse(error_message=scopes_response.error) - - if not team: - return EditMemberPermissionsServiceResponse(error_message="Invalid team, something went wrong") - - user_team_perms: TeamMemberPermission | None = team.permissions.filter(user=receiver).first() - - if not user_team_perms: - team.permissions.add(TeamMemberPermission.objects.create(user=receiver, team=team, scopes=permissions)) - else: - user_team_perms.scopes = permissions - user_team_perms.save() - - return EditMemberPermissionsServiceResponse(True) - - -def validate_receiver(receiver: User | None, team: Organization | None) -> bool: - """ - Make sure receiver is in team and not already owner - """ - - if not receiver: - return False - - if not team: - return False - - if not team.members.filter(id=receiver.id).first(): - return False - - if not team.leader == receiver: - return True - return False diff --git a/backend/core/service/webhooks/__init__.py b/backend/core/service/webhooks/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/webhooks/auth.py b/backend/core/service/webhooks/auth.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/service/webhooks/get_url.py b/backend/core/service/webhooks/get_url.py deleted file mode 100644 index 53a95e884..000000000 --- a/backend/core/service/webhooks/get_url.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -from django.urls import reverse - - -def get_global_webhook_response_url(): - return os.environ.get("SITE_URL", default="http://127.0.0.1:8000") + reverse("api:public:webhooks:receive_global") diff --git a/backend/core/signals/__init__.py b/backend/core/signals/__init__.py deleted file mode 100644 index 6e946f549..000000000 --- a/backend/core/signals/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from __future__ import annotations - -from . import migrations -from . import signals diff --git a/backend/core/signals/migrations.py b/backend/core/signals/migrations.py deleted file mode 100644 index 3adafca2f..000000000 --- a/backend/core/signals/migrations.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import logging - -from django.db.models.signals import post_migrate -from django.dispatch import receiver - -from backend.core.data.default_feature_flags import default_feature_flags -from backend.core.data.default_quota_limits import default_quota_limits -from backend.models import FeatureFlags, QuotaLimit - - -@receiver(post_migrate) -def update_feature_flags(**kwargs): - for feature_flag in default_feature_flags: - existing_item = FeatureFlags.objects.filter(name=feature_flag.name).first() - - if existing_item: - name, value, description = ( - existing_item.name, - existing_item.value, - existing_item.description, - ) - - existing_item.name = name - existing_item.description = description - - if existing_item.name != name or existing_item.description != description: - existing_item.save() - logging.info(f"Updated feature flag: {feature_flag.name}") - else: - FeatureFlags.objects.create(name=feature_flag.name, value=feature_flag.default, description=feature_flag.description) - logging.info(f"Added feature flag: {feature_flag.name}") - - -@receiver(post_migrate) -def update_quota_limits(**kwargs): - 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.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}") diff --git a/backend/core/signals/signals.py b/backend/core/signals/signals.py deleted file mode 100644 index 751a51d3d..000000000 --- a/backend/core/signals/signals.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from django.core.cache import cache -from django.core.cache.backends.redis import RedisCacheClient - -cache: RedisCacheClient = cache -from django.core.files.storage import default_storage -from django.db.models.signals import pre_save, post_delete, post_save, 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 settings.helpers import send_email - - -@receiver(pre_save, sender=UserSettings) -def delete_old_profile_picture(sender, instance, **kwargs): - if not instance.pk: - return - - try: - old_profile = UserSettings.objects.get(pk=instance.pk) - except UserSettings.DoesNotExist: - return - - if old_profile.profile_picture and old_profile.profile_picture != instance.profile_picture: - # If the profile picture has been updated, delete the old file - old_profile.profile_picture.delete(save=False) - - -@receiver(post_delete, sender=UserSettings) -def set_profile_picture_to_none(sender, instance, **kwargs): - # Check if the file exists in the storage - if instance.profile_picture and default_storage.exists(instance.profile_picture.name): - instance.profile_picture.delete(save=False) - - -@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) - instance.image = None - instance.save() - - -@receiver(post_save, sender=User) -def user_account_create_make_usersettings(sender, instance, created, **kwargs): - if created: - try: - users_settings = instance.user_profile - except UserSettings.DoesNotExist: - users_settings = None - - if not users_settings: - UserSettings.objects.create(user=instance) - - -@receiver(post_save, sender=FeatureFlags) -def refresh_feature_cache(sender, instance: FeatureFlags, **kwargs): - feature = instance.name - key = f"myfinances:feature_flag:{feature}" - - cached_value = cache.get(key) - - if cached_value: - return cache.delete(key) - - -@receiver(post_save, sender=User) -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 working on the core features. - - In app we have a live chat, so please drop us a message or email support@myfinances.cloud if you have any queries. - - Thank you for using MyFinances! - """ - magic_link = VerificationCodes.objects.create(user=instance, service="create_account") - token_plain = magic_link.token - magic_link.hash_token() - magic_link_url = settings.settings.SITE_URL + reverse( - "auth:login create_account verify", kwargs={"uuid": magic_link.uuid, "token": token_plain} - ) - 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} - """ - - email = send_email(destination=instance.email, subject="Welcome to MyFinances", content=email_message) - - # User.send_welcome_email(instance) diff --git a/backend/core/types/__init__.py b/backend/core/types/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/types/emails.py b/backend/core/types/emails.py deleted file mode 100644 index cb1abc08c..000000000 --- a/backend/core/types/emails.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass, field -from typing import TypedDict - -from mypy_boto3_sesv2.type_defs import SendEmailResponseTypeDef, SendBulkEmailResponseTypeDef - -from backend.core.utils.dataclasses import BaseServiceResponse - - -class SingleEmailSendServiceResponse(BaseServiceResponse[SendEmailResponseTypeDef]): ... - - -class BulkEmailSendServiceResponse(BaseServiceResponse[SendBulkEmailResponseTypeDef]): ... - - -class SingleTemplatedEmailContent(TypedDict): - template_name: str - template_data: dict | str - - -@dataclass(frozen=False) -class SingleEmailInput: - destination: str | list[str] - subject: str | None - content: str | SingleTemplatedEmailContent - ConfigurationSetName: str | None = None - from_address: str | None = None - from_address_name_prefix: str | None = None - - -@dataclass -class BulkEmailEmailItem: - destination: str - template_data: dict | str - cc: list[str] = field(default_factory=list) - bcc: list[str] = field(default_factory=list) - - -@dataclass(frozen=False) -class BulkTemplatedEmailInput: - email_list: list[BulkEmailEmailItem] - template_name: str - default_template_data: dict | str - ConfigurationSetName: str | None = None - from_address: str | None = None - from_address_name_prefix: str | None = None diff --git a/backend/core/types/htmx.py b/backend/core/types/htmx.py deleted file mode 100644 index d4d968897..000000000 --- a/backend/core/types/htmx.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib.auth.models import AnonymousUser -from django.core.handlers.wsgi import WSGIRequest -from django.http import HttpRequest -from django_htmx.middleware import HtmxDetails - -from backend.models import User, Organization - - -class HtmxHttpRequest(HttpRequest): - htmx: HtmxDetails - user: User - no_retarget: bool | None - - -class UnauthorizedHttpRequest(HttpRequest): - user: AnonymousUser - htmx: HtmxDetails - no_retarget: bool | None - - -class HtmxAnyHttpRequest(HttpRequest): - user: User | AnonymousUser - htmx: HtmxDetails - no_retarget: bool | None diff --git a/backend/core/types/requests.py b/backend/core/types/requests.py deleted file mode 100644 index 7120180e4..000000000 --- a/backend/core/types/requests.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -from django.contrib.auth.models import AnonymousUser -from django.http import HttpRequest -from django_htmx.middleware import HtmxDetails - -from backend.models import User, Organization - - -class WebRequest(HttpRequest): - user: User - team: Organization | None - team_id: int | None - actor: User | Organization - - users_subscription: Any | None - - htmx: HtmxDetails - no_retarget: bool | None diff --git a/backend/core/utils/__init__.py b/backend/core/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/utils/calendar.py b/backend/core/utils/calendar.py deleted file mode 100644 index a16bf7f25..000000000 --- a/backend/core/utils/calendar.py +++ /dev/null @@ -1,24 +0,0 @@ -import datetime - -from django.utils import timezone - - -def get_months_text() -> list[str]: - return [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ] - - -def timezone_now() -> datetime.datetime: - return timezone.now() diff --git a/backend/core/utils/dataclasses.py b/backend/core/utils/dataclasses.py deleted file mode 100644 index 7255afd1c..000000000 --- a/backend/core/utils/dataclasses.py +++ /dev/null @@ -1,134 +0,0 @@ -from dataclasses import dataclass -from typing import TypeVar, Any, Optional, Generic - -T = TypeVar("T") - - -def extract_to_dataclass(request, class_type: T, request_types: list[str], *args, **kwargs) -> T: - """ - - Turn kwargs from Key:Value and get request.POST.get(value) and set class.key = request.POST.get(value) - - Usage: - - from pydantic.dataclasses import dataclass - from typing import Optional - - @dataclass - class MyView: - name: str - age: int - different: bool - non_required: Optional[str] - - def myview(request): - try: - data = extract_to_dataclass(request, MyView, ["post"], "name", "age", diff_bool="different") - except pydantic.ValidationError: - pass - """ - data: dict = {} - if "get" in request_types: - if args: - data |= {key: request.GET.get(key) for key in args} - - if kwargs: - data |= {key: request.GET.get(value) for key, value in kwargs.items()} - - if "post" in request_types: - if args: - data |= {key: request.POST.get(key) for key in args} - - if kwargs: - data |= {key: request.POST.get(value) for key, value in kwargs.items()} - - if "headers" in request_types: - if args: - data |= {key: request.headers.get(key) for key in args} - - if kwargs: - data |= {key: request.headers.get(value) for key, value in kwargs.items()} - - if isinstance(class_type, type): - return class_type(**data) - else: - raise TypeError("class_type must be a class") - - -class BaseServiceResponse(Generic[T]): - _success: bool = False - _response: Optional[T] = None - _error_message: str = "" - _status_code: Optional[int] = None - - def __init__(self, success: bool = False, response: Optional[T] = None, error_message: str = "", status_code: Optional[int] = None): - self._success = success - self._response = response - self._error_message = error_message - self._status_code = status_code - - @property - def success(self) -> bool: - if not isinstance(self._success, bool): - raise TypeError("success must be a boolean") - - return self._success - - @property - def response(self) -> T: - if self._response is None: - raise TypeError("response must be present if it was a successful response") - return self._response - - @property - def error_message(self) -> str: - if not isinstance(self._error_message, str): - raise TypeError("error_message must be a string") - return self._error_message - - @property - def status_code(self) -> int: - if not isinstance(self._status_code, int): - raise TypeError("status code must be an integer") - return self._status_code - - @property - def failed(self) -> bool: - return not self.success - - @property - def error(self) -> str: - return self.error_message if self.failed else "Unknown error" - - def __post_init__(self): - if self.success and self.response is None: - raise ValueError("Response cannot be None when success is True.") - if not self.success and self.response is not None: - raise ValueError("Response must be None when success is False.") - if not self.success and not self.error_message: - raise ValueError("Error message cannot be empty when success is False.") - - -# * BaseServiceResponse Usage - -# from backend.utils.dataclasses import BaseServiceResponse -# -# -# class XyzServiceResponse(BaseServiceResponse[ResponseObject]): -# response: Optional[ResponseObject] = None -# or -# ... - -# * Return Response - -# return CreateClientServiceResponse(False, error_message="my error") -# return CreateClientServiceResponse(False, ClientObject) - -# * View Usage -# -# client_response: CreateClientServiceResponse = create_client(request) -# -# if client_response.failed: -# print(client_response.error) -# else: -# print(client_response.response) # < ClientObject> diff --git a/backend/core/utils/feature_flags.py b/backend/core/utils/feature_flags.py deleted file mode 100644 index 297ef1e5f..000000000 --- a/backend/core/utils/feature_flags.py +++ /dev/null @@ -1,29 +0,0 @@ -from backend.models import FeatureFlags -from django.core.cache import cache -from django.core.cache.backends.redis import RedisCacheClient - -cache: RedisCacheClient = cache - - -def get_feature_status(feature, should_use_cache=True): - if should_use_cache: - key = f"myfinances:feature_flag:{feature}" - cached_value = cache.get(key) - if cached_value: - return cached_value - - value = FeatureFlags.objects.filter(name=feature).first() - if value: - if should_use_cache: - cache.set(key, value.value, timeout=300) - return value.value - else: - return False - - -def set_cache(key, value, timeout=300): - cache.set(key, value, timeout=timeout) - - -def get_cache(key): - return cache.get(key) diff --git a/backend/core/utils/http_utils.py b/backend/core/utils/http_utils.py deleted file mode 100644 index 95446893b..000000000 --- a/backend/core/utils/http_utils.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.http import HttpResponseRedirect - - -def redirect_to_last_visited(request, fallback_url="dashboard"): - """ - Redirects user to the last visited URL stored in session. - If no previous URL is found, redirects to the fallback URL. - :param request: HttpRequest object - :param fallback_url: URL to redirect to if no previous URL found - :return: HttpResponseRedirect object - """ - try: - last_visited_url = request.session.get("last_visited", fallback_url) - return HttpResponseRedirect(last_visited_url) - except KeyError: - return HttpResponseRedirect(fallback_url) diff --git a/backend/core/utils/quota_limit_ops.py b/backend/core/utils/quota_limit_ops.py deleted file mode 100644 index 3b57e62fa..000000000 --- a/backend/core/utils/quota_limit_ops.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponse, HttpResponseRedirect -from django.shortcuts import render -from django.urls import reverse - -from backend.models import QuotaLimit - - -def quota_usage_check_under( - request, limit: str | QuotaLimit, extra_data: str | int | None = None, api=False, htmx=False, add: int = 0 -) -> 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, add=add): - return True - - if api and htmx: - messages.error(request, f"You have reached the quota limit for this service '{quota_limit.name}'") - return render(request, "base/toast.html", {"autohide": False}) - elif api: - return HttpResponse(status=403, content=f"You have reached the quota limit for this service '{quota_limit.name}'") - messages.error(request, f"You have reached the quota limit for this service '{quota_limit.name}'") - try: - last_visited_url = request.session["last_visited"] - current_url = request.build_absolute_uri() - if last_visited_url != current_url: - return HttpResponseRedirect(last_visited_url) - except KeyError: - pass - return HttpResponseRedirect(reverse("dashboard")) - - -def render_quota_error(request, quota_limit): - 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}) - - -def render_quota_error_response(quota_limit): - return HttpResponse(status=403, content=f"You have reached the quota limit for this service '{quota_limit.slug}'") diff --git a/backend/core/utils/service_retry.py b/backend/core/utils/service_retry.py deleted file mode 100644 index 8c7579382..000000000 --- a/backend/core/utils/service_retry.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Callable, TypeVar -from backend.core.utils.dataclasses import BaseServiceResponse - -T = TypeVar("T", bound=BaseServiceResponse) - - -def retry_handler(function: Callable[..., T], *args, retry_max_attempts: int = 3, **kwargs) -> T: - attempts: int = 0 - - while attempts < retry_max_attempts: - response: T = function(*args, **kwargs) - - if response.failed: - attempts += 1 - if attempts == retry_max_attempts: - return response - continue - return response - return response diff --git a/backend/core/views/__init__.py b/backend/core/views/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/auth/__init__.py b/backend/core/views/auth/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/auth/create_account.py b/backend/core/views/auth/create_account.py deleted file mode 100644 index 69822ee51..000000000 --- a/backend/core/views/auth/create_account.py +++ /dev/null @@ -1,97 +0,0 @@ -from django.contrib import messages -from django.contrib.auth import authenticate -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.db.models import Q -from django.shortcuts import redirect, render -from django.views import View - -from backend.core.models import User -from backend.core.utils.feature_flags import get_feature_status -from settings.settings import ( - SOCIAL_AUTH_GITHUB_ENABLED, - SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED, -) - - -class CreateAccountChooseView(View): - def get(self, request): - if request.user.is_authenticated: - return redirect("dashboard") - SIGNUPS_ENABLED = get_feature_status("areSignupsEnabled") - if not SIGNUPS_ENABLED: - messages.error(request, "New account signups are currently disabled") - return redirect("auth:login") - return render( - request, - "pages/auth/create_account_choose.html", - { - "github_enabled": SOCIAL_AUTH_GITHUB_ENABLED, - "google_enabled": SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED, - }, - ) - - -class CreateAccountManualView(View): - def get(self, request): - if request.user.is_authenticated: - return redirect("dashboard") - SIGNUPS_ENABLED = get_feature_status("areSignupsEnabled") - if not SIGNUPS_ENABLED: - messages.error(request, "New account signups are currently disabled") - return redirect("auth:login") - return render(request, "pages/auth/create_account_manual.html") - - def post(self, request): - if request.user.is_authenticated: - return redirect("dashboard") - SIGNUPS_ENABLED = get_feature_status("areSignupsEnabled") - if not SIGNUPS_ENABLED: - messages.error(request, "New account signups are currently disabled") - return redirect("auth:login") - - email = request.POST.get("email") - password = request.POST.get("password") - password_confirm = request.POST.get("confirm_password") - - if password != password_confirm: - messages.error(request, "Passwords don't match") - return render( - request, - "pages/auth/create_account_manual.html", - {"attempted_email": email}, - ) - - try: - validate_email(email) - except ValidationError: - messages.error(request, "Invalid email") - return render(request, "pages/auth/create_account_manual.html") - - emails_taken = User.objects.filter(Q(username=email) | Q(email=email)).exists() - - if emails_taken: - messages.error(request, "Email is already taken") - return render(request, "pages/auth/create_account_manual.html") - - if len(password) < 6: - messages.error(request, "Password must be at least 6 characters") - return render(request, "pages/auth/create_account_manual.html") - - created_user = User.objects.create_user(email=email, username=email, password=password) - created_user.awaiting_email_verification = True - created_user.save() - # created_user.is_active = False - # created_user.save() - - user = authenticate(request, username=email, password=password) - if not user: - messages.error(request, "Something went wrong") - return render(request, "pages/auth/create_account_manual.html") - - # login(request, user) - messages.success( - request, - "Successfully created account. Please verify your account via the email we are " "sending you now!", - ) - return redirect("auth:login") diff --git a/backend/core/views/auth/helpers.py b/backend/core/views/auth/helpers.py deleted file mode 100644 index e59ee1841..000000000 --- a/backend/core/views/auth/helpers.py +++ /dev/null @@ -1,3 +0,0 @@ -def generate_magic_link(user): - - return "" diff --git a/backend/core/views/auth/login.py b/backend/core/views/auth/login.py deleted file mode 100644 index d4771fb4a..000000000 --- a/backend/core/views/auth/login.py +++ /dev/null @@ -1,275 +0,0 @@ -from textwrap import dedent - -import django_ratelimit -from django.contrib import messages -from django.contrib.auth import login, logout, authenticate -from django.contrib.auth.hashers import check_password -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.http import HttpRequest, HttpResponse -from django.shortcuts import render, redirect -from django.urls import resolve, reverse -from django.utils.http import url_has_allowed_host_and_scheme -from django.urls.exceptions import Resolver404 -from django.utils.http import url_has_allowed_host_and_scheme -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.http import require_GET, require_POST -from django_ratelimit.decorators import ratelimit - -from backend.decorators import not_authenticated -from backend.models import LoginLog, User, VerificationCodes, AuditLog -from backend.core.views.auth.verify import create_magic_link -from backend.core.types.htmx import HtmxAnyHttpRequest -from settings.helpers import send_email, ARE_EMAILS_ENABLED - -from settings.settings import ( - SOCIAL_AUTH_GITHUB_ENABLED, - SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED, -) - - -@require_GET -@not_authenticated -def login_initial_page(request: HttpRequest): - redirect_url = request.GET.get("next") - - return render( - request, - "pages/auth/login_initial.html", - {"github_enabled": SOCIAL_AUTH_GITHUB_ENABLED, "next": redirect_url, "google_enabled": SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED}, - ) - - -@not_authenticated -@require_POST -def login_manual(request: HttpRequest): - email = request.POST.get("email") - password = request.POST.get("password") - redirect_url = request.POST.get("next", "") - - if not email: - messages.error(request, "Please enter an email") - return redirect_to_login("", redirect_url) - - try: - validate_email(email) - except ValidationError: - messages.error(request, "Please enter a valid email") - return redirect_to_login("", redirect_url) - - if not password: - messages.error(request, "Please enter a password") - return redirect_to_login(email, redirect_url) - - user = authenticate(request, username=email, password=password) - - if not user: - messages.error(request, "Incorrect email or password") - return redirect_to_login(email, redirect_url) - - if user.awaiting_email_verification and ARE_EMAILS_ENABLED: # type: ignore[attr-defined] - messages.error(request, "You must verify your email before logging in.") - return redirect_to_login(email, redirect_url) - - login(request, user) - - if user.require_change_password: # type: ignore[attr-defined] - messages.warning(request, "You have been requested by an administrator to change your account password.") - return redirect("settings:change_password") - - if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None): - try: - resolve(redirect_url) - return redirect(redirect_url) - except Resolver404: - return redirect("dashboard") - else: - return redirect("dashboard") - - -def redirect_to_login(email: str, redirect_url: str): - if not url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None): - redirect_url = reverse("dashboard") - return redirect(f"{reverse('auth:login')}?email={email}&next={redirect_url}") - - -def render_error_toast_message(request: HttpRequest, message: str) -> HttpResponse: - messages.error(request, message) - return render_toast_message(request) - - -def render_toast_message(request: HttpRequest) -> HttpResponse: - return render(request, "base/toasts.html") # htmx will handle the toast - - -class MagicLinkRequestView(View): - @method_decorator(ratelimit(key="post:email", method=django_ratelimit.UNSAFE, rate="5/m")) - @method_decorator(ratelimit(key="post:email", method=django_ratelimit.UNSAFE, rate="10/5m")) - @method_decorator(ratelimit(key="ip", method=django_ratelimit.UNSAFE, rate="2/m")) - @method_decorator(ratelimit(key="ip", method=django_ratelimit.UNSAFE, rate="3/10m")) - @method_decorator(ratelimit(key="ip", method=django_ratelimit.UNSAFE, rate="6/1h")) - def post(self, request: HtmxAnyHttpRequest) -> HttpResponse | bool: - if request.user.is_authenticated: - return redirect("dashboard") - if not request.htmx: - return redirect("auth:login") - - email = request.POST.get("email") - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - return self.send_message(request) - - if not user.is_active: - return self.send_message(request, "This account is not currently active.", False) - - magic_link, plain_token = create_magic_link(user, service="login") - self.send_magic_link_email(request, user, str(magic_link.uuid), plain_token) - self.send_message(request, should_redirect=False) - return render(request, "pages/auth/magic_link_waiting.html", {"email": request.POST.get("email")}) - - def send_message( - self, request: HttpRequest, message: str = "", success: bool = True, should_redirect: bool = True - ) -> HttpResponse | bool: - message = message or "If this is a valid email address, we have sent you an email! Keep this tab open!" - if success: - messages.success(request, message) - else: - messages.error(request, message) - - if should_redirect: - return render_toast_message(request) - else: - return True - - def send_magic_link_email(self, request: HttpRequest, user: User, uuid: str, plain_token: str) -> None: - magic_link_url = request.build_absolute_uri(reverse("auth:login magic_link verify", kwargs={"uuid": uuid, "token": plain_token})) - - send_email( - destination=user.email, - subject="Login Request", - content=dedent( - f""" - Hi {user.first_name if user.first_name else "User"}, - - A login request was made on your MyFinances account. If this was not you, please ignore - this email. - - If you would like to login, please use the following link: \n {magic_link_url} - """ - ), - ) - - -class MagicLinkWaitingView(View): - def post(self, request: HtmxAnyHttpRequest) -> HttpResponse: - if request.user.is_authenticated: - return redirect("dashboard") - if not request.htmx: - return redirect("auth:login") - return render(request, "pages/auth/magic_link_waiting.html", {"email": request.POST.get("email")}) - - -class MagicLinkVerifyView(View): - def get(self, request: HttpRequest, uuid: str, token: str) -> HttpResponse: - if request.user.is_authenticated: - return redirect("dashboard") - - magic_link = get_magic_link(uuid) - - magic_link_valid, magic_link_msg = is_magiclink_valid(magic_link, token) - if not magic_link_valid: - messages.error(request, magic_link_msg) - return redirect("auth:login") - - # user = magic_link.user - # magic_link.delete() - # user.backend = "backend.auth_backends.EmailInsteadOfUsernameBackend" - # login(request, magic_link.user) - - return render(request, "pages/auth/magic_link_verify.html", {"uuid": uuid, "token": token}) - - # - # messages.success(request, "Successfully logged in") - # # TODO: Add page to make sure they click an extra "verify request btn" - # return redirect("dashboard") - - -class MagicLinkVerifyDecline(View): - def post(self, request: HtmxAnyHttpRequest, uuid: str, token: str) -> HttpResponse: - if request.user.is_authenticated or not request.htmx: - return redirect("dashboard") - - magic_link = get_magic_link(uuid) - magic_link_valid, magic_link_msg = is_magiclink_valid(magic_link, token) - - if not magic_link_valid or magic_link is None: - messages.error(request, magic_link_msg) - return render_toast_message(request) - else: - user = magic_link.user - magic_link.delete() - - AuditLog.objects.create(user=user, action="magic link declined") - messages.success(request, "Successfully declined the magic link verification request.") - return render(request, "pages/auth/_magic_link_partial.html", {"declined": True}) - - -class MagicLinkVerifyAccept(View): - def post(self, request: HtmxAnyHttpRequest, uuid: str, token: str) -> HttpResponse: - if request.user.is_authenticated or not request.htmx: - return redirect("dashboard") - - magic_link = get_magic_link(uuid) - magic_link_valid, magic_link_msg = is_magiclink_valid(magic_link, token) - - if not magic_link_valid or magic_link is None: - messages.error(request, magic_link_msg) - return render_toast_message(request) - else: - user = magic_link.user - magic_link.delete() - - user.backend = "backend.auth_backends.EmailInsteadOfUsernameBackend" # type: ignore[attr-defined] - LoginLog.objects.create(user=user, service=LoginLog.ServiceTypes.MAGIC_LINK) - AuditLog.objects.create(user=user, action="magic link accepted") - login(request, magic_link.user) - - messages.success(request, "Successfully accepted the magic link verification request.") - return render(request, "pages/auth/_magic_link_partial.html", {"accepted": True}) - - -def is_magiclink_valid(magic_link: VerificationCodes | None, token: str) -> tuple[bool, str]: - if not magic_link: - return False, "Invalid magic link" - - if not magic_link.is_active(): - return False, "This link has expired" - - if not check_password(token, magic_link.token): - return False, "Invalid magic link" - - return True, "" - - -def get_magic_link(uuid: str) -> VerificationCodes | None: - try: - return VerificationCodes.objects.get(uuid=uuid, service="login") - except VerificationCodes.DoesNotExist: - return None - - -def logout_view(request): - logout(request) - - messages.success(request, "You've now been logged out.") - - return redirect("auth:login") # + "?next=" + request.POST.get('next')) - - -@not_authenticated -def forgot_password_page(request: HttpRequest): - if request.user.is_authenticated: - return redirect("dashboard") - return render(request, "pages/auth/forgot_password.html") diff --git a/backend/core/views/auth/passwords/__init__.py b/backend/core/views/auth/passwords/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/auth/passwords/generate.py b/backend/core/views/auth/passwords/generate.py deleted file mode 100644 index 1067f8d37..000000000 --- a/backend/core/views/auth/passwords/generate.py +++ /dev/null @@ -1,105 +0,0 @@ -from datetime import datetime, timedelta, date - -from django.contrib import messages -from django.contrib.auth.hashers import make_password -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.shortcuts import redirect -from django.urls import reverse, resolve, NoReverseMatch -from django.utils import timezone -from django.utils.http import url_has_allowed_host_and_scheme - -from backend.models import User, PasswordSecret -from backend.core.models import RandomCode -from backend.core.types.htmx import HtmxHttpRequest -from settings import settings - - -def msg_if_valid_email_then_sent(request): - return messages.success( - request, - f"If this is a valid email address then we have sent you an email.\n Please check spam, if you cannot find the email press forgot password again.", - ) - - -def set_password_generate(request: HtmxHttpRequest): - if not request.user.is_superuser or not request.user.is_staff: - return redirect("dashboard") - - USER = request.GET.get("id") - NEXT = request.GET.get("next") or "index" - - if USER is None or not USER.isnumeric(): - messages.error(request, "User ID must be a valid integer") - return redirect("dashboard") - - USER_OBJ = User.objects.filter(id=USER).first() - - if not USER_OBJ: - messages.error(request, f"User not found") - return redirect("dashboard") - CODE = RandomCode(40) - HASHED_CODE = make_password(CODE, salt=settings.SECRET_KEY) - - PWD_SECRET, created = PasswordSecret.objects.update_or_create( - user=USER_OBJ, - defaults={"expires": date.today() + timedelta(days=3), "secret": HASHED_CODE}, - ) - PWD_SECRET.save() - messages.error( - request, - f'Successfully created a code. {CODE}', - ) - - if url_has_allowed_host_and_scheme(NEXT, allowed_hosts=None): - try: - resolve(NEXT) - return redirect(NEXT) - except NoReverseMatch: - return redirect("dashboard") - else: - return redirect("dashboard") - - -def password_reset(request: HtmxHttpRequest): - EMAIL = request.POST.get("email") - - # if not EMAIL_SERVER_ENABLED: - # messages.error(request, "Unfortunately our email server is not currently available.") - - if not EMAIL: - msg_if_valid_email_then_sent(request) - return redirect("login forgot_password") - - try: - validate_email(EMAIL) - except ValidationError as e: - msg_if_valid_email_then_sent(request) - return redirect("login forgot_password") - - USER = User.objects.filter(email=EMAIL).first() - - if not USER: - msg_if_valid_email_then_sent(request) - return redirect("login forgot_password") - - PasswordSecret.objects.filter(user=USER).all().delete() - - CODE = RandomCode(40) - HASHED_CODE = make_password(CODE) - expires_date = date.today() + timedelta(days=3) - expires_datetime = timezone.make_aware(datetime.combine(expires_date, datetime.min.time())) - - PasswordSecret.objects.create(user=USER, expires=expires_datetime, secret=HASHED_CODE) - - # SEND_SENDGRID_EMAIL(USER.email, "Password Reset" ,f""" - # My Finances | Password Reset - # You've now got a new password reset code. - # - # Reset Here: {request.build_absolute_uri(reverse('user set password', args=(CODE,)))} - # """, request=request) - print(f"code is {CODE}") - - msg_if_valid_email_then_sent(request) - - return redirect("login forgot_password") diff --git a/backend/core/views/auth/passwords/set.py b/backend/core/views/auth/passwords/set.py deleted file mode 100644 index ad79e3893..000000000 --- a/backend/core/views/auth/passwords/set.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.contrib import messages -from django.contrib.auth.hashers import check_password -from django.http import ( - HttpRequest, -) -from django.shortcuts import redirect -from django.utils import timezone -from django.views.decorators.http import require_POST - -from backend.decorators import not_authenticated -from backend.models import PasswordSecret - - -@not_authenticated -@require_POST -def set_password_set(request: HttpRequest, secret): - password = request.POST.get("password", "") - if len(password) > 7: - SECRET_RETURNED = PasswordSecret.objects.all() - - for SECRET in SECRET_RETURNED: - if SECRET.expires is not None and SECRET.expires < timezone.now(): - SECRET.delete() - continue - elif check_password(secret, SECRET.secret): - USER = SECRET.user - USER.set_password(password) - USER.save() - SECRET.delete() - messages.success(request, "Successfully changed your password.") - return redirect("auth:login") - - messages.error( - request, - "Invalid password code. The code has either expired or was not entered correctly." - "Please contact an administrator for support.", - ) - return redirect("auth:login") - - else: - messages.error(request, "No code provided. Please contact an administrator for support.") - return redirect("auth:login") - - messages.error(request, "Sorry, somethging went wrong!") - return redirect("auth:login") diff --git a/backend/core/views/auth/passwords/view.py b/backend/core/views/auth/passwords/view.py deleted file mode 100644 index 24c50fe15..000000000 --- a/backend/core/views/auth/passwords/view.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.contrib import messages -from django.contrib.auth.hashers import check_password -from django.http import HttpRequest -from django.shortcuts import render, redirect -from django.utils import timezone - -from backend.core.models import PasswordSecret -from backend.decorators import not_authenticated - - -@not_authenticated -def set_password(request: HttpRequest, secret): - SECRET_RETURNED = PasswordSecret.objects.all() - SECRET_RETURNED.filter(expires__lte=timezone.now()).all().delete() - - for SECRET in SECRET_RETURNED: - if check_password(secret, SECRET.secret): - return render(request, "pages/reset_password.html", {"secret": secret}) - - messages.error(request, "Invalid or expired password reset code") - return redirect("dashboard") diff --git a/backend/core/views/auth/urls.py b/backend/core/views/auth/urls.py deleted file mode 100644 index 1cc2839b2..000000000 --- a/backend/core/views/auth/urls.py +++ /dev/null @@ -1,75 +0,0 @@ -from django.urls import path - -from . import login, create_account, verify -from .passwords import view as passwords_view, generate as passwords_generate, set as passwords_set - -urlpatterns = [ - path("login/", login.login_initial_page, name="login"), - path("login/manual/", login.login_manual, name="login manual"), - path("login/magic_link/request/", login.MagicLinkRequestView.as_view(), name="login magic_link request"), - path("login/magic_link/request/wait/", login.MagicLinkWaitingView.as_view(), name="login magic_link request wait"), - path("login/magic_link/verify///", login.MagicLinkVerifyView.as_view(), name="login magic_link verify"), - path( - "login/magic_link/verify///accept/", - login.MagicLinkVerifyAccept.as_view(), - name="login magic_link verify accept", - ), - path( - "login/magic_link/verify///decline/", - login.MagicLinkVerifyDecline.as_view(), - name="login magic_link verify decline", - ), - path( - "login/forgot_password/", - login.forgot_password_page, - name="login forgot_password", - ), - path("logout/", login.logout_view, name="logout"), - path( - "create_account/", - create_account.CreateAccountChooseView.as_view(), - name="login create_account", - ), - path( - "create_account/manual/", - create_account.CreateAccountManualView.as_view(), - name="login create_account manual", - ), - path( - "create_account/verify///", - verify.create_account_verify, - name="login create_account verify", - ), - path( - "create_account/verify/resend/", - verify.resend_verification_code, - name="login create_account verify resend", - ), - # path( - # "login/magic_link//", - # login.magic_link, - # name="login magic_link", - # ) - path( - "reset-password/", - passwords_generate.password_reset, - name="user set password reset", - ), - path( - "set-password//", - passwords_view.set_password, - name="user set password", - ), - path( - "set-password//set/", - passwords_set.set_password_set, - name="user set password set", - ), - path( - "admin/generate-password/", - passwords_generate.set_password_generate, - name="admin set password generate", - ), -] - -app_name = "auth" diff --git a/backend/core/views/auth/verify.py b/backend/core/views/auth/verify.py deleted file mode 100644 index df2eafe39..000000000 --- a/backend/core/views/auth/verify.py +++ /dev/null @@ -1,92 +0,0 @@ -from textwrap import dedent - -from django.contrib import messages -from django.contrib.auth.hashers import check_password -from django.shortcuts import redirect -from django.urls import reverse -from django.views.decorators.http import require_POST -from django_ratelimit.decorators import ratelimit - -from backend.models import VerificationCodes, User, TracebackError -from settings import settings -from settings.helpers import send_email, ARE_EMAILS_ENABLED - - -def create_account_verify(request, uuid, token): - object = VerificationCodes.objects.filter(uuid=uuid, service="create_account").first() - - if not object: - messages.error(request, "Invalid URL") # Todo: add some way a user can resend code? - return redirect("auth:login create_account") - - if not object.is_active(): - messages.error(request, "This code has already expired") # Todo: add some way a user can resend code? - return redirect("auth:login create_account") - - if not object.user.awaiting_email_verification: - messages.error(request, "Your email has already been verified. You can login.") - return redirect("auth:login") - - if not check_password(token, object.token): - messages.error(request, "This verification token is invalid.") - return redirect("auth:login create_account") - - user = object.user - user.is_active = True - user.awaiting_email_verification = False - user.save() - object.delete() - - messages.success(request, "Successfully verified your email! You can now login.") - return redirect("auth:login") - - -def create_magic_link(user: User, service: str) -> tuple[VerificationCodes, str]: - magic_link = VerificationCodes.objects.create(user=user, service=service) - token_plain = magic_link.token - magic_link.hash_token() - return magic_link, token_plain - - -@ratelimit(group="resend_verification_code", key="ip", rate="1/m") -@ratelimit(group="resend_verification_code", key="ip", rate="3/25m") -@ratelimit(group="resend_verification_code", key="ip", rate="10/6h") -@ratelimit(group="resend_verification_code", key="post:email", rate="1/m") -@ratelimit(group="resend_verification_code", key="post:email", rate="3/25m") -@require_POST -def resend_verification_code(request): - email = request.POST.get("email") - if not email: - messages.error(request, "Invalid resend verification request") - return redirect("auth:login") - if not ARE_EMAILS_ENABLED: - messages.error(request, "Emails are currently disabled.") - TracebackError.objects.create(error="Emails are currently disabled.") - return redirect("auth:login create_account") - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - messages.error(request, "Invalid resend verification request") - return redirect("auth:login create_account") - VerificationCodes.objects.filter(user=user, service="create_account").delete() - magic_link = create_magic_link(user, "create_account") - magic_link_url = settings.SITE_URL + reverse("auth:login create_account verify", kwargs={"uuid": magic_link.uuid, "token": token_plain}) - - send_email( - destination=email, - subject="Verify your email", - content=dedent( - f""" - Hi {user.first_name if user.first_name else "User"}, - - Verification for your email has been requested to link this email to your MyFinances account. - If this wasn't you, you can simply ignore this email. - - If it was you, you can complete the verification by clicking the link below. - Verify Link: {magic_link_url} - """ - ), - ) - - messages.success(request, "Verification email sent, check your inbox or spam!") - return redirect("auth:login") diff --git a/backend/core/views/emails/__init__.py b/backend/core/views/emails/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/emails/dashboard.py b/backend/core/views/emails/dashboard.py deleted file mode 100644 index 1dffbc89e..000000000 --- a/backend/core/views/emails/dashboard.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from django.http import HttpResponse -from django.shortcuts import render - -from backend.decorators import feature_flag_check, web_require_scopes -from backend.core.types.htmx import HtmxHttpRequest - - -@feature_flag_check("areUserEmailsAllowed", status=True) -@web_require_scopes("emails:read", False, False, "dashboard") -def dashboard(request: HtmxHttpRequest) -> HttpResponse: - return render(request, "pages/emails/dashboard.html", {}) diff --git a/backend/core/views/emails/urls.py b/backend/core/views/emails/urls.py deleted file mode 100644 index 1ed17a05d..000000000 --- a/backend/core/views/emails/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from django.urls import path - -from . import dashboard - -urlpatterns = [ - path( - "", - dashboard.dashboard, - name="dashboard", - ), -] - -app_name = "emails" diff --git a/backend/core/views/other/__init__.py b/backend/core/views/other/__init__.py deleted file mode 100644 index 064a9bbc5..000000000 --- a/backend/core/views/other/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .index import index -from .errors import universal, e_403 diff --git a/backend/core/views/other/errors.py b/backend/core/views/other/errors.py deleted file mode 100644 index 5cc555a7c..000000000 --- a/backend/core/views/other/errors.py +++ /dev/null @@ -1,63 +0,0 @@ -import traceback - -from django.contrib import messages -from django.http import HttpRequest -from django.shortcuts import redirect -from django_ratelimit.exceptions import Ratelimited - -from backend.models import TracebackError, AuditLog - - -def universal(request: HttpRequest, exception=None): - messages.error( - request, - "Sorry, something went wrong on our end! We've contacted our team, please email us if this issue continues.", - ) - traceback.print_exc() - exec_error = traceback.format_exc() - print(f"WAS A TRACEBACK ERROR: EXCEPTION: {exception}") - - if len(exec_error) > 4999: - return - - if request.user.is_authenticated: - messages.error(request, "Sorry, something went wrong!") - TracebackError(user=request.user, error=exec_error).save() - else: - TracebackError(error=exec_error).save() - - return redirect("dashboard") - - -def e_403(request: HttpRequest, exception=None): - if isinstance(exception, Ratelimited): - messages.error( - request, - "Woah, slow down there. You've been temporarily blocked from this page due to extreme requests.", - ) - user_ip = request.META.get("REMOTE_ADDR") - user_id = f"User #{request.user.id}" if request.user.is_authenticated else "Not logged in" - action = f"{user_ip} | Ratelimited | {user_id}" - auditlog = AuditLog(action=action) - if request.user.is_authenticated: - auditlog.user = request.user - auditlog.save() - return redirect("auth:login") - else: - messages.error( - request, - "Sorry, something went wrong on our end!" "We've contacted our team, please email us if this issue continues.", - ) - traceback.print_exc() - exec_error = traceback.format_exc() - print(f"WAS A TRACEBACK ERROR: EXCEPTION: {exception}") - - if len(exec_error) > 4999: - return - - if request.user.is_authenticated: - TracebackError(user=request.user, error=exec_error).save() - else: - TracebackError(error=exec_error).save() - - return redirect("dashboard") diff --git a/backend/core/views/other/index.py b/backend/core/views/other/index.py deleted file mode 100644 index e3b45f23a..000000000 --- a/backend/core/views/other/index.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.http import HttpRequest -from django.shortcuts import render -from login_required import login_not_required - - -def index(request: HttpRequest): - return render(request, "pages/landing/index.html") - - -@login_not_required -def pricing(request: HttpRequest): - return render(request, "pages/landing/pricing.html") - - -def dashboard(request: HttpRequest): - return render(request, "pages/dashboard.html") diff --git a/backend/core/views/quotas/__init__.py b/backend/core/views/quotas/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/quotas/view.py b/backend/core/views/quotas/view.py deleted file mode 100644 index 1671067e5..000000000 --- a/backend/core/views/quotas/view.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.http import HttpResponse -from django.shortcuts import render - -from backend.decorators import superuser_only -from backend.models import QuotaIncreaseRequest, QuotaLimit -from backend.core.types.htmx import HtmxHttpRequest - - -def quotas_page(request: HtmxHttpRequest) -> HttpResponse: - groups = list(QuotaLimit.objects.values_list("slug", flat=True).distinct()) - - quotas = {q.split("-")[0] for q in groups if q.split("-")} - - return render( - request, - "pages/quotas/dashboard.html", - {"quotas": quotas}, - ) - - -def quotas_list(request: HtmxHttpRequest, group: str) -> HttpResponse: - return render(request, "pages/quotas/list.html", {"group": group}) - - -@superuser_only -def view_quota_increase_requests(request: HtmxHttpRequest) -> 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/core/views/settings/__init__.py b/backend/core/views/settings/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/settings/teams.py b/backend/core/views/settings/teams.py deleted file mode 100644 index 5134fe578..000000000 --- a/backend/core/views/settings/teams.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Optional - -from django.db.models import When, Case, BooleanField, QuerySet -from django.shortcuts import render - -from backend.models import Organization, User -from backend.core.service.teams.fetch import get_all_users_teams -from backend.core.types.requests import WebRequest - - -def teams_dashboard(request: WebRequest): - context: dict[str, str | int] = {} - - users_team: Optional[Organization] = request.user.logged_in_as_team - - if not users_team: - user_with_counts = User.objects.prefetch_related("teams_joined", "teams_leader_of").get(pk=request.user.pk) - return render( - request, - "pages/settings/teams/main.html", - context - | { - "team": None, - "team_count": user_with_counts.teams_joined.count() + user_with_counts.teams_leader_of.count(), - }, - ) - - try: - team = ( - Organization.objects.annotate( - is_leader=Case( - When(leader=request.user, then=True), - default=False, - output_field=BooleanField(), - ), - ) - .prefetch_related("members", "permissions") - .get(id=users_team.id) - ) - - user_permissions: dict[User, list] = {} - - for member in team.members.all(): - member_perms = list(team.permissions.filter(user=member).values_list("scopes", flat=True)) - - if len(member_perms) > 0: - user_permissions[member] = member_perms[0] - else: - user_permissions[member] = [] - - except Organization.DoesNotExist: - user_with_counts = User.objects.prefetch_related("teams_joined", "teams_leader_of").get(pk=request.user.pk) - return render( - request, - "pages/settings/teams/main.html", - context - | { - "team": None, - "team_count": user_with_counts.teams_joined.count() + user_with_counts.teams_leader_of.count(), - }, - ) - - return render( - request, - "pages/settings/teams/main.html", - context | {"team": team, "user_permissions": user_permissions}, - ) - - -def login_to_team_page(request: WebRequest, all_teams: QuerySet[Organization]): - print(all_teams) - return render(request, "pages/settings/teams/login_to_team.html", {"team_list": all_teams}) - - -def teams_dashboard_handler(request: WebRequest): - all_teams: QuerySet[Organization] = get_all_users_teams(request) - logged_in_team: Organization | None = request.user.logged_in_as_team - - if not logged_in_team: - return login_to_team_page(request, all_teams) - return teams_dashboard(request) diff --git a/backend/core/views/settings/urls.py b/backend/core/views/settings/urls.py deleted file mode 100644 index a56449426..000000000 --- a/backend/core/views/settings/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.urls import path - -from backend.core.views.settings.view import change_password, view_settings_page_endpoint - -urlpatterns = [ - path("", view_settings_page_endpoint, name="dashboard"), - path("/", view_settings_page_endpoint, name="dashboard with page"), - path( - "profile/change_password/", - change_password, - name="change_password", - ), -] - -app_name = "settings" diff --git a/backend/core/views/settings/view.py b/backend/core/views/settings/view.py deleted file mode 100644 index 9df835c65..000000000 --- a/backend/core/views/settings/view.py +++ /dev/null @@ -1,84 +0,0 @@ -from django.views.decorators.http import require_http_methods -from django.contrib.auth import update_session_auth_hash -from django.contrib import messages -from django.shortcuts import redirect -from django.shortcuts import render - -from backend.core.service.settings.view import ( - validate_page, - account_page_context, - api_keys_page_context, - account_defaults_context, - email_templates_context, -) -from backend.core.types.requests import WebRequest - - -@require_http_methods(["GET"]) -def view_settings_page_endpoint(request: WebRequest, page: str | None = None): - if not validate_page(page): - messages.error(request, "Invalid settings page") - if request.htmx: - return render(request, "base/toast.html") - return redirect("settings:dashboard") - - context: dict = {} - - match page: - case "account": - account_page_context(request, context) - case "api_keys": - api_keys_page_context(request, context) - case "account_defaults": - account_defaults_context(request, context) - case "email_templates": - email_templates_context(request, context) - - template = f"pages/settings/pages/{page or 'profile'}.html" - - if not page or not request.GET.get("on_main"): - context["page_template"] = template - return render(request, "pages/settings/main.html", context) - - response = render(request, template, context) - - response.no_retarget = True # type: ignore[attr-defined] - return response - - -def change_password(request: WebRequest): - if request.method == "POST": - current_password = request.POST.get("current_password") - password = request.POST.get("password") - confirm_password = request.POST.get("confirm_password") - - error = validate_password_change(request.user, current_password, password, confirm_password) - - if error: - messages.error(request, error) - return redirect("settings:change_password") - - # If no errors, update the password - request.user.set_password(password) - request.user.save() - update_session_auth_hash(request, request.user) - messages.success(request, "Successfully changed your password.") - return redirect("settings:dashboard") - - return render(request, "pages/reset_password.html", {"type": "change"}) - - -def validate_password_change(user, current_password, new_password, confirm_password): - if not user.check_password(current_password): - return "Incorrect current password" - - if new_password != confirm_password: - return "Passwords don't match" - - if not new_password: - return "Something went wrong, no password was provided." - - if len(new_password) < 8 or len(new_password) > 128: - return "Password must be between 8 and 128 characters." - - return None diff --git a/backend/core/views/teams/__init__.py b/backend/core/views/teams/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/views/teams/urls.py b/backend/core/views/teams/urls.py deleted file mode 100644 index 4de008fd8..000000000 --- a/backend/core/views/teams/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - -from backend.core.views.settings.teams import teams_dashboard_handler - -urlpatterns = [ - path( - "", - teams_dashboard_handler, - name="dashboard", - ), -] - -app_name = "teams" diff --git a/backend/core/webhooks/__init__.py b/backend/core/webhooks/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/webhooks/invoices/__init__.py b/backend/core/webhooks/invoices/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/core/api/quotas/__init__.py b/backend/data/__init__.py similarity index 100% rename from backend/core/api/quotas/__init__.py rename to backend/data/__init__.py diff --git a/backend/core/data/default_email_templates.py b/backend/data/default_email_templates.py similarity index 100% rename from backend/core/data/default_email_templates.py rename to backend/data/default_email_templates.py diff --git a/backend/decorators.py b/backend/decorators.py deleted file mode 100644 index 7cc91414c..000000000 --- a/backend/decorators.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -import logging -from functools import wraps -from typing import TypedDict - -from django.contrib import messages -from django.http import HttpResponse -from django.http import HttpResponseRedirect -from django.shortcuts import redirect -from django.shortcuts import render -from django.urls import reverse - -from backend.core.models import QuotaLimit, TeamMemberPermission -from backend.core.types.requests import WebRequest -from backend.core.utils.feature_flags import get_feature_status - -logger = logging.getLogger(__name__) - - -def not_authenticated(view_func): - def wrapper_func(request, *args, **kwargs): - if request.user.is_authenticated: - return redirect("dashboard") - else: - return view_func(request, *args, **kwargs) - - return wrapper_func - - -def staff_only(view_func): - def wrapper_func(request, *args, **kwargs): - if request.user.is_staff and request.user.is_authenticated: - return view_func(request, *args, **kwargs) - else: - messages.error(request, "You don't have permission to view this page.") - return redirect("dashboard") - - return wrapper_func - - -def superuser_only(view_func): - def wrapper_func(request, *args, **kwargs): - if request.user.is_authenticated and request.user.is_superuser: - return view_func(request, *args, **kwargs) - else: - messages.error(request, "You don't have permission to view this page.") - return redirect("dashboard") - - return wrapper_func - - -def htmx_only(viewname: str = "dashboard"): - def decorator(view_func): - def wrapper_func(request, *args, **kwargs): - if request.htmx: - return view_func(request, *args, **kwargs) - else: - return redirect(viewname) - - return wrapper_func - - return decorator - - -def hx_boost(view): - """ - Decorator for HTMX requests. - - used by wrapping FBV in @hx_boost and adding **kwargs to param - then you can use context = kwargs.get("context", {}) to continue and then it will handle HTMX boosts - """ - - @wraps(view) - def wrapper(request, *args, **kwargs): - if request.htmx.boosted: - kwargs["context"] = kwargs.get("context", {}) | {"base": "base/htmx.html"} - return view(request, *args, **kwargs) - - return wrapper - - -def feature_flag_check(flag, status=True, api=False, htmx=False): - def decorator(view_func): - @wraps(view_func) - def wrapper(request, *args, **kwargs): - feat_status = get_feature_status(flag) - - if feat_status == status: - return view_func(request, *args, **kwargs) - - if api and htmx: - messages.error(request, "This feature is currently disabled.") - return render(request, "base/toasts.html") - elif api: - return HttpResponse(status=403, content="This feature is currently disabled.") - messages.error(request, "This feature is currently disabled.") - try: - last_visited_url = request.session["last_visited"] - current_url = request.build_absolute_uri() - if last_visited_url != current_url: - return HttpResponseRedirect(last_visited_url) - except KeyError: - pass - return HttpResponseRedirect(reverse("dashboard")) - - return wrapper - - return decorator - - -class FlagItem(TypedDict): - name: str - desired: bool - - -def feature_flag_check_multi(flag_list: list[FlagItem], api=False, htmx=False): - """ - Checks if at least one of the flags in the list is the desired status - """ - - def decorator(view_func): - @wraps(view_func) - def wrapper(request, *args, **kwargs): - if not any(get_feature_status(flag["name"]) == flag["desired"] for flag in flag_list): - if api and htmx: - messages.error(request, "This feature is currently disabled.") - return render(request, "base/toasts.html") - 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")) - - return view_func(request, *args, **kwargs) - - return wrapper - - return decorator - - -def quota_usage_check(limit: str | QuotaLimit, extra_data: str | int | None = 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}'") - try: - last_visited_url = request.session["last_visited"] - current_url = request.build_absolute_uri() - if last_visited_url != current_url: - return HttpResponseRedirect(last_visited_url) - except KeyError: - pass - return HttpResponseRedirect(reverse("dashboard")) - - return wrapper - - return decorator - - -not_logged_in = not_authenticated -logged_out = not_authenticated - - -def web_require_scopes(scopes: str | list[str], htmx=False, api=False, redirect_url=None): - """ - Only to be used by WebRequests (htmx or html) NOT PUBLIC API - """ - - def decorator(view_func): - @wraps(view_func) - def _wrapped_view(request: WebRequest, *args, **kwargs): - if request.team_id and not request.team: - return return_error(request, "Team not found") - - if request.team: - # Check for team permissions based on team_id and scopes - if not request.team.is_owner(request.user): - team_permissions = TeamMemberPermission.objects.filter(team=request.team, user=request.user).first() - - if not team_permissions: - return return_error(request, "You do not have permission to perform this action (no permissions for team)") - - # single scope - if isinstance(scopes, str) and scopes not in team_permissions.scopes: - return return_error(request, f"You do not have permission to perform this action ({scopes})") - - # scope list - if isinstance(scopes, list): - for scope in scopes: - if scope not in team_permissions.scopes: - return return_error(request, f"You do not have permission to perform this action ({scope})") - return view_func(request, *args, **kwargs) - - _wrapped_view.required_scopes = scopes - return _wrapped_view - - def return_error(request: WebRequest, msg: str): - logging.info(f"User does not have permission to perform this action (User ID: {request.user.id}, Scopes: {scopes})") - if api and htmx: - messages.error(request, msg) - return render(request, "base/toast.html", {"autohide": False}) - elif api: - return HttpResponse(status=403, content=msg) - elif request.htmx: - messages.error(request, msg) - resp = HttpResponse(status=200) - - try: - last_visited_url = request.session["last_visited"] - current_url = request.build_absolute_uri() - if last_visited_url != current_url: - resp["HX-Replace-Url"] = last_visited_url - except KeyError: - ... - resp["HX-Refresh"] = "true" - return resp - - messages.error(request, msg) - - try: - last_visited_url = request.session["last_visited"] - current_url = request.build_absolute_uri() - if last_visited_url != current_url: - return HttpResponseRedirect(last_visited_url) - except KeyError: - pass - - if not redirect_url: - return HttpResponseRedirect(reverse("dashboard")) - - try: - return HttpResponseRedirect(reverse(redirect_url)) - except KeyError: - return HttpResponseRedirect(reverse("dashboard")) - - return decorator - - -# wrapper around billing has_entitlements only load - -from django.conf import settings - - -def has_entitlements(entitlements: list[str] | str, htmx_api: bool = False): - def decorator(view_func): - @wraps(view_func) - def wrapper(request, *args, **kwargs): - if settings.BILLING_ENABLED: - from billing.decorators import has_entitlements_called_from_backend_handler - - wrapped_view_func = has_entitlements_called_from_backend_handler( - entitlements if isinstance(entitlements, list) else [entitlements], htmx_api - )(view_func) - return wrapped_view_func(request, *args, **kwargs) - return view_func(request, *args, **kwargs) - - return wrapper - - return decorator diff --git a/backend/finance/api/invoices/create/services/add_service.py b/backend/finance/api/invoices/create/services/add_service.py index 4d7244218..744c69cb9 100644 --- a/backend/finance/api/invoices/create/services/add_service.py +++ b/backend/finance/api/invoices/create/services/add_service.py @@ -1,8 +1,8 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.core.service.invoices.common.create.services.add import add -from backend.core.types.requests import WebRequest +from backend.finance.service.invoices.common.create.services.add import add +from core.types.requests import WebRequest @require_http_methods(["POST"]) diff --git a/backend/finance/api/invoices/create/set_destination.py b/backend/finance/api/invoices/create/set_destination.py index 8902a1bb7..a028f7586 100644 --- a/backend/finance/api/invoices/create/set_destination.py +++ b/backend/finance/api/invoices/create/set_destination.py @@ -2,9 +2,9 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest from backend.models import Client -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest to_get = ["name", "address", "city", "country", "company", "is_representative", "email"] diff --git a/backend/finance/api/invoices/delete.py b/backend/finance/api/invoices/delete.py index 16b85bafa..2dd33e720 100644 --- a/backend/finance/api/invoices/delete.py +++ b/backend/finance/api/invoices/delete.py @@ -5,9 +5,9 @@ from django.urls.exceptions import Resolver404 from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes -from backend.models import Invoice, QuotaLimit -from backend.core.types.htmx import HtmxHttpRequest +from core.decorators import web_require_scopes +from backend.models import Invoice +from core.types.htmx import HtmxHttpRequest @require_http_methods(["DELETE"]) @@ -21,11 +21,11 @@ def delete_invoice(request: HtmxHttpRequest): invoice = Invoice.objects.get(id=delete_items.get("invoice", "")) except Invoice.DoesNotExist: messages.error(request, "Invoice Not Found") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") if not invoice.has_access(request.user): messages.error(request, "You do not have permission to delete this invoice") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") QuotaLimit.delete_quota_usage("invoices-count", request.user, invoice.id, invoice.date_created) @@ -34,7 +34,7 @@ def delete_invoice(request: HtmxHttpRequest): if request.htmx: if not redirect: messages.success(request, "Invoice deleted") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") try: resolve(redirect) diff --git a/backend/finance/api/invoices/edit.py b/backend/finance/api/invoices/edit.py index 6357f3c3e..4b2ff180c 100644 --- a/backend/finance/api/invoices/edit.py +++ b/backend/finance/api/invoices/edit.py @@ -6,9 +6,9 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods, require_POST -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import Invoice -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @require_http_methods(["POST"]) @@ -61,14 +61,14 @@ def edit_invoice(request: HtmxHttpRequest): new_value = datetime.strptime(new_value, "%Y-%m-%d").date() # type: ignore[assignment] except ValueError: messages.error(request, "Invalid date format for date_due") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") setattr(invoice, column_name, new_value) invoice.save() if request.htmx: messages.success(request, "Invoice edited") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") return JsonResponse({"message": "Invoice successfully edited"}, status=200) @@ -141,14 +141,14 @@ def edit_discount(request: HtmxHttpRequest, invoice_id: str): messages.success(request, "Discount was applied successfully") - response = render(request, "base/toasts.html") + response = render(request, "core/base/toasts.html") response["HX-Trigger"] = "update_invoice" return response def return_message(request: HttpRequest, message: str, success: bool = True) -> HttpResponse: send_message(request, message, success) - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") def send_message(request: HttpRequest, message: str, success: bool = False) -> None: diff --git a/backend/finance/api/invoices/fetch.py b/backend/finance/api/invoices/fetch.py index 2a945a0e4..1dda518d9 100644 --- a/backend/finance/api/invoices/fetch.py +++ b/backend/finance/api/invoices/fetch.py @@ -1,10 +1,10 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import Invoice -from backend.core.types.htmx import HtmxHttpRequest -from backend.core.service.invoices.common.fetch import get_context +from core.types.htmx import HtmxHttpRequest +from backend.finance.service.invoices.common.fetch import get_context @require_http_methods(["GET"]) diff --git a/backend/finance/api/invoices/manage.py b/backend/finance/api/invoices/manage.py index 78bba95a9..450a76dcf 100644 --- a/backend/finance/api/invoices/manage.py +++ b/backend/finance/api/invoices/manage.py @@ -6,9 +6,9 @@ from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import Invoice -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest class PreviewContext(TypedDict): diff --git a/backend/finance/api/invoices/recurring/delete.py b/backend/finance/api/invoices/recurring/delete.py index 5157c6e50..7d9a6dbd1 100644 --- a/backend/finance/api/invoices/recurring/delete.py +++ b/backend/finance/api/invoices/recurring/delete.py @@ -5,11 +5,11 @@ from django.urls.exceptions import Resolver404 from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.asyn_tasks.tasks import Task -from backend.core.service.boto3.scheduler.delete_schedule import delete_boto_schedule -from backend.core.types.requests import WebRequest +from backend.boto3.async_tasks.tasks import Task +from backend.boto3.scheduler.delete_schedule import delete_boto_schedule +from core.types.requests import WebRequest @require_http_methods(["DELETE"]) @@ -23,11 +23,11 @@ def delete_invoice_recurring_profile_endpoint(request: WebRequest): invoice_profile = InvoiceRecurringProfile.objects.get(id=delete_items.get("invoice_profile", "")) except InvoiceRecurringProfile.DoesNotExist: messages.error(request, "Invoice recurring profile Not Found") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") if not invoice_profile.has_access(request.user): messages.error(request, "You do not have permission to delete this Invoice recurring profile") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") # QuotaLimit.delete_quota_usage("invoices-count", request.user, invoice.id, invoice.date_created) @@ -39,7 +39,7 @@ def delete_invoice_recurring_profile_endpoint(request: WebRequest): if request.htmx: if not redirect: messages.success(request, "Invoice profile deleted") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") try: resolve(redirect) diff --git a/backend/finance/api/invoices/recurring/edit.py b/backend/finance/api/invoices/recurring/edit.py index 36c384a4d..bb75c23b6 100644 --- a/backend/finance/api/invoices/recurring/edit.py +++ b/backend/finance/api/invoices/recurring/edit.py @@ -5,11 +5,11 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes, has_entitlements +from core.decorators import web_require_scopes, has_entitlements from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.invoices.recurring.get import get_invoice_profile -from backend.core.service.invoices.recurring.validate.frequencies import validate_and_update_frequency -from backend.core.types.requests import WebRequest +from backend.finance.service.invoices.recurring.get import get_invoice_profile +from backend.finance.service.invoices.recurring.validate.frequencies import validate_and_update_frequency +from core.types.requests import WebRequest @require_http_methods(["POST"]) @@ -20,7 +20,7 @@ def edit_invoice_recurring_profile_endpoint(request: WebRequest, invoice_profile if invoice_profile_response.failed: messages.error(request, invoice_profile_response.error) - return render(request, "base/toasts.html", {"autohide": False}) + return render(request, "core/base/toasts.html", {"autohide": False}) invoice_profile: InvoiceRecurringProfile = invoice_profile_response.response @@ -34,7 +34,7 @@ def edit_invoice_recurring_profile_endpoint(request: WebRequest, invoice_profile if frequency_update_response.failed: messages.error(request, frequency_update_response.error) - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") attributes_to_update = { "date_due": request.POST.get("date_due"), @@ -67,13 +67,13 @@ def edit_invoice_recurring_profile_endpoint(request: WebRequest, invoice_profile new_value = datetime.strptime(new_value, "%Y-%m-%d").date() # type: ignore[assignment] except ValueError: messages.error(request, "Invalid date format for date_due") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") setattr(invoice_profile, column_name, new_value) invoice_profile.save() if request.htmx: messages.success(request, "Successfully saved profile!") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") return JsonResponse({"message": "Invoice successfully edited"}, status=200) diff --git a/backend/finance/api/invoices/recurring/fetch.py b/backend/finance/api/invoices/recurring/fetch.py index abee2dbf4..e669694ba 100644 --- a/backend/finance/api/invoices/recurring/fetch.py +++ b/backend/finance/api/invoices/recurring/fetch.py @@ -2,10 +2,10 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.invoices.common.fetch import get_context -from backend.core.types.requests import WebRequest +from backend.finance.service.invoices.common.fetch import get_context +from core.types.requests import WebRequest @require_http_methods(["GET"]) diff --git a/backend/finance/api/invoices/recurring/generate_next_invoice_now.py b/backend/finance/api/invoices/recurring/generate_next_invoice_now.py index a51f168d5..cb03aff1a 100644 --- a/backend/finance/api/invoices/recurring/generate_next_invoice_now.py +++ b/backend/finance/api/invoices/recurring/generate_next_invoice_now.py @@ -2,11 +2,11 @@ from django.shortcuts import render from django.views.decorators.http import require_POST -from backend.decorators import web_require_scopes, htmx_only +from core.decorators import web_require_scopes, htmx_only from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.defaults.get import get_account_defaults -from backend.core.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.core.types.requests import WebRequest +from backend.finance.service.defaults.get import get_account_defaults +from backend.finance.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service +from core.types.requests import WebRequest import logging @@ -24,7 +24,7 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): if not invoice_recurring_profile: messages.error(request, "Failed to fetch next invoice; cannot find Invoice recurring profile.") - return render(request, "base/toast.html", {"autohide": False}) + return render(request, "core/base/toast.html", {"autohide": False}) if invoice_recurring_profile.client_to: account_defaults = get_account_defaults(invoice_recurring_profile.owner, invoice_recurring_profile.client_to) @@ -33,7 +33,7 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): if not invoice_recurring_profile.has_access(request.user): messages.error(request, "You do not have permission to modify this invoice recurring profile.") - return render(request, "base/toast.html", {"autohide": False}) + return render(request, "core/base/toast.html", {"autohide": False}) next_invoice_issue_date = invoice_recurring_profile.next_invoice_issue_date() @@ -59,4 +59,4 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): else: logger.info(svc_resp.error) messages.error(request, f"Failed to fetch next invoice; {svc_resp.error}") - return render(request, "base/toast.html", {"autohide": False}) + return render(request, "core/base/toast.html", {"autohide": False}) diff --git a/backend/finance/api/invoices/recurring/poll.py b/backend/finance/api/invoices/recurring/poll.py index 5f6b7b0be..a668dbd97 100644 --- a/backend/finance/api/invoices/recurring/poll.py +++ b/backend/finance/api/invoices/recurring/poll.py @@ -5,13 +5,13 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes, htmx_only +from core.decorators import web_require_scopes, htmx_only from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.asyn_tasks.tasks import Task -from backend.core.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.core.service.boto3.scheduler.get import get_boto_schedule +from backend.boto3.async_tasks.tasks import Task +from backend.boto3.scheduler.create_schedule import create_boto_schedule +from backend.boto3.scheduler.get import get_boto_schedule -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest def return_create_schedule(recurring_schedule): diff --git a/backend/finance/api/invoices/recurring/update_status.py b/backend/finance/api/invoices/recurring/update_status.py index 49cf302b8..5e691c4de 100644 --- a/backend/finance/api/invoices/recurring/update_status.py +++ b/backend/finance/api/invoices/recurring/update_status.py @@ -1,20 +1,20 @@ -from typing import Literal from django.conf import settings from django.contrib import messages from django.http import HttpRequest, HttpResponse from django.shortcuts import render, redirect from django.views.decorators.http import require_POST -from backend.decorators import web_require_scopes -from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.asyn_tasks.tasks import Task -from backend.core.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.core.service.boto3.scheduler.get import get_boto_schedule -from backend.core.service.boto3.scheduler.pause import pause_boto_schedule -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes +from backend.boto3.async_tasks.tasks import Task +from backend.boto3.scheduler.create_schedule import create_boto_schedule +from backend.boto3.scheduler.get import get_boto_schedule +from backend.boto3.scheduler.pause import pause_boto_schedule +from core.types.requests import WebRequest from datetime import timedelta, datetime +from backend.models import InvoiceRecurringProfile + @require_POST @web_require_scopes("invoices:write", True, True) @@ -90,7 +90,7 @@ def recurring_profile_change_status_endpoint(request: WebRequest, invoice_profil def return_message(request: HttpRequest, message: str, success: bool = True) -> HttpResponse: send_message(request, message, success) - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") def send_message(request: HttpRequest, message: str, success: bool = False) -> None: diff --git a/backend/finance/api/invoices/reminders/create.py b/backend/finance/api/invoices/reminders/create.py index 4ef2a309e..a724a318b 100644 --- a/backend/finance/api/invoices/reminders/create.py +++ b/backend/finance/api/invoices/reminders/create.py @@ -6,7 +6,7 @@ # from django.shortcuts import render, redirect # from django.utils import timezone # -# from backend.decorators import web_require_scopes +# from core.decorators import web_require_scopes # from backend.finance.models import Invoice, InvoiceReminder, QuotaUsage # from backend.utils.quota_limit_ops import quota_usage_check_under # from infrastructure.aws.schedules.create_reminder import CreateReminderInputData, create_reminder_schedule @@ -47,17 +47,17 @@ # invoice = Invoice.objects.get(id=invoice_id) # except Invoice.DoesNotExist: # messages.error(request, "Invoice not found") -# return render(request, "base/toast.html", {"autohide": False}) +# return render(request, "core/base/toast.html", {"autohide": False}) # # # Check user permission # if not invoice.has_access(user=request.user): # messages.error(request, "You do not have permission to create schedules for this invoice") -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") # # # Check reminder type # if reminder_type not in InvoiceReminder.ReminderTypes.values: # messages.error(request, "Invalid reminder type") -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") # # # Ensure days is set for non-overdue reminders # if reminder_type == "on_overdue": @@ -70,7 +70,7 @@ # raise ValueError # except ValueError: # messages.error(request, "Invalid days value. Make sure it's an integer from 1-31") -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") # # # Create reminder object # reminder = InvoiceReminder(invoice=invoice, reminder_type=reminder_type) @@ -96,4 +96,4 @@ # return render(request, "pages/invoices/single/schedules/reminders/_table_row.html", {"reminder": REMINDER.reminder}) # else: # messages.error(request, REMINDER.message) -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") diff --git a/backend/finance/api/invoices/reminders/delete.py b/backend/finance/api/invoices/reminders/delete.py index 8935b1c77..89b0178bc 100644 --- a/backend/finance/api/invoices/reminders/delete.py +++ b/backend/finance/api/invoices/reminders/delete.py @@ -4,7 +4,7 @@ # from django.shortcuts import render # from django.views.decorators.http import require_http_methods # -# from backend.decorators import feature_flag_check, web_require_scopes +# from core.decorators import feature_flag_check, web_require_scopes # from backend.finance.models import InvoiceReminder # # from backend.types.htmx import HtmxHttpRequest @@ -20,11 +20,11 @@ # reminder = InvoiceReminder.objects.get(id=reminder_id) # except InvoiceReminder.DoesNotExist: # messages.error(request, "Schedule not found!") -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") # # if not reminder.invoice.has_access(request.user): # messages.error(request, "You do not have access to this invoice.") -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") # # original_status = reminder.status # reminder.set_status("deleting") @@ -40,7 +40,7 @@ # else: # reminder.set_status(original_status) # messages.error(request, f"Failed to delete schedule: {delete_status.message}") -# return render(request, "base/toasts.html") +# return render(request, "core/base/toasts.html") # # reminder.set_status("cancelled") # diff --git a/backend/finance/api/invoices/reminders/fetch.py b/backend/finance/api/invoices/reminders/fetch.py index d4afe81db..396f6703b 100644 --- a/backend/finance/api/invoices/reminders/fetch.py +++ b/backend/finance/api/invoices/reminders/fetch.py @@ -4,9 +4,9 @@ from django.views.decorators.http import require_GET from django_ratelimit.core import is_ratelimited -from backend.decorators import feature_flag_check, web_require_scopes +from core.decorators import feature_flag_check, web_require_scopes from backend.finance.models import Invoice -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @require_GET @@ -16,17 +16,17 @@ def fetch_reminders(request: HtmxHttpRequest, invoice_id: str): ratelimit = is_ratelimited(request, group="fetch_reminders", key="user", rate="20/30s", increment=True) if ratelimit: messages.error(request, "Too many requests") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") try: invoice = Invoice.objects.prefetch_related("invoice_reminders").get(id=invoice_id) except Invoice.DoesNotExist: messages.error(request, "Invoice not found") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") if not invoice.has_access(request.user): messages.error(request, "You do not have permission to view this invoice") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") context: dict = {} diff --git a/backend/finance/api/products/create.py b/backend/finance/api/products/create.py index a1ba8b7d8..92f2fc063 100644 --- a/backend/finance/api/products/create.py +++ b/backend/finance/api/products/create.py @@ -1,9 +1,9 @@ from django.contrib import messages from backend.finance.api.products.fetch import fetch_products -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import InvoiceProduct -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @web_require_scopes("invoices:write", True, True) diff --git a/backend/finance/api/products/fetch.py b/backend/finance/api/products/fetch.py index 5de0d1c5f..78fa16eb9 100644 --- a/backend/finance/api/products/fetch.py +++ b/backend/finance/api/products/fetch.py @@ -1,9 +1,9 @@ from django.db.models import Q, QuerySet from django.shortcuts import render -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import InvoiceProduct -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @web_require_scopes("invoices:read", True, True) diff --git a/backend/finance/api/receipts/delete.py b/backend/finance/api/receipts/delete.py index 748ddece1..3ab2ed00a 100644 --- a/backend/finance/api/receipts/delete.py +++ b/backend/finance/api/receipts/delete.py @@ -4,9 +4,9 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.models import Receipt -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest @require_http_methods(["DELETE"]) diff --git a/backend/finance/api/receipts/download.py b/backend/finance/api/receipts/download.py index 70e199c25..525f0c320 100644 --- a/backend/finance/api/receipts/download.py +++ b/backend/finance/api/receipts/download.py @@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.models import Receipt, ReceiptDownloadToken diff --git a/backend/finance/api/receipts/edit.py b/backend/finance/api/receipts/edit.py index c3ccf74eb..6771ce86f 100644 --- a/backend/finance/api/receipts/edit.py +++ b/backend/finance/api/receipts/edit.py @@ -7,7 +7,7 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods, require_POST -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.models import Receipt @@ -23,7 +23,7 @@ def edit_receipt(request, receipt_id): raise Receipt.DoesNotExist except Receipt.DoesNotExist: messages.error(request, "Receipt not found") - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") file: InMemoryUploadedFile | None = request.FILES.get("receipt_image") date = request.POST.get("receipt_date") diff --git a/backend/finance/api/receipts/fetch.py b/backend/finance/api/receipts/fetch.py index 84f56a053..238d7dd4e 100644 --- a/backend/finance/api/receipts/fetch.py +++ b/backend/finance/api/receipts/fetch.py @@ -1,9 +1,9 @@ from django.db.models import Q, QuerySet from django.shortcuts import render, redirect -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.models import Receipt -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @web_require_scopes("receipts:read", True, True) diff --git a/backend/finance/api/receipts/new.py b/backend/finance/api/receipts/new.py index b7b1e20e9..9775962d2 100644 --- a/backend/finance/api/receipts/new.py +++ b/backend/finance/api/receipts/new.py @@ -4,9 +4,9 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes, has_entitlements -from backend.models import Receipt, QuotaUsage -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes, has_entitlements +from backend.models import Receipt +from core.types.requests import WebRequest @require_http_methods(["POST"]) @@ -50,7 +50,7 @@ def receipt_create(request: WebRequest): receipts = Receipt.filter_by_owner(owner=request.actor).order_by("-date") receipt = Receipt(**receipt_data) - QuotaUsage.create_str(request.user, "receipts-count", receipt.id) + # QuotaUsage.create_str(request.user, "receipts-count", receipt.id) receipt.save() # r = requests.post( # "https://ocr.asprise.com/api/receipt", diff --git a/backend/finance/api/reports/fetch.py b/backend/finance/api/reports/fetch.py index ab2528829..024b554d0 100644 --- a/backend/finance/api/reports/fetch.py +++ b/backend/finance/api/reports/fetch.py @@ -2,7 +2,7 @@ from django.shortcuts import render from backend.models import MonthlyReport -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest def fetch_reports_endpoint(request: WebRequest): diff --git a/backend/finance/api/reports/generate.py b/backend/finance/api/reports/generate.py index 4968ef8d1..cdb750dac 100644 --- a/backend/finance/api/reports/generate.py +++ b/backend/finance/api/reports/generate.py @@ -1,9 +1,9 @@ from django.contrib import messages from django.shortcuts import render -from backend.decorators import web_require_scopes -from backend.core.service.reports.generate import generate_report -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes +from backend.finance.service.reports.generate import generate_report +from core.types.requests import WebRequest @web_require_scopes("invoices:write", True, True) @@ -16,10 +16,10 @@ def generate_report_endpoint(request: WebRequest): if generated_report.failed: messages.error(request, generated_report.error) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") messages.success(request, f"Successfully generated report ({str(generated_report.response.uuid)[:4]})") - resp = render(request, "base/toast.html") + resp = render(request, "core/base/toast.html") resp["HX-Trigger"] = "refresh_reports_table" return resp diff --git a/backend/finance/models.py b/backend/finance/models.py index 67da42c99..3e2c01695 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -3,15 +3,22 @@ from decimal import Decimal from typing import Literal from uuid import uuid4 + +from core.models import DefaultValuesBase from django.core.validators import MaxValueValidator from django.db import models from django.utils import timezone from shortuuid.django_fields import ShortUUIDField -from backend.clients.models import Client, DefaultValues +from backend.data.default_email_templates import ( + recurring_invoices_invoice_created_default_email_template, + recurring_invoices_invoice_overdue_default_email_template, + recurring_invoices_invoice_cancelled_default_email_template, +) +from backend.models import Client from backend.managers import InvoiceRecurringProfile_WithItemsManager -from backend.core.models import OwnerBase, UserSettings, _private_storage, USER_OR_ORGANIZATION_CONSTRAINT, User, ExpiresBase, Organization +from backend.models import OwnerBase, UserSettings, _private_storage, USER_OR_ORGANIZATION_CONSTRAINT, User, ExpiresBase, Organization class BotoSchedule(models.Model): @@ -288,7 +295,7 @@ def next_invoice_issue_date(self) -> date: case _: return datetime.now().date() - def next_invoice_due_date(self, account_defaults: DefaultValues, from_date: date = datetime.now().date()) -> date: + def next_invoice_due_date(self, account_defaults: FinanceDefaultValues, from_date: date = datetime.now().date()) -> date: match account_defaults.invoice_due_date_type: case account_defaults.InvoiceDueDateType.days_after: return from_date + timedelta(days=account_defaults.invoice_due_date_value) @@ -405,3 +412,76 @@ class ReceiptDownloadToken(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) file = models.ForeignKey(Receipt, on_delete=models.CASCADE) token = models.UUIDField(default=uuid4, editable=False, unique=True) + + +class FinanceDefaultValues(DefaultValuesBase): + class InvoiceDueDateType(models.TextChoices): + days_after = "days_after" + date_following = "date_following" + date_current = "date_current" + + class InvoiceDateType(models.TextChoices): + day_of_month = "day_of_month" + days_after = "days_after" + + invoice_due_date_value = models.PositiveSmallIntegerField(default=7, null=False, blank=False) + invoice_due_date_type = models.CharField( + max_length=20, + choices=InvoiceDueDateType.choices, + default=InvoiceDueDateType.days_after, + ) + + invoice_date_value = models.PositiveSmallIntegerField(default=15, null=False, blank=False) + invoice_date_type = models.CharField( + max_length=20, + choices=InvoiceDateType.choices, + default=InvoiceDateType.day_of_month, + ) + + invoice_from_name = models.CharField(max_length=100, null=True, blank=True) + invoice_from_company = models.CharField(max_length=100, null=True, blank=True) + invoice_from_address = models.CharField(max_length=100, null=True, blank=True) + invoice_from_city = models.CharField(max_length=100, null=True, blank=True) + invoice_from_county = models.CharField(max_length=100, null=True, blank=True) + invoice_from_country = models.CharField(max_length=100, null=True, blank=True) + invoice_from_email = models.CharField(max_length=100, null=True, blank=True) + + invoice_account_number = models.CharField(max_length=100, null=True, blank=True) + invoice_sort_code = models.CharField(max_length=100, null=True, blank=True) + invoice_account_holder_name = models.CharField(max_length=100, null=True, blank=True) + + email_template_recurring_invoices_invoice_created = models.TextField(default=recurring_invoices_invoice_created_default_email_template) + email_template_recurring_invoices_invoice_overdue = models.TextField(default=recurring_invoices_invoice_overdue_default_email_template) + email_template_recurring_invoices_invoice_cancelled = models.TextField( + default=recurring_invoices_invoice_cancelled_default_email_template + ) + + default_invoice_logo = models.ImageField( + upload_to="invoice_logos/", + storage=_private_storage, + blank=True, + null=True, + ) + + def get_issue_and_due_dates(self, issue_date: date | str | None = None) -> tuple[str, str]: + due: date + issue: date + + if isinstance(issue_date, str): + issue = date.fromisoformat(issue_date) or date.today() + else: + issue = issue_date or date.today() + + match self.invoice_due_date_type: + case self.InvoiceDueDateType.days_after: + due = issue + timedelta(days=self.invoice_due_date_value) + case self.InvoiceDueDateType.date_following: + due = date(issue.year, issue.month + 1, self.invoice_due_date_value) + case self.InvoiceDueDateType.date_current: + due = date(issue.year, issue.month, self.invoice_due_date_value) + case _: + raise ValueError("Invalid invoice due date type") + return date.isoformat(issue), date.isoformat(due) + + class Meta: + constraints: list = [USER_OR_ORGANIZATION_CONSTRAINT()] diff --git a/backend/core/api/settings/__init__.py b/backend/finance/service/__init__.py similarity index 100% rename from backend/core/api/settings/__init__.py rename to backend/finance/service/__init__.py diff --git a/backend/core/api/teams/__init__.py b/backend/finance/service/clients/__init__.py similarity index 100% rename from backend/core/api/teams/__init__.py rename to backend/finance/service/clients/__init__.py diff --git a/backend/core/service/clients/create.py b/backend/finance/service/clients/create.py similarity index 87% rename from backend/core/service/clients/create.py rename to backend/finance/service/clients/create.py index 788e65cec..2a37fc104 100644 --- a/backend/core/service/clients/create.py +++ b/backend/finance/service/clients/create.py @@ -1,6 +1,6 @@ -from backend.clients.models import Client -from backend.core.service.clients.validate import validate_client_create -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.models import Client +from backend.finance.service.clients.validate import validate_client_create +from core.utils.dataclasses import BaseServiceResponse class CreateClientServiceResponse(BaseServiceResponse[Client]): ... diff --git a/backend/core/service/clients/delete.py b/backend/finance/service/clients/delete.py similarity index 88% rename from backend/core/service/clients/delete.py rename to backend/finance/service/clients/delete.py index 01265b0a4..b60727bcc 100644 --- a/backend/core/service/clients/delete.py +++ b/backend/finance/service/clients/delete.py @@ -1,8 +1,8 @@ -from backend.core.service.clients.validate import validate_client +from backend.finance.service.clients.validate import validate_client from django.core.exceptions import ValidationError, PermissionDenied from backend.models import Client, AuditLog -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse class DeleteClientServiceResponse(BaseServiceResponse[None]): diff --git a/backend/core/service/clients/get.py b/backend/finance/service/clients/get.py similarity index 91% rename from backend/core/service/clients/get.py rename to backend/finance/service/clients/get.py index 8d87f2ec3..3db1115ac 100644 --- a/backend/core/service/clients/get.py +++ b/backend/finance/service/clients/get.py @@ -1,7 +1,7 @@ from django.db.models import Q, QuerySet from backend.models import Client, Organization -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse class FetchClientServiceResponse(BaseServiceResponse[QuerySet[Client]]): ... diff --git a/backend/core/service/clients/validate.py b/backend/finance/service/clients/validate.py similarity index 100% rename from backend/core/service/clients/validate.py rename to backend/finance/service/clients/validate.py diff --git a/backend/core/data/__init__.py b/backend/finance/service/defaults/__init__.py similarity index 100% rename from backend/core/data/__init__.py rename to backend/finance/service/defaults/__init__.py diff --git a/backend/finance/service/defaults/get.py b/backend/finance/service/defaults/get.py new file mode 100644 index 000000000..7cde191c1 --- /dev/null +++ b/backend/finance/service/defaults/get.py @@ -0,0 +1,12 @@ +from backend.models import User, Organization +from backend.models import FinanceDefaultValues, Client + + +def get_account_defaults(actor: User | Organization, client: Client | None = None) -> FinanceDefaultValues: + if not client: + account_defaults = FinanceDefaultValues.filter_by_owner(owner=actor).filter(client__isnull=True).first() + + if account_defaults: + return account_defaults + return FinanceDefaultValues.objects.create(owner=actor, client=None) # type: ignore[misc] + return FinanceDefaultValues.filter_by_owner(owner=actor).get(client=client) diff --git a/backend/core/service/defaults/update.py b/backend/finance/service/defaults/update.py similarity index 93% rename from backend/core/service/defaults/update.py rename to backend/finance/service/defaults/update.py index 55072044f..4428c839c 100644 --- a/backend/core/service/defaults/update.py +++ b/backend/finance/service/defaults/update.py @@ -1,15 +1,14 @@ from PIL import Image -from backend.models import DefaultValues -from backend.core.types.requests import WebRequest -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.models import FinanceDefaultValues +from core.types.requests import WebRequest +from core.utils.dataclasses import BaseServiceResponse -class ClientDefaultsServiceResponse(BaseServiceResponse[DefaultValues]): ... +class ClientDefaultsServiceResponse(BaseServiceResponse[FinanceDefaultValues]): ... -def change_client_defaults(request: WebRequest, defaults: DefaultValues) -> ClientDefaultsServiceResponse: - +def change_client_defaults(request: WebRequest, defaults: FinanceDefaultValues) -> ClientDefaultsServiceResponse: # put = QueryDict(request.body) invoice_due_date_option = request.POST.get("invoice_due_date_option", "") invoice_due_date_value = request.POST.get("invoice_due_date_value", "") diff --git a/backend/core/management/__init__.py b/backend/finance/service/invoices/__init__.py similarity index 100% rename from backend/core/management/__init__.py rename to backend/finance/service/invoices/__init__.py diff --git a/backend/core/management/commands/__init__.py b/backend/finance/service/invoices/common/__init__.py similarity index 100% rename from backend/core/management/commands/__init__.py rename to backend/finance/service/invoices/common/__init__.py diff --git a/backend/core/management/scheduled_tasks/__init__.py b/backend/finance/service/invoices/common/create/__init__.py similarity index 100% rename from backend/core/management/scheduled_tasks/__init__.py rename to backend/finance/service/invoices/common/create/__init__.py diff --git a/backend/core/service/invoices/common/create/create.py b/backend/finance/service/invoices/common/create/create.py similarity index 90% rename from backend/core/service/invoices/common/create/create.py rename to backend/finance/service/invoices/common/create/create.py index 43588dd76..e45884423 100644 --- a/backend/core/service/invoices/common/create/create.py +++ b/backend/finance/service/invoices/common/create/create.py @@ -1,8 +1,8 @@ from django.contrib import messages -from backend.models import Invoice, InvoiceRecurringProfile, InvoiceItem, Client, QuotaUsage, DefaultValues -from backend.core.service.defaults.get import get_account_defaults -from backend.core.types.requests import WebRequest +from backend.models import Invoice, InvoiceRecurringProfile, InvoiceItem, Client, FinanceDefaultValues +from backend.finance.service.defaults.get import get_account_defaults +from core.types.requests import WebRequest def create_invoice_items(request: WebRequest): @@ -55,7 +55,7 @@ def save_invoice_common(request: WebRequest, invoice_items, invoice: Invoice | I if invoice.client_to is not None and invoice.client_to.default_values.default_invoice_logo: invoice.logo = invoice.client_to.default_values.default_invoice_logo else: - defaults: DefaultValues = get_account_defaults(request.actor) + defaults: FinanceDefaultValues = get_account_defaults(request.actor) if defaults: invoice.logo = defaults.default_invoice_logo invoice.sort_code = request.POST.get("sort_code") @@ -65,6 +65,6 @@ def save_invoice_common(request: WebRequest, invoice_items, invoice: Invoice | I invoice.save() invoice.items.set(invoice_items) - QuotaUsage.create_str(request.user, "invoices-count", invoice.id) + # QuotaUsage.create_str(request.user, "invoices-count", invoice.id) return invoice diff --git a/backend/core/service/invoices/common/create/get_page.py b/backend/finance/service/invoices/common/create/get_page.py similarity index 89% rename from backend/core/service/invoices/common/create/get_page.py rename to backend/finance/service/invoices/common/create/get_page.py index a3d5cf63e..0d0034d58 100644 --- a/backend/core/service/invoices/common/create/get_page.py +++ b/backend/finance/service/invoices/common/create/get_page.py @@ -2,15 +2,15 @@ from typing import NamedTuple from django.core.exceptions import PermissionDenied, ValidationError -from backend.models import Client, InvoiceProduct, DefaultValues -from backend.core.service.clients.validate import validate_client -from backend.core.service.defaults.get import get_account_defaults -from backend.core.types.requests import WebRequest -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.models import Client, InvoiceProduct, FinanceDefaultValues +from backend.finance.service.clients.validate import validate_client +from backend.finance.service.defaults.get import get_account_defaults +from core.types.requests import WebRequest +from core.utils.dataclasses import BaseServiceResponse class CreateInvoiceContextTuple(NamedTuple): - defaults: DefaultValues + defaults: FinanceDefaultValues context: dict @@ -23,7 +23,7 @@ def global_get_invoice_context(request: WebRequest) -> CreateInvoiceContextServi "existing_products": InvoiceProduct.objects.filter(user=request.user), } - defaults: DefaultValues + defaults: FinanceDefaultValues if client_id := request.GET.get("client"): try: diff --git a/backend/core/service/api_keys/__init__.py b/backend/finance/service/invoices/common/create/services/__init__.py similarity index 100% rename from backend/core/service/api_keys/__init__.py rename to backend/finance/service/invoices/common/create/services/__init__.py diff --git a/backend/core/service/invoices/common/create/services/add.py b/backend/finance/service/invoices/common/create/services/add.py similarity index 94% rename from backend/core/service/invoices/common/create/services/add.py rename to backend/finance/service/invoices/common/create/services/add.py index 11f56502b..42f0fb36b 100644 --- a/backend/core/service/invoices/common/create/services/add.py +++ b/backend/finance/service/invoices/common/create/services/add.py @@ -1,9 +1,9 @@ from django.http import JsonResponse -from backend.core.api.public.types import APIRequest -from backend.core.types.requests import WebRequest +from core.api.public.types import APIRequest +from core.types.requests import WebRequest from backend.finance.models import InvoiceProduct -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest def add(request: APIRequest | WebRequest): diff --git a/backend/core/service/asyn_tasks/__init__.py b/backend/finance/service/invoices/common/emails/__init__.py similarity index 100% rename from backend/core/service/asyn_tasks/__init__.py rename to backend/finance/service/invoices/common/emails/__init__.py diff --git a/backend/core/service/invoices/common/emails/on_create.py b/backend/finance/service/invoices/common/emails/on_create.py similarity index 87% rename from backend/core/service/invoices/common/emails/on_create.py rename to backend/finance/service/invoices/common/emails/on_create.py index f2a53a3ae..189071c4b 100644 --- a/backend/core/service/invoices/common/emails/on_create.py +++ b/backend/finance/service/invoices/common/emails/on_create.py @@ -2,12 +2,12 @@ from django.urls import reverse -from backend.core.data.default_email_templates import email_footer +from backend.data.default_email_templates import email_footer from backend.models import Invoice, EmailSendStatus, InvoiceURL -from backend.core.service.defaults.get import get_account_defaults -from backend.core.service.invoices.single.create_url import create_invoice_url -from backend.core.utils.dataclasses import BaseServiceResponse -from backend.core.utils.service_retry import retry_handler +from backend.finance.service.defaults.get import get_account_defaults +from backend.finance.service.invoices.single.create_url import create_invoice_url +from core.utils.dataclasses import BaseServiceResponse +from core.utils.service_retry import retry_handler from settings.helpers import send_email, get_var """ diff --git a/backend/core/service/invoices/common/fetch.py b/backend/finance/service/invoices/common/fetch.py similarity index 100% rename from backend/core/service/invoices/common/fetch.py rename to backend/finance/service/invoices/common/fetch.py diff --git a/backend/core/service/invoices/handler.py b/backend/finance/service/invoices/handler.py similarity index 100% rename from backend/core/service/invoices/handler.py rename to backend/finance/service/invoices/handler.py diff --git a/backend/core/service/base/__init__.py b/backend/finance/service/invoices/recurring/__init__.py similarity index 100% rename from backend/core/service/base/__init__.py rename to backend/finance/service/invoices/recurring/__init__.py diff --git a/backend/core/service/boto3/__init__.py b/backend/finance/service/invoices/recurring/create/__init__.py similarity index 100% rename from backend/core/service/boto3/__init__.py rename to backend/finance/service/invoices/recurring/create/__init__.py diff --git a/backend/core/service/invoices/recurring/create/get_page.py b/backend/finance/service/invoices/recurring/create/get_page.py similarity index 87% rename from backend/core/service/invoices/recurring/create/get_page.py rename to backend/finance/service/invoices/recurring/create/get_page.py index 32d48e872..053a1803b 100644 --- a/backend/core/service/invoices/recurring/create/get_page.py +++ b/backend/finance/service/invoices/recurring/create/get_page.py @@ -1,7 +1,7 @@ from datetime import date -from backend.core.service.invoices.common.create.get_page import global_get_invoice_context -from backend.core.types.requests import WebRequest +from backend.finance.service.invoices.common.create.get_page import global_get_invoice_context +from core.types.requests import WebRequest def get_invoice_context(request: WebRequest) -> dict: diff --git a/backend/core/service/invoices/recurring/create/save.py b/backend/finance/service/invoices/recurring/create/save.py similarity index 85% rename from backend/core/service/invoices/recurring/create/save.py rename to backend/finance/service/invoices/recurring/create/save.py index f9c2fac60..aae318609 100644 --- a/backend/core/service/invoices/recurring/create/save.py +++ b/backend/finance/service/invoices/recurring/create/save.py @@ -3,11 +3,11 @@ from django.contrib import messages from django.core.exceptions import ValidationError -from backend.models import InvoiceRecurringProfile, QuotaUsage -from backend.core.service.invoices.common.create.create import save_invoice_common -from backend.core.service.invoices.recurring.validate.frequencies import validate_and_update_frequency -from backend.core.types.requests import WebRequest -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.models import InvoiceRecurringProfile +from backend.finance.service.invoices.common.create.create import save_invoice_common +from backend.finance.service.invoices.recurring.validate.frequencies import validate_and_update_frequency +from core.types.requests import WebRequest +from core.utils.dataclasses import BaseServiceResponse class SaveInvoiceServiceResponse(BaseServiceResponse[InvoiceRecurringProfile]): ... @@ -69,6 +69,6 @@ def save_invoice(request: WebRequest, invoice_items) -> SaveInvoiceServiceRespon invoice_profile.save() invoice_profile.items.set(invoice_items) - QuotaUsage.create_str(request.user, "invoices-count", invoice_profile.id) + # QuotaUsage.create_str(request.user, "invoices-count", invoice_profile.id) return SaveInvoiceServiceResponse(True, invoice_profile) diff --git a/backend/core/service/boto3/scheduler/__init__.py b/backend/finance/service/invoices/recurring/generation/__init__.py similarity index 100% rename from backend/core/service/boto3/scheduler/__init__.py rename to backend/finance/service/invoices/recurring/generation/__init__.py diff --git a/backend/core/service/invoices/recurring/generation/next_invoice.py b/backend/finance/service/invoices/recurring/generation/next_invoice.py similarity index 92% rename from backend/core/service/invoices/recurring/generation/next_invoice.py rename to backend/finance/service/invoices/recurring/generation/next_invoice.py index 0c041af41..56fb3a2aa 100644 --- a/backend/core/service/invoices/recurring/generation/next_invoice.py +++ b/backend/finance/service/invoices/recurring/generation/next_invoice.py @@ -2,10 +2,10 @@ from django.db import transaction, IntegrityError -from backend.models import Invoice, InvoiceRecurringProfile, DefaultValues, AuditLog -from backend.core.service.defaults.get import get_account_defaults -from backend.core.service.invoices.common.emails.on_create import on_create_invoice_email_service -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.models import Invoice, InvoiceRecurringProfile, FinanceDefaultValues, AuditLog +from backend.finance.service.defaults.get import get_account_defaults +from backend.finance.service.invoices.common.emails.on_create import on_create_invoice_email_service +from core.utils.dataclasses import BaseServiceResponse import logging @@ -19,7 +19,7 @@ class GenerateNextInvoiceServiceResponse(BaseServiceResponse[Invoice]): ... def generate_next_invoice_service( invoice_recurring_profile: InvoiceRecurringProfile, issue_date: date = date.today(), - account_defaults: DefaultValues | None = None, + account_defaults: FinanceDefaultValues | None = None, ) -> GenerateNextInvoiceServiceResponse: """ This will generate the next single invoice based on the invoice recurring profile @@ -114,7 +114,7 @@ def handle_invoice_generation_failure(invoice_recurring_profile, error_message): def safe_generate_next_invoice_service( invoice_recurring_profile: InvoiceRecurringProfile, issue_date: date = date.today(), - account_defaults: DefaultValues | None = None, + account_defaults: FinanceDefaultValues | None = None, ) -> GenerateNextInvoiceServiceResponse: """ Safe wrapper to generate the next invoice with transaction rollback and error logging. diff --git a/backend/core/service/invoices/recurring/get.py b/backend/finance/service/invoices/recurring/get.py similarity index 88% rename from backend/core/service/invoices/recurring/get.py rename to backend/finance/service/invoices/recurring/get.py index a2cfde003..1ec817404 100644 --- a/backend/core/service/invoices/recurring/get.py +++ b/backend/finance/service/invoices/recurring/get.py @@ -1,6 +1,6 @@ from backend.finance.models import InvoiceRecurringProfile -from backend.core.types.requests import WebRequest -from backend.core.utils.dataclasses import BaseServiceResponse +from core.types.requests import WebRequest +from core.utils.dataclasses import BaseServiceResponse class GetRecurringSetServiceResponse(BaseServiceResponse[InvoiceRecurringProfile]): ... diff --git a/backend/core/service/clients/__init__.py b/backend/finance/service/invoices/recurring/schedules/__init__.py similarity index 100% rename from backend/core/service/clients/__init__.py rename to backend/finance/service/invoices/recurring/schedules/__init__.py diff --git a/backend/core/service/invoices/recurring/schedules/date_handlers.py b/backend/finance/service/invoices/recurring/schedules/date_handlers.py similarity index 98% rename from backend/core/service/invoices/recurring/schedules/date_handlers.py rename to backend/finance/service/invoices/recurring/schedules/date_handlers.py index bab910cf0..223a1e218 100644 --- a/backend/core/service/invoices/recurring/schedules/date_handlers.py +++ b/backend/finance/service/invoices/recurring/schedules/date_handlers.py @@ -1,4 +1,4 @@ -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse from datetime import date as Date diff --git a/backend/core/service/defaults/__init__.py b/backend/finance/service/invoices/recurring/validate/__init__.py similarity index 100% rename from backend/core/service/defaults/__init__.py rename to backend/finance/service/invoices/recurring/validate/__init__.py diff --git a/backend/core/service/invoices/recurring/validate/frequencies.py b/backend/finance/service/invoices/recurring/validate/frequencies.py similarity index 97% rename from backend/core/service/invoices/recurring/validate/frequencies.py rename to backend/finance/service/invoices/recurring/validate/frequencies.py index 515330f25..21416f8d9 100644 --- a/backend/core/service/invoices/recurring/validate/frequencies.py +++ b/backend/finance/service/invoices/recurring/validate/frequencies.py @@ -1,5 +1,5 @@ from backend.finance.models import InvoiceRecurringProfile -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse class ValidateFrequencyServiceResponse(BaseServiceResponse[None]): diff --git a/backend/core/service/file_storage/__init__.py b/backend/finance/service/invoices/single/__init__.py similarity index 100% rename from backend/core/service/file_storage/__init__.py rename to backend/finance/service/invoices/single/__init__.py diff --git a/backend/core/service/invoices/__init__.py b/backend/finance/service/invoices/single/create/__init__.py similarity index 100% rename from backend/core/service/invoices/__init__.py rename to backend/finance/service/invoices/single/create/__init__.py diff --git a/backend/core/service/invoices/single/create/create.py b/backend/finance/service/invoices/single/create/create.py similarity index 86% rename from backend/core/service/invoices/single/create/create.py rename to backend/finance/service/invoices/single/create/create.py index 1eaca8105..e16be54dc 100644 --- a/backend/core/service/invoices/single/create/create.py +++ b/backend/finance/service/invoices/single/create/create.py @@ -3,12 +3,13 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied, ValidationError -from backend.finance.models import Invoice, InvoiceItem, Client, InvoiceProduct, DefaultValues -from backend.models import QuotaUsage -from backend.core.service.clients.validate import validate_client -from backend.core.service.defaults.get import get_account_defaults -from backend.core.service.invoices.common.create.create import save_invoice_common -from backend.core.types.requests import WebRequest +from backend.models import Invoice, InvoiceItem, Client, InvoiceProduct, FinanceDefaultValues + +# from backend.models import QuotaUsage +from backend.finance.service.clients.validate import validate_client +from backend.finance.service.defaults.get import get_account_defaults +from backend.finance.service.invoices.common.create.create import save_invoice_common +from core.types.requests import WebRequest def get_invoice_context(request: WebRequest) -> dict: @@ -17,7 +18,7 @@ def get_invoice_context(request: WebRequest) -> dict: "existing_products": InvoiceProduct.objects.filter(user=request.user), } - defaults: DefaultValues + defaults: FinanceDefaultValues if client_id := request.GET.get("client"): try: @@ -99,6 +100,6 @@ def save_invoice(request: WebRequest, invoice_items): invoice.save() invoice.items.set(invoice_items) - QuotaUsage.create_str(request.user, "invoices-count", invoice.id) + # QuotaUsage.create_str(request.user, "invoices-count", invoice.id) return invoice diff --git a/backend/core/service/invoices/single/create/get_page.py b/backend/finance/service/invoices/single/create/get_page.py similarity index 81% rename from backend/core/service/invoices/single/create/get_page.py rename to backend/finance/service/invoices/single/create/get_page.py index 322551849..491bf3e34 100644 --- a/backend/core/service/invoices/single/create/get_page.py +++ b/backend/finance/service/invoices/single/create/get_page.py @@ -1,7 +1,7 @@ from datetime import date -from backend.core.service.invoices.common.create.get_page import global_get_invoice_context -from backend.core.types.requests import WebRequest +from backend.finance.service.invoices.common.create.get_page import global_get_invoice_context +from core.types.requests import WebRequest def get_invoice_context(request: WebRequest) -> dict: diff --git a/backend/core/service/invoices/single/create_pdf.py b/backend/finance/service/invoices/single/create_pdf.py similarity index 100% rename from backend/core/service/invoices/single/create_pdf.py rename to backend/finance/service/invoices/single/create_pdf.py diff --git a/backend/core/service/invoices/single/create_url.py b/backend/finance/service/invoices/single/create_url.py similarity index 80% rename from backend/core/service/invoices/single/create_url.py rename to backend/finance/service/invoices/single/create_url.py index 92250713d..5fe60c5c2 100644 --- a/backend/core/service/invoices/single/create_url.py +++ b/backend/finance/service/invoices/single/create_url.py @@ -1,6 +1,6 @@ from backend.finance.models import InvoiceURL, Invoice -from backend.core.models import User -from backend.core.utils.dataclasses import BaseServiceResponse +from backend.models import User +from core.utils.dataclasses import BaseServiceResponse class CreateInvoiceURLServiceResponse(BaseServiceResponse[InvoiceURL]): ... diff --git a/backend/core/service/invoices/single/get_invoice.py b/backend/finance/service/invoices/single/get_invoice.py similarity index 91% rename from backend/core/service/invoices/single/get_invoice.py rename to backend/finance/service/invoices/single/get_invoice.py index 7789b93a2..b88aaad9a 100644 --- a/backend/core/service/invoices/single/get_invoice.py +++ b/backend/finance/service/invoices/single/get_invoice.py @@ -1,5 +1,5 @@ from backend.finance.models import Invoice, Organization, User -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse class GetInvoiceServiceResponse(BaseServiceResponse[Invoice]): ... diff --git a/backend/core/service/invoices/common/__init__.py b/backend/finance/service/reports/__init__.py similarity index 100% rename from backend/core/service/invoices/common/__init__.py rename to backend/finance/service/reports/__init__.py diff --git a/backend/core/service/reports/generate.py b/backend/finance/service/reports/generate.py similarity index 96% rename from backend/core/service/reports/generate.py rename to backend/finance/service/reports/generate.py index f58ef6e8e..bfd43c974 100644 --- a/backend/core/service/reports/generate.py +++ b/backend/finance/service/reports/generate.py @@ -4,7 +4,7 @@ from django.db import transaction from backend.models import User, Organization, Invoice, MonthlyReport, MonthlyReportRow -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse class GenerateReportServiceResponse(BaseServiceResponse[MonthlyReport]): ... diff --git a/backend/core/service/reports/get.py b/backend/finance/service/reports/get.py similarity index 87% rename from backend/core/service/reports/get.py rename to backend/finance/service/reports/get.py index a9f139f99..5cef36d4b 100644 --- a/backend/core/service/reports/get.py +++ b/backend/finance/service/reports/get.py @@ -1,5 +1,5 @@ from backend.models import MonthlyReport, User, Organization -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse class GetReportServiceResponse(BaseServiceResponse[MonthlyReport]): ... diff --git a/backend/finance/signals/billing.py b/backend/finance/signals/billing.py new file mode 100644 index 000000000..1c0734e32 --- /dev/null +++ b/backend/finance/signals/billing.py @@ -0,0 +1,23 @@ +from billing.models import BillingUsage +from django.db.models.signals import post_save +from django.dispatch import receiver + +from backend.models import Invoice + + +@receiver(post_save, sender=Invoice) +def created_invoice(sender, instance: Invoice, created, **kwargs): + if not created: + return + + BillingUsage.objects.create( + owner=instance.owner, + event_name="invoices_created", + ) + + if instance.invoice_recurring_profile: + BillingUsage.objects.create( + owner=instance.owner, + event_name="invoice_schedule_invocations", + ) + return diff --git a/backend/finance/signals/receipts.py b/backend/finance/signals/receipts.py new file mode 100644 index 000000000..ad7a49312 --- /dev/null +++ b/backend/finance/signals/receipts.py @@ -0,0 +1,14 @@ +from django.core.files.storage import default_storage +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +from backend.finance.models import Receipt + + +@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) + instance.image = None + instance.save() diff --git a/backend/finance/signals/schedules.py b/backend/finance/signals/schedules.py index 5a101fb7b..50847c6ac 100644 --- a/backend/finance/signals/schedules.py +++ b/backend/finance/signals/schedules.py @@ -3,8 +3,8 @@ from django.dispatch import receiver from django.db.models.signals import post_save -from backend.core.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.core.service.boto3.scheduler.update_schedule import update_boto_schedule +from backend.boto3.scheduler.create_schedule import create_boto_schedule +from backend.boto3.scheduler.update_schedule import update_boto_schedule from backend.finance.models import InvoiceRecurringProfile diff --git a/backend/finance/views/invoices/handler.py b/backend/finance/views/invoices/handler.py index 73cd09c27..afb983a50 100644 --- a/backend/finance/views/invoices/handler.py +++ b/backend/finance/views/invoices/handler.py @@ -3,7 +3,7 @@ from django.http import HttpResponse from django.shortcuts import render -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest def invoices_core_handler(request: WebRequest, template_name: str, start_context: Dict[str, Any] | None = None, **kwargs) -> HttpResponse: diff --git a/backend/finance/views/invoices/recurring/create.py b/backend/finance/views/invoices/recurring/create.py index abc4f086a..d6bcdfdef 100644 --- a/backend/finance/views/invoices/recurring/create.py +++ b/backend/finance/views/invoices/recurring/create.py @@ -3,14 +3,14 @@ from django.views.decorators.http import require_http_methods from backend.finance.models import InvoiceRecurringProfile -from backend.decorators import web_require_scopes -from backend.core.service import BOTO3_HANDLER -from backend.core.service.asyn_tasks.tasks import Task -from backend.core.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.core.service.invoices.common.create.create import create_invoice_items -from backend.core.service.invoices.recurring.create.get_page import get_invoice_context -from backend.core.service.invoices.recurring.create.save import save_invoice -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes +from backend.boto3.handler import BOTO3_HANDLER +from backend.boto3.async_tasks.tasks import Task +from backend.boto3.scheduler.create_schedule import create_boto_schedule +from backend.finance.service.invoices.common.create.create import create_invoice_items +from backend.finance.service.invoices.recurring.create.get_page import get_invoice_context +from backend.finance.service.invoices.recurring.create.save import save_invoice +from core.types.requests import WebRequest from backend.finance.views.invoices.handler import invoices_core_handler diff --git a/backend/finance/views/invoices/recurring/dashboard.py b/backend/finance/views/invoices/recurring/dashboard.py index 8ff75ff2b..564c8ae89 100644 --- a/backend/finance/views/invoices/recurring/dashboard.py +++ b/backend/finance/views/invoices/recurring/dashboard.py @@ -1,7 +1,7 @@ from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes, has_entitlements -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes, has_entitlements +from core.types.requests import WebRequest from backend.finance.views.invoices.handler import invoices_core_handler diff --git a/backend/finance/views/invoices/recurring/edit.py b/backend/finance/views/invoices/recurring/edit.py index a3493c534..496239b63 100644 --- a/backend/finance/views/invoices/recurring/edit.py +++ b/backend/finance/views/invoices/recurring/edit.py @@ -2,9 +2,9 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes, has_entitlements +from core.decorators import web_require_scopes, has_entitlements from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.invoices.recurring.get import get_invoice_profile +from backend.finance.service.invoices.recurring.get import get_invoice_profile from backend.finance.views.invoices.handler import invoices_core_handler @@ -71,7 +71,7 @@ def invoice_edit_page_endpoint(request, invoice_profile_id): if get_response.failed: messages.error(request, get_response.error_message) - return render(request, "base/toast.html") + return render(request, "core/base/toast.html") invoice_profile: InvoiceRecurringProfile = get_response.response diff --git a/backend/finance/views/invoices/recurring/overview.py b/backend/finance/views/invoices/recurring/overview.py index 8b2bb9010..ff5e77785 100644 --- a/backend/finance/views/invoices/recurring/overview.py +++ b/backend/finance/views/invoices/recurring/overview.py @@ -1,6 +1,6 @@ -from backend.decorators import * +from core.decorators import * from backend.models import * -from backend.core.service.defaults.get import get_account_defaults +from backend.finance.service.defaults.get import get_account_defaults from backend.finance.views.invoices.handler import invoices_core_handler diff --git a/backend/finance/views/invoices/single/create.py b/backend/finance/views/invoices/single/create.py index b77db574d..abe5d5ad0 100644 --- a/backend/finance/views/invoices/single/create.py +++ b/backend/finance/views/invoices/single/create.py @@ -1,10 +1,10 @@ from django.shortcuts import redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes, has_entitlements -from backend.core.service.invoices.single.create.create import create_invoice_items, save_invoice -from backend.core.service.invoices.single.create.get_page import get_invoice_context -from backend.core.types.requests import WebRequest +from core.decorators import web_require_scopes, has_entitlements +from backend.finance.service.invoices.single.create.create import create_invoice_items, save_invoice +from backend.finance.service.invoices.single.create.get_page import get_invoice_context +from core.types.requests import WebRequest from backend.finance.views.invoices.handler import invoices_core_handler diff --git a/backend/finance/views/invoices/single/dashboard.py b/backend/finance/views/invoices/single/dashboard.py index 96c9f51f6..7f1badbc7 100644 --- a/backend/finance/views/invoices/single/dashboard.py +++ b/backend/finance/views/invoices/single/dashboard.py @@ -2,9 +2,9 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import Invoice -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest from backend.finance.views.invoices.handler import invoices_core_handler diff --git a/backend/finance/views/invoices/single/edit.py b/backend/finance/views/invoices/single/edit.py index e6d509857..80d6790f9 100644 --- a/backend/finance/views/invoices/single/edit.py +++ b/backend/finance/views/invoices/single/edit.py @@ -5,10 +5,10 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods -from backend.core.types.requests import WebRequest -from backend.decorators import web_require_scopes +from core.types.requests import WebRequest +from core.decorators import web_require_scopes from backend.finance.models import Invoice, Client, InvoiceItem -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest # RELATED PATH FILES : \frontend\templates\pages\invoices\dashboard\_fetch_body.html, \backend\urls.py @@ -140,7 +140,8 @@ def edit_invoice(request: WebRequest, invoice_id): "client_city": request.POST.get("to_city"), "client_county": request.POST.get("to_county"), "client_country": request.POST.get("to_country"), - "client_is_representative": True if request.POST.get("is_representative") == "on" else False, # type: ignore[dict-item] + "client_is_representative": True if request.POST.get("is_representative") == "on" else False, + # type: ignore[dict-item] "client_to": None, } ) @@ -166,7 +167,7 @@ def edit_invoice(request: WebRequest, invoice_id): messages.success(request, "Invoice edited") if request.htmx: - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") return invoice_edit_page_get(request, invoice_id) diff --git a/backend/finance/views/invoices/single/manage_access.py b/backend/finance/views/invoices/single/manage_access.py index e5f3ea413..dc1d70fe4 100644 --- a/backend/finance/views/invoices/single/manage_access.py +++ b/backend/finance/views/invoices/single/manage_access.py @@ -2,11 +2,11 @@ from django.http import HttpResponse from django.shortcuts import redirect, render -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import Invoice, InvoiceURL -from backend.core.service.invoices.single.get_invoice import get_invoice_by_actor -from backend.core.types.htmx import HtmxHttpRequest -from backend.core.types.requests import WebRequest +from backend.finance.service.invoices.single.get_invoice import get_invoice_by_actor +from core.types.htmx import HtmxHttpRequest +from core.types.requests import WebRequest @web_require_scopes("invoices:write", False, False, "finance:invoices:single:dashboard") @@ -65,7 +65,7 @@ def delete_code(request: HtmxHttpRequest, code): raise InvoiceURL.DoesNotExist except (Invoice.DoesNotExist, InvoiceURL.DoesNotExist): messages.error(request, "Invalid URL") - return render(request, "base/toasts.html") + return render(request, "core/base/toasts.html") # QuotaLimit.delete_quota_usage("invoices-access_codes", request.user, invoice.id, code_obj.created_on) diff --git a/backend/finance/views/invoices/single/overview.py b/backend/finance/views/invoices/single/overview.py index f7ca164ac..90b509df1 100644 --- a/backend/finance/views/invoices/single/overview.py +++ b/backend/finance/views/invoices/single/overview.py @@ -1,6 +1,6 @@ from urllib.parse import urlencode -from backend.decorators import * +from core.decorators import * from backend.models import * from backend.finance.views.invoices.handler import invoices_core_handler diff --git a/backend/finance/views/invoices/single/schedule.py b/backend/finance/views/invoices/single/schedule.py index 39317965c..e4777d67c 100644 --- a/backend/finance/views/invoices/single/schedule.py +++ b/backend/finance/views/invoices/single/schedule.py @@ -2,7 +2,7 @@ # from django.http import HttpResponse # from django.shortcuts import render, redirect # -# from backend.decorators import feature_flag_check, web_require_scopes +# from core.decorators import feature_flag_check, web_require_scopes # from backend.finance.models import Invoice, QuotaLimit # from backend.types.htmx import HtmxHttpRequest # diff --git a/backend/finance/views/invoices/single/view.py b/backend/finance/views/invoices/single/view.py index f50daa1e3..3f9eec81f 100644 --- a/backend/finance/views/invoices/single/view.py +++ b/backend/finance/views/invoices/single/view.py @@ -7,9 +7,9 @@ from login_required import login_not_required -from backend.decorators import web_require_scopes +from core.decorators import web_require_scopes from backend.finance.models import Invoice, InvoiceURL -from backend.core.types.htmx import HtmxHttpRequest +from core.types.htmx import HtmxHttpRequest @web_require_scopes("invoices:read", False, False, "dashboard") diff --git a/backend/finance/views/receipts/dashboard.py b/backend/finance/views/receipts/dashboard.py index b810764e3..47109b46d 100644 --- a/backend/finance/views/receipts/dashboard.py +++ b/backend/finance/views/receipts/dashboard.py @@ -1,8 +1,8 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import render -from backend.decorators import web_require_scopes -from backend.core.types.htmx import HtmxHttpRequest +from core.decorators import web_require_scopes +from core.types.htmx import HtmxHttpRequest @login_required diff --git a/backend/finance/views/reports/dashboard.py b/backend/finance/views/reports/dashboard.py index 5ce098bd1..16728ea36 100644 --- a/backend/finance/views/reports/dashboard.py +++ b/backend/finance/views/reports/dashboard.py @@ -1,4 +1,4 @@ -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest from django.shortcuts import render diff --git a/backend/finance/views/reports/view.py b/backend/finance/views/reports/view.py index 1041ab29d..dd239bd05 100644 --- a/backend/finance/views/reports/view.py +++ b/backend/finance/views/reports/view.py @@ -1,7 +1,7 @@ from django.contrib import messages -from backend.core.service.reports.get import get_report -from backend.core.types.requests import WebRequest +from backend.finance.service.reports.get import get_report +from core.types.requests import WebRequest from django.shortcuts import render, redirect diff --git a/backend/core/service/invoices/common/create/__init__.py b/backend/management/__init__.py similarity index 100% rename from backend/core/service/invoices/common/create/__init__.py rename to backend/management/__init__.py diff --git a/backend/core/service/invoices/common/create/services/__init__.py b/backend/management/scheduled_tasks/__init__.py similarity index 100% rename from backend/core/service/invoices/common/create/services/__init__.py rename to backend/management/scheduled_tasks/__init__.py diff --git a/backend/core/management/scheduled_tasks/update_all_schedules.py b/backend/management/scheduled_tasks/update_all_schedules.py similarity index 80% rename from backend/core/management/scheduled_tasks/update_all_schedules.py rename to backend/management/scheduled_tasks/update_all_schedules.py index 02b55b1d6..188752b67 100644 --- a/backend/core/management/scheduled_tasks/update_all_schedules.py +++ b/backend/management/scheduled_tasks/update_all_schedules.py @@ -1,6 +1,7 @@ import threading + +from backend.boto3.scheduler.update_schedule import update_boto_schedule from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.boto3.scheduler.update_schedule import update_boto_schedule # thread = threading.Thread(target=self._send_message, args=(func_name, args, kwargs)) @@ -12,7 +13,7 @@ def refresh_all_schedules_statuses(): - print("REFRESHING ALL SCHEDULE STATUSES!!!!!!!!!!!!!!!!!!") + print("REFRESHING ALL SCHEDULE STATUSES") threads: list = [] all_recurring_profiles = InvoiceRecurringProfile.objects.filter(active=True).all() diff --git a/backend/middleware.py b/backend/middleware.py deleted file mode 100644 index 2e5ec6908..000000000 --- a/backend/middleware.py +++ /dev/null @@ -1,74 +0,0 @@ -from django.contrib.auth.models import AnonymousUser -from django.utils.deprecation import MiddlewareMixin -from django.contrib.auth import get_user -from django.db import connection, OperationalError -from django.http import HttpResponse - -from backend.models import User -from backend.core.types.htmx import HtmxAnyHttpRequest -from backend.core.types.requests import WebRequest - - -class HealthCheckMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - if request.path == "/api/hc/healthcheck/": - try: - status = connection.ensure_connection() - except OperationalError: - status = "error" - - if not status: # good - return HttpResponse(status=200, content="All operations are up and running!") - return HttpResponse(status=503, content="Service Unavailable") - return self.get_response(request) - - -class HTMXPartialLoadMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request: HtmxAnyHttpRequest): - response: HttpResponse = self.get_response(request) - - if hasattr(response, "retarget"): - response.headers["HX-Retarget"] = response.retarget - elif request.htmx.boosted and not response.headers.get("HX-Retarget") and not hasattr(response, "no_retarget"): - response.headers["HX-Retarget"] = "#main_content" - response.headers["HX-Reswap"] = "innerHTML" - # if 'data-layout="breadcrumbs"' not in str(response.content): - response.headers["HX-Trigger"] = "update_breadcrumbs" - return response - - -class LastVisitedMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - if request.method == "GET" and "text/html" in request.headers.get("Accept", ""): - try: - request.session["last_visited"] = request.session["currently_visiting"] - except KeyError: - pass - current_url = request.build_absolute_uri() - request.session["currently_visiting"] = current_url - return self.get_response(request) - - -class CustomUserMiddleware(MiddlewareMixin): - def process_request(self, request: WebRequest): - user = get_user(request) - - # Replace request.user with CustomUser instance if authenticated - if user.is_authenticated: - request.user = User.objects.get(pk=user.pk) - request.team = request.user.logged_in_as_team or None - request.team_id = request.team.id if request.team else None - request.actor = request.team or request.user - else: - # If user is not authenticated, set request.user to AnonymousUser - request.user = AnonymousUser() # type: ignore[assignment] - request.actor = request.user diff --git a/backend/migrations/0001_initial.py b/backend/migrations/0001_initial.py index f9cff14bb..02e7fd0e9 100644 --- a/backend/migrations/0001_initial.py +++ b/backend/migrations/0001_initial.py @@ -1,322 +1,305 @@ -# Generated by Django 4.2.5 on 2023-11-25 20:13 +# Generated by Django 5.1.4 on 2025-01-01 17:51 -import django.contrib.auth.validators -import django.utils.timezone +import backend.data.default_email_templates +import core.models +import django.core.validators +import django.db.models.deletion +import django.db.models.manager import shortuuid.django_fields +import uuid from django.conf import settings from django.db import migrations, models -import backend.models - class Migration(migrations.Migration): + initial = True dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0002_client"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name="User", + name="InvoiceItem", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=50)), + ("description", models.CharField(max_length=100)), + ("is_service", models.BooleanField(default=True)), + ("hours", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)), + ("price_per_hour", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)), + ("price", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)), + ], + ), + migrations.CreateModel( + name="FileStorageFile", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("file", models.FileField(storage=core.models._private_storage, upload_to=core.models.upload_to_user_separate_folder)), + ("file_uri_path", models.CharField(max_length=500)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + "last_edited_by", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="files_edited", + to=settings.AUTH_USER_MODEL, ), ), - ("password", models.CharField(max_length=128, verbose_name="password")), ( - "last_login", - models.DateTimeField(blank=True, null=True, verbose_name="last login"), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), + "user", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="FinanceDefaultValues", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ( - "username", + "currency", models.CharField( - error_messages={"unique": "A user with that username already exists."}, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], - verbose_name="username", + choices=[ + ("GBP", "British Pound Sterling"), + ("EUR", "Euro"), + ("USD", "United States Dollar"), + ("JPY", "Japanese Yen"), + ("INR", "Indian Rupee"), + ("AUD", "Australian Dollar"), + ("CAD", "Canadian Dollar"), + ], + default="GBP", + max_length=3, ), ), + ("invoice_due_date_value", models.PositiveSmallIntegerField(default=7)), ( - "first_name", - models.CharField(blank=True, max_length=150, verbose_name="first name"), + "invoice_due_date_type", + models.CharField( + choices=[("days_after", "Days After"), ("date_following", "Date Following"), ("date_current", "Date Current")], + default="days_after", + max_length=20, + ), ), + ("invoice_date_value", models.PositiveSmallIntegerField(default=15)), ( - "last_name", - models.CharField(blank=True, max_length=150, verbose_name="last name"), + "invoice_date_type", + models.CharField( + choices=[("day_of_month", "Day Of Month"), ("days_after", "Days After")], default="day_of_month", max_length=20 + ), ), + ("invoice_from_name", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_from_company", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_from_address", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_from_city", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_from_county", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_from_country", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_from_email", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_account_number", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_sort_code", models.CharField(blank=True, max_length=100, null=True)), + ("invoice_account_holder_name", models.CharField(blank=True, max_length=100, null=True)), ( - "email", - models.EmailField(blank=True, max_length=254, verbose_name="email address"), + "email_template_recurring_invoices_invoice_created", + models.TextField( + default=backend.data.default_email_templates.recurring_invoices_invoice_created_default_email_template + ), ), ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", + "email_template_recurring_invoices_invoice_overdue", + models.TextField( + default=backend.data.default_email_templates.recurring_invoices_invoice_overdue_default_email_template ), ), ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", + "email_template_recurring_invoices_invoice_cancelled", + models.TextField( + default=backend.data.default_email_templates.recurring_invoices_invoice_cancelled_default_email_template ), ), ( - "date_joined", - models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"), + "default_invoice_logo", + models.ImageField(blank=True, null=True, storage=core.models._private_storage, upload_to="invoice_logos/"), ), ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", + "client", + models.OneToOneField( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="default_values", to="core.client" ), ), ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), + ), + ( + "user", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), - ], - options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, - }, - managers=[ - ("objects", backend.core.models.CustomUserManager()), ], ), migrations.CreateModel( - name="Client", + name="InvoiceProduct", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=50)), + ("description", models.CharField(max_length=100)), + ("quantity", models.IntegerField()), + ("rate", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("active", models.BooleanField(default=True)), - ("name", models.CharField(max_length=64)), - ( - "phone_number", - models.CharField(blank=True, max_length=100, null=True), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), - ("email", models.EmailField(blank=True, max_length=254, null=True)), - ("address", models.CharField(blank=True, max_length=100, null=True)), - ("city", models.CharField(blank=True, max_length=100, null=True)), - ("country", models.CharField(blank=True, max_length=100, null=True)), ( "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), ], + options={ + "abstract": False, + }, ), migrations.CreateModel( - name="Invoice", + name="InvoiceRecurringProfile", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("boto_schedule_arn", models.CharField(blank=True, max_length=2048, null=True)), + ("boto_schedule_uuid", models.UUIDField(blank=True, default=None, null=True)), + ("boto_last_updated", models.DateTimeField(auto_now=True)), + ("received", models.BooleanField(default=False)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + "boto_schedule_status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("creating", "Creating"), + ("completed", "Completed"), + ("failed", "Failed"), + ("deleting", "Deleting"), + ("cancelled", "Cancelled"), + ], + default="pending", + max_length=100, ), ), - ("invoice_id", models.IntegerField(blank=True, null=True, unique=True)), - ( - "client_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "client_company", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "client_address", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "client_city", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "client_county", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "client_country", - models.CharField(blank=True, max_length=100, null=True), - ), + ("client_name", models.CharField(blank=True, max_length=100, null=True)), + ("client_email", models.EmailField(blank=True, max_length=254, null=True)), + ("client_company", models.CharField(blank=True, max_length=100, null=True)), + ("client_address", models.CharField(blank=True, max_length=100, null=True)), + ("client_city", models.CharField(blank=True, max_length=100, null=True)), + ("client_county", models.CharField(blank=True, max_length=100, null=True)), + ("client_country", models.CharField(blank=True, max_length=100, null=True)), + ("client_is_representative", models.BooleanField(default=False)), ("self_name", models.CharField(blank=True, max_length=100, null=True)), - ( - "self_company", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "self_address", - models.CharField(blank=True, max_length=100, null=True), - ), + ("self_company", models.CharField(blank=True, max_length=100, null=True)), + ("self_address", models.CharField(blank=True, max_length=100, null=True)), ("self_city", models.CharField(blank=True, max_length=100, null=True)), - ( - "self_county", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "self_country", - models.CharField(blank=True, max_length=100, null=True), - ), - ("sort_code", models.CharField(blank=True, max_length=100, null=True)), - ( - "account_holder_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "account_number", - models.CharField(blank=True, max_length=100, null=True), - ), - ("reference", models.CharField(blank=True, max_length=100, null=True)), - ( - "invoice_number", - models.CharField(blank=True, max_length=100, null=True), - ), + ("self_county", models.CharField(blank=True, max_length=100, null=True)), + ("self_country", models.CharField(blank=True, max_length=100, null=True)), + ("sort_code", models.CharField(blank=True, max_length=8, null=True)), + ("account_holder_name", models.CharField(blank=True, max_length=100, null=True)), + ("account_number", models.CharField(blank=True, max_length=100, null=True)), ("vat_number", models.CharField(blank=True, max_length=100, null=True)), - ( - "logo", - models.ImageField(blank=True, null=True, upload_to="invoice_logos"), - ), + ("logo", models.ImageField(blank=True, null=True, storage=core.models._private_storage, upload_to="invoice_logos")), ("notes", models.TextField(blank=True, null=True)), - ("date_created", models.DateTimeField(auto_now_add=True)), - ("date_due", models.DateField()), - ("date_issued", models.DateField(blank=True, null=True)), ( - "payment_status", + "currency", models.CharField( choices=[ - ("pending", "Pending"), - ("paid", "Paid"), - ("overdue", "Overdue"), + ("GBP", "British Pound Sterling"), + ("EUR", "Euro"), + ("USD", "United States Dollar"), + ("JPY", "Japanese Yen"), + ("INR", "Indian Rupee"), + ("AUD", "Australian Dollar"), + ("CAD", "Canadian Dollar"), ], - default="pending", - max_length=10, + default="GBP", + max_length=3, ), ), + ("date_created", models.DateTimeField(auto_now_add=True)), + ("date_issued", models.DateField(blank=True, null=True)), + ("discount_amount", models.DecimalField(decimal_places=2, default=0, max_digits=15)), ( - "client_to", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="backend.client", + "discount_percentage", + models.DecimalField( + decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MaxValueValidator(100)] ), ), - ], - ), - migrations.CreateModel( - name="InvoiceItem", - fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("active", models.BooleanField(default=True)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + "status", + models.CharField( + choices=[("ongoing", "Ongoing"), ("paused", "paused"), ("cancelled", "cancelled")], default="paused", max_length=10 ), ), - ("name", models.CharField(max_length=50)), - ("description", models.CharField(max_length=100)), - ("is_service", models.BooleanField(default=True)), - ( - "hours", - models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), - ), - ( - "price_per_hour", - models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), - ), - ( - "price", - models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), - ), - ], - ), - migrations.CreateModel( - name="Team", - fields=[ ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + "frequency", + models.CharField( + choices=[("weekly", "Weekly"), ("monthly", "Monthly"), ("yearly", "Yearly")], default="monthly", max_length=20 ), ), - ("name", models.CharField(max_length=100)), + ("end_date", models.DateField(blank=True, null=True)), + ("due_after_days", models.PositiveSmallIntegerField(default=7)), + ("day_of_week", models.PositiveSmallIntegerField(blank=True, null=True)), + ("day_of_month", models.PositiveSmallIntegerField(blank=True, null=True)), + ("month_of_year", models.PositiveSmallIntegerField(blank=True, null=True)), + ("client_to", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="core.client")), + ("items", models.ManyToManyField(blank=True, to="backend.invoiceitem")), ( - "leader", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), ( - "members", - models.ManyToManyField(related_name="teams_joined", to=settings.AUTH_USER_MODEL), + "user", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), ], + options={ + "abstract": False, + }, + managers=[ + ("with_items", django.db.models.manager.Manager()), + ], ), migrations.CreateModel( - name="UserSettings", + name="Invoice", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("dark_mode", models.BooleanField(default=True)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("client_name", models.CharField(blank=True, max_length=100, null=True)), + ("client_email", models.EmailField(blank=True, max_length=254, null=True)), + ("client_company", models.CharField(blank=True, max_length=100, null=True)), + ("client_address", models.CharField(blank=True, max_length=100, null=True)), + ("client_city", models.CharField(blank=True, max_length=100, null=True)), + ("client_county", models.CharField(blank=True, max_length=100, null=True)), + ("client_country", models.CharField(blank=True, max_length=100, null=True)), + ("client_is_representative", models.BooleanField(default=False)), + ("self_name", models.CharField(blank=True, max_length=100, null=True)), + ("self_company", models.CharField(blank=True, max_length=100, null=True)), + ("self_address", models.CharField(blank=True, max_length=100, null=True)), + ("self_city", models.CharField(blank=True, max_length=100, null=True)), + ("self_county", models.CharField(blank=True, max_length=100, null=True)), + ("self_country", models.CharField(blank=True, max_length=100, null=True)), + ("sort_code", models.CharField(blank=True, max_length=8, null=True)), + ("account_holder_name", models.CharField(blank=True, max_length=100, null=True)), + ("account_number", models.CharField(blank=True, max_length=100, null=True)), + ("vat_number", models.CharField(blank=True, max_length=100, null=True)), + ("logo", models.ImageField(blank=True, null=True, storage=core.models._private_storage, upload_to="invoice_logos")), + ("notes", models.TextField(blank=True, null=True)), ( "currency", models.CharField( @@ -333,301 +316,319 @@ class Migration(migrations.Migration): max_length=3, ), ), + ("date_created", models.DateTimeField(auto_now_add=True)), + ("date_issued", models.DateField(blank=True, null=True)), + ("discount_amount", models.DecimalField(decimal_places=2, default=0, max_digits=15)), ( - "profile_picture", - models.ImageField(blank=True, null=True, upload_to="profile_pictures/"), + "discount_percentage", + models.DecimalField( + decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MaxValueValidator(100)] + ), ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("reference", models.CharField(blank=True, max_length=16, null=True)), + ("date_due", models.DateField()), ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="user_profile", - to=settings.AUTH_USER_MODEL, + "status", + models.CharField( + choices=[("draft", "Draft"), ("pending", "Pending"), ("paid", "Paid")], default="draft", max_length=10 ), ), - ], - options={ - "verbose_name": "User Settings", - "verbose_name_plural": "User Settings", - }, - ), - migrations.CreateModel( - name="TracebackError", - fields=[ + ("status_updated_at", models.DateTimeField(auto_now_add=True)), + ("client_to", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="core.client")), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), - ("error", models.CharField(max_length=5000, null=True)), - ("date", models.DateTimeField(auto_now=True)), ( "user", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ("items", models.ManyToManyField(blank=True, to="backend.invoiceitem")), + ( + "invoice_recurring_profile", models.ForeignKey( blank=True, null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.SET_NULL, + related_name="generated_invoices", + to="backend.invoicerecurringprofile", ), ), ], + options={ + "abstract": False, + }, ), migrations.CreateModel( - name="TeamInvitation", + name="InvoiceReminder", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("code", models.CharField(max_length=10)), - ("expires", models.DateTimeField(blank=True, null=True)), - ("active", models.BooleanField(default=True)), - ( - "invited_by", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("boto_schedule_arn", models.CharField(blank=True, max_length=2048, null=True)), + ("boto_schedule_uuid", models.UUIDField(blank=True, default=None, null=True)), + ("boto_last_updated", models.DateTimeField(auto_now=True)), + ("received", models.BooleanField(default=False)), + ( + "boto_schedule_status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("creating", "Creating"), + ("completed", "Completed"), + ("failed", "Failed"), + ("deleting", "Deleting"), + ("cancelled", "Cancelled"), + ], + default="pending", + max_length=100, ), ), + ("days", models.PositiveIntegerField(blank=True, null=True)), ( - "team", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="team_invitations", - to="backend.team", + "reminder_type", + models.CharField( + choices=[("before_due", "Before Due"), ("after_due", "After Due"), ("on_overdue", "On Overdue")], + default="before_due", + max_length=100, ), ), ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="team_invitations", - to=settings.AUTH_USER_MODEL, - ), + "invoice", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invoice_reminders", to="backend.invoice"), ), ], options={ - "verbose_name": "Team Invitation", - "verbose_name_plural": "Team Invitations", + "verbose_name": "Invoice Reminder", + "verbose_name_plural": "Invoice Reminders", }, ), migrations.CreateModel( - name="Receipt", + name="InvoiceURL", fields=[ + ("expires", models.DateTimeField(blank=True, help_text="When the item will expire", null=True, verbose_name="Expires")), + ("active", models.BooleanField(default=True)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + "uuid", + shortuuid.django_fields.ShortUUIDField( + alphabet=None, length=8, max_length=8, prefix="", primary_key=True, serialize=False ), ), - ("name", models.CharField(max_length=100)), - ("image", models.ImageField(upload_to="receipts")), - ("total_price", models.FloatField(blank=True, null=True)), - ("date", models.DateField(blank=True, null=True)), - ("date_uploaded", models.DateTimeField(auto_now=True)), - ("receipt_parsed", models.JSONField(blank=True, null=True)), + ("system_created", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), + "created_by", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ( + "invoice", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invoice_urls", to="backend.invoice"), ), ], + options={ + "verbose_name": "Invoice URL", + "verbose_name_plural": "Invoice URLs", + }, ), migrations.CreateModel( - name="PasswordSecret", + name="MonthlyReportRow", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("secret", models.TextField(max_length=300)), - ("expires", models.DateTimeField(blank=True, null=True)), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="password_secrets", - to=settings.AUTH_USER_MODEL, - ), - ), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateField()), + ("reference_number", models.CharField(max_length=100)), + ("item_type", models.CharField(max_length=100)), + ("client_name", models.CharField(blank=True, max_length=64, null=True)), + ("paid_in", models.DecimalField(decimal_places=2, default=0, max_digits=15)), + ("paid_out", models.DecimalField(decimal_places=2, default=0, max_digits=15)), + ("client", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.client")), ], ), migrations.CreateModel( - name="Notification", + name="MonthlyReport", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(blank=True, max_length=100, null=True)), + ("profit", models.DecimalField(decimal_places=2, default=0, max_digits=15)), + ("invoices_sent", models.PositiveIntegerField(default=0)), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ("recurring_customers", models.PositiveIntegerField(default=0)), + ("payments_in", models.DecimalField(decimal_places=2, default=0, max_digits=15)), + ("payments_out", models.DecimalField(decimal_places=2, default=0, max_digits=15)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("message", models.CharField(max_length=100)), - ( - "action", + "currency", models.CharField( choices=[ - ("normal", "Normal"), - ("modal", "Modal"), - ("redirect", "Redirect"), + ("GBP", "British Pound Sterling"), + ("EUR", "Euro"), + ("USD", "United States Dollar"), + ("JPY", "Japanese Yen"), + ("INR", "Indian Rupee"), + ("AUD", "Australian Dollar"), + ("CAD", "Canadian Dollar"), ], - default="normal", - max_length=10, + default="GBP", + max_length=3, ), ), ( - "action_value", - models.CharField(blank=True, max_length=100, null=True), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), - ("date", models.DateTimeField(auto_now_add=True)), ( "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), + ("items", models.ManyToManyField(blank=True, to="backend.monthlyreportrow")), ], + options={ + "abstract": False, + }, ), migrations.CreateModel( - name="LoginLog", + name="MultiFileUpload", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("finished_at", models.DateTimeField(blank=True, editable=False, null=True)), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("files", models.ManyToManyField(related_name="multi_file_uploads", to="backend.filestoragefile")), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), - ("date", models.DateTimeField(auto_now_add=True)), ( "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="InvoiceURL", - fields=[ - ( - "uuid", - shortuuid.django_fields.ShortUUIDField( - alphabet=None, - length=8, - max_length=8, - prefix="", - primary_key=True, - serialize=False, - ), - ), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("expires", models.DateTimeField(blank=True, null=True)), - ("active", models.BooleanField(default=True)), - ( - "created_by", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "invoice", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="invoice_urls", - to="backend.invoice", - ), + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), ], options={ - "verbose_name": "Invoice URL", - "verbose_name_plural": "Invoice URLs", + "abstract": False, }, ), - migrations.AddField( - model_name="invoice", - name="items", - field=models.ManyToManyField(to="backend.invoiceitem"), - ), - migrations.AddField( - model_name="invoice", - name="user", - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), migrations.CreateModel( - name="Error", + name="Receipt", fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100)), + ("image", models.ImageField(storage=core.models._private_storage, upload_to="receipts")), + ("total_price", models.FloatField(blank=True, null=True)), + ("date", models.DateField(blank=True, null=True)), + ("date_uploaded", models.DateTimeField(auto_now_add=True)), + ("receipt_parsed", models.JSONField(blank=True, null=True)), + ("merchant_store", models.CharField(blank=True, max_length=255, null=True)), + ("purchase_category", models.CharField(blank=True, max_length=200, null=True)), ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), + "organization", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="core.organization"), ), - ("error", models.CharField(max_length=250, null=True)), - ("error_code", models.CharField(max_length=100, null=True)), - ("error_colour", models.CharField(default="danger", max_length=25)), - ("date", models.DateTimeField(auto_now=True)), ( "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), ], + options={ + "abstract": False, + }, ), migrations.CreateModel( - name="AuditLog", + name="ReceiptDownloadToken", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("action", models.CharField(max_length=100)), - ("date", models.DateTimeField(auto_now_add=True)), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("token", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("file", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="backend.receipt")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), + migrations.AddConstraint( + model_name="filestoragefile", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_filestoragefile_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="financedefaultvalues", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_financedefaultvalues_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="invoiceproduct", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_invoiceproduct_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="invoicerecurringprofile", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_invoicerecurringprofile_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="invoice", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_invoice_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="monthlyreport", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_monthlyreport_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="multifileupload", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_multifileupload_check_user_or_organization", + ), + ), + migrations.AddConstraint( + model_name="receipt", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("organization__isnull", False), ("user__isnull", True)), + models.Q(("organization__isnull", True), ("user__isnull", False)), + _connector="OR", + ), + name="backend_receipt_check_user_or_organization", + ), + ), ] diff --git a/backend/migrations/0002_alter_receipt_date_uploaded.py b/backend/migrations/0002_alter_receipt_date_uploaded.py deleted file mode 100644 index 190b1949f..000000000 --- a/backend/migrations/0002_alter_receipt_date_uploaded.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-25 22:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="receipt", - name="date_uploaded", - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/backend/migrations/0003_client_company_client_is_representative.py b/backend/migrations/0003_client_company_client_is_representative.py deleted file mode 100644 index 8e6bc3a65..000000000 --- a/backend/migrations/0003_client_company_client_is_representative.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.1.7 on 2023-12-12 14:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0002_alter_receipt_date_uploaded"), - ] - - operations = [ - migrations.AddField( - model_name="client", - name="company", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="client", - name="is_representative", - field=models.BooleanField(default=False), - ), - ] diff --git a/backend/migrations/0004_invoice_client_is_representative.py b/backend/migrations/0004_invoice_client_is_representative.py deleted file mode 100644 index 4b5d16191..000000000 --- a/backend/migrations/0004_invoice_client_is_representative.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.1.7 on 2023-12-15 08:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0003_client_company_client_is_representative"), - ] - - operations = [ - migrations.AddField( - model_name="invoice", - name="client_is_representative", - field=models.BooleanField(default=False), - ), - ] diff --git a/backend/migrations/0005_invoiceproduct.py b/backend/migrations/0005_invoiceproduct.py deleted file mode 100644 index f9d9a7678..000000000 --- a/backend/migrations/0005_invoiceproduct.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 4.1.7 on 2023-12-18 13:24 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0004_invoice_client_is_representative"), - ] - - operations = [ - migrations.CreateModel( - name="InvoiceProduct", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=50)), - ("description", models.CharField(max_length=100)), - ("quantity", models.IntegerField()), - ( - "rate", - models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/backend/migrations/0006_receipt_merchant_store_receipt_purchase_category.py b/backend/migrations/0006_receipt_merchant_store_receipt_purchase_category.py deleted file mode 100644 index 955990917..000000000 --- a/backend/migrations/0006_receipt_merchant_store_receipt_purchase_category.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.2.7 on 2024-01-08 20:32 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0005_invoiceproduct"), - ] - - operations = [ - migrations.AddField( - model_name="receipt", - name="merchant_store", - field=models.CharField(default=None, max_length=200), - preserve_default=False, - ), - migrations.AddField( - model_name="receipt", - name="purchase_category", - field=models.CharField(default=django.utils.timezone.now, max_length=200), - preserve_default=False, - ), - ] diff --git a/backend/migrations/0007_alter_receipt_merchant_store_and_more.py b/backend/migrations/0007_alter_receipt_merchant_store_and_more.py deleted file mode 100644 index 1d061f8be..000000000 --- a/backend/migrations/0007_alter_receipt_merchant_store_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.7 on 2024-01-09 08:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0006_receipt_merchant_store_receipt_purchase_category"), - ] - - operations = [ - migrations.AlterField( - model_name="receipt", - name="merchant_store", - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name="receipt", - name="purchase_category", - field=models.CharField(blank=True, max_length=200, null=True), - ), - ] diff --git a/backend/migrations/0008_receiptdownloadtoken.py b/backend/migrations/0008_receiptdownloadtoken.py deleted file mode 100644 index ccd39f78a..000000000 --- a/backend/migrations/0008_receiptdownloadtoken.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 4.2.7 on 2024-01-16 19:06 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0007_alter_receipt_merchant_store_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="ReceiptDownloadToken", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "token", - models.UUIDField(default=uuid.uuid4, editable=False, unique=True), - ), - ( - "file", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="backend.receipt", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/backend/migrations/0009_alter_invoice_sort_code.py b/backend/migrations/0009_alter_invoice_sort_code.py deleted file mode 100644 index 6b0a9e71f..000000000 --- a/backend/migrations/0009_alter_invoice_sort_code.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.1.7 on 2024-01-31 08:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0008_receiptdownloadtoken"), - ] - - operations = [ - migrations.AlterField( - model_name="invoice", - name="sort_code", - field=models.CharField(blank=True, max_length=8, null=True), - ), - ] diff --git a/backend/migrations/0010_user_logged_in_as_team.py b/backend/migrations/0010_user_logged_in_as_team.py deleted file mode 100644 index d6978976c..000000000 --- a/backend/migrations/0010_user_logged_in_as_team.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-04 19:20 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0009_alter_invoice_sort_code"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="logged_in_as_team", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="backend.team", - ), - ), - ] diff --git a/backend/migrations/0011_alter_team_leader.py b/backend/migrations/0011_alter_team_leader.py deleted file mode 100644 index 535e5a5f2..000000000 --- a/backend/migrations/0011_alter_team_leader.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-04 20:36 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0010_user_logged_in_as_team"), - ] - - operations = [ - migrations.AlterField( - model_name="team", - name="leader", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="teams_leader_of", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/backend/migrations/0012_receipt_organization_alter_receipt_user_and_more.py b/backend/migrations/0012_receipt_organization_alter_receipt_user_and_more.py deleted file mode 100644 index da5f66196..000000000 --- a/backend/migrations/0012_receipt_organization_alter_receipt_user_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-05 13:41 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0011_alter_team_leader"), - ] - - operations = [ - migrations.AddField( - model_name="receipt", - name="organization", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="backend.team", - ), - ), - migrations.AlterField( - model_name="receipt", - name="user", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddConstraint( - model_name="receipt", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_receipt_check_user_or_organization", - ), - ), - ] diff --git a/backend/migrations/0013_auditlog_organization_client_organization_and_more.py b/backend/migrations/0013_auditlog_organization_client_organization_and_more.py deleted file mode 100644 index cbad74139..000000000 --- a/backend/migrations/0013_auditlog_organization_client_organization_and_more.py +++ /dev/null @@ -1,82 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-05 15:30 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0012_receipt_organization_alter_receipt_user_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="auditlog", - name="organization", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="backend.team", - ), - ), - migrations.AddField( - model_name="client", - name="organization", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="backend.team", - ), - ), - migrations.AddField( - model_name="invoice", - name="organization", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="backend.team", - ), - ), - migrations.AlterField( - model_name="client", - name="user", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="invoice", - name="user", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddConstraint( - model_name="client", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_client_check_user_or_organization", - ), - ), - migrations.AddConstraint( - model_name="invoice", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_invoice_check_user_or_organization", - ), - ), - ] diff --git a/backend/migrations/0014_notification_extra_type_notification_extra_value.py b/backend/migrations/0014_notification_extra_type_notification_extra_value.py deleted file mode 100644 index 60b907752..000000000 --- a/backend/migrations/0014_notification_extra_type_notification_extra_value.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-06 08:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0013_auditlog_organization_client_organization_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="notification", - name="extra_type", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="notification", - name="extra_value", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/backend/migrations/0015_alter_notification_user_alter_team_name.py b/backend/migrations/0015_alter_notification_user_alter_team_name.py deleted file mode 100644 index 131a4ee8a..000000000 --- a/backend/migrations/0015_alter_notification_user_alter_team_name.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-07 08:10 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0014_notification_extra_type_notification_extra_value"), - ] - - operations = [ - migrations.AlterField( - model_name="notification", - name="user", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="user_notifications", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="team", - name="name", - field=models.CharField(max_length=100, unique=True), - ), - ] diff --git a/backend/migrations/0016_alter_invoice_logo_alter_receipt_image_and_more.py b/backend/migrations/0016_alter_invoice_logo_alter_receipt_image_and_more.py deleted file mode 100644 index 1372786f2..000000000 --- a/backend/migrations/0016_alter_invoice_logo_alter_receipt_image_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-14 19:26 - -from django.db import migrations, models - -import settings.settings - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0015_alter_notification_user_alter_team_name"), - ] - - operations = [ - migrations.AlterField( - model_name="invoice", - name="logo", - field=models.ImageField( - blank=True, - null=True, - storage=settings.settings.CustomPrivateMediaStorage(), - upload_to="invoice_logos", - ), - ), - migrations.AlterField( - model_name="receipt", - name="image", - field=models.ImageField( - storage=settings.settings.CustomPrivateMediaStorage(), - upload_to="receipts", - ), - ), - migrations.AlterField( - model_name="usersettings", - name="profile_picture", - field=models.ImageField( - blank=True, - null=True, - storage=settings.settings.CustomPublicMediaStorage(), - upload_to="profile_pictures/", - ), - ), - ] diff --git a/backend/migrations/0017_featureflags.py b/backend/migrations/0017_featureflags.py deleted file mode 100644 index 22721a0c2..000000000 --- a/backend/migrations/0017_featureflags.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-18 20:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0016_alter_invoice_logo_alter_receipt_image_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="FeatureFlags", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100)), - ("value", models.BooleanField(default=False)), - ("updated_at", models.DateTimeField(auto_now=True)), - ], - ), - ] diff --git a/backend/migrations/0018_user_role.py b/backend/migrations/0018_user_role.py deleted file mode 100644 index 5bbecc7c9..000000000 --- a/backend/migrations/0018_user_role.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.0.1 on 2024-02-20 10:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0017_featureflags"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="role", - field=models.CharField( - choices=[ - ("DEV", "Developer"), - ("STAFF", "Staff"), - ("USER", "User"), - ("TESTER", "Tester"), - ], - default="USER", - max_length=10, - ), - ), - ] diff --git a/backend/migrations/0019_alter_featureflags_options_and_more.py b/backend/migrations/0019_alter_featureflags_options_and_more.py deleted file mode 100644 index af94ee737..000000000 --- a/backend/migrations/0019_alter_featureflags_options_and_more.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-22 18:16 - -import datetime -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0018_user_role"), - ] - - operations = [ - migrations.AlterModelOptions( - name="featureflags", - options={ - "verbose_name": "Feature Flag", - "verbose_name_plural": "Feature Flags", - }, - ), - migrations.AddField( - model_name="user", - name="awaiting_email_verification", - field=models.BooleanField(default=True), - ), - migrations.CreateModel( - name="VerificationCodes", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "uuid", - models.UUIDField(default=uuid.uuid4, editable=False, unique=True), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ( - "expiry", - models.DateTimeField(default=datetime.datetime(2024, 2, 22, 21, 16, 55, 46745, tzinfo=datetime.timezone.utc)), - ), - ( - "service", - models.CharField( - choices=[ - ("create_account", "Create Account"), - ("reset_password", "Reset Password"), - ], - max_length=14, - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/backend/migrations/0020_alter_verificationcodes_options_and_more.py b/backend/migrations/0020_alter_verificationcodes_options_and_more.py deleted file mode 100644 index 0285241ea..000000000 --- a/backend/migrations/0020_alter_verificationcodes_options_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-22 20:41 - -import datetime - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0019_alter_featureflags_options_and_more"), - ] - - operations = [ - migrations.AlterModelOptions( - name="verificationcodes", - options={ - "verbose_name": "Verification Code", - "verbose_name_plural": "Verification Codes", - }, - ), - migrations.AddField( - model_name="verificationcodes", - name="token", - field=models.TextField(default="BZQQWE", editable=False), - ), - migrations.AlterField( - model_name="verificationcodes", - name="expiry", - field=models.DateTimeField(default=datetime.datetime(2024, 2, 22, 23, 41, 26, 332896, tzinfo=datetime.timezone.utc)), - ), - ] diff --git a/backend/migrations/0021_alter_verificationcodes_expiry_and_more.py b/backend/migrations/0021_alter_verificationcodes_expiry_and_more.py deleted file mode 100644 index 71adcf375..000000000 --- a/backend/migrations/0021_alter_verificationcodes_expiry_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-23 19:00 - -import datetime - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0020_alter_verificationcodes_options_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="verificationcodes", - name="expiry", - field=models.DateTimeField(default=datetime.datetime(2024, 2, 23, 22, 0, 25, 744643, tzinfo=datetime.timezone.utc)), - ), - migrations.AlterField( - model_name="verificationcodes", - name="token", - field=models.TextField(default="XBNKTM", editable=False), - ), - ] diff --git a/backend/migrations/0022_loginlog_service_alter_verificationcodes_expiry_and_more.py b/backend/migrations/0022_loginlog_service_alter_verificationcodes_expiry_and_more.py deleted file mode 100644 index a192c24d6..000000000 --- a/backend/migrations/0022_loginlog_service_alter_verificationcodes_expiry_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-25 11:42 - -from django.db import migrations, models - -import backend.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0021_alter_verificationcodes_expiry_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="loginlog", - name="service", - field=models.CharField(choices=[("manual", "Manual"), ("magic_link", "Magic Link")], default="manual", max_length=14), - ), - migrations.AlterField( - model_name="verificationcodes", - name="expiry", - field=models.DateTimeField(default=backend.core.models.add_3hrs_from_now), - ), - migrations.AlterField( - model_name="verificationcodes", - name="token", - field=models.TextField(default=backend.core.models.RandomCode, editable=False), - ), - ] diff --git a/backend/migrations/0023_apikey_invoiceonetimeschedule.py b/backend/migrations/0023_apikey_invoiceonetimeschedule.py deleted file mode 100644 index 5af19f8c8..000000000 --- a/backend/migrations/0023_apikey_invoiceonetimeschedule.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-08 23:17 - -import django.db.models.deletion -from django.db import migrations, models - -import backend.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0022_loginlog_service_alter_verificationcodes_expiry_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="APIKey", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("service", models.CharField(choices=[("aws_api_destination", "Aws Api Destination")], max_length=20, null=True)), - ("key", models.CharField(default=backend.core.models.RandomAPICode, max_length=100)), - ("last_used", models.DateTimeField(auto_now_add=True)), - ], - options={ - "verbose_name": "API Key", - "verbose_name_plural": "API Keys", - }, - ), - migrations.CreateModel( - name="InvoiceOnetimeSchedule", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("stored_schedule_arn", models.CharField(blank=True, max_length=100, null=True)), - ("received", models.BooleanField(default=False)), - ( - "status", - models.CharField( - choices=[ - ("pending", "Pending"), - ("creating", "Creating"), - ("completed", "Completed"), - ("failed", "Failed"), - ("deleting", "Deleting"), - ("cancelled", "Cancelled"), - ], - default="pending", - max_length=100, - ), - ), - ("due", models.DateTimeField()), - ( - "invoice", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="onetime_invoice_schedules", to="backend.invoice" - ), - ), - ], - options={ - "verbose_name": "One-Time Invoice Schedule", - "verbose_name_plural": "One-Time Invoice Schedules", - }, - ), - ] diff --git a/backend/migrations/0024_invoiceurl_never_expire_invoiceurl_system_created_and_more.py b/backend/migrations/0024_invoiceurl_never_expire_invoiceurl_system_created_and_more.py deleted file mode 100644 index 0eed8347d..000000000 --- a/backend/migrations/0024_invoiceurl_never_expire_invoiceurl_system_created_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-09 13:50 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0023_apikey_invoiceonetimeschedule"), - ] - - operations = [ - migrations.AddField( - model_name="invoiceurl", - name="never_expire", - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name="invoiceurl", - name="system_created", - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name="invoiceurl", - name="created_by", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/backend/migrations/0025_alter_invoiceonetimeschedule_stored_schedule_arn.py b/backend/migrations/0025_alter_invoiceonetimeschedule_stored_schedule_arn.py deleted file mode 100644 index 410dac5f9..000000000 --- a/backend/migrations/0025_alter_invoiceonetimeschedule_stored_schedule_arn.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-16 14:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0024_invoiceurl_never_expire_invoiceurl_system_created_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="invoiceonetimeschedule", - name="stored_schedule_arn", - field=models.CharField(blank=True, max_length=500, null=True), - ), - ] diff --git a/backend/migrations/0026_invoice_discount_amount_invoice_discount_percentage.py b/backend/migrations/0026_invoice_discount_amount_invoice_discount_percentage.py deleted file mode 100644 index 04c63b4f1..000000000 --- a/backend/migrations/0026_invoice_discount_amount_invoice_discount_percentage.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-29 20:00 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0025_alter_invoiceonetimeschedule_stored_schedule_arn"), - ] - - operations = [ - migrations.AddField( - model_name="invoice", - name="discount_amount", - field=models.DecimalField(decimal_places=2, default=0, max_digits=15), - ), - migrations.AddField( - model_name="invoice", - name="discount_percentage", - field=models.DecimalField( - decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MaxValueValidator(100)] - ), - ), - ] diff --git a/backend/migrations/0027_invoice_currency.py b/backend/migrations/0027_invoice_currency.py deleted file mode 100644 index f2c9a9de4..000000000 --- a/backend/migrations/0027_invoice_currency.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-31 23:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0026_invoice_discount_amount_invoice_discount_percentage"), - ] - - operations = [ - migrations.AddField( - model_name="invoice", - name="currency", - field=models.CharField( - choices=[ - ("GBP", "British Pound Sterling"), - ("EUR", "Euro"), - ("USD", "United States Dollar"), - ("JPY", "Japanese Yen"), - ("INR", "Indian Rupee"), - ("AUD", "Australian Dollar"), - ("CAD", "Canadian Dollar"), - ], - default="GBP", - max_length=3, - ), - ), - ] diff --git a/backend/migrations/0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more.py b/backend/migrations/0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more.py deleted file mode 100644 index ca011aa92..000000000 --- a/backend/migrations/0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more.py +++ /dev/null @@ -1,111 +0,0 @@ -# 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/migrations/0029_alter_invoice_organization_alter_invoice_user_and_more.py b/backend/migrations/0029_alter_invoice_organization_alter_invoice_user_and_more.py deleted file mode 100644 index 59943713d..000000000 --- a/backend/migrations/0029_alter_invoice_organization_alter_invoice_user_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.3 on 2024-04-01 19:49 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0028_quotalimit_quotaincreaserequest_quotaoverrides_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="invoice", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.team"), - ), - migrations.AlterField( - model_name="invoice", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="user", - name="logged_in_as_team", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="backend.team"), - ), - ] diff --git a/backend/migrations/0030_alter_invoice_items.py b/backend/migrations/0030_alter_invoice_items.py deleted file mode 100644 index f8906bfde..000000000 --- a/backend/migrations/0030_alter_invoice_items.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-04-04 15:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0029_alter_invoice_organization_alter_invoice_user_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="invoice", - name="items", - field=models.ManyToManyField(blank=True, to="backend.invoiceitem"), - ), - ] diff --git a/backend/migrations/0031_featureflags_description_alter_featureflags_name.py b/backend/migrations/0031_featureflags_description_alter_featureflags_name.py deleted file mode 100644 index c93047183..000000000 --- a/backend/migrations/0031_featureflags_description_alter_featureflags_name.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.0.4 on 2024-04-05 19:52 -from __future__ import annotations - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0030_alter_invoice_items"), - ] - - operations = [ - migrations.AddField( - model_name="featureflags", - name="description", - field=models.TextField(blank=True, editable=False, max_length=500, null=True), - ), - migrations.AlterField( - model_name="featureflags", - name="name", - field=models.CharField(editable=False, max_length=100, unique=True), - ), - ] diff --git a/backend/migrations/0032_client_email_verified_alter_client_organization_and_more.py b/backend/migrations/0032_client_email_verified_alter_client_organization_and_more.py deleted file mode 100644 index 31ef9fa2a..000000000 --- a/backend/migrations/0032_client_email_verified_alter_client_organization_and_more.py +++ /dev/null @@ -1,98 +0,0 @@ -# Generated by Django 5.0.4 on 2024-04-10 13:40 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0031_featureflags_description_alter_featureflags_name"), - ] - - operations = [ - migrations.AddField( - model_name="client", - name="email_verified", - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name="client", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.team"), - ), - migrations.AlterField( - model_name="client", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.CreateModel( - name="EmailSendStatus", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("updated_status_at", models.DateTimeField(auto_now_add=True)), - ("recipient", models.TextField()), - ("aws_message_id", models.CharField(blank=True, editable=False, max_length=100, null=True)), - ( - "status", - models.CharField( - choices=[ - ("send", "Send"), - ("reject", "Reject"), - ("bounce", "Bounce"), - ("complaint", "Complaint"), - ("delivery", "Delivery"), - ("open", "Open"), - ("click", "Click"), - ("rendering_failure", "Rendering_Failure"), - ("delivery_delay", "Delivery_Delay"), - ("subscription", "Subscription"), - ("failed_to_send", "Failed_To_Send"), - ("pending", "Pending"), - ], - max_length=20, - ), - ), - ( - "organization", - models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="emails_created", to="backend.team" - ), - ), - ( - "sent_by", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="emails_sent", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="emails_created", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddConstraint( - model_name="emailsendstatus", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_emailsendstatus_check_user_or_organization", - ), - ), - ] diff --git a/backend/migrations/0033_alter_auditlog_organization.py b/backend/migrations/0033_alter_auditlog_organization.py deleted file mode 100644 index 202d0f74c..000000000 --- a/backend/migrations/0033_alter_auditlog_organization.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.4 on 2024-04-10 16:11 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0032_client_email_verified_alter_client_organization_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="auditlog", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="backend.team"), - ), - ] diff --git a/backend/migrations/0034_invoice_client_email_quotaincreaserequest_reason_and_more.py b/backend/migrations/0034_invoice_client_email_quotaincreaserequest_reason_and_more.py deleted file mode 100644 index 6657a171c..000000000 --- a/backend/migrations/0034_invoice_client_email_quotaincreaserequest_reason_and_more.py +++ /dev/null @@ -1,66 +0,0 @@ -# Generated by Django 5.0.4 on 2024-04-19 20:15 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0033_alter_auditlog_organization"), - ] - - operations = [ - migrations.AddField( - model_name="invoice", - name="client_email", - field=models.EmailField(blank=True, max_length=254, null=True), - ), - migrations.AddField( - model_name="quotaincreaserequest", - name="reason", - field=models.CharField(default="", max_length=1000), - preserve_default=False, - ), - migrations.CreateModel( - name="InvoiceReminder", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("stored_schedule_arn", models.CharField(blank=True, max_length=500, null=True)), - ("received", models.BooleanField(default=False)), - ( - "status", - models.CharField( - choices=[ - ("pending", "Pending"), - ("creating", "Creating"), - ("completed", "Completed"), - ("failed", "Failed"), - ("deleting", "Deleting"), - ("cancelled", "Cancelled"), - ], - default="pending", - max_length=100, - ), - ), - ("days", models.PositiveIntegerField(blank=True, null=True)), - ( - "reminder_type", - models.CharField( - choices=[("before_due", "Before Due"), ("after_due", "After Due"), ("on_overdue", "On Overdue")], - default="before_due", - max_length=100, - ), - ), - ( - "invoice", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invoice_reminders", to="backend.invoice"), - ), - ], - options={ - "verbose_name": "Invoice Reminder", - "verbose_name_plural": "Invoice Reminders", - }, - ), - ] diff --git a/backend/migrations/0035_client_contact_method.py b/backend/migrations/0035_client_contact_method.py deleted file mode 100644 index 7cda02391..000000000 --- a/backend/migrations/0035_client_contact_method.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.4 on 2024-05-30 01:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0034_invoice_client_email_quotaincreaserequest_reason_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="client", - name="contact_method", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/backend/migrations/0036_alter_client_address_clientdefaults.py b/backend/migrations/0036_alter_client_address_clientdefaults.py deleted file mode 100644 index 940b7486d..000000000 --- a/backend/migrations/0036_alter_client_address_clientdefaults.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 5.0.4 on 2024-06-18 17:07 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0035_client_contact_method"), - ] - - operations = [ - migrations.AlterField( - model_name="client", - name="address", - field=models.TextField(blank=True, max_length=100, null=True), - ), - migrations.CreateModel( - name="ClientDefaults", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "currency", - models.CharField( - choices=[ - ("GBP", "British Pound Sterling"), - ("EUR", "Euro"), - ("USD", "United States Dollar"), - ("JPY", "Japanese Yen"), - ("INR", "Indian Rupee"), - ("AUD", "Australian Dollar"), - ("CAD", "Canadian Dollar"), - ], - default="GBP", - max_length=3, - ), - ), - ("invoice_due_date_value", models.PositiveSmallIntegerField(default=7)), - ( - "invoice_due_date_type", - models.CharField( - choices=[("days_after", "Days After"), ("date_following", "Date Following"), ("date_current", "Date Current")], - default="days_after", - max_length=20, - ), - ), - ("invoice_date_value", models.PositiveSmallIntegerField(default=15)), - ( - "invoice_date_type", - models.CharField( - choices=[("day_of_month", "Day Of Month"), ("days_after", "Days After")], default="day_of_month", max_length=20 - ), - ), - ( - "client", - models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="client_defaults", to="backend.client"), - ), - ], - ), - ] diff --git a/backend/migrations/0036_apiauthtoken.py b/backend/migrations/0036_apiauthtoken.py deleted file mode 100644 index d9ce8ac20..000000000 --- a/backend/migrations/0036_apiauthtoken.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.4 on 2024-06-10 16:59 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0035_client_contact_method"), - ] - - operations = [ - migrations.CreateModel( - name="APIAuthToken", - fields=[ - ("key", models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name="Key")), - ("created", models.DateTimeField(auto_now_add=True, verbose_name="Created")), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/backend/migrations/0037_merge_20240619_2223.py b/backend/migrations/0037_merge_20240619_2223.py deleted file mode 100644 index a3adee93d..000000000 --- a/backend/migrations/0037_merge_20240619_2223.py +++ /dev/null @@ -1,13 +0,0 @@ -# Generated by Django 5.0.4 on 2024-06-19 21:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0036_alter_client_address_clientdefaults"), - ("backend", "0036_apiauthtoken"), - ] - - operations = [] diff --git a/backend/migrations/0038_alter_apiauthtoken_options.py b/backend/migrations/0038_alter_apiauthtoken_options.py deleted file mode 100644 index 79e6017fa..000000000 --- a/backend/migrations/0038_alter_apiauthtoken_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-20 16:42 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0037_merge_20240619_2223"), - ] - - operations = [ - migrations.AlterModelOptions( - name="apiauthtoken", - options={"verbose_name": "API Key", "verbose_name_plural": "API Keys"}, - ), - ] diff --git a/backend/migrations/0039_apiauthtoken_active_apiauthtoken_description_and_more.py b/backend/migrations/0039_apiauthtoken_active_apiauthtoken_description_and_more.py deleted file mode 100644 index b4096b57f..000000000 --- a/backend/migrations/0039_apiauthtoken_active_apiauthtoken_description_and_more.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-21 17:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0038_alter_apiauthtoken_options"), - ] - - operations = [ - migrations.RemoveField( - model_name="apiauthtoken", - name="key", - ), - migrations.AddField( - model_name="apiauthtoken", - name="active", - field=models.BooleanField(default=True, help_text="If the key is active", verbose_name="Active"), - ), - migrations.AddField( - model_name="apiauthtoken", - name="description", - field=models.TextField(blank=True, null=True, verbose_name="Description"), - ), - migrations.AddField( - model_name="apiauthtoken", - name="expired", - field=models.BooleanField(default=False, help_text="If the key has expired", verbose_name="Expired"), - ), - migrations.AddField( - model_name="apiauthtoken", - name="expires", - field=models.DateTimeField(blank=True, help_text="Leave blank for no expiry", null=True, verbose_name="Expires"), - ), - migrations.AddField( - model_name="apiauthtoken", - name="id", - field=models.AutoField(primary_key=True, serialize=False, auto_created=True), - preserve_default=False, - ), - migrations.AddField( - model_name="apiauthtoken", - name="last_used", - field=models.DateTimeField(blank=True, null=True, verbose_name="Last Used"), - ), - migrations.AddField( - model_name="apiauthtoken", - name="name", - field=models.CharField(default="bob", max_length=64, verbose_name="Key Name"), - preserve_default=False, - ), - migrations.AddField( - model_name="apiauthtoken", - name="key", - field=models.CharField(max_length=40, unique=True), - ), - ] diff --git a/backend/migrations/0040_apiauthtoken_scopes_apiauthtoken_team_and_more.py b/backend/migrations/0040_apiauthtoken_scopes_apiauthtoken_team_and_more.py deleted file mode 100644 index a71c884de..000000000 --- a/backend/migrations/0040_apiauthtoken_scopes_apiauthtoken_team_and_more.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-21 19:51 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0039_apiauthtoken_active_apiauthtoken_description_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="apiauthtoken", - name="scopes", - field=models.JSONField(default=list, help_text="List of permitted scopes", verbose_name="Scopes"), - ), - migrations.AddField( - model_name="apiauthtoken", - name="team", - field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="tokens", to="backend.team" - ), - ), - migrations.AlterField( - model_name="apiauthtoken", - name="id", - field=models.AutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name="apiauthtoken", - name="key", - field=models.CharField(max_length=40, unique=True, verbose_name="Key"), - ), - migrations.CreateModel( - name="TeamMemberPermission", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("scopes", models.JSONField(default=list, help_text="List of permitted scopes", verbose_name="Scopes")), - ("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="permissions", to="backend.team")), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="team_permissions", to=settings.AUTH_USER_MODEL - ), - ), - ], - options={ - "unique_together": {("team", "user")}, - }, - ), - ] diff --git a/backend/migrations/0041_alter_apiauthtoken_user.py b/backend/migrations/0041_alter_apiauthtoken_user.py deleted file mode 100644 index 5fb36919a..000000000 --- a/backend/migrations/0041_alter_apiauthtoken_user.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-28 23:57 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0040_apiauthtoken_scopes_apiauthtoken_team_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="apiauthtoken", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/backend/migrations/0042_remove_apiauthtoken_key_apiauthtoken_hashed_key.py b/backend/migrations/0042_remove_apiauthtoken_key_apiauthtoken_hashed_key.py deleted file mode 100644 index ad1934645..000000000 --- a/backend/migrations/0042_remove_apiauthtoken_key_apiauthtoken_hashed_key.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-29 18:19 -from django.contrib.auth.hashers import make_password -from django.db import migrations, models, transaction - - -def forwards_func(apps, schema_editor): - APIAuthToken = apps.get_model("backend", "APIAuthToken") - - tokens = APIAuthToken.objects.all() - - for token in tokens: - token.hashed_key = make_password(token.key, salt="api_tokens", hasher="default") - - with transaction.atomic(): - APIAuthToken.objects.bulk_update(tokens, ["hashed_key"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("backend", "0041_alter_apiauthtoken_user"), - ] - - operations = [ - migrations.AddField( - model_name="apiauthtoken", - name="hashed_key", - field=models.CharField(max_length=128, unique=True, null=True, verbose_name="Key"), - ), - migrations.RunPython(forwards_func), # (cant really reverse) - migrations.AlterField( - model_name="apiauthtoken", - name="hashed_key", - field=models.CharField(max_length=128, unique=True, null=False, verbose_name="Key"), - ), - migrations.RemoveField( - model_name="apiauthtoken", - name="key", - ), - ] diff --git a/backend/migrations/0043_rename_team_organization_remove_apiauthtoken_team_and_more.py b/backend/migrations/0043_rename_team_organization_remove_apiauthtoken_team_and_more.py deleted file mode 100644 index ad41712b4..000000000 --- a/backend/migrations/0043_rename_team_organization_remove_apiauthtoken_team_and_more.py +++ /dev/null @@ -1,137 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-30 19:11 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -def forwards_func(apps, schema_editor): - QuotaIncreaseRequest = apps.get_model("backend", "QuotaIncreaseRequest") - - objs = QuotaIncreaseRequest.objects.all() - - for obj in objs: - obj.requester = obj.user - - QuotaIncreaseRequest.objects.bulk_update(objs, ["requester"]) - - -def reverse_func(apps, schema_editor): - pass - # Reverse code not needed - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0042_remove_apiauthtoken_key_apiauthtoken_hashed_key"), - ] - - operations = [ - migrations.RenameModel( - old_name="Team", - new_name="Organization", - ), - migrations.RemoveField( - model_name="apiauthtoken", - name="team", - ), - migrations.AddField( - model_name="apiauthtoken", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AddField( - model_name="invoiceproduct", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AddField( - model_name="quotaincreaserequest", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AddField( - model_name="quotaincreaserequest", - name="requester", - field=models.ForeignKey( - default=1, - on_delete=django.db.models.deletion.CASCADE, - related_name="quota_increase_requests", - to=settings.AUTH_USER_MODEL, - ), - preserve_default=False, - ), - migrations.RunPython(forwards_func, reverse_code=reverse_func), - migrations.AddField( - model_name="quotaoverrides", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AddField( - model_name="quotausage", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AlterField( - model_name="auditlog", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AlterField( - model_name="auditlog", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="emailsendstatus", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AlterField( - model_name="emailsendstatus", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="invoiceproduct", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="quotaincreaserequest", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="quotaoverrides", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="quotausage", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name="receipt", - name="organization", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - migrations.AlterField( - model_name="receipt", - name="user", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AddConstraint( - model_name="invoiceproduct", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_invoiceproduct_check_user_or_organization", - ), - ), - ] diff --git a/backend/migrations/0044_defaultvalues_delete_clientdefaults_and_more.py b/backend/migrations/0044_defaultvalues_delete_clientdefaults_and_more.py deleted file mode 100644 index e2b6f8fd0..000000000 --- a/backend/migrations/0044_defaultvalues_delete_clientdefaults_and_more.py +++ /dev/null @@ -1,88 +0,0 @@ -# Generated by Django 5.0.6 on 2024-07-18 21:32 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0043_rename_team_organization_remove_apiauthtoken_team_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="DefaultValues", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "currency", - models.CharField( - choices=[ - ("GBP", "British Pound Sterling"), - ("EUR", "Euro"), - ("USD", "United States Dollar"), - ("JPY", "Japanese Yen"), - ("INR", "Indian Rupee"), - ("AUD", "Australian Dollar"), - ("CAD", "Canadian Dollar"), - ], - default="GBP", - max_length=3, - ), - ), - ("invoice_due_date_value", models.PositiveSmallIntegerField(default=7)), - ( - "invoice_due_date_type", - models.CharField( - choices=[("days_after", "Days After"), ("date_following", "Date Following"), ("date_current", "Date Current")], - default="days_after", - max_length=20, - ), - ), - ("invoice_date_value", models.PositiveSmallIntegerField(default=15)), - ( - "invoice_date_type", - models.CharField( - choices=[("day_of_month", "Day Of Month"), ("days_after", "Days After")], default="day_of_month", max_length=20 - ), - ), - ( - "client", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="default_values", - to="backend.client", - ), - ), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.DeleteModel( - name="ClientDefaults", - ), - migrations.AddConstraint( - model_name="defaultvalues", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_defaultvalues_check_user_or_organization", - ), - ), - ] diff --git a/backend/migrations/0045_usersettings_disabled_features.py b/backend/migrations/0045_usersettings_disabled_features.py deleted file mode 100644 index a6e6bfcc1..000000000 --- a/backend/migrations/0045_usersettings_disabled_features.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.6 on 2024-07-19 22:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0044_defaultvalues_delete_clientdefaults_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="usersettings", - name="disabled_features", - field=models.JSONField(default=list), - ), - ] diff --git a/backend/migrations/0046_rename_status_invoicereminder_boto_schedule_status_and_more.py b/backend/migrations/0046_rename_status_invoicereminder_boto_schedule_status_and_more.py deleted file mode 100644 index 6b0548fbb..000000000 --- a/backend/migrations/0046_rename_status_invoicereminder_boto_schedule_status_and_more.py +++ /dev/null @@ -1,197 +0,0 @@ -# Generated by Django 5.0.7 on 2024-08-22 15:09 - -import backend.models -import django.core.validators -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0045_usersettings_disabled_features"), - ] - - operations = [ - migrations.RenameField( - model_name="invoicereminder", - old_name="status", - new_name="boto_schedule_status", - ), - migrations.RemoveField( - model_name="invoicereminder", - name="stored_schedule_arn", - ), - migrations.AddField( - model_name="apiauthtoken", - name="administrator_service_type", - field=models.CharField(blank=True, max_length=64, null=True, verbose_name="Administrator Service Type"), - ), - migrations.AddField( - model_name="invoicereminder", - name="boto_last_updated", - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name="invoicereminder", - name="boto_schedule_arn", - field=models.CharField(blank=True, max_length=2048, null=True), - ), - migrations.AddField( - model_name="invoicereminder", - name="boto_schedule_uuid", - field=models.UUIDField(blank=True, default=None, null=True), - ), - migrations.AlterField( - model_name="invoice", - name="logo", - field=models.ImageField(blank=True, null=True, storage=backend.core.models._private_storage, upload_to="invoice_logos"), - ), - migrations.AlterField( - model_name="receipt", - name="image", - field=models.ImageField(storage=backend.core.models._private_storage, upload_to="receipts"), - ), - migrations.AlterField( - model_name="teammemberpermission", - name="user", - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, related_name="team_permissions", to=settings.AUTH_USER_MODEL - ), - ), - migrations.AlterField( - model_name="usersettings", - name="profile_picture", - field=models.ImageField(blank=True, null=True, storage=backend.core.models._public_storage, upload_to="profile_pictures/"), - ), - migrations.CreateModel( - name="InvoiceRecurringProfile", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("boto_schedule_arn", models.CharField(blank=True, max_length=2048, null=True)), - ("boto_schedule_uuid", models.UUIDField(blank=True, default=None, null=True)), - ("boto_last_updated", models.DateTimeField(auto_now=True)), - ("received", models.BooleanField(default=False)), - ( - "boto_schedule_status", - models.CharField( - choices=[ - ("pending", "Pending"), - ("creating", "Creating"), - ("completed", "Completed"), - ("failed", "Failed"), - ("deleting", "Deleting"), - ("cancelled", "Cancelled"), - ], - default="pending", - max_length=100, - ), - ), - ("client_name", models.CharField(blank=True, max_length=100, null=True)), - ("client_email", models.EmailField(blank=True, max_length=254, null=True)), - ("client_company", models.CharField(blank=True, max_length=100, null=True)), - ("client_address", models.CharField(blank=True, max_length=100, null=True)), - ("client_city", models.CharField(blank=True, max_length=100, null=True)), - ("client_county", models.CharField(blank=True, max_length=100, null=True)), - ("client_country", models.CharField(blank=True, max_length=100, null=True)), - ("client_is_representative", models.BooleanField(default=False)), - ("self_name", models.CharField(blank=True, max_length=100, null=True)), - ("self_company", models.CharField(blank=True, max_length=100, null=True)), - ("self_address", models.CharField(blank=True, max_length=100, null=True)), - ("self_city", models.CharField(blank=True, max_length=100, null=True)), - ("self_county", models.CharField(blank=True, max_length=100, null=True)), - ("self_country", models.CharField(blank=True, max_length=100, null=True)), - ("sort_code", models.CharField(blank=True, max_length=8, null=True)), - ("account_holder_name", models.CharField(blank=True, max_length=100, null=True)), - ("account_number", models.CharField(blank=True, max_length=100, null=True)), - ("reference", models.CharField(blank=True, max_length=100, null=True)), - ("invoice_number", models.CharField(blank=True, max_length=100, null=True)), - ("vat_number", models.CharField(blank=True, max_length=100, null=True)), - ("logo", models.ImageField(blank=True, null=True, storage=backend.core.models._private_storage, upload_to="invoice_logos")), - ("notes", models.TextField(blank=True, null=True)), - ( - "currency", - models.CharField( - choices=[ - ("GBP", "British Pound Sterling"), - ("EUR", "Euro"), - ("USD", "United States Dollar"), - ("JPY", "Japanese Yen"), - ("INR", "Indian Rupee"), - ("AUD", "Australian Dollar"), - ("CAD", "Canadian Dollar"), - ], - default="GBP", - max_length=3, - ), - ), - ("date_created", models.DateTimeField(auto_now_add=True)), - ("date_issued", models.DateField(blank=True, null=True)), - ("discount_amount", models.DecimalField(decimal_places=2, default=0, max_digits=15)), - ( - "discount_percentage", - models.DecimalField( - decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MaxValueValidator(100)] - ), - ), - ("active", models.BooleanField(default=True)), - ( - "status", - models.CharField( - choices=[("ongoing", "Ongoing"), ("paused", "paused"), ("cancelled", "cancelled")], default="paused", max_length=10 - ), - ), - ( - "frequency", - models.CharField( - choices=[("weekly", "Weekly"), ("monthly", "Monthly"), ("yearly", "Yearly")], default="monthly", max_length=20 - ), - ), - ("end_date", models.DateField(blank=True, null=True)), - ("due_after_days", models.PositiveSmallIntegerField(default=7)), - ("day_of_week", models.PositiveSmallIntegerField(blank=True, null=True)), - ("day_of_month", models.PositiveSmallIntegerField(blank=True, null=True)), - ("month_of_year", models.PositiveSmallIntegerField(blank=True, null=True)), - ("client_to", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="backend.client")), - ("items", models.ManyToManyField(blank=True, to="backend.invoiceitem")), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.AddField( - model_name="invoice", - name="invoice_recurring_profile", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="generated_invoices", - to="backend.invoicerecurringprofile", - ), - ), - migrations.DeleteModel( - name="InvoiceOnetimeSchedule", - ), - migrations.AddConstraint( - model_name="invoicerecurringprofile", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_invoicerecurringprofile_check_user_or_organization", - ), - ), - ] diff --git a/backend/migrations/0047_defaultvalues_default_invoice_logo.py b/backend/migrations/0047_defaultvalues_default_invoice_logo.py deleted file mode 100644 index 61a66dd8f..000000000 --- a/backend/migrations/0047_defaultvalues_default_invoice_logo.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1 on 2024-08-23 11:13 - -import settings.settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0046_rename_status_invoicereminder_boto_schedule_status_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="defaultvalues", - name="default_invoice_logo", - field=models.ImageField( - blank=True, null=True, storage=settings.settings.CustomPublicMediaStorage(), upload_to="invoice_logos/" - ), - ), - ] diff --git a/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py b/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py deleted file mode 100644 index e00e2effb..000000000 --- a/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1 on 2024-08-23 11:54 - -import backend.models -from django.db import migrations, models - -from backend.core.models import _private_storage - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0047_defaultvalues_default_invoice_logo"), - ] - - operations = [ - migrations.AlterField( - model_name="defaultvalues", - name="default_invoice_logo", - field=models.ImageField(blank=True, null=True, storage=_private_storage, upload_to="invoice_logos/"), - ), - ] diff --git a/backend/migrations/0049_filestoragefile.py b/backend/migrations/0049_filestoragefile.py deleted file mode 100644 index 31578e422..000000000 --- a/backend/migrations/0049_filestoragefile.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 5.1 on 2024-08-25 20:16 - -import backend.models -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0048_alter_defaultvalues_default_invoice_logo"), - ] - - operations = [ - migrations.CreateModel( - name="FileStorageFile", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "file", - models.FileField( - storage=backend.core.models._private_storage, upload_to=backend.core.models.upload_to_user_separate_folder - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "last_edited_by", - models.ForeignKey( - blank=True, - editable=False, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="files_edited", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_filestoragefile_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/backend/migrations/0050_multifileupload.py b/backend/migrations/0050_multifileupload.py deleted file mode 100644 index 1b8f11dd9..000000000 --- a/backend/migrations/0050_multifileupload.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.1 on 2024-08-26 10:53 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0049_filestoragefile"), - ] - - operations = [ - migrations.CreateModel( - name="MultiFileUpload", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("started_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("finished_at", models.DateTimeField(blank=True, editable=False, null=True)), - ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ("files", models.ManyToManyField(related_name="multi_file_uploads", to="backend.filestoragefile")), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_multifileupload_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/backend/migrations/0051_planfeaturegroup_subscriptionplan_planfeature_and_more.py b/backend/migrations/0051_planfeaturegroup_subscriptionplan_planfeature_and_more.py deleted file mode 100644 index fe7c81423..000000000 --- a/backend/migrations/0051_planfeaturegroup_subscriptionplan_planfeature_and_more.py +++ /dev/null @@ -1,173 +0,0 @@ -# Generated by Django 5.1 on 2024-08-26 17:46 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0050_multifileupload"), - ] - - operations = [ - migrations.CreateModel( - name="PlanFeatureGroup", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=50)), - ], - ), - migrations.CreateModel( - name="SubscriptionPlan", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "name", - models.CharField( - choices=[ - ("free", "Free Plan"), - ("basic", "Basic Plan"), - ("standard", "Standard Plan"), - ("enterprise", "Enterprise Plan"), - ], - max_length=50, - unique=True, - ), - ), - ("price_per_month", models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ("description", models.TextField(blank=True, max_length=500, null=True)), - ("maximum_duration_months", models.IntegerField(blank=True, null=True)), - ], - ), - migrations.CreateModel( - name="PlanFeature", - 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)), - ( - "group", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="features", to="backend.planfeaturegroup"), - ), - ( - "subscription_plan", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="features", to="backend.subscriptionplan"), - ), - ], - ), - migrations.CreateModel( - name="PlanFeatureVersion", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("version", models.IntegerField(editable=False)), - ("free_tier_limit", models.FloatField(default=0)), - ("free_period_in_months", models.IntegerField(blank=True, null=True)), - ("unit", models.CharField(max_length=20)), - ("cost_per_unit", models.DecimalField(decimal_places=6, max_digits=10)), - ("units_per_cost", models.FloatField(default=1)), - ("minimum_billable_size", models.FloatField(blank=True, null=True)), - ("valid_from", models.DateTimeField(auto_now_add=True)), - ("valid_to", models.DateTimeField(blank=True, null=True)), - ("plan_feature", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="backend.planfeature")), - ], - ), - migrations.CreateModel( - name="Usage", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("feature", models.CharField(max_length=50)), - ("quantity", models.FloatField()), - ("unit", models.CharField(max_length=20)), - ("timestamp", models.DateTimeField(auto_now_add=True)), - ("end_time", models.DateTimeField(blank=True, null=True)), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_usage_check_user_or_organization", - ) - ], - }, - ), - migrations.CreateModel( - name="UserPlan", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("start_date", models.DateField()), - ("is_active", models.BooleanField(default=True)), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ("plan", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="backend.planfeature")), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_userplan_check_user_or_organization", - ) - ], - }, - ), - migrations.CreateModel( - name="UserSubscription", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("custom_subscription_price_per_month", models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ("start_date", models.DateField()), - ("end_date", models.DateField(blank=True, null=True)), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "subscription_plan", - models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="backend.subscriptionplan"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_usersubscription_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/backend/migrations/0052_filestoragefile_file_uri_path.py b/backend/migrations/0052_filestoragefile_file_uri_path.py deleted file mode 100644 index 34ddd213e..000000000 --- a/backend/migrations/0052_filestoragefile_file_uri_path.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1 on 2024-08-27 08:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0051_planfeaturegroup_subscriptionplan_planfeature_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="filestoragefile", - name="file_uri_path", - field=models.CharField(default="/null/", max_length=500), - preserve_default=False, - ), - ] diff --git a/backend/migrations/0053_usage_instance_id_alter_planfeature_name_and_more.py b/backend/migrations/0053_usage_instance_id_alter_planfeature_name_and_more.py deleted file mode 100644 index bbac4f49e..000000000 --- a/backend/migrations/0053_usage_instance_id_alter_planfeature_name_and_more.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.1 on 2024-08-27 18:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0052_filestoragefile_file_uri_path"), - ] - - operations = [ - migrations.AddField( - model_name="usage", - name="instance_id", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AlterField( - model_name="planfeature", - name="name", - field=models.CharField(max_length=100), - ), - migrations.AlterField( - model_name="planfeature", - name="slug", - field=models.CharField(editable=False, max_length=100), - ), - migrations.AlterField( - model_name="subscriptionplan", - name="name", - field=models.CharField(max_length=50, unique=True), - ), - migrations.AlterField( - model_name="usersubscription", - name="end_date", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name="usersubscription", - name="start_date", - field=models.DateTimeField(auto_now_add=True), - ), - migrations.DeleteModel( - name="UserPlan", - ), - ] diff --git a/backend/migrations/0054_transferusage_storageusage.py b/backend/migrations/0054_transferusage_storageusage.py deleted file mode 100644 index 2fe694416..000000000 --- a/backend/migrations/0054_transferusage_storageusage.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.1 on 2024-08-28 14:43 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0053_usage_instance_id_alter_planfeature_name_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="TransferUsage", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("amount_in_MB", models.DecimalField(decimal_places=2, max_digits=10)), - ("timestamp", models.DateTimeField(auto_now_add=True)), - ("feature", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="backend.planfeature")), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name="StorageUsage", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("file_uri_path", models.CharField(max_length=255)), - ("size_in_MB", models.DecimalField(decimal_places=8, max_digits=10)), - ("start_time", models.DateTimeField(auto_now_add=True)), - ("end_time", models.DateTimeField(blank=True, null=True)), - ("feature", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="backend.planfeature")), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_storageusage_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/backend/migrations/0055_remove_planfeature_group_and_more.py b/backend/migrations/0055_remove_planfeature_group_and_more.py deleted file mode 100644 index 54a56f2c5..000000000 --- a/backend/migrations/0055_remove_planfeature_group_and_more.py +++ /dev/null @@ -1,89 +0,0 @@ -# Generated by Django 5.1 on 2024-08-29 13:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0054_transferusage_storageusage"), - ] - - operations = [ - migrations.RemoveField( - model_name="planfeature", - name="group", - ), - migrations.RemoveField( - model_name="planfeature", - name="subscription_plan", - ), - migrations.RemoveField( - model_name="planfeatureversion", - name="plan_feature", - ), - migrations.RemoveField( - model_name="storageusage", - name="feature", - ), - migrations.RemoveField( - model_name="transferusage", - name="feature", - ), - # migrations.RemoveField( - # model_name="storageusage", - # name="organization", - # ), - # migrations.RemoveField( - # model_name="storageusage", - # name="user", - # ), - migrations.RemoveField( - model_name="usersubscription", - name="subscription_plan", - ), - # migrations.RemoveField( - # model_name="transferusage", - # name="user", - # ), - # migrations.RemoveField( - # model_name="usage", - # name="organization", - # ), - # migrations.RemoveField( - # model_name="usage", - # name="user", - # ), - # migrations.RemoveField( - # model_name="usersubscription", - # name="organization", - # ), - # migrations.RemoveField( - # model_name="usersubscription", - # name="user", - # ), - migrations.DeleteModel( - name="PlanFeatureGroup", - ), - migrations.DeleteModel( - name="PlanFeatureVersion", - ), - migrations.DeleteModel( - name="PlanFeature", - ), - migrations.DeleteModel( - name="StorageUsage", - ), - migrations.DeleteModel( - name="SubscriptionPlan", - ), - migrations.DeleteModel( - name="TransferUsage", - ), - migrations.DeleteModel( - name="Usage", - ), - migrations.DeleteModel( - name="UserSubscription", - ), - ] diff --git a/backend/migrations/0056_user_stripe_customer_id.py b/backend/migrations/0056_user_stripe_customer_id.py deleted file mode 100644 index 2d2938fe6..000000000 --- a/backend/migrations/0056_user_stripe_customer_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1 on 2024-08-29 13:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0055_remove_planfeature_group_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="stripe_customer_id", - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/backend/migrations/0057_user_entitlements.py b/backend/migrations/0057_user_entitlements.py deleted file mode 100644 index d5aa91033..000000000 --- a/backend/migrations/0057_user_entitlements.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1 on 2024-09-01 17:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0056_user_stripe_customer_id"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="entitlements", - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/backend/migrations/0058_organization_entitlements_and_more.py b/backend/migrations/0058_organization_entitlements_and_more.py deleted file mode 100644 index f526ef1b0..000000000 --- a/backend/migrations/0058_organization_entitlements_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1 on 2024-09-02 18:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0057_user_entitlements"), - ] - - operations = [ - migrations.AddField( - model_name="organization", - name="entitlements", - field=models.JSONField(blank=True, default=list, null=True), - ), - migrations.AddField( - model_name="organization", - name="stripe_customer_id", - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/backend/migrations/0059_alter_invoicerecurringprofile_managers_and_more.py b/backend/migrations/0059_alter_invoicerecurringprofile_managers_and_more.py deleted file mode 100644 index 3f0355aec..000000000 --- a/backend/migrations/0059_alter_invoicerecurringprofile_managers_and_more.py +++ /dev/null @@ -1,89 +0,0 @@ -# Generated by Django 5.1 on 2024-09-08 13:50 - -import django.db.models.deletion -import django.db.models.manager -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0058_organization_entitlements_and_more"), - ] - - operations = [ - migrations.AlterModelManagers( - name="invoicerecurringprofile", - managers=[ - ("with_items", django.db.models.manager.Manager()), - ], - ), - migrations.CreateModel( - name="MonthlyReportRow", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("date", models.DateField()), - ("reference_number", models.CharField(max_length=100)), - ("item_type", models.CharField(max_length=100)), - ("client_name", models.CharField(blank=True, max_length=64, null=True)), - ("paid_in", models.DecimalField(decimal_places=2, default=0, max_digits=15)), - ("paid_out", models.DecimalField(decimal_places=2, default=0, max_digits=15)), - ("client", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.client")), - ], - ), - migrations.CreateModel( - name="MonthlyReport", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ("name", models.CharField(blank=True, max_length=100, null=True)), - ("profit", models.DecimalField(decimal_places=2, default=0, max_digits=15)), - ("invoices_sent", models.PositiveIntegerField(default=0)), - ("start_date", models.DateField()), - ("end_date", models.DateField()), - ("recurring_customers", models.PositiveIntegerField(default=0)), - ("payments_in", models.DecimalField(decimal_places=2, default=0, max_digits=15)), - ("payments_out", models.DecimalField(decimal_places=2, default=0, max_digits=15)), - ( - "currency", - models.CharField( - choices=[ - ("GBP", "British Pound Sterling"), - ("EUR", "Euro"), - ("USD", "United States Dollar"), - ("JPY", "Japanese Yen"), - ("INR", "Indian Rupee"), - ("AUD", "Australian Dollar"), - ("CAD", "Canadian Dollar"), - ], - default="GBP", - max_length=3, - ), - ), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ("items", models.ManyToManyField(blank=True, to="backend.monthlyreportrow")), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="backend_monthlyreport_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/backend/migrations/0060_user_require_change_password.py b/backend/migrations/0060_user_require_change_password.py deleted file mode 100644 index e17cf8764..000000000 --- a/backend/migrations/0060_user_require_change_password.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1 on 2024-09-14 21:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0059_alter_invoicerecurringprofile_managers_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="require_change_password", - field=models.BooleanField(default=False), - ), - ] diff --git a/backend/migrations/0061_defaultvalues_invoice_from_address_and_more.py b/backend/migrations/0061_defaultvalues_invoice_from_address_and_more.py deleted file mode 100644 index 053b5b856..000000000 --- a/backend/migrations/0061_defaultvalues_invoice_from_address_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.1 on 2024-09-16 19:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0060_user_require_change_password"), - ] - - operations = [ - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_address", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_city", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_company", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_country", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_county", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_name", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/backend/migrations/0062_defaultvalues_invoice_account_holder_name_and_more.py b/backend/migrations/0062_defaultvalues_invoice_account_holder_name_and_more.py deleted file mode 100644 index 076be836f..000000000 --- a/backend/migrations/0062_defaultvalues_invoice_account_holder_name_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1 on 2024-09-16 20:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0061_defaultvalues_invoice_from_address_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="defaultvalues", - name="invoice_account_holder_name", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_account_number", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_sort_code", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py b/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py deleted file mode 100644 index 4f30d311f..000000000 --- a/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 5.1 on 2024-09-28 18:46 - -import backend.core.data.default_email_templates -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0062_defaultvalues_invoice_account_holder_name_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="defaultvalues", - name="email_template_recurring_invoices_invoice_cancelled", - field=models.TextField( - default=backend.core.data.default_email_templates.recurring_invoices_invoice_cancelled_default_email_template - ), - ), - migrations.AddField( - model_name="defaultvalues", - name="email_template_recurring_invoices_invoice_created", - field=models.TextField( - default=backend.core.data.default_email_templates.recurring_invoices_invoice_created_default_email_template - ), - ), - migrations.AddField( - model_name="defaultvalues", - name="email_template_recurring_invoices_invoice_overdue", - field=models.TextField( - default=backend.core.data.default_email_templates.recurring_invoices_invoice_overdue_default_email_template - ), - ), - migrations.AddField( - model_name="defaultvalues", - name="invoice_from_email", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/backend/migrations/0064_remove_invoice_payment_status_invoice_status.py b/backend/migrations/0064_remove_invoice_payment_status_invoice_status.py deleted file mode 100644 index d0049a0ed..000000000 --- a/backend/migrations/0064_remove_invoice_payment_status_invoice_status.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1 on 2024-09-28 19:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="invoice", - name="payment_status", - ), - migrations.AddField( - model_name="invoice", - name="status", - field=models.CharField( - choices=[("draft", "Draft"), ("pending", "Pending"), ("paid", "Paid")], default="pending", max_length=10 - ), - ), - ] diff --git a/backend/migrations/0065_remove_invoiceurl_never_expire_passwordsecret_active_and_more.py b/backend/migrations/0065_remove_invoiceurl_never_expire_passwordsecret_active_and_more.py deleted file mode 100644 index 647f8b132..000000000 --- a/backend/migrations/0065_remove_invoiceurl_never_expire_passwordsecret_active_and_more.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 20:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0064_remove_invoice_payment_status_invoice_status"), - ] - - operations = [ - migrations.RemoveField( - model_name="invoiceurl", - name="never_expire", - ), - migrations.AddField( - model_name="passwordsecret", - name="active", - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name="invoice", - name="status", - field=models.CharField(choices=[("draft", "Draft"), ("pending", "Pending"), ("paid", "Paid")], default="draft", max_length=10), - ), - migrations.AlterField( - model_name="invoiceurl", - name="expires", - field=models.DateTimeField(blank=True, help_text="When the item will expire", null=True, verbose_name="Expires"), - ), - migrations.AlterField( - model_name="passwordsecret", - name="expires", - field=models.DateTimeField(blank=True, help_text="When the item will expire", null=True, verbose_name="Expires"), - ), - migrations.AlterField( - model_name="teaminvitation", - name="expires", - field=models.DateTimeField(blank=True, help_text="When the item will expire", null=True, verbose_name="Expires"), - ), - ] diff --git a/backend/migrations/0066_delete_apikey_remove_verificationcodes_expiry_and_more.py b/backend/migrations/0066_delete_apikey_remove_verificationcodes_expiry_and_more.py deleted file mode 100644 index 4eaffce95..000000000 --- a/backend/migrations/0066_delete_apikey_remove_verificationcodes_expiry_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-06 07:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0065_remove_invoiceurl_never_expire_passwordsecret_active_and_more"), - ] - - operations = [ - migrations.DeleteModel( - name="APIKey", - ), - migrations.RemoveField( - model_name="verificationcodes", - name="expiry", - ), - migrations.AddField( - model_name="verificationcodes", - name="active", - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name="verificationcodes", - name="expires", - field=models.DateTimeField(blank=True, help_text="When the item will expire", null=True, verbose_name="Expires"), - ), - ] diff --git a/backend/migrations/0067_remove_apiauthtoken_expired_and_more.py b/backend/migrations/0067_remove_apiauthtoken_expired_and_more.py deleted file mode 100644 index 1272d48dd..000000000 --- a/backend/migrations/0067_remove_apiauthtoken_expired_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-19 19:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0066_delete_apikey_remove_verificationcodes_expiry_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="apiauthtoken", - name="expired", - ), - migrations.AlterField( - model_name="apiauthtoken", - name="active", - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name="apiauthtoken", - name="expires", - field=models.DateTimeField(blank=True, help_text="When the item will expire", null=True, verbose_name="Expires"), - ), - ] diff --git a/backend/migrations/0068_invoice_created_at_invoice_status_updated_at_and_more.py b/backend/migrations/0068_invoice_created_at_invoice_status_updated_at_and_more.py deleted file mode 100644 index 66513144b..000000000 --- a/backend/migrations/0068_invoice_created_at_invoice_status_updated_at_and_more.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-08 19:57 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0067_remove_apiauthtoken_expired_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="invoice", - name="created_at", - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name="invoice", - name="status_updated_at", - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name="invoice", - name="updated_at", - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name="invoicerecurringprofile", - name="updated_at", - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/backend/migrations/0069_alter_auditlog_action.py b/backend/migrations/0069_alter_auditlog_action.py deleted file mode 100644 index e9aae2fbf..000000000 --- a/backend/migrations/0069_alter_auditlog_action.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-15 09:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0068_invoice_created_at_invoice_status_updated_at_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="auditlog", - name="action", - field=models.CharField(max_length=300), - ), - ] diff --git a/backend/migrations/0070_remove_invoice_invoice_id_and_more.py b/backend/migrations/0070_remove_invoice_invoice_id_and_more.py deleted file mode 100644 index a059eaa86..000000000 --- a/backend/migrations/0070_remove_invoice_invoice_id_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-17 15:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0069_alter_auditlog_action"), - ] - - operations = [ - migrations.RemoveField( - model_name="invoice", - name="invoice_id", - ), - migrations.RemoveField( - model_name="invoice", - name="invoice_number", - ), - migrations.RemoveField( - model_name="invoicerecurringprofile", - name="invoice_number", - ), - migrations.RemoveField( - model_name="invoicerecurringprofile", - name="reference", - ), - migrations.AlterField( - model_name="invoice", - name="reference", - field=models.CharField(blank=True, max_length=16, null=True), - ), - ] diff --git a/backend/modals.py b/backend/modals.py new file mode 100644 index 000000000..1b469f30d --- /dev/null +++ b/backend/modals.py @@ -0,0 +1,277 @@ +from core.service.modals.registry import Modal +from core.types.requests import WebRequest +from core.utils.feature_flags import get_feature_status +from django.contrib import messages +from django.shortcuts import render + +from backend.finance.models import InvoiceURL, Invoice, Receipt +from backend.finance.service.defaults.get import get_account_defaults +from backend.models import Client + + +class InvoicesToDestinationModal(Modal): + modal_name = "invoices_to_destination" + + def get(self, request: WebRequest): + context = self.get_context(request) + + if existing_client := request.GET.get("client"): + context["existing_client_id"] = existing_client + + return render(request, self.get_template_name(), context) + + +class EmailContext: + def get_context(self, request: WebRequest) -> dict: + return { + "content_min_length": 64, + "content_max_length": 1000, + "email_list": Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True), + } + + +class SendSingleEmailModal(Modal, EmailContext): + modal_name = "send_single_email" + + def get(self, request: WebRequest): + if not get_feature_status("areUserEmailsAllowed"): + messages.error(request, "Emails are disabled") + return render(request, "core/base/toast.html") + + context = self.get_context(request) + + if request.GET.get("type") == "invoice_code_send": + invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=request.GET.get("code")).prefetch_related("invoice").first() + + if not invoice_url or not invoice_url.invoice.has_access(request.user): + messages.error(request, "You don't have access to this invoice") + return render(request, "core/base/toast.html", {"autohide": False}) + + context["invoice"] = invoice_url.invoice + context["selected_clients"] = [ + invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email + for value in [invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email] + if value is not None + ] + + context["email_list"] = list(context["email_list"]) + context["selected_clients"] + + return render(request, self.get_template_name(), context) + + +class EditReceiptModal(Modal): + modal_name = "edit_receipt" + + def get(self, request: WebRequest): + context = self.get_context(request) + + try: + receipt = Receipt.filter_by_owner(request.actor).get(pk=request.GET.get("receipt_id")) + except Receipt.DoesNotExist: + return self.Response(request, context) + + receipt_date = receipt.date.strftime("%Y-%m-%d") if receipt.date else "" + context.update( + { + "modal_id": f"modal_{receipt.id}_receipts_upload", + "receipt_id": request.GET.get("receipt_id"), + "receipt_name": receipt.name, + "receipt_date": receipt_date, + "merchant_store_name": receipt.merchant_store, + "purchase_category": receipt.purchase_category, + "total_price": receipt.total_price, + "has_receipt_image": True if receipt.image else False, + "edit_flag": True, + } + ) + + return self.Response(request, context) + + +class UploadReceiptModal(Modal): + modal_name = "upload_receipt" + template_name = "modals/receipts_upload.html" + + def get(self, request: WebRequest): + context = self.get_context(request) + + context.update({"modal_id": "modal_receipts_upload"}) + + return self.Response(request, context) + + +class EditInvoiceToModal(Modal): + modal_name = "edit_invoice_to" + + def get(self, request: WebRequest): + context = self.get_context(request) + + invoice_id = request.GET.get("invoice_id") + + try: + invoice = Invoice.filter_by_owner(request.actor).get(id=invoice_id) # todo: add permission checks + except Invoice.DoesNotExist: + return self.Response(request, context) + + if invoice.client_to: + context["to_name"] = invoice.client_to.name + context["to_company"] = invoice.client_to.company + context["to_email"] = invoice.client_to.email + context["to_address"] = invoice.client_to.address + context["existing_client_id"] = ( + invoice.client_to.id + ) # context["to_city"] = invoice.client_to.city # context["to_county"] = invoice.client_to.county # context["to_country"] = invoice.client_to.country + else: + context["to_name"] = invoice.client_name + context["to_company"] = invoice.client_company + context["to_email"] = invoice.client_email + context["is_representative"] = invoice.client_is_representative + context["to_address"] = ( + invoice.client_address + ) # context["to_city"] = invoice.client_city # context["to_county"] = invoice.client_county # context["to_country"] = invoice.client_country + + return self.Response(request, context) + + +class EditInvoiceFromModal(Modal): + modal_name = "edit_invoice_from" + + def get(self, request: WebRequest): + context = self.get_context(request) + + invoice_id = request.GET.get("invoice_id") + + try: + invoice = Invoice.filter_by_owner(request.actor).get(id=invoice_id) # todo: add permission checks + except Invoice.DoesNotExist: + return self.Response(request, context) + + context["from_name"] = invoice.self_name + context["from_company"] = invoice.self_company + context["from_address"] = invoice.self_address + context["from_city"] = invoice.self_city + context["from_county"] = invoice.self_county + context["from_country"] = invoice.self_country + return self.Response(request, context) + + +# create_invoice_from +class CreateInvoiceFromModal(Modal): + modal_name = "create_invoice_from" + + def get(self, request: WebRequest): + context = self.get_context(request) + + defaults = get_account_defaults(request.actor) + + context["from_name"] = getattr(defaults, f"invoice_from_name") + context["from_company"] = getattr(defaults, f"invoice_from_company") + context["from_address"] = getattr(defaults, f"invoice_from_address") + context["from_city"] = getattr(defaults, f"invoice_from_city") + context["from_county"] = getattr(defaults, f"invoice_from_county") + context["from_country"] = getattr(defaults, f"invoice_from_country") + + return self.Response(request, context) + + +class InvoiceContext: + def get_context(self, request: WebRequest) -> dict: + try: + invoice = Invoice.filter_by_owner(request.actor).get(id=request.GET.get("invoice_id")) + if invoice.has_access(request.user): + return {"invoice": invoice} + except Invoice.DoesNotExist: + return {} + + +class EditInvoiceDiscountModal(Modal, InvoiceContext): + modal_name = "invoices_edit_discount" + + def get(self, request: WebRequest): + context = self.get_context(request) + + return self.Response(request, context) + + +# class ViewQuotaLimitInfoModal(Modal): +# modal_name = 'view_quota_limit_info' +# +# def get(self, request: WebRequest): +# context = self.get_context(request) +# +# 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: +# ... +# +# return self.Response(request, context) + + +class CreateInvoiceReminderModal(Modal): + modal_name = "create_invoice_reminder" + + def get(self, request: WebRequest): + context = self.get_context(request) + + try: + invoice = Invoice.filter_by_owner(request.actor).get(id=request.GET.get("invoice_id")) + if invoice.has_access(request.user): + context["invoice"] = invoice + else: + messages.error(request, "You don't have access to this invoice") + return render(request, "core/base/toasts.html") + except Invoice.DoesNotExist: + return self.Response(request, context) + + return self.Response(request, context) + + +class SendEmailContext: + def get_context(self, request: WebRequest) -> dict: + if not get_feature_status("areUserEmailsAllowed"): + messages.error(request, "Emails are disabled") + return render(request, "core/base/toast.html") + + context = {} + + context["content_min_length"] = 64 + # quota = QuotaLimit.objects.prefetch_related("quota_overrides").get(slug="emails-email_character_count") + # context["content_max_length"] = quota.get_quota_limit(user=request.user, quota_limit=quota) + context["content_max_length"] = 1000 + + context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True) + + return context + + +class InvoiceCodeSendModal(Modal, SendEmailContext): + modal_name = "invoice_code_send" + + def get(self, request: WebRequest): + context = self.get_context(request) + + invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=request.GET.get("code")).prefetch_related("invoice").first() + + if not invoice_url or not invoice_url.invoice.has_access(request.user): + messages.error(request, "You don't have access to this invoice") + return render(request, "core/base/toast.html", {"autohide": False}) + + context["invoice"] = invoice_url.invoice + context["selected_clients"] = [ + invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email + for value in [invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email] + if value is not None + ] + + context["email_list"] = list(context["email_list"]) + context["selected_clients"] + + return self.Response(request, context) + + +class GenerateReportModal(Modal): + modal_name = "generate_report" diff --git a/backend/models.py b/backend/models.py index 6b354acfe..233555ea8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,24 +1,30 @@ -from backend.core.models import ( - PasswordSecret, +from core.models import ( AuditLog, LoginLog, Error, TracebackError, - UserSettings, - Notification, Organization, TeamInvitation, TeamMemberPermission, User, FeatureFlags, + UserSettings, + Notification, VerificationCodes, - QuotaLimit, - QuotaOverrides, - QuotaUsage, - QuotaIncreaseRequest, + PasswordSecret, EmailSendStatus, - FileStorageFile, - MultiFileUpload, + OwnerBase, + _private_storage, + USER_OR_ORGANIZATION_CONSTRAINT, + ExpiresBase, + CustomUserManager, + add_3hrs_from_now, + RandomCode, + _public_storage, + upload_to_user_separate_folder, + RandomAPICode, + Client, + DefaultValuesBase, ) from backend.finance.models import ( @@ -32,6 +38,7 @@ ReceiptDownloadToken, MonthlyReport, MonthlyReportRow, + FinanceDefaultValues, ) -from backend.clients.models import Client, DefaultValues +from backend.storage.models import FileStorageFile, MultiFileUpload diff --git a/backend/storage/api/delete.py b/backend/storage/api/delete.py index a2ac79f14..262be1105 100644 --- a/backend/storage/api/delete.py +++ b/backend/storage/api/delete.py @@ -4,9 +4,9 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.decorators import htmx_only +from core.decorators import htmx_only from backend.models import FileStorageFile -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest @require_http_methods(["DELETE"]) @@ -31,7 +31,7 @@ def recursive_file_delete_endpoint(request: WebRequest) -> HttpResponse: if failed_files: messages.error(request, f"Failed to delete: {', '.join([file for file in failed_files])}") - resp = render(request, "base/toast.html") + resp = render(request, "core/base/toast.html") else: resp = HttpResponse(status=200) diff --git a/backend/storage/api/fetch.py b/backend/storage/api/fetch.py index e8b0e33da..d6f6915e4 100644 --- a/backend/storage/api/fetch.py +++ b/backend/storage/api/fetch.py @@ -1,12 +1,12 @@ from django.utils.html import escape from django.views.decorators.http import require_GET -from backend.decorators import htmx_only +from core.decorators import htmx_only from backend.models import FileStorageFile # from backend.core.service.billing.calculate.test import generate_monthly_billing_summary -from backend.core.service.file_storage.utils import format_file_size -from backend.core.types.requests import WebRequest +from backend.storage.service.utils import format_file_size +from core.types.requests import WebRequest from django.shortcuts import render diff --git a/backend/storage/file_storage.py b/backend/storage/file_storage.py index 4c01777c1..9282a735c 100644 --- a/backend/storage/file_storage.py +++ b/backend/storage/file_storage.py @@ -3,7 +3,7 @@ from django.db.models.signals import pre_delete, post_save from django.dispatch import receiver -from backend.core.models import _private_storage +from backend.models import _private_storage from backend.models import FileStorageFile logger = logging.getLogger(__name__) diff --git a/backend/storage/models.py b/backend/storage/models.py new file mode 100644 index 000000000..b6225ea07 --- /dev/null +++ b/backend/storage/models.py @@ -0,0 +1,31 @@ +from __future__ import annotations +from uuid import uuid4 +from django.db import models +from backend.models import OwnerBase, _private_storage, upload_to_user_separate_folder, User + + +class FileStorageFile(OwnerBase): + file = models.FileField(upload_to=upload_to_user_separate_folder, storage=_private_storage) + file_uri_path = models.CharField(max_length=500) # relative path not including user folder/media + last_edited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name="files_edited") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + __original_file = None + __original_file_uri_path = None + + def __init__(self, *args, **kwargs): + super(FileStorageFile, self).__init__(*args, **kwargs) + self.__original_file = self.file + self.__original_file_uri_path = self.file_uri_path + + +class MultiFileUpload(OwnerBase): + files = models.ManyToManyField(FileStorageFile, related_name="multi_file_uploads") + started_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + finished_at = models.DateTimeField(null=True, blank=True, editable=False) + uuid = models.UUIDField(default=uuid4, editable=False, unique=True) + + def is_finished(self): + return self.finished_at is not None diff --git a/backend/core/service/invoices/common/emails/__init__.py b/backend/storage/service/__init__.py similarity index 100% rename from backend/core/service/invoices/common/emails/__init__.py rename to backend/storage/service/__init__.py diff --git a/backend/core/service/file_storage/create.py b/backend/storage/service/create.py similarity index 95% rename from backend/core/service/file_storage/create.py rename to backend/storage/service/create.py index 84c16cafc..5da1c3c79 100644 --- a/backend/core/service/file_storage/create.py +++ b/backend/storage/service/create.py @@ -1,6 +1,6 @@ from django.core.files.uploadedfile import UploadedFile -from backend.core.utils.dataclasses import BaseServiceResponse +from core.utils.dataclasses import BaseServiceResponse from backend.models import FileStorageFile, User, Organization diff --git a/backend/core/service/file_storage/utils.py b/backend/storage/service/utils.py similarity index 100% rename from backend/core/service/file_storage/utils.py rename to backend/storage/service/utils.py diff --git a/backend/storage/views/dashboard.py b/backend/storage/views/dashboard.py index 47d9a79b4..7408eb28c 100644 --- a/backend/storage/views/dashboard.py +++ b/backend/storage/views/dashboard.py @@ -1,9 +1,9 @@ from django.shortcuts import render from django.utils.html import escape -from backend.core.models import FileStorageFile -from backend.core.service.file_storage.utils import format_file_size -from backend.core.types.requests import WebRequest +from backend.models import FileStorageFile +from backend.storage.service.utils import format_file_size +from core.types.requests import WebRequest def file_storage_dashboard_endpoint(request: WebRequest): diff --git a/backend/storage/views/upload.py b/backend/storage/views/upload.py index 408d80d04..b78068261 100644 --- a/backend/storage/views/upload.py +++ b/backend/storage/views/upload.py @@ -8,11 +8,11 @@ from django.utils import timezone from django.views.decorators.http import require_http_methods -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest from backend.models import FileStorageFile, MultiFileUpload -from backend.core.models import _private_storage, upload_to_user_separate_folder +from backend.models import _private_storage, upload_to_user_separate_folder -from backend.core.service.file_storage.create import parse_files_for_creation +from backend.storage.service.create import parse_files_for_creation from django.urls import reverse diff --git a/backend/templatetags/cal_filters.py b/backend/templatetags/cal_filters.py deleted file mode 100644 index e68d14793..000000000 --- a/backend/templatetags/cal_filters.py +++ /dev/null @@ -1,16 +0,0 @@ -from django import template - -register = template.Library() - - -@register.filter -def ordinal(value): - try: - value = int(value) - if 10 <= value % 100 <= 20: - suffix = "th" - else: - suffix = {1: "st", 2: "nd", 3: "rd"}.get(value % 10, "th") - return f"{value}{suffix}" - except (ValueError, TypeError): - return value diff --git a/backend/templatetags/dictfilters.py b/backend/templatetags/dictfilters.py deleted file mode 100644 index ed99a7ff0..000000000 --- a/backend/templatetags/dictfilters.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Any - -from django import template - -register = template.Library() - - -@register.simple_tag -def dict_get(dictionary: dict, key: Any): - return dictionary.get(key) - - -register.filter("dict_get", dict_get) diff --git a/backend/templatetags/feature_enabled.py b/backend/templatetags/feature_enabled.py deleted file mode 100644 index 3d00c8ed2..000000000 --- a/backend/templatetags/feature_enabled.py +++ /dev/null @@ -1,44 +0,0 @@ -from django import template -from django.urls import NoReverseMatch - -from backend.models import User, Organization -from backend.core.utils.feature_flags import get_feature_status - -from django.conf import settings - -register = template.Library() - - -@register.simple_tag -def has_module(module_str: str): - return module_str in settings.INSTALLED_APPS - - -@register.simple_tag -def safe_url(view_name, *args, **kwargs): - from django.urls import reverse - - try: - return reverse(view_name, args=args, kwargs=kwargs) - except NoReverseMatch: - return "" - - -@register.simple_tag -def feature_enabled(feature): - return get_feature_status(feature) - - -@register.simple_tag -def personal_feature_enabled(user: User, feature: str): - return user.user_profile.has_feature(feature) - - -@register.simple_tag -def has_entitlement(actor: User | Organization, entitlement: str) -> bool: - if not settings.BILLING_ENABLED: - return True - - from billing.service.entitlements import has_entitlement as _has_entitlement - - return _has_entitlement(actor, entitlement) diff --git a/backend/templatetags/listfilters.py b/backend/templatetags/listfilters.py deleted file mode 100644 index 8a1bde5f1..000000000 --- a/backend/templatetags/listfilters.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any - -from django import template - -register = template.Library() - - -@register.simple_tag -def list_item_prefix_distinct(items: list, index: int = 0, separator=":"): - return set(i.split(separator)[index] for i in items) - - -def lookup_separator_perms(items: list, lookup_value: Any): - values = [i.split(":")[-1] for i in items if i.split(":")[0] == lookup_value] - return values - - -def at_index(items: list, index: int = 0): - return items[index] - - -@register.simple_tag -def common_items(*lists: list[list]) -> list: - result = set(lists[0]) - for lst in lists[1:]: - result.intersection_update(lst) - return list(result) - - -@register.simple_tag -def common_items_count(*lists: list[list]) -> int: - return len(common_items(*lists)) - - -def common_children_filter(list1, list2): - return common_items(list1, list2) - - -@register.filter -def get_first_n_items(value, n): - """ - Returns the first n items of a list. - """ - try: - return value[:n] - except TypeError: - return [] - - -register.filter("list_item_prefix_distinct", list_item_prefix_distinct) -register.filter("lookup_separator_perms", lookup_separator_perms) -register.filter("at_index", at_index) -register.filter("common_children_filter", common_children_filter) diff --git a/backend/templatetags/strfilters.py b/backend/templatetags/strfilters.py deleted file mode 100644 index c71ffe66f..000000000 --- a/backend/templatetags/strfilters.py +++ /dev/null @@ -1,59 +0,0 @@ -import time - -from django import template - -register = template.Library() - - -def split(string, char=" "): - return string.split(char) - - -def dashify(string, recurrence=2): - num_str = str(string) - - return "-".join(num_str[i : i + recurrence] for i in range(0, len(num_str), recurrence)) - - -def to_list(string, separator=",") -> list[str]: - return string.split(separator) - - -def contains(value, arg): - return arg in str(value) - - -def day_to_number_sunday(day: str) -> int: - """ - Converts a day of the week to a number with Sunday as the first day. - - Args: - day (str): The day of the week (e.g., "Sunday", "Monday"). - - Returns: - int: The corresponding number with Sunday as 0 and Saturday as 6. - """ - # Get the day number with Monday as 0 - day_number = time.strptime(day, "%A").tm_wday - - # Adjust the day number to make Sunday the first day - sunday_first_day_number = (day_number + 1) % 7 - - return sunday_first_day_number - - -def day_to_number_monday(day: str) -> int: - return time.strptime(day, "%A").tm_wday + 1 - - -def month_to_number(month: str) -> int: - return time.strptime(month, "%B").tm_mon - - -register.filter("split", split) -register.filter("dashify", dashify) -register.filter("contains", contains) -register.filter("to_list", to_list) -register.filter("day_to_number_monday", day_to_number_monday) -register.filter("day_to_number_sunday", day_to_number_sunday) -register.filter("month_to_number", month_to_number) diff --git a/backend/templatetags/utils.py b/backend/templatetags/utils.py deleted file mode 100644 index abc003c5c..000000000 --- a/backend/templatetags/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -import re - -from django.template import Library, Node -from django.utils.encoding import force_str - -register = Library() - - -def strip_spaces_in_tags(value): - value = force_str(value) - value = re.sub(r"\s+", " ", value) - value = re.sub(r">\s+", ">", value) - value = re.sub(r"\s+<", "<", value) - return value - - -class NoSpacesNode(Node): - def __init__(self, nodelist): - self.nodelist = nodelist - - def render(self, context): - return strip_spaces_in_tags(self.nodelist.render(context).strip()) - - -@register.tag -def nospaces(parser, token): - """ - Removes any duplicite whitespace in tags and text. Can be used as supplementary tag for {% spaceless %}:: - - {% nospaces %} - - Hello - this is text - - {% nospaces %} - - Returns:: - Hello this is text - """ - nodelist = parser.parse(("endnospaces",)) - parser.delete_first_token() - return NoSpacesNode(nodelist) diff --git a/backend/urls.py b/backend/urls.py index 494ea5dc6..0c66608c4 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -1,5 +1,7 @@ from __future__ import annotations +from core.views.other.index import dashboard +from core.views.other.index import index, pricing from django.conf import settings from django.conf.urls.static import static from django.contrib import admin @@ -9,35 +11,31 @@ from django.views.generic import RedirectView from django.views.static import serve -from backend.core.api.public.swagger_ui import get_swagger_ui, get_swagger_endpoints from backend.finance.views.invoices.single.view import view_invoice_with_uuid_endpoint from backend.finance.views.receipts.dashboard import receipts_dashboard -from backend.core.views.other.index import dashboard -from backend.core.views.other.index import index, pricing -from backend.core.views.quotas.view import quotas_list -from backend.core.views.quotas.view import view_quota_increase_requests -from settings.settings import BILLING_ENABLED + +# from core.views.quotas.view import quotas_list +# from core.views.quotas.view import view_quota_increase_requests url( r"^frontend/static/(?P.*)$", serve, {"document_root": settings.STATICFILES_DIRS[0]}, ) + urlpatterns = [ path("tz_detect/", include("tz_detect.urls")), - path("webhooks/", include("backend.core.webhooks.urls")), + path("", include(("core.urls", "core"), namespace="core")), + path("webhooks/", include("backend.webhooks.urls")), path("", index, name="index"), path("pricing", pricing, name="pricing"), path("dashboard/", dashboard, name="dashboard"), - path("dashboard/settings/", include("backend.core.views.settings.urls")), - path("dashboard/teams/", include("backend.core.views.teams.urls")), path("dashboard/", include("backend.finance.views.urls")), # path("dashboard/quotas/", quotas_page, name="quotas"), path("dashboard/quotas/", RedirectView.as_view(url="/dashboard"), name="quotas"), - path("dashboard/quotas//", quotas_list, name="quotas group"), - path("dashboard/emails/", include("backend.core.views.emails.urls")), + # path("dashboard/quotas//", quotas_list, name="quotas group"), path("dashboard/reports/", include("backend.finance.views.reports.urls")), - path("dashboard/admin/quota_requests/", view_quota_increase_requests, name="admin quota increase requests"), + # path("dashboard/admin/quota_requests/", view_quota_increase_requests, name="admin quota increase requests"), path("dashboard/file_storage/", include("backend.storage.views.urls")), path("dashboard/clients/", include("backend.clients.views.urls")), path("favicon.ico", RedirectView.as_view(url=settings.STATIC_URL + "favicon.ico")), @@ -51,25 +49,14 @@ view_invoice_with_uuid_endpoint, name="invoices view invoice", ), - path("login/external/", include("social_django.urls", namespace="social")), - path("auth/", include("backend.core.views.auth.urls")), - path("api/", include("backend.core.api.urls")), + path("api/", include("backend.api.urls", namespace="api")), + # path("api/", include("core.api.urls", namespace="api")) path("admin/", admin.site.urls), ] + static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0]) -if settings.DEBUG: - urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))] - - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - # may not need to be in debug - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0]) - -if BILLING_ENABLED: - urlpatterns.append(path("", include("billing.urls"))) - -schema_view = get_swagger_ui() -urlpatterns += get_swagger_endpoints(settings.DEBUG) +if getattr(settings, "BILLING_ENABLED"): + urlpatterns.append(path("", include(("billing.urls", "billing"), namespace="billing"))) -handler500 = "backend.core.views.other.errors.universal" -handler404 = "backend.core.views.other.errors.universal" -handler403 = "backend.core.views.other.errors.e_403" +handler500 = "core.views.other.errors.universal" +handler404 = "core.views.other.errors.universal" +handler403 = "core.views.other.errors.e_403" diff --git a/backend/core/service/invoices/recurring/__init__.py b/backend/webhooks/__init__.py similarity index 100% rename from backend/core/service/invoices/recurring/__init__.py rename to backend/webhooks/__init__.py diff --git a/backend/core/service/invoices/recurring/create/__init__.py b/backend/webhooks/invoices/__init__.py similarity index 100% rename from backend/core/service/invoices/recurring/create/__init__.py rename to backend/webhooks/invoices/__init__.py diff --git a/backend/core/webhooks/invoices/invoice_status.py b/backend/webhooks/invoices/invoice_status.py similarity index 87% rename from backend/core/webhooks/invoices/invoice_status.py rename to backend/webhooks/invoices/invoice_status.py index 3f9e85d9f..ed8c1c0fa 100644 --- a/backend/core/webhooks/invoices/invoice_status.py +++ b/backend/webhooks/invoices/invoice_status.py @@ -1,17 +1,17 @@ from datetime import datetime +from core.service.webhooks.auth import authenticate_api_key from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from login_required import login_not_required from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.core.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key +from backend.finance.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service import logging -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest logger = logging.getLogger(__name__) @@ -20,7 +20,6 @@ @csrf_exempt @login_not_required def handle_invoice_now_webhook_endpoint(request: WebRequest): - invoice_profile_id = request.POST.get("invoice_profile_id", "") logger.info("Received Scheduled Invoice. Now authenticating...") diff --git a/backend/core/webhooks/invoices/recurring.py b/backend/webhooks/invoices/recurring.py similarity index 88% rename from backend/core/webhooks/invoices/recurring.py rename to backend/webhooks/invoices/recurring.py index 3f0a1b627..38d241ade 100644 --- a/backend/core/webhooks/invoices/recurring.py +++ b/backend/webhooks/invoices/recurring.py @@ -1,17 +1,17 @@ from datetime import datetime +from core.service.webhooks.auth import authenticate_api_key from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from login_required import login_not_required from backend.finance.models import InvoiceRecurringProfile -from backend.core.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.core.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key +from backend.finance.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service import logging -from backend.core.types.requests import WebRequest +from core.types.requests import WebRequest logger = logging.getLogger(__name__) diff --git a/backend/core/webhooks/urls.py b/backend/webhooks/urls.py similarity index 67% rename from backend/core/webhooks/urls.py rename to backend/webhooks/urls.py index 2ceeab7d0..2a9035270 100644 --- a/backend/core/webhooks/urls.py +++ b/backend/webhooks/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from backend.core.webhooks.invoices.recurring import handle_recurring_invoice_webhook_endpoint +from backend.webhooks.invoices.recurring import handle_recurring_invoice_webhook_endpoint urlpatterns = [ path("schedules/receive/recurring_invoices/", handle_recurring_invoice_webhook_endpoint, name="receive_recurring_invoices"), diff --git a/billing/__init__.py b/billing/__init__.py deleted file mode 100644 index 2088f7c1e..000000000 --- a/billing/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import apps, billing_settings diff --git a/billing/admin.py b/billing/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/billing/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/billing/apps.py b/billing/apps.py deleted file mode 100644 index a663d988f..000000000 --- a/billing/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.apps import AppConfig - - -class BillingConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "billing" - - def ready(self): - from . import signals - - pass diff --git a/billing/billing_settings.py b/billing/billing_settings.py deleted file mode 100644 index 124cb9c77..000000000 --- a/billing/billing_settings.py +++ /dev/null @@ -1,43 +0,0 @@ -import stripe - -from settings.helpers import get_var - -STRIPE_TEST_SECRET_KEY = get_var("STRIPE_TEST_SECRET_KEY") -STRIPE_LIVE_SECRET_KEY = get_var("STRIPE_LIVE_SECRET_KEY") -STRIPE_WEBHOOK_ENDPOINT_SECRET = get_var("STRIPE_WEBHOOK_ENDPOINT_SECRET") - -STRIPE_MAIN_API_KEY = STRIPE_LIVE_SECRET_KEY if STRIPE_LIVE_SECRET_KEY else STRIPE_TEST_SECRET_KEY - -STRIPE_LIVE_MODE = True if STRIPE_LIVE_SECRET_KEY else False - -stripe.api_key = STRIPE_MAIN_API_KEY - -NO_SUBSCRIPTION_PLAN_DENY_VIEW_NAMES: set[str] = { - "clients:create", - "file_storage:upload:start_batch", - "file_storage:upload:end_batch", - "file_storage:upload:add_to_batch", - "file_storage:upload:dashboard", - # "finance:invoices:single:manage_access", - "finance:invoices:single:manage_access create", - # "finance:invoices:single:manage_access delete", - "finance:invoices:single:edit", - "finance:invoices:single:create", - "finance:invoices:recurring:create", - "finance:invoices:recurring:edit", - # APIS - "teams:invite", - "teams:create", - "receipts:edit", - "receipts:new", - "finance:invoices:single:edit", - "finance:invoices:single:edit discount", - "finance:invoices:recurring:generate next invoice", - "finance:invoices:recurring:edit", - "finance:invoices:create:set_destination from", - "finance:invoices:create:set_destination to", - "finance:invoices:create:services add", - "products:create", - "public:clients:create", - "public:invoices:create", -} diff --git a/billing/data/__init__.py b/billing/data/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/billing/data/default_usage_plans.py b/billing/data/default_usage_plans.py deleted file mode 100644 index 0f50ec7a5..000000000 --- a/billing/data/default_usage_plans.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from decimal import Decimal -from typing import Literal - - -@dataclass -class DefaultFeature: - """ - Single Product, e.g. "invoices-created" - This is a stripe "PRICE" (part of a PRODUCT) - """ - - slug: str # Consistent slug across plans - description: str - max_limit_per_month: int - subscription_plan: DefaultSubscriptionPlan - - -@dataclass -class DefaultFeatureGroup: - """ - Group of products, e.g. "Invoices" - """ - - name: str - items: list[DefaultFeature] - - -@dataclass -class DefaultSubscriptionPlan: - """ - This is a Stripe PRODUCT - """ - - name: str - price_per_month: int - description: str - - -# Default subscription plans -free_plan = DefaultSubscriptionPlan( - name="Free Trial", - price_per_month=0, - description="Try out MyFinances", -) - -starter_plan = DefaultSubscriptionPlan( - name="Starter", - price_per_month=5, - description="For small businesses that need limited features", -) - -growth_plan = DefaultSubscriptionPlan( - name="Growth", - price_per_month=10, - description="For growing businesses that need a little extra", -) - -# enterprise_plan = DefaultSubscriptionPlan( -# name="Enterprise", -# price_per_month=-1, -# description="Additional customization for your ideal business", -# ) - -default_subscription_plans: list[DefaultSubscriptionPlan] = [free_plan, starter_plan, growth_plan] - -# Default usage plans -default_usage_plans: list[DefaultFeatureGroup] = [ - DefaultFeatureGroup( - "invoices", - [ - # region "invoices-created" - DefaultFeature( - slug="invoices-created", - description="Amount of invoices created per month", - max_limit_per_month=10, - subscription_plan=free_plan, - ), - DefaultFeature( - slug="invoices-created", - description="Amount of invoices created per month (starter plan)", - max_limit_per_month=500, - subscription_plan=starter_plan, - ), - DefaultFeature( - slug="invoices-created", - description="Amount of invoices created per month", - max_limit_per_month=-1, - subscription_plan=growth_plan, - ), - # endregion "invoices-created" - # region "invoices-sent-via-schedule" - DefaultFeature( - slug="invoices-sent-via-schedule", - description="Amount of invoices sent from a schedule per month", - max_limit_per_month=1, - subscription_plan=free_plan, - ), - DefaultFeature( - slug="invoices-sent-via-schedule", - description="Amount of invoices sent from a schedule per month", - max_limit_per_month=50, - subscription_plan=starter_plan, - ), - DefaultFeature( - slug="invoices-sent-via-schedule", - description="Amount of invoices sent from a schedule per month", - max_limit_per_month=-1, - subscription_plan=growth_plan, - ), - # endregion "invoices-sent-via-schedule" - ], - ), - DefaultFeatureGroup( - "teams", - [ - # region "organization-access" - DefaultFeature( - slug="organization-access", - description="Amount of invoices created per month (starter plan)", - max_limit_per_month=1, - subscription_plan=starter_plan, - ), - DefaultFeature( - slug="organization-access", - description="Amount of invoices created per month", - max_limit_per_month=1, - subscription_plan=growth_plan, - ), - # endregion "organization-access" - ], - ), -] diff --git a/billing/decorators.py b/billing/decorators.py deleted file mode 100644 index ed755de17..000000000 --- a/billing/decorators.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.contrib import messages -from django.http import HttpResponseRedirect -from django.shortcuts import redirect, render -from django.urls import reverse - -from billing.service.entitlements import has_entitlement, get_entitlements - - -def has_entitlements_called_from_backend_handler(entitlements: list[str] | str, htmx_api: bool = False): - def decorator(view_func): - def wrapper_func(request, *args, **kwargs): - user_does_have_entitlements: bool - if isinstance(entitlements, (list, set)): - users_entitlements = get_entitlements(request.actor) - user_does_have_entitlements = all(entitlement in users_entitlements for entitlement in entitlements) - else: - user_does_have_entitlements = has_entitlement(request.actor, entitlements) - - if user_does_have_entitlements: - return view_func(request, *args, **kwargs) - else: - messages.warning(request, f"Your plan unfortunately doesn't include this feature.") - - if htmx_api: - return render(request, "base/toast.html", {"autohide": False}) - elif request.htmx: - return HttpResponseRedirect(reverse("billing:dashboard")) - return redirect("billing:dashboard") - - return wrapper_func - - return decorator diff --git a/billing/management/__init__.py b/billing/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/billing/management/commands/__init__.py b/billing/management/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/billing/management/commands/stripe.py b/billing/management/commands/stripe.py deleted file mode 100644 index 136e531f5..000000000 --- a/billing/management/commands/stripe.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.core.management.base import BaseCommand -from django.core.cache import cache -import stripe - - -class Command(BaseCommand): - def add_arguments(self, parser): - parser.add_argument( - "action", - type=str, - help="help, create_entitlements", - ) - - def handle(self, *args, **kwargs): - action = kwargs.get("action") - - match action: - case "create_entitlements": - for entitlement in [ - "Receipts", - "File Storage", - "Organizations", - "Invoice Reminders", - "API Access", - "Emails", - "Advanced Onboarding", - "Basic Onboarding", - "Invoice Schedules", - "Invoices", - "Customers", - ]: - try: - stripe.entitlements.Feature.create(name=entitlement, lookup_key=entitlement.lower().replace(" ", "-")) - print(f"Created entitlement: {entitlement}") - except stripe.error.InvalidRequestError: - print(f"Entitlement already exists: {entitlement}") - case (_, "help"): - print( - """ - Available actions: - - create_entitlements: This will create all entitlements that you don't have - """ - ) diff --git a/billing/middleware.py b/billing/middleware.py deleted file mode 100644 index 6873b5cd6..000000000 --- a/billing/middleware.py +++ /dev/null @@ -1,50 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect, render -from django.urls import resolve - -from backend.core.types.requests import WebRequest -from billing.billing_settings import NO_SUBSCRIPTION_PLAN_DENY_VIEW_NAMES -from billing.models import UserSubscription - - -# middleware to check if user is subscribed to a plan yet - - -class CheckUserSubScriptionMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request: WebRequest): - if not request.user.is_authenticated: - return self.get_response(request) - - if request.team: - # todo: handle organization billing - return self.get_response(request) - - subscription: UserSubscription | None = ( - UserSubscription.filter_by_owner(request.actor).filter(end_date__isnull=True).prefetch_related("subscription_plan").first() - ) - request.users_subscription = subscription - - resolver_match = resolve(request.path_info) - - view_name = resolver_match.view_name - - if view_name not in NO_SUBSCRIPTION_PLAN_DENY_VIEW_NAMES: - return self.get_response(request) - - if not subscription: - print("[BILLING] [MIDDLEWARE] User doesn't have an active subscription.") - messages.warning( - request, - """ - You currently are not subscribed to a plan. If you think this is a mistake scroll down and - press "Refetch" or contact support at - support@strelix.org.""", - ) - - if request.htmx: - return render(request, "base/toast.html", {"autohide": False}) - return redirect("billing:dashboard") - return self.get_response(request) diff --git a/billing/migrations/0001_initial.py b/billing/migrations/0001_initial.py deleted file mode 100644 index b246e1340..000000000 --- a/billing/migrations/0001_initial.py +++ /dev/null @@ -1,89 +0,0 @@ -# Generated by Django 5.1 on 2024-08-29 13:26 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("backend", "0055_remove_planfeature_group_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="PlanFeatureGroup", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=50)), - ], - ), - migrations.CreateModel( - name="SubscriptionPlan", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=50, unique=True)), - ("price_per_month", models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ("description", models.TextField(blank=True, max_length=500, null=True)), - ("maximum_duration_months", models.IntegerField(blank=True, null=True)), - ("stripe_product_id", models.CharField(blank=True, max_length=100, null=True)), - ], - ), - migrations.CreateModel( - name="PlanFeature", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("slug", models.CharField(max_length=100)), - ("stripe_price_id", models.CharField(blank=True, max_length=100, null=True)), - ("description", models.TextField(blank=True, max_length=500, null=True)), - ("max_limit_per_month", models.IntegerField(blank=True, null=True)), - ( - "group", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="features", to="billing.planfeaturegroup"), - ), - ( - "subscription_plan", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="features", to="billing.subscriptionplan"), - ), - ], - ), - migrations.CreateModel( - name="UserSubscription", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("stripe_subscription_id", models.CharField(blank=True, max_length=100, null=True)), - ("custom_subscription_price_per_month", models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ("start_date", models.DateTimeField(auto_now_add=True)), - ("end_date", models.DateTimeField(blank=True, null=True)), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "subscription_plan", - models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="billing.subscriptionplan"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="billing_usersubscription_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/billing/migrations/0002_subscriptionplan_stripe_price_id.py b/billing/migrations/0002_subscriptionplan_stripe_price_id.py deleted file mode 100644 index ee5c42b5a..000000000 --- a/billing/migrations/0002_subscriptionplan_stripe_price_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1 on 2024-08-29 19:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("billing", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="subscriptionplan", - name="stripe_price_id", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/billing/migrations/0003_stripewebhookevent_usersubscription_uuid_and_more.py b/billing/migrations/0003_stripewebhookevent_usersubscription_uuid_and_more.py deleted file mode 100644 index 4d4e5881d..000000000 --- a/billing/migrations/0003_stripewebhookevent_usersubscription_uuid_and_more.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 5.1 on 2024-08-30 15:55 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0056_user_stripe_customer_id"), - ("billing", "0002_subscriptionplan_stripe_price_id"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="StripeWebhookEvent", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("event_id", models.CharField(max_length=100, unique=True)), - ("event_type", models.CharField(max_length=100)), - ("data", models.JSONField()), - ("raw_event", models.JSONField()), - ], - ), - migrations.AddField( - model_name="usersubscription", - name="uuid", - field=models.UUIDField(default=uuid.uuid4, null=True), - ), - migrations.CreateModel( - name="StripeCheckoutSession", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), - ("stripe_session_id", models.CharField(blank=True, max_length=100, null=True, unique=True)), - ("features", models.ManyToManyField(related_name="checkout_sessions", to="billing.planfeature")), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "plan", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="checkout_sessions", to="billing.subscriptionplan" - ), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="billing_stripecheckoutsession_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/billing/migrations/0004_auto_20240830_1655.py b/billing/migrations/0004_auto_20240830_1655.py deleted file mode 100644 index 94531271e..000000000 --- a/billing/migrations/0004_auto_20240830_1655.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1 on 2024-08-30 15:55 -import uuid - -from django.db import migrations - - -def gen_uuid(apps, schema_editor): - UserSubscription = apps.get_model("billing", "UserSubscription") - for row in UserSubscription.objects.all(): - row.uuid = uuid.uuid4() - row.save(update_fields=["uuid"]) - - -class Migration(migrations.Migration): - - dependencies = [ - ("billing", "0003_stripewebhookevent_usersubscription_uuid_and_more"), - ] - - operations = [ - migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), - ] diff --git a/billing/migrations/0005_auto_20240830_1655.py b/billing/migrations/0005_auto_20240830_1655.py deleted file mode 100644 index 9ce66ecfd..000000000 --- a/billing/migrations/0005_auto_20240830_1655.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1 on 2024-08-30 15:55 -import uuid - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("billing", "0004_auto_20240830_1655"), - ] - - operations = [ - migrations.AlterField( - model_name="usersubscription", - name="uuid", - field=models.UUIDField(default=uuid.uuid4, unique=True), - ), - ] diff --git a/billing/migrations/0006_billingusage.py b/billing/migrations/0006_billingusage.py deleted file mode 100644 index 9fb0b537a..000000000 --- a/billing/migrations/0006_billingusage.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 5.1 on 2024-08-31 10:47 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("backend", "0056_user_stripe_customer_id"), - ("billing", "0005_auto_20240830_1655"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="BillingUsage", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("event_name", models.CharField(max_length=100)), - ("event_type", models.CharField(choices=[("usage", "Metered Usage")], default="usage", max_length=20)), - ("quantity", models.PositiveSmallIntegerField(default=1)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("processed_at", models.DateTimeField(blank=True, null=True)), - ("processed", models.BooleanField(default=False)), - ("stripe_unique_usage_identifier", models.CharField(blank=True, max_length=100, null=True)), - ( - "organization", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="backend.organization"), - ), - ( - "user", - models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - options={ - "abstract": False, - "constraints": [ - models.CheckConstraint( - check=models.Q( - models.Q(("organization__isnull", False), ("user__isnull", True)), - models.Q(("organization__isnull", True), ("user__isnull", False)), - _connector="OR", - ), - name="billing_billingusage_check_user_or_organization", - ) - ], - }, - ), - ] diff --git a/billing/migrations/__init__.py b/billing/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/billing/models.py b/billing/models.py deleted file mode 100644 index 65a184cd1..000000000 --- a/billing/models.py +++ /dev/null @@ -1,117 +0,0 @@ -from uuid import uuid4 - -from django.db import models - -from backend.core.models import OwnerBase - -from django.utils import timezone - -from django.utils.timezone import now as timezone_now - - -class SubscriptionPlan(models.Model): - """ - Subscription plans available for users. - """ - - name = models.CharField(max_length=50, unique=True) - price_per_month = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) - description = models.TextField(max_length=500, null=True, blank=True) - stripe_product_id = models.CharField(max_length=100, null=True, blank=True) - stripe_price_id = models.CharField(max_length=100, null=True, blank=True) - - -def __str__(self): - return f"{self.name} - {self.price_per_month or 'free' if self.price_per_month != -1 else 'custom'}" - - -class UserSubscription(OwnerBase): - """ - Track which subscription plan a user is currently subscribed to. - """ - - uuid = models.UUIDField(unique=True, default=uuid4) - subscription_plan = models.ForeignKey(SubscriptionPlan, on_delete=models.SET_NULL, null=True) - stripe_subscription_id = models.CharField(max_length=100, null=True, blank=True) - # Custom price only used for enterprise or negotiated plans - custom_subscription_price_per_month = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) - - start_date = models.DateTimeField(auto_now_add=True) - end_date = models.DateTimeField(null=True, blank=True) - - def __str__(self): - return f"{self.owner} - {self.subscription_plan.name} ({self.start_date} to {self.end_date or 'ongoing'})" - - @property - def has_ended(self): - return bool(self.end_date) - - @property - def get_price(self): - return self.custom_subscription_price_per_month or self.subscription_plan.price_per_month or "0.00" - - def end_now(self): - self.end_date = timezone.now() - self.save() - return self - - -class PlanFeatureGroup(models.Model): - name = models.CharField(max_length=50) # E.g. 'invoices' - - -class PlanFeature(models.Model): - """ - Details related to certain features. E.g. "emails sent", we can allow site admins to change prices, units, and customise their - billing - """ - - slug = models.CharField(max_length=100) - stripe_price_id = models.CharField(max_length=100, null=True, blank=True) - description = models.TextField(max_length=500, null=True, blank=True) - - max_limit_per_month = models.IntegerField(null=True, blank=True) - - subscription_plan = models.ForeignKey(SubscriptionPlan, on_delete=models.CASCADE, related_name="features") - group = models.ForeignKey(PlanFeatureGroup, on_delete=models.CASCADE, related_name="features") - - def __str__(self): - return f"{self.slug} - subscription id: {self.subscription_plan_id}" - - -class StripeWebhookEvent(models.Model): - event_id = models.CharField(max_length=100, unique=True) - event_type = models.CharField(max_length=100) # e.g. 'customer.subscription.created' - data = models.JSONField() - raw_event = models.JSONField() - - -class StripeCheckoutSession(OwnerBase): - uuid = models.UUIDField(unique=True, default=uuid4) - - stripe_session_id = models.CharField(max_length=100, unique=True, blank=True, null=True) - plan = models.ForeignKey(SubscriptionPlan, on_delete=models.CASCADE, related_name="checkout_sessions") - features = models.ManyToManyField(PlanFeature, related_name="checkout_sessions") - - -class BillingUsage(OwnerBase): - EVENT_TYPES = ( - ("usage", "Metered Usage"), - # ("storage", "Storage"), - ) - - event_name = models.CharField(max_length=100) # e.g. 'invoices-created' - event_type = models.CharField(max_length=20, choices=EVENT_TYPES, default="usage") - quantity = models.PositiveSmallIntegerField(default=1) # e.g. 1 - - created_at = models.DateTimeField(auto_now_add=True) - - processed_at = models.DateTimeField(null=True, blank=True) - processed = models.BooleanField(default=False) - stripe_unique_usage_identifier = models.CharField(max_length=100, null=True, blank=True) - - def set_processed(self, processed_time): - self.processed = True - self.processed_at = processed_time or timezone_now() - self.save() - return self diff --git a/billing/service/__init__.py b/billing/service/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/billing/service/checkout_completed.py b/billing/service/checkout_completed.py deleted file mode 100644 index 62d90d97f..000000000 --- a/billing/service/checkout_completed.py +++ /dev/null @@ -1,48 +0,0 @@ -import stripe - -from backend.core.utils.calendar import timezone_now -from billing.models import StripeCheckoutSession, StripeWebhookEvent, UserSubscription - - -def checkout_completed(webhook_event: StripeWebhookEvent): - event_data: stripe.checkout.Session = webhook_event.data["object"] - - stripe_session_obj = StripeCheckoutSession.objects.filter( - uuid=event_data.metadata.get("dj_checkout_uuid", "doesn't_exist") if event_data.metadata else "doesn't_exist" - ).first() # type: ignore[misc] - - if not stripe_session_obj: - print("No matching session object found.") - return - - completed_with_session_object(stripe_session_obj, event_data) - - -def completed_with_session_object(stripe_session_obj: StripeCheckoutSession, event_data: stripe.checkout.Session) -> None: - # Fetch current active subscriptions based on the owner (user or organization) - user_current_plans = UserSubscription.filter_by_owner(owner=stripe_session_obj.owner).filter(end_date__isnull=True) - - # Get plan ID from metadata - stripe_plan_id = event_data.metadata.get("dj_subscription_plan_id", None) if event_data.metadata else None - if not stripe_plan_id: - print("No subscription plan ID found in metadata.") - return - - # Cancel existing subscriptions except the one in the metadata - for current_plan in user_current_plans: - if current_plan.subscription_plan.id != stripe_plan_id: # Fix: Using `subscription_plan.id` - stripe.Subscription.modify(current_plan.stripe_subscription_id, cancel_at_period_end=True) # type: ignore[arg-type] - current_plan.end_date = timezone_now() - current_plan.save() - - # Create new subscription if the user doesn't have it - if not user_current_plans.filter(subscription_plan__id=stripe_plan_id).exists(): # Fix: Using `subscription_plan__id` - UserSubscription.objects.create( - owner=stripe_session_obj.owner, - subscription_plan_id=stripe_plan_id, - stripe_subscription_id=event_data.subscription, - ) - - # Expire the checkout session - stripe.checkout.Session.expire(stripe_session_obj.stripe_session_id) # type: ignore[arg-type] - stripe_session_obj.delete() diff --git a/billing/service/entitlements.py b/billing/service/entitlements.py deleted file mode 100644 index cc751f8c6..000000000 --- a/billing/service/entitlements.py +++ /dev/null @@ -1,57 +0,0 @@ -import stripe.entitlements -from django.contrib import messages -from django.core.cache import cache -from django.core.cache.backends.redis import RedisCacheClient -from django.shortcuts import redirect - -from backend.models import User, Organization -from billing.models import StripeWebhookEvent -from billing.service.get_user import get_actor_from_stripe_customer - -cache: RedisCacheClient = cache - - -def entitlements_updated_via_stripe_webhook(webhook_event: StripeWebhookEvent) -> None: - data: stripe.entitlements.ActiveEntitlementSummary = webhook_event.data["object"] - actor = get_actor_from_stripe_customer(data["customer"]) - - if not actor: - print("No actor found for customer.") - return - - # Re-fetch and update the entitlements for the actor (User or Organization) - update_user_entitlements(actor) - - -def update_user_entitlements(actor: User | Organization) -> list[str]: - if not actor.stripe_customer_id: - return [] - - entitlements = stripe.entitlements.ActiveEntitlement.list(customer=actor.stripe_customer_id, limit=25).data - - entitlement_names = [entitlement.lookup_key for entitlement in entitlements] - - actor.entitlements = entitlement_names - actor.save(update_fields=["entitlements"]) - - cache_actor_type = "user" if isinstance(actor, User) else "org" - - cache.set(f"myfinances:entitlements:{cache_actor_type}:{actor.id}", entitlement_names, timeout=3600) - - return entitlement_names - - -def get_entitlements(actor: User | Organization, avoid_cache=False) -> list[str]: - cache_key = "user" if isinstance(actor, User) else "org" - - if not avoid_cache and (cached_entitlements := cache.get(f"myfinances:entitlements:{cache_key}:{actor.id}", default=[])): - return cached_entitlements - return update_user_entitlements(actor) - - -def has_entitlement(actor: User | Organization, entitlement: str) -> bool: - return entitlement in get_entitlements(actor) - - -def has_entitlements(actor: User | Organization, entitlements: list[str]) -> bool: - return all(entitlement in entitlements for entitlement in get_entitlements(actor)) diff --git a/billing/service/get_user.py b/billing/service/get_user.py deleted file mode 100644 index 65c93fec0..000000000 --- a/billing/service/get_user.py +++ /dev/null @@ -1,8 +0,0 @@ -from backend.models import User, Organization - - -def get_actor_from_stripe_customer(stripe_customer_id: str) -> User | Organization | None: - return ( - User.objects.filter(stripe_customer_id=stripe_customer_id).first() - or Organization.objects.filter(stripe_customer_id=stripe_customer_id).first() - ) diff --git a/billing/service/plan_change.py b/billing/service/plan_change.py deleted file mode 100644 index 7154705ef..000000000 --- a/billing/service/plan_change.py +++ /dev/null @@ -1,31 +0,0 @@ -from datetime import datetime -import stripe -from django.db.models import QuerySet -from backend.models import User, Organization -from billing.models import UserSubscription, SubscriptionPlan -from billing.service.entitlements import update_user_entitlements -from billing.service.stripe_customer import get_or_create_customer_id - - -def handle_plan_change(user_subscription: UserSubscription, new_plan: SubscriptionPlan) -> UserSubscription: - """ - Handles plan upgrades or downgrades. - """ - # Cancel the current Stripe subscription if necessary - stripe.Subscription.modify( - user_subscription.stripe_subscription_id, - cancel_at_period_end=False, # Cancels immediately - ) - - # Create a new Stripe subscription for the new plan - new_subscription = stripe.Subscription.create( - customer=user_subscription.owner.stripe_customer_id, - items=[{"price": new_plan.stripe_price_id}], - ) - - # Update the UserSubscription object with new plan and subscription id - user_subscription.subscription_plan = new_plan - user_subscription.stripe_subscription_id = new_subscription.id - user_subscription.save() - - return user_subscription diff --git a/billing/service/price.py b/billing/service/price.py deleted file mode 100644 index 009249a27..000000000 --- a/billing/service/price.py +++ /dev/null @@ -1,8 +0,0 @@ -import stripe - - -def get_price_id_from_lookup_key(lookup_key: str) -> str: - prices = stripe.Price.list(lookup_keys=[lookup_key]) - if prices.data: - return prices.data[0].id # Assuming the lookup key returns one price - raise ValueError(f"Price with lookup key {lookup_key} not found.") diff --git a/billing/service/stripe_customer.py b/billing/service/stripe_customer.py deleted file mode 100644 index a66108958..000000000 --- a/billing/service/stripe_customer.py +++ /dev/null @@ -1,32 +0,0 @@ -from backend.models import User, Organization -import stripe - - -def get_or_create_customer_id(actor: User | Organization) -> str: - if actor.stripe_customer_id: - return actor.stripe_customer_id - - return create_stripe_customer_id(actor) - - -def create_stripe_customer_id(actor: User | Organization) -> str: - if isinstance(actor, User): - customer = stripe.Customer.create( - email=actor.email, - name=actor.get_full_name(), - ) - actor.stripe_customer_id = customer.id - actor.save() - - return customer.id - - else: - customer = stripe.Customer.create( - email=actor.leader.email, - name=actor.name, - ) - - actor.stripe_customer_id = customer.id - actor.save() - - return customer.id diff --git a/billing/service/subscription_ended.py b/billing/service/subscription_ended.py deleted file mode 100644 index 253e013f9..000000000 --- a/billing/service/subscription_ended.py +++ /dev/null @@ -1,39 +0,0 @@ -import stripe - -from backend.core.models import User, Organization -from billing.models import StripeWebhookEvent, UserSubscription - - -def subscription_ended(webhook_event: StripeWebhookEvent) -> None: - event_data: stripe.Subscription = webhook_event.data.object - stripe_customer = event_data.customer - - # Find the user or organization based on the stripe customer - actor = ( - User.objects.filter(stripe_customer_id=stripe_customer).first() - or Organization.objects.filter(stripe_customer_id=stripe_customer).first() - ) - - actor_subscription_plan = None - - if not actor: - # If no user found, try to fetch the subscription plan using the stripe subscription ID - plan = UserSubscription.objects.filter( - stripe_subscription_id=event_data.id, stripe_subscription_id__isnull=False - ).first() # type: ignore[misc] - - if plan: - actor_subscription_plan = plan - actor = plan.owner - else: - print("Error: Could not find user or subscription plan.") - return - - if not actor_subscription_plan: - actor_subscriptions = UserSubscription.filter_by_owner(owner=actor).all() - if not actor_subscriptions: - return - - # Find a subscription plan with the same Stripe subscription ID - if plan_with_same_id := actor_subscriptions.filter(stripe_subscription_id=event_data.id).first(): - plan_with_same_id.end_now() diff --git a/billing/service/subscription_handler.py b/billing/service/subscription_handler.py deleted file mode 100644 index 78381a77a..000000000 --- a/billing/service/subscription_handler.py +++ /dev/null @@ -1,86 +0,0 @@ -import stripe -from typing import Union -from django.utils import timezone - -from backend.models import Organization -from billing.models import UserSubscription, SubscriptionPlan -from django.contrib.auth.models import User - -from billing.service.entitlements import update_user_entitlements - - -def create_subscription(owner: Union[User, Organization], subscription_plan: SubscriptionPlan) -> UserSubscription: - """ - Creates a new Stripe subscription for a user or organization. - - Args: - owner: The user or organization subscribing. - subscription_plan: The plan the owner is subscribing to. - - Returns: - A UserSubscription object representing the subscription. - """ - # Create a new Stripe subscription for the given owner (user or organization) - stripe_subscription = stripe.Subscription.create( - customer=owner.stripe_customer_id, - items=[{"price": subscription_plan.stripe_price_id}], - ) - - # Create the corresponding UserSubscription record in your database - user_subscription = UserSubscription.objects.create( - owner=owner, subscription_plan=subscription_plan, stripe_subscription_id=stripe_subscription.id, start_date=timezone.now() - ) - - # Update user entitlements via Stripe entitlements - update_user_entitlements(owner) - - return user_subscription - - -def cancel_subscription(user_subscription: UserSubscription) -> UserSubscription: - """ - Cancels an active Stripe subscription and updates the local subscription record. - - Args: - user_subscription: The subscription to cancel. - - Returns: - The updated UserSubscription object with the end_date set. - """ - stripe.Subscription.delete(subscription=user_subscription.stripe_subscription_id) - - # Mark the subscription as canceled in your local database - user_subscription.end_date = timezone.now() - user_subscription.save() - - # Update entitlements after cancellation - update_user_entitlements(user_subscription.owner) - - return user_subscription - - -def handle_plan_change(user_subscription: UserSubscription, new_plan: SubscriptionPlan) -> UserSubscription: - """ - Updates the user's Stripe subscription to a new plan, with proration handled automatically. - - Args: - user_subscription: The current UserSubscription to update. - new_plan: The new SubscriptionPlan to switch to. - - Returns: - The updated UserSubscription object with the new plan. - """ - stripe.Subscription.modify( - user_subscription.stripe_subscription_id, - cancel_at_period_end=False, # Cancels the current plan immediately - items=[{"price": new_plan.stripe_price_id}], - ) - - # Update the local subscription model - user_subscription.subscription_plan = new_plan - user_subscription.save() - - # Update entitlements after the plan change - update_user_entitlements(user_subscription.owner) - - return user_subscription diff --git a/billing/service/test.py b/billing/service/test.py deleted file mode 100644 index 927b6893c..000000000 --- a/billing/service/test.py +++ /dev/null @@ -1,38 +0,0 @@ -# import os -# -# import stripe -# from django.urls import reverse -# -# from backend.models import User -# -# user: User = User.objects.first() -# -# # stripe.billing.MeterEvent.create( -# # event_name="invoices_created", -# # payload={"invoices": "250", "stripe_customer_id": user.stripe_customer_id}, -# # # identifier="id" -# # ) -# # -# # stripe.billing.Meter.list_event_summaries( -# # "" -# # ) -# -# # a = stripe.Customer.create( -# # name=user.get_full_name(), -# # email=user.email -# # ) -# # -# # user.stripe_customer_id = a.id -# # user.save() -# -# # print(a) -# -# # stripe.checkout.Session.create( -# # success_url=os.environ.get("SITE_URL", default="http://127.0.0.1:8000") + reverse("api:public:webhooks:receive_global"), -# # line_items=[ -# # { -# # "price": "price_", -# # "quantity": 1 -# # } -# # ] -# # ) diff --git a/billing/signals/__init__.py b/billing/signals/__init__.py deleted file mode 100644 index ccb677602..000000000 --- a/billing/signals/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import migrations, usage, stripe diff --git a/billing/signals/migrations.py b/billing/signals/migrations.py deleted file mode 100644 index 011548902..000000000 --- a/billing/signals/migrations.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -import logging - -from django.db.models.signals import post_migrate -from django.dispatch import receiver - -from billing.data.default_usage_plans import default_usage_plans, default_subscription_plans, DefaultFeature, DefaultSubscriptionPlan -from billing.models import PlanFeature, PlanFeatureGroup, SubscriptionPlan - - -# -@receiver(post_migrate) -def update_usage_plans(**kwargs): - subscription_plans: dict = {} - - subscription_plan: DefaultSubscriptionPlan - - for subscription_plan in default_subscription_plans: - if plan := SubscriptionPlan.objects.filter(name=subscription_plan.name).first(): - subscription_plans[plan.name] = plan - else: - subscription_plans[subscription_plan.name] = SubscriptionPlan.objects.create( - name=subscription_plan.name, - description=subscription_plan.description, - price_per_month=subscription_plan.price_per_month, - ) - logging.info(f"Added SubscriptionPlan {subscription_plan.name}") - - for group in default_usage_plans: - group_obj, created = PlanFeatureGroup.objects.get_or_create(name=group.name) - - if created: - logging.info(f"Created group {group.name}") - - item: DefaultFeature - for item in group.items: - existing: PlanFeature = PlanFeature.objects.filter( - slug=item.slug, subscription_plan=SubscriptionPlan.objects.get(name=item.subscription_plan.name) - ).first() - - if existing: - description, old_subscription_plan, max_limit_per_month = ( - existing.description, - existing.subscription_plan, - existing.max_limit_per_month, - ) - - existing.description = item.description - existing.max_limit_per_month = item.max_limit_per_month if item.max_limit_per_month != -1 else None - - if ( - existing.description != description - or (existing.max_limit_per_month == None and max_limit_per_month != -1) - or (existing.max_limit_per_month != None and max_limit_per_month == -1) - or (existing.max_limit_per_month != None and existing.max_limit_per_month != max_limit_per_month) - ): - existing.save() - - logging.info(f"Updated PlanFeature description/limits for {item.slug}") - else: - existing = PlanFeature.objects.create( - group=group_obj, - description=item.description, - slug=item.slug, - max_limit_per_month=item.max_limit_per_month, - subscription_plan=SubscriptionPlan.objects.get(name=item.subscription_plan.name), - ) - logging.info(f"Added PlanFeature {item.slug}") diff --git a/billing/signals/quotas.py b/billing/signals/quotas.py deleted file mode 100644 index d79ba6bd1..000000000 --- a/billing/signals/quotas.py +++ /dev/null @@ -1,15 +0,0 @@ -# from django.db.models.signals import post_save -# from django.dispatch import receiver -# -# from backend.finance.models import Invoice, Usage -# -# -# @receiver(post_save, sender=Invoice) -# def created_invoice(sender, instance: Invoice, **kwargs): -# Usage.objects.create( -# owner=instance.owner, -# feature="invoices-created", -# quantity=1, -# unit="invocations", -# instance_id=instance.id, -# ) diff --git a/billing/signals/stripe/__init__.py b/billing/signals/stripe/__init__.py deleted file mode 100644 index ae73ac8a2..000000000 --- a/billing/signals/stripe/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import webhook_handler diff --git a/billing/signals/stripe/webhook_handler.py b/billing/signals/stripe/webhook_handler.py deleted file mode 100644 index 2b003a783..000000000 --- a/billing/signals/stripe/webhook_handler.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db.models.signals import post_save -from django.dispatch import receiver -from billing.models import StripeWebhookEvent -from billing.service.checkout_completed import checkout_completed -from billing.service.subscription_ended import subscription_ended -from billing.service.entitlements import entitlements_updated_via_stripe_webhook - - -@receiver(post_save, sender=StripeWebhookEvent) -def stripe_webhook_event_created(sender, instance: StripeWebhookEvent, **kwargs): - match instance.event_type: - case "checkout.session.completed": - checkout_completed(instance) - case "customer.subscription.deleted": - subscription_ended(instance) - case "entitlements.active_entitlement_summary.updated": - entitlements_updated_via_stripe_webhook(instance) - case _: - print(f"Unhandled event type: {instance.event_type}") diff --git a/billing/signals/usage.py b/billing/signals/usage.py deleted file mode 100644 index f67c51a40..000000000 --- a/billing/signals/usage.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -from datetime import datetime - -import stripe -from django.db.models.signals import post_save -from django.dispatch import receiver - -from backend.finance.models import Invoice -from backend.core.models import User -from billing.models import BillingUsage - -logger = logging.getLogger(__name__) - - -@receiver(post_save, sender=BillingUsage) -def usage_occurred(sender, instance: BillingUsage, created, **kwargs): - if not created or instance.processed: - return - - if instance.event_type != "usage": - return # may add storage at a later point - - if not instance.owner: - print("CANNOT HANDLE ORGS AT THE MOMENT!") - return # todo: cannot handle organisations at the moment - - stripe_customer_id = instance.owner.stripe_customer_id - - if not stripe_customer_id: - print(f"No stripe customer id for actor #{'usr_' if isinstance(instance.owner, User) else 'org_'}{instance.owner.id}") - return # todo - - meter_event = stripe.billing.MeterEvent.create( - event_name=instance.event_name, payload={"value": str(instance.quantity), f"stripe_customer_id": stripe_customer_id} - ) - - if meter_event.created: - instance.stripe_unique_usage_identifier = meter_event.identifier - instance.set_processed(datetime.fromtimestamp(meter_event.created)) - - return - - -@receiver(post_save, sender=Invoice) -def created_invoice(sender, instance: Invoice, created, **kwargs): - if not created: - return - - BillingUsage.objects.create( - owner=instance.owner, - event_name="invoices_created", - ) - - if instance.invoice_recurring_profile: - BillingUsage.objects.create( - owner=instance.owner, - event_name="invoice_schedule_invocations", - ) - return diff --git a/billing/templates/pages/billing/dashboard/all_subscriptions.html b/billing/templates/pages/billing/dashboard/all_subscriptions.html deleted file mode 100644 index 4353101c5..000000000 --- a/billing/templates/pages/billing/dashboard/all_subscriptions.html +++ /dev/null @@ -1,71 +0,0 @@ -
-
-
- All Subscriptions - -
- - - - - - - - - - - - - {% for subscription in all_user_subscriptions|dictsortreversed:"start_date" %} - - - - - - {% if subscription.has_ended %} - - {% else %} - - - {% endif %} - - {% endfor %} - -
StatusPlan NamePriceStartedEndedActions
- {% if subscription.has_ended %} - Ended - {% else %} - Ongoing - {% endif %} - {{ subscription.subscription_plan.name }} - {% if subscription.subscription_plan.price_per_month == -1 %} - {{ subscription.custom_subscription_price_per_month }} - {% else %} - {{ subscription.subscription_plan.price_per_month }} - {% endif %} - {{ subscription.start_date }}{{ subscription.end_date | default_if_none:"" }} - - - -
-
-
\ No newline at end of file diff --git a/billing/templates/pages/billing/dashboard/choose_plan_section.html b/billing/templates/pages/billing/dashboard/choose_plan_section.html deleted file mode 100644 index b6994e4d2..000000000 --- a/billing/templates/pages/billing/dashboard/choose_plan_section.html +++ /dev/null @@ -1,199 +0,0 @@ -
-
-
- Current Subscription Plan: {{ active_subscription.subscription_plan.name | default_if_none:"Not subscribed" }} -
- -
-
- -
-
-

Starter

-

- For individual freelancers or small businesses with less recurring customers -

-
- £5 - /month -
-
    -
  • - - Unlimited Customers -
  • -
  • - - - Unlimited Invoices (first 10 on us!)* - -
  • -
  • - - Basic Customer Onboarding -
  • -
  • - - Emailing (automated, normal & bulk) -
  • -
  • - - File Storage (10 GB included!) -
  • -
  • - - - No Limits * - -
  • -
- {% if active_subscription and active_subscription.subscription_plan.name|lower == "starter" %} - - Your active plan - - {% else %} - - {% endif %} -
-
-

Growth

-

- For small-medium sized businesses that require more features or have a wider customer base -

-
- £15 - /month -
-
    -
  • - Everything in Starter, plus: -
  • -
  • - - Automated Invoice Reminders -
  • -
  • - - Advanced Customer Onboarding -
  • -
  • - - API Access -
  • -
  • - - File Storage (50 GB included!) -
  • -
  • - - - No Limits * - -
  • -
- {% if active_subscription and active_subscription.subscription_plan.name|lower == "growth" %} - - Your active plan - - {% else %} - - {% endif %} -
-
-

Enterprise

-

- For large businesses or if your business needs a bit more - customisation -

-
- - Custom - -
-
    -
  • - - Choose a price that fits your business -
  • -
  • - - - Organization Mode - - - - -
  • -
  • - - Dedicated Customer Success -
  • -
  • - - SLA can be discussed -
  • -
  • - - - Analytics and live updates - - - - -
  • -
  • - - Pre-release notifications -
  • -
- - Contact Us - -
-
- -
- - {% include "pages/billing/dashboard/starter_usages.html" %} - - - {% include "pages/billing/dashboard/growth_usages.html" %} -
-
-
\ No newline at end of file diff --git a/billing/templates/pages/billing/dashboard/dashboard.html b/billing/templates/pages/billing/dashboard/dashboard.html deleted file mode 100644 index d8f6310de..000000000 --- a/billing/templates/pages/billing/dashboard/dashboard.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends base|default:"base/base.html" %} -{% load listfilters %} -{% block content %} - {% load get_first_n_items from listfilters %} -
-
-
-

Billing

- -
-
- {% include "pages/billing/dashboard/choose_plan_section.html" %} - {% include "pages/billing/dashboard/all_subscriptions.html" %} -{% endblock %} diff --git a/billing/templates/pages/billing/dashboard/growth_usages.html b/billing/templates/pages/billing/dashboard/growth_usages.html deleted file mode 100644 index c0e71d280..000000000 --- a/billing/templates/pages/billing/dashboard/growth_usages.html +++ /dev/null @@ -1,137 +0,0 @@ -
-
-
- Growth Plan - Usage-Based Pricing -
- -
- - -
- -
- Invoices -
-
- - - - - - - - - - - - - - - - - - - - - -
QuantityUnit Price
First 80 invoicesIncluded for free
Next 170 invoices£0.04/invoice
Greater than 250 invoices£0.03/invoice
-
-
- - -
- -
- Recurring Invoices -
-
- - - - - - - - - - - - - - - - - - - - - -
QuantityUnit Price
First 100 invocationsIncluded for free
Next 400 invocations£0.07/invocation
Greater than 500 invocations£0.04/invocation
-
-
- - -
- -
- Invoice Reminders -
-
- - - - - - - - - - - - - - - - - - - - - -
QuantityUnit Price
First 400 invocationsIncluded for free
Next 600 invocations£0.02/invocation
Greater than 1000 invocations£0.01/invocation
-
-
- - -
- -
- Advanced Onboarding -
-
- - - - - - - - - - - - - - - - - - - - - -
QuantityUnit Price
First 5 customersIncluded for free
Next 20 customers£0.10/customer
Greater than 25 customers£0.08/customer
-
-
-
-
diff --git a/billing/templates/pages/billing/dashboard/starter_usages.html b/billing/templates/pages/billing/dashboard/starter_usages.html deleted file mode 100644 index 0ae8d737a..000000000 --- a/billing/templates/pages/billing/dashboard/starter_usages.html +++ /dev/null @@ -1,73 +0,0 @@ -
-
-
- Starter Plan - Usage-Based Pricing -
- -
- - -
- -
- Invoices -
-
- - - - - - - - - - - - - - - - - - - - - -
QuantityUnit Price
First 10 invoicesIncluded for free
Next 40 invoices£0.05/invoice
Greater than 50 invoices£0.035/invoice
-
-
- - -
- -
- Recurring Invoices -
-
- - - - - - - - - - - - - - - - - - - - - -
QuantityUnit Price
First 10 invocationsIncluded for free
Next 20 invocations£0.07/invocation
Greater than 30 invocations£0.05/invocation
-
-
-
-
diff --git a/billing/urls.py b/billing/urls.py deleted file mode 100644 index 7fd307871..000000000 --- a/billing/urls.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.urls import path - -from billing.views.dashboard import billing_dashboard_endpoint, all_subscriptions_htmx_endpoint -from billing.views.stripe_misc import customer_client_portal_endpoint -from billing.views.stripe_webhooks import stripe_listener_webhook_endpoint - -from .views.return_urls.success import stripe_success_return_endpoint -from .views.change_plan import change_plan_endpoint - -urlpatterns = [ - path("dashboard/billing/", billing_dashboard_endpoint, name="dashboard"), - path("api/billing/all_subscriptions/", all_subscriptions_htmx_endpoint, name="all_subscriptions"), - # path("dashboard/billing/", RedirectView.as_view(url="/dashboard"), name="dashboard"), - path("api/public/webhooks/receive/payments/stripe/", stripe_listener_webhook_endpoint, name="receive_stripe_webhook"), - path("api/billing/stripe/change_plan/", change_plan_endpoint, name="change_plan"), - path("dashboard/billing/stripe/portal/", customer_client_portal_endpoint, name="stripe_customer_portal"), - path("dashboard/billing/stripe/checkout/response/success/", stripe_success_return_endpoint, name="stripe_checkout_success_response"), - path("dashboard/billing/stripe/checkout/response/failed/", stripe_success_return_endpoint, name="stripe_checkout_failed_response"), -] - -app_name = "billing" diff --git a/billing/views.py b/billing/views.py deleted file mode 100644 index 91ea44a21..000000000 --- a/billing/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/billing/views/__init__.py b/billing/views/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/billing/views/change_plan.py b/billing/views/change_plan.py deleted file mode 100644 index c35735d0a..000000000 --- a/billing/views/change_plan.py +++ /dev/null @@ -1,111 +0,0 @@ -import logging - -import stripe -from django.contrib import messages -from django.db.models import QuerySet -from django.http import HttpResponse -from django.shortcuts import render -from django.urls import reverse - -from backend.decorators import htmx_only, web_require_scopes -from backend.core.models import User -from backend.core.types.requests import WebRequest -from billing.models import SubscriptionPlan, UserSubscription, StripeCheckoutSession -from billing.service.stripe_customer import get_or_create_customer_id - -logger = logging.getLogger(__name__) - - -@web_require_scopes("billing:manage", api=True, htmx=True) -@htmx_only("billing:dashboard") -def change_plan_endpoint(request: WebRequest): - - plan: SubscriptionPlan | None = None - - if plan_filter := request.POST.get("plan_name"): - plan = SubscriptionPlan.objects.filter(name=plan_filter).first() - elif plan_filter := request.POST.get("plan_id"): - plan = SubscriptionPlan.objects.filter(id=plan_filter).first() - - if not plan: - messages.error(request, "Invalid plan") - return render(request, "base/toast.html") - elif plan.price_per_month == -1 or plan.name.lower() == "enterprise": - print("THIS PLAN IS ENTERPRISE, currently not implemented") - messages.error(request, "Invalid plan (not yet implemented)") - return render(request, "base/toast.html") - - users_plans: QuerySet[UserSubscription] = UserSubscription.filter_by_owner(request.actor) - - if plan.price_per_month == 0 and users_plans.exists(): - messages.error( - request, - """ - Unfortunately you have already used up your free trial. Please upgrade to a paid plan to continue. - If you have another query, feel free to book a call with the project lead + founder! - - Book here - - """, - ) - return render(request, "base/toast.html", {"autohide": False}) - - users_active_plans: QuerySet[UserSubscription] = users_plans.filter(end_date__isnull=True) - # if users_active_plans.exists(): - # for active_plan in users_active_plans: - # active_plan.end_date = timezone_now() - # active_plan.save() - - line_items = [{"adjustable_quantity": {"enabled": False}, "quantity": 1, "price": plan.stripe_price_id}] # type: ignore - - checkout_session_django_object = ( - StripeCheckoutSession.objects.create(user=request.actor, plan=plan) - if isinstance(request.actor, User) - else StripeCheckoutSession.objects.create(organization=request.actor, plan=plan) - ) - - for feature in plan.features.all(): - if not feature.stripe_price_id: - continue - - checkout_session_django_object.features.add(feature) - - line_items.append( - { - # "adjustable_quantity": { - # "enabled": False - # }, - "price": feature.stripe_price_id, - # "quantity": 1, - } - ) - - customer_id = get_or_create_customer_id(request.actor) - - if isinstance(request.actor, User): - customer_email = request.actor.email - else: - customer_email = request.actor.leader.email - - checkout_session = stripe.checkout.Session.create( - customer=customer_id, - customer_email=customer_email if not customer_email else None, # type: ignore[arg-type] - line_items=line_items, # type: ignore[arg-type] - mode="subscription", - # return_url="http://127.0.0.1:8000" + reverse("billing:stripe_checkout_failed_response"), - cancel_url=request.build_absolute_uri(reverse("billing:dashboard")), - success_url=request.build_absolute_uri(reverse("billing:stripe_checkout_success_response")), - metadata={"dj_checkout_uuid": checkout_session_django_object.uuid, "dj_subscription_plan_id": str(plan.id)}, - saved_payment_method_options={"payment_method_save": "enabled"}, - ) - - checkout_session_django_object.stripe_session_id = checkout_session.id - - checkout_session_django_object.save() - - # UserSubscription.objects.create(owner=request.actor, subscription_plan=plan) - messages.success(request, "Great! Redirecting you to stripe now!") - r = HttpResponse(status=200) - r["HX-Redirect"] = str(checkout_session.url) - return r diff --git a/billing/views/dashboard.py b/billing/views/dashboard.py deleted file mode 100644 index f7db39ed8..000000000 --- a/billing/views/dashboard.py +++ /dev/null @@ -1,50 +0,0 @@ -from django.shortcuts import render - -from backend.decorators import web_require_scopes -from billing.models import UserSubscription, SubscriptionPlan -from backend.core.types.requests import WebRequest - - -@web_require_scopes("billing:manage", api=True, htmx=True) -def billing_dashboard_endpoint(request: WebRequest): - context: dict = {} - - subscriptions = UserSubscription.filter_by_owner(request.actor).select_related("subscription_plan").all() - all_subscription_plans = SubscriptionPlan.objects.all() - - if subscriptions.exists(): - context["free_plan_available"] = True - - context.update( - { - "active_subscription": subscriptions.filter(end_date__isnull=True).first(), - "all_user_subscriptions": subscriptions, - "all_subscription_plans": all_subscription_plans, - } - ) - - return render( - request, - "pages/billing/dashboard/dashboard.html", - context, - ) - - -@web_require_scopes("billing:manage", api=True, htmx=True) -def all_subscriptions_htmx_endpoint(request: WebRequest): - context: dict = {} - - subscriptions = UserSubscription.filter_by_owner(request.actor).select_related("subscription_plan").all() - - context.update( - { - "active_subscription": subscriptions.filter(end_date__isnull=True).first(), - "all_user_subscriptions": subscriptions, - } - ) - - return render( - request, - "pages/billing/dashboard/all_subscriptions.html", - context, - ) diff --git a/billing/views/return_urls/failed.py b/billing/views/return_urls/failed.py deleted file mode 100644 index c03d4a888..000000000 --- a/billing/views/return_urls/failed.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect - -from backend.core.types.requests import WebRequest - - -def stripe_failed_return_endpoint(request: WebRequest): - messages.warning(request, "FAILED RESPONSE") - return redirect("billing:dashboard") diff --git a/billing/views/return_urls/success.py b/billing/views/return_urls/success.py deleted file mode 100644 index dd0d328a8..000000000 --- a/billing/views/return_urls/success.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.shortcuts import redirect - -from backend.core.types.requests import WebRequest - - -def stripe_success_return_endpoint(request: WebRequest): - return redirect("billing:dashboard") diff --git a/billing/views/stripe_misc.py b/billing/views/stripe_misc.py deleted file mode 100644 index c7d0905f8..000000000 --- a/billing/views/stripe_misc.py +++ /dev/null @@ -1,30 +0,0 @@ -import stripe -from django.http import HttpResponseRedirect, HttpResponse -from django.urls import reverse, resolve, NoReverseMatch - -from backend.decorators import web_require_scopes -from backend.core.types.requests import WebRequest - -from billing.service.stripe_customer import get_or_create_customer_id - - -@web_require_scopes("billing:manage", api=True, htmx=True) -def customer_client_portal_endpoint(request: WebRequest): - if NEXT := request.GET.get("back"): - try: - resolve(NEXT) - except NoReverseMatch: - NEXT = None - - customer_id = get_or_create_customer_id(request.actor) - - stripe_resp = stripe.billing_portal.Session.create( - customer=customer_id, return_url=request.build_absolute_uri(NEXT or reverse("dashboard")) - ) - - if request.htmx: - response = HttpResponse(status=200) - response["HX-Redirect"] = stripe_resp.url - return response - - return HttpResponseRedirect(stripe_resp.url) diff --git a/billing/views/stripe_webhooks.py b/billing/views/stripe_webhooks.py deleted file mode 100644 index cb058232d..000000000 --- a/billing/views/stripe_webhooks.py +++ /dev/null @@ -1,33 +0,0 @@ -import stripe -from rest_framework.permissions import AllowAny -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.decorators import api_view, authentication_classes, permission_classes -from billing.billing_settings import STRIPE_WEBHOOK_ENDPOINT_SECRET -from billing.models import StripeWebhookEvent -from django.views.decorators.csrf import csrf_exempt - - -@api_view(["POST"]) -@authentication_classes([]) # No auth required for webhooks -@permission_classes([AllowAny]) -@csrf_exempt -def stripe_listener_webhook_endpoint(request: Request): - payload = request.body - sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "") - - try: - event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_ENDPOINT_SECRET) - print(f"Webhook received: {event['type']}") - except ValueError as error: - print(f"Invalid payload: {error}") - return Response(status=400) - except stripe.error.SignatureVerificationError as error: - print(f"Invalid signature: {error}") - return Response(status=400) - - # Store event in database - StripeWebhookEvent.objects.create(event_id=event.id, event_type=event["type"], data=event["data"], raw_event=event) - - # Call specific event handler (signal) - return Response(status=200) diff --git a/frontend/templates/base/+left_drawer.html b/frontend/templates/base/+left_drawer.html deleted file mode 100644 index 3c8a986db..000000000 --- a/frontend/templates/base/+left_drawer.html +++ /dev/null @@ -1,81 +0,0 @@ -{% load feature_enabled %} -{% feature_enabled "areUserEmailsAllowed" as are_user_emails_allowed %} -{#{% personal_feature_enabled request.user "invoices" as feature_enabled_invoices %}#} -{#{% personal_feature_enabled request.user "receipts" as feature_enabled_receipts %}#} -{#{% personal_feature_enabled request.user "email_sending" as feature_enabled_emails %}#} -
- - -
diff --git a/frontend/templates/base/_head.html b/frontend/templates/base/_head.html deleted file mode 100644 index 2e634e3dd..000000000 --- a/frontend/templates/base/_head.html +++ /dev/null @@ -1,70 +0,0 @@ -{% load static %} -{% load render_bundle from webpack_loader %} -{% load tz_detect %} - - - - My Finances - - - - - - {% if import_method == "public_cdn" %} - - - - - {# #} - - - - {# #} - - - {# #} - - {# #} - {# - #} - - {# #} - {# Not in use at the moment MAY USE LATER ^^ #} - - - - {# #} - - {% else %} - {# #} - {# #} - {# #} - {# {% render_bundle 'all' 'js' %}#} - {% render_bundle 'init' 'js' %} - {% render_bundle 'tableify' 'js' %} - {% render_bundle 'htmx' 'js' %} - {% render_bundle 'font_awesome' 'js' %} - - - {% endif %} - {% render_bundle 'receipt_downloads' 'js' %} - {{ analytics|safe }} - {% tz_detect %} - {# #} - diff --git a/frontend/templates/base/auth.html b/frontend/templates/base/auth.html deleted file mode 100644 index a5a6a95eb..000000000 --- a/frontend/templates/base/auth.html +++ /dev/null @@ -1,37 +0,0 @@ -{% load static %} - - {% include 'base/_head.html' %} - -
-
-
-
-
-
-

Dashboard

-
- -
-

What do you get to manage?

-

✓ Client Lists

-

✓ Invoices

-

✓ Receipt Storage

-

✓ Financial Reports

-
-
-
-
- {# {% component "messages_list" %}#} - {% include "base/toasts.html" %} -

- {% block title %} - {% endblock title %} -

- {% block content %} - {% endblock content %} -
-
-
-
- - diff --git a/frontend/templates/base/base.html b/frontend/templates/base/base.html deleted file mode 100644 index 37a54d72d..000000000 --- a/frontend/templates/base/base.html +++ /dev/null @@ -1,55 +0,0 @@ - - - {% include 'base/_head.html' %} - -
- -
- {% include 'base/topbar/_topbar.html' %} -
-
- {% include 'base/breadcrumbs.html' with breadcrumb_first_load=True %} -
{% include 'base/toasts.html' %}
- -
- -
- {% block content %} - {% endblock content %} -
- {% component "base:left_drawer" %} -
- -
- - -
-
- - diff --git a/frontend/templates/base/htmx.html b/frontend/templates/base/htmx.html deleted file mode 100644 index 3836e49ad..000000000 --- a/frontend/templates/base/htmx.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {% block content %} - {% endblock content %} -
-{# Profile Picture dropdown item #} -
- {% component "base:topbar:icon_dropdown" %} -
-{% include "base/+left_drawer.html" with swap=True %} -{% include "base/breadcrumbs.html" with swap=True %} -{% include "base/toasts.html" %} diff --git a/frontend/templates/base/topbar/_topbar.html b/frontend/templates/base/topbar/_topbar.html deleted file mode 100644 index c387696e6..000000000 --- a/frontend/templates/base/topbar/_topbar.html +++ /dev/null @@ -1,125 +0,0 @@ -{% load static %} -{% load feature_enabled %} -{% personal_feature_enabled request.user "invoices" as feature_enabled_invoices %} -{% personal_feature_enabled request.user "receipts" as feature_enabled_receipts %} -{% personal_feature_enabled request.user "email_sending" as feature_enabled_emails %} -{% personal_feature_enabled request.user "monthly_reports" as feature_enabled_monthly_reports %} - diff --git a/frontend/templates/core/auth/auth.html b/frontend/templates/core/auth/auth.html new file mode 100644 index 000000000..170e420ff --- /dev/null +++ b/frontend/templates/core/auth/auth.html @@ -0,0 +1,11 @@ +{% extends "core/auth/auth.html" %} +{% block head %} + {% include 'core/base/_head.html' %} +{% endblock %} +{% block details %} +

What do you get to manage?

+

✓ Client Lists

+

✓ Invoices

+

✓ Receipt Storage

+

✓ Financial Reports

+{% endblock details %} diff --git a/frontend/templates/core/base/+left_drawer.html b/frontend/templates/core/base/+left_drawer.html new file mode 100644 index 000000000..9b615e541 --- /dev/null +++ b/frontend/templates/core/base/+left_drawer.html @@ -0,0 +1,67 @@ +{% extends "core/base/+left_drawer.html" %} +{% load feature_enabled %} +{% feature_enabled "areUserEmailsAllowed" as are_user_emails_allowed %} +{% personal_feature_enabled request.user "invoices" as feature_enabled_invoices %} +{% personal_feature_enabled request.user "receipts" as feature_enabled_receipts %} +{% personal_feature_enabled request.user "email_sending" as feature_enabled_emails %} +{% block drawer_items %} +
  • + {% with i_url="dashboard" %} + My Dashboard + {% endwith %} +
  • +
  • + {% with i_url="clients:dashboard" %} + Clients + {% endwith %} +
  • + {% if feature_enabled_invoices %} +
  • + {% with i_url="finance:invoices:single:dashboard" %} + + Single Invoices + + {% endwith %} +
  • +
  • + {% with i_url="finance:invoices:recurring:dashboard" %} + + Recurring Invoices + + {% endwith %} +
  • + {% endif %} + {% if feature_enabled_receipts %} +
  • + {% with i_url="receipts dashboard" %} + Receipts + {% endwith %} +
  • + {% endif %} +
  • + {% with i_url="quotas" %} + Service Quotas + {% endwith %} +
  • + {% if are_user_emails_allowed %} + {# and feature_enabled_emails %} #} +
  • + {% with i_url="emails:dashboard" %} + + Email Sending + + {% endwith %} +
  • + {% endif %} + {#
  • #} + {# File Storage#} + {#
  • #} +{% endblock drawer_items %} diff --git a/frontend/templates/core/base/_head.html b/frontend/templates/core/base/_head.html new file mode 100644 index 000000000..c68e20ba1 --- /dev/null +++ b/frontend/templates/core/base/_head.html @@ -0,0 +1 @@ +{% extends "core/base/_head.html" %} diff --git a/frontend/templates/core/base/base.html b/frontend/templates/core/base/base.html new file mode 100644 index 000000000..7a46e8c7a --- /dev/null +++ b/frontend/templates/core/base/base.html @@ -0,0 +1,7 @@ +{% extends "core/base/base.html" %} +{% block topbar %} + {% include 'core/base/topbar/_topbar.html' %} +{% endblock %} +{% block drawer %} + {% include "core/base/+left_drawer.html" %} +{% endblock drawer %} diff --git a/frontend/templates/base/breadcrumbs.html b/frontend/templates/core/base/breadcrumbs.html similarity index 79% rename from frontend/templates/base/breadcrumbs.html rename to frontend/templates/core/base/breadcrumbs.html index e4c15558d..e8c55a02d 100644 --- a/frontend/templates/base/breadcrumbs.html +++ b/frontend/templates/core/base/breadcrumbs.html @@ -2,13 +2,13 @@ {% endif %} diff --git a/frontend/templates/base/breadcrumbs_ul.html b/frontend/templates/core/base/breadcrumbs_ul.html similarity index 100% rename from frontend/templates/base/breadcrumbs_ul.html rename to frontend/templates/core/base/breadcrumbs_ul.html diff --git a/frontend/templates/core/base/htmx.html b/frontend/templates/core/base/htmx.html new file mode 100644 index 000000000..15c065300 --- /dev/null +++ b/frontend/templates/core/base/htmx.html @@ -0,0 +1,7 @@ +{% extends "core/base/htmx.html" %} +{##} +{% block left_drawer %} + {% include "core/base/+left_drawer.html" with swap=True %} +{% endblock %} +{#{% include "core/base/breadcrumbs.html" with swap=True %}#} +{#{% include "core/base/toasts.html" %}#} diff --git a/frontend/templates/base/toast.html b/frontend/templates/core/base/toast.html similarity index 100% rename from frontend/templates/base/toast.html rename to frontend/templates/core/base/toast.html diff --git a/frontend/templates/base/toasts.html b/frontend/templates/core/base/toasts.html similarity index 100% rename from frontend/templates/base/toasts.html rename to frontend/templates/core/base/toasts.html diff --git a/frontend/templates/base/topbar/+icon_dropdown.html b/frontend/templates/core/base/topbar/+icon_dropdown.html similarity index 76% rename from frontend/templates/base/topbar/+icon_dropdown.html rename to frontend/templates/core/base/topbar/+icon_dropdown.html index a39f5765a..542588256 100644 --- a/frontend/templates/base/topbar/+icon_dropdown.html +++ b/frontend/templates/core/base/topbar/+icon_dropdown.html @@ -2,15 +2,15 @@ {% has_module "billing" as billing_enabled %}
  • - + Settings
  • - + Manage Team @@ -37,7 +37,7 @@
  • {% endif %}
  • - Current Version: {{ version }} + Current Version: {{ finances_version }}
  • {% if git_version and git_version != "prod" %}
  • diff --git a/frontend/templates/base/topbar/_notification_count.html b/frontend/templates/core/base/topbar/_notification_count.html similarity index 79% rename from frontend/templates/base/topbar/_notification_count.html rename to frontend/templates/core/base/topbar/_notification_count.html index 5fd635444..eba864005 100644 --- a/frontend/templates/base/topbar/_notification_count.html +++ b/frontend/templates/core/base/topbar/_notification_count.html @@ -2,5 +2,5 @@ hx-swap-oob='innerHTML:[data-notifications="count"]' data-notifications="count" hx-target="this" - hx-get="{% url 'api:base:notifications get count' %}" + hx-get="{% url 'core:api:base:notifications get count' %}" hx-swap="innerHTML">{{ notif_count | default:request.user.notification_count }}

    diff --git a/frontend/templates/base/topbar/_notification_dropdown_items.html b/frontend/templates/core/base/topbar/_notification_dropdown_items.html similarity index 83% rename from frontend/templates/base/topbar/_notification_dropdown_items.html rename to frontend/templates/core/base/topbar/_notification_dropdown_items.html index 7d9e36d30..6f1b4ce77 100644 --- a/frontend/templates/base/topbar/_notification_dropdown_items.html +++ b/frontend/templates/core/base/topbar/_notification_dropdown_items.html @@ -2,7 +2,7 @@ {#
  • #} {# {{ notification.message }}#} {#
  • #} {#{% endfor %}#} @@ -21,7 +21,7 @@ {# hx-trigger="click once"#} {# hx-swap="beforeend"#} {# hx-target="#modal_container"#} -{# hx-get="{% url "api:base:modal retrieve with context" modal_name=notification.action_value context_type=notification.extra_type context_value=notification.extra_value %}">#} +{# hx-get="{% url "core:api:base:modal retrieve" modal_name=notification.action_value context_type=notification.extra_type context_value=notification.extra_value %}">#} {# {{ notification.message }}#} {# #} {# #} @@ -36,7 +36,7 @@
  • {{ notification.message }}
  • {% elif notification.action == "redirect" %} @@ -65,7 +65,7 @@ hx-trigger="click once" hx-swap="beforeend" hx-target="#modal_container" - hx-get="{% url "api:base:modal retrieve with context" modal_name=notification.action_value context_type=notification.extra_type context_value=notification.extra_value %}"> + hx-get="{% url "core:api:base:modal retrieve" modal_name=notification.action_value %}?{{ notification.extra_type }}={{ notification.extra_value }}"> {{ notification.message }} @@ -85,5 +85,5 @@ {% endif %} {% if not initial_load %} - {% include "base/topbar/_notification_count.html" %} + {% include "core/base/topbar/_notification_count.html" %} {% endif %} diff --git a/frontend/templates/base/topbar/_organizations_list.html b/frontend/templates/core/base/topbar/_organizations_list.html similarity index 52% rename from frontend/templates/base/topbar/_organizations_list.html rename to frontend/templates/core/base/topbar/_organizations_list.html index 0e0367722..07c39d3ea 100644 --- a/frontend/templates/base/topbar/_organizations_list.html +++ b/frontend/templates/core/base/topbar/_organizations_list.html @@ -1,15 +1,16 @@ {% for team in request.user.teams_joined.all %}
  • - {{ team.name | title }}
  • {% endfor %} {% for team in request.user.teams_leader_of.all %}
  • - {{ team.name | title }}
  • {% endfor %}
  • - Personal + Personal
  • diff --git a/frontend/templates/core/base/topbar/_topbar.html b/frontend/templates/core/base/topbar/_topbar.html new file mode 100644 index 000000000..4f070fec8 --- /dev/null +++ b/frontend/templates/core/base/topbar/_topbar.html @@ -0,0 +1,47 @@ +{% extends "core/base/topbar/_topbar.html" %} +{% load personal_feature_enabled from feature_enabled %} +{% block topbar_list %} + {% personal_feature_enabled request.user "invoices" as feature_enabled_invoices %} + {% personal_feature_enabled request.user "receipts" as feature_enabled_receipts %} + {% personal_feature_enabled request.user "email_sending" as feature_enabled_emails %} + {% personal_feature_enabled request.user "monthly_reports" as feature_enabled_monthly_reports %} + {% if feature_enabled_receipts %} +
    +
  • + + + Receipts + +
  • + {% endif %} + {% if feature_enabled_invoices %} +
    +
  • + + Invoices + +
  • +{% endif %} +
    +
  • + Clients + +
  • +{% if feature_enabled_monthly_reports %} +
    +
  • + + + Monthly Reports + +
  • +{% endif %} +{% endblock topbar_list %} diff --git a/frontend/templates/base/topbar/team_selector/selector.html b/frontend/templates/core/base/topbar/team_selector/selector.html similarity index 92% rename from frontend/templates/base/topbar/team_selector/selector.html rename to frontend/templates/core/base/topbar/team_selector/selector.html index 4b700b6c7..08e0726a9 100644 --- a/frontend/templates/base/topbar/team_selector/selector.html +++ b/frontend/templates/core/base/topbar/team_selector/selector.html @@ -6,12 +6,12 @@ Logo -
    {{ request.actor.name }}
    +
    {{ request.actor.name | default:"Personal" }}
    {% else %} Logo -
    {{ request.user.name }}
    +
    {{ request.user.name | default:"Personal" }}
    {% endif %}
    @@ -24,7 +24,7 @@ data-hx-container="dropdown_items">
    Personal Account
    diff --git a/frontend/templates/modals/change_profile_picture.html b/frontend/templates/modals/change_profile_picture.html index a6a9be406..2843d9440 100644 --- a/frontend/templates/modals/change_profile_picture.html +++ b/frontend/templates/modals/change_profile_picture.html @@ -2,7 +2,7 @@ {% fill "content" %}