From 618a646514c6dbf2f1dfa128c9e05baba4ead9ea Mon Sep 17 00:00:00 2001 From: Trey <73353716+TreyWW@users.noreply.github.com> Date: Sat, 12 Oct 2024 22:46:29 +0100 Subject: [PATCH] Restructured project to work with new community aspect (#513) * restructured project into more categories --------- Signed-off-by: Trey <73353716+TreyWW@users.noreply.github.com> --- WHERE_ARE_THINGS.md | 50 - backend/admin.py | 28 +- backend/api/urls.py | 23 - backend/apps.py | 2 - backend/{api => clients}/__init__.py | 0 .../file_storage => clients/api}/__init__.py | 0 .../{api/clients => clients/api}/delete.py | 4 +- backend/{api/clients => clients/api}/fetch.py | 8 +- backend/{api/clients => clients/api}/urls.py | 3 +- .../core_signals => clients}/clients.py | 2 +- backend/clients/models.py | 107 ++ .../invoices => clients/views}/__init__.py | 0 .../core/clients => clients/views}/create.py | 4 +- .../clients => clients/views}/dashboard.py | 2 +- .../core/clients => clients/views}/detail.py | 10 +- .../core/clients => clients/views}/edit.py | 0 .../core/clients => clients/views}/urls.py | 0 backend/context_processors.py | 2 +- .../{api/invoices/create => core}/__init__.py | 0 .../create/services => core/api}/__init__.py | 0 .../recurring => core/api/base}/__init__.py | 0 backend/{ => core}/api/base/breadcrumbs.py | 5 +- backend/{ => core}/api/base/modal.py | 20 +- backend/{ => core}/api/base/notifications.py | 2 +- backend/{ => core}/api/base/urls.py | 0 .../single => core/api/emails}/__init__.py | 0 backend/{ => core}/api/emails/fetch.py | 2 +- backend/{ => core}/api/emails/send.py | 8 +- backend/{ => core}/api/emails/status.py | 2 +- backend/{ => core}/api/emails/urls.py | 0 .../api/healthcheck}/__init__.py | 0 .../{ => core}/api/healthcheck/healthcheck.py | 0 backend/{ => core}/api/healthcheck/urls.py | 0 .../api/landing_page}/__init__.py | 0 .../api/landing_page/email_waitlist.py | 6 +- backend/{ => core}/api/landing_page/urls.py | 0 .../api/maintenance}/__init__.py | 0 backend/{ => core}/api/maintenance/now.py | 10 +- backend/{ => core}/api/maintenance/urls.py | 0 backend/{ => core}/api/public/__init__.py | 0 .../{ => core}/api/public/authentication.py | 5 +- backend/{ => core}/api/public/decorators.py | 0 .../public/endpoints/Invoices}/__init__.py | 0 .../api/public/endpoints/Invoices/create.py | 11 +- .../api/public/endpoints/Invoices/delete.py | 5 +- .../public/endpoints/Invoices/download_pdf.py | 12 +- .../api/public/endpoints/Invoices/edit.py | 6 +- .../api/public/endpoints/Invoices/get.py | 10 +- .../api/public/endpoints/Invoices/list.py | 13 +- .../api/public/endpoints/Invoices/urls.py | 0 .../api/public/endpoints}/__init__.py | 0 .../api/public/endpoints/clients}/__init__.py | 0 .../api/public/endpoints/clients/create.py | 12 +- .../api/public/endpoints/clients/delete.py | 9 +- .../api/public/endpoints/clients/list.py | 11 +- .../api/public/endpoints/clients/urls.py | 0 .../api/public/endpoints/system_health.py | 4 +- .../public/endpoints/webhooks}/__init__.py | 0 .../api/public/endpoints/webhooks/urls.py | 0 .../webhooks/webhook_task_queue_handler.py | 5 +- .../api/public/helpers}/__init__.py | 0 .../api/public/helpers/deprecate.py | 0 backend/{ => core}/api/public/middleware.py | 3 +- backend/{ => core}/api/public/models.py | 2 +- backend/{ => core}/api/public/permissions.py | 0 .../api/public/serializers}/__init__.py | 0 .../api/public/serializers/clients.py | 2 +- .../api/public/serializers/invoices.py | 2 +- backend/{ => core}/api/public/swagger_ui.py | 0 backend/{ => core}/api/public/types.py | 2 +- backend/{ => core}/api/public/urls.py | 6 +- .../services => core/api/quotas}/__init__.py | 0 backend/{ => core}/api/quotas/fetch.py | 2 +- backend/{ => core}/api/quotas/requests.py | 2 +- backend/{ => core}/api/quotas/urls.py | 0 .../emails => core/api/settings}/__init__.py | 0 backend/{ => core}/api/settings/api_keys.py | 13 +- .../{ => core}/api/settings/change_name.py | 3 +- backend/{ => core}/api/settings/defaults.py | 10 +- .../api/settings/email_templates.py | 2 +- .../{ => core}/api/settings/preferences.py | 0 .../api/settings/profile_picture.py | 6 +- backend/{ => core}/api/settings/urls.py | 0 .../recurring => core/api/teams}/__init__.py | 0 backend/{ => core}/api/teams/create.py | 2 +- backend/{ => core}/api/teams/create_user.py | 11 +- .../{ => core}/api/teams/edit_permissions.py | 6 +- backend/{ => core}/api/teams/invites.py | 11 +- backend/{ => core}/api/teams/kick.py | 0 backend/{ => core}/api/teams/leave.py | 4 +- backend/{ => core}/api/teams/switch_team.py | 2 +- backend/{ => core}/api/teams/urls.py | 0 backend/core/api/urls.py | 19 + .../create => core/data}/__init__.py | 0 .../data/default_email_templates.py | 0 .../{ => core}/data/default_feature_flags.py | 0 .../{ => core}/data/default_quota_limits.py | 0 .../management}/__init__.py | 0 .../management/commands}/__init__.py | 0 .../{ => core}/management/commands/auto.py | 3 +- .../management/commands/contributors.json | 0 .../management/commands/contributors.py | 0 .../management/commands/feature_flags.py | 0 .../commands/generate_aws_scheduler_apikey.py | 2 +- .../{ => core}/management/commands/lint.py | 0 .../management/commands/navbar_refresh.py | 0 .../management/commands/test_urls.py | 0 .../management/commands/test_views.py | 0 .../management/scheduled_tasks}/__init__.py | 0 .../scheduled_tasks/update_all_schedules.py | 4 +- backend/core/models.py | 701 ++++++++++ backend/core/service/__init__.py | 1 + .../service/api_keys}/__init__.py | 0 backend/{ => core}/service/api_keys/delete.py | 4 +- .../{ => core}/service/api_keys/generate.py | 6 +- backend/{ => core}/service/api_keys/get.py | 3 +- .../service/asyn_tasks}/__init__.py | 0 .../{ => core}/service/asyn_tasks/tasks.py | 0 .../create => core/service/base}/__init__.py | 0 .../{ => core}/service/base/breadcrumbs.py | 22 +- .../service/boto3}/__init__.py | 0 backend/{ => core}/service/boto3/handler.py | 0 .../service/boto3/scheduler}/__init__.py | 0 .../boto3/scheduler/create_schedule.py | 6 +- .../boto3/scheduler/delete_schedule.py | 6 +- .../{ => core}/service/boto3/scheduler/get.py | 4 +- .../service/boto3/scheduler/pause.py | 7 +- .../boto3/scheduler/update_schedule.py | 10 +- .../service/clients}/__init__.py | 0 backend/{ => core}/service/clients/create.py | 9 +- backend/{ => core}/service/clients/delete.py | 7 +- backend/{ => core}/service/clients/get.py | 5 +- .../{ => core}/service/clients/validate.py | 0 .../service/defaults}/__init__.py | 0 backend/{ => core}/service/defaults/get.py | 4 +- backend/{ => core}/service/defaults/update.py | 10 +- .../service/file_storage}/__init__.py | 0 .../{ => core}/service/file_storage/create.py | 2 +- .../{ => core}/service/file_storage/utils.py | 0 .../service/invoices}/__init__.py | 0 .../service/invoices/common}/__init__.py | 0 .../invoices/common/create}/__init__.py | 0 .../service/invoices/common/create/create.py | 6 +- .../invoices/common/create/get_page.py | 8 +- .../common/create/services}/__init__.py | 0 .../invoices/common/create/services/add.py | 6 +- .../invoices/common/emails}/__init__.py | 0 .../invoices/common/emails/on_create.py | 13 +- .../service/invoices/common/fetch.py | 2 +- .../{ => core}/service/invoices/handler.py | 0 .../service/invoices/recurring}/__init__.py | 0 .../invoices/recurring/create/__init__.py} | 0 .../invoices/recurring/create/get_page.py | 4 +- .../service/invoices/recurring/create/save.py | 8 +- .../invoices/recurring/generation/__init__.py | 0 .../recurring/generation/next_invoice.py | 8 +- .../service/invoices/recurring/get.py | 6 +- .../invoices/recurring/schedules/__init__.py | 0 .../recurring/schedules/date_handlers.py | 2 +- .../invoices/recurring/validate/__init__.py | 0 .../recurring/validate/frequencies.py | 4 +- .../invoices/recurring/webhooks/__init__.py | 0 .../recurring/webhooks/webhook_apikey_auth.py | 6 +- .../core/service/invoices/single/__init__.py | 0 .../invoices/single/create/__init__.py | 0 .../service/invoices/single/create/create.py | 11 +- .../invoices/single/create/get_page.py | 4 +- .../service/invoices/single/create_pdf.py | 2 +- .../service/invoices/single/create_url.py | 5 +- .../service/invoices/single/get_invoice.py | 4 +- backend/core/service/maintenance/__init__.py | 0 .../service/maintenance/expire/__init__.py | 0 .../service/maintenance/expire/run.py | 0 backend/core/service/permissions/__init__.py | 0 .../{ => core}/service/permissions/scopes.py | 8 +- backend/core/service/reports/__init__.py | 0 .../{ => core}/service/reports/generate.py | 3 +- backend/{ => core}/service/reports/get.py | 2 +- backend/core/service/settings/__init__.py | 0 backend/{ => core}/service/settings/update.py | 5 +- backend/{ => core}/service/settings/view.py | 7 +- backend/core/service/teams/__init__.py | 0 .../{ => core}/service/teams/create_user.py | 5 +- backend/{ => core}/service/teams/fetch.py | 6 +- .../{ => core}/service/teams/permissions.py | 7 +- backend/core/service/webhooks/__init__.py | 0 backend/core/service/webhooks/auth.py | 0 .../{ => core}/service/webhooks/get_url.py | 0 backend/core/signals/__init__.py | 4 + backend/{ => core}/signals/migrations.py | 4 +- backend/{ => core}/signals/signals.py | 4 +- backend/core/types/__init__.py | 0 backend/{ => core}/types/emails.py | 4 +- backend/{ => core}/types/htmx.py | 0 backend/{ => core}/types/requests.py | 0 backend/core/utils/__init__.py | 0 backend/{ => core}/utils/calendar.py | 0 backend/{ => core}/utils/dataclasses.py | 0 backend/{ => core}/utils/feature_flags.py | 0 backend/{ => core}/utils/http_utils.py | 0 backend/{ => core}/utils/quota_limit_ops.py | 0 backend/{ => core}/utils/service_retry.py | 4 +- backend/core/views/__init__.py | 0 backend/core/views/auth/__init__.py | 0 .../views}/auth/create_account.py | 4 +- .../core => core/views}/auth/helpers.py | 0 .../{views/core => core/views}/auth/login.py | 13 +- backend/core/views/auth/passwords/__init__.py | 0 .../views}/auth/passwords/generate.py | 5 +- .../core => core/views}/auth/passwords/set.py | 7 +- .../views}/auth/passwords/view.py | 8 +- .../{views/core => core/views}/auth/urls.py | 0 .../{views/core => core/views}/auth/verify.py | 2 - backend/core/views/emails/__init__.py | 0 .../core => core/views}/emails/dashboard.py | 2 +- .../{views/core => core/views}/emails/urls.py | 0 .../core => core/views}/other/__init__.py | 0 .../core => core/views}/other/errors.py | 5 +- .../{views/core => core/views}/other/index.py | 0 backend/core/views/quotas/__init__.py | 0 .../{views/core => core/views}/quotas/view.py | 3 +- backend/core/views/settings/__init__.py | 0 .../core => core/views}/settings/teams.py | 8 +- backend/core/views/settings/urls.py | 15 + .../core => core/views}/settings/view.py | 7 +- backend/core/views/teams/__init__.py | 0 .../{views/core => core/views}/teams/urls.py | 6 +- backend/core/webhooks/__init__.py | 0 backend/core/webhooks/invoices/__init__.py | 0 .../webhooks/invoices/invoice_status.py | 11 +- .../{ => core}/webhooks/invoices/recurring.py | 13 +- backend/{ => core}/webhooks/urls.py | 4 +- backend/decorators.py | 9 +- backend/finance/__init__.py | 0 backend/finance/api/__init__.py | 0 backend/finance/api/invoices/__init__.py | 0 .../finance/api/invoices/create/__init__.py | 0 .../api/invoices/create/services/__init__.py | 0 .../invoices/create/services/add_service.py | 4 +- .../api/invoices/create/set_destination.py | 2 +- backend/{ => finance}/api/invoices/delete.py | 2 +- backend/{ => finance}/api/invoices/edit.py | 8 +- backend/{ => finance}/api/invoices/fetch.py | 8 +- backend/{ => finance}/api/invoices/manage.py | 8 +- .../api/invoices/recurring/__init__.py | 0 .../api/invoices/recurring/delete.py | 10 +- .../api/invoices/recurring/edit.py | 8 +- .../api/invoices/recurring/fetch.py | 10 +- .../recurring/generate_next_invoice_now.py | 10 +- .../api/invoices/recurring/poll.py | 12 +- .../api/invoices/recurring/update_status.py | 15 +- .../api/invoices/reminders/__init__.py | 0 .../api/invoices/reminders/create.py | 4 +- .../api/invoices/reminders/delete.py | 2 +- .../api/invoices/reminders/fetch.py | 4 +- .../api/invoices/reminders/urls.py | 0 .../finance/api/invoices/single/__init__.py | 0 backend/{ => finance}/api/invoices/urls.py | 4 +- backend/finance/api/products/__init__.py | 0 backend/{ => finance}/api/products/create.py | 6 +- backend/{ => finance}/api/products/fetch.py | 4 +- backend/{ => finance}/api/products/urls.py | 0 backend/finance/api/receipts/__init__.py | 0 backend/{ => finance}/api/receipts/delete.py | 2 +- .../{ => finance}/api/receipts/download.py | 2 +- backend/{ => finance}/api/receipts/edit.py | 0 backend/{ => finance}/api/receipts/fetch.py | 4 +- backend/{ => finance}/api/receipts/new.py | 2 +- backend/{ => finance}/api/receipts/urls.py | 0 backend/finance/api/reports/__init__.py | 0 backend/{ => finance}/api/reports/fetch.py | 2 +- backend/{ => finance}/api/reports/generate.py | 7 +- backend/{ => finance}/api/reports/urls.py | 0 backend/finance/api/urls.py | 13 + backend/finance/models.py | 396 ++++++ backend/finance/signals/__init__.py | 0 .../invoices => finance/signals}/schedules.py | 6 +- backend/finance/views/__init__.py | 0 backend/finance/views/invoices/__init__.py | 0 .../views}/invoices/handler.py | 2 +- .../views/invoices/recurring/__init__.py | 0 .../views}/invoices/recurring/create.py | 29 +- .../views}/invoices/recurring/dashboard.py | 7 +- .../views}/invoices/recurring/edit.py | 9 +- .../views}/invoices/recurring/overview.py | 15 +- .../finance/views/invoices/single/__init__.py | 0 .../views}/invoices/single/create.py | 14 +- .../views}/invoices/single/dashboard.py | 12 +- .../views}/invoices/single/edit.py | 11 +- .../views}/invoices/single/manage_access.py | 20 +- .../views}/invoices/single/overview.py | 13 +- .../views}/invoices/single/schedule.py | 4 +- .../views}/invoices/single/view.py | 9 +- .../core => finance/views}/invoices/urls.py | 23 +- backend/finance/views/receipts/__init__.py | 0 .../views}/receipts/dashboard.py | 2 +- backend/finance/views/receipts/urls.py | 7 + backend/finance/views/reports/__init__.py | 0 backend/finance/views/reports/dashboard.py | 6 + .../core => finance/views}/reports/urls.py | 0 .../core => finance/views}/reports/view.py | 7 +- backend/finance/views/urls.py | 13 + backend/middleware.py | 7 +- backend/migrations/0001_initial.py | 2 +- ...alter_verificationcodes_expiry_and_more.py | 4 +- .../0023_apikey_invoiceonetimeschedule.py | 2 +- ...ereminder_boto_schedule_status_and_more.py | 8 +- ...lter_defaultvalues_default_invoice_logo.py | 4 +- backend/migrations/0049_filestoragefile.py | 4 +- ...ing_invoices_invoice_cancelled_and_more.py | 12 +- backend/models.py | 1225 +---------------- backend/onboarding/__init__.py | 0 backend/onboarding/api/__init__.py | 0 backend/service/__init__.py | 1 - backend/signals/__init__.py | 6 - backend/storage/__init__.py | 0 backend/storage/api/__init__.py | 0 .../file_storage => storage/api}/delete.py | 4 +- .../file_storage => storage/api}/fetch.py | 11 +- .../{api/file_storage => storage/api}/urls.py | 0 .../core_signals => storage}/file_storage.py | 3 +- backend/storage/views/__init__.py | 0 .../views}/dashboard.py | 6 +- .../file_storage => storage/views}/upload.py | 9 +- .../file_storage => storage/views}/urls.py | 5 +- backend/templatetags/__init__.py | 0 backend/templatetags/feature_enabled.py | 2 +- backend/urls.py | 43 +- backend/views/core/__init__.py | 7 - backend/views/core/reports/dashboard.py | 12 - backend/views/core/settings/urls.py | 17 - billing/billing_settings.py | 28 +- billing/middleware.py | 10 +- billing/models.py | 2 +- billing/service/checkout_completed.py | 2 +- billing/service/subscription_ended.py | 2 +- billing/signals/quotas.py | 2 +- billing/signals/usage.py | 6 +- billing/views/change_plan.py | 8 +- billing/views/dashboard.py | 2 +- billing/views/return_urls/failed.py | 2 +- billing/views/return_urls/success.py | 3 +- billing/views/stripe_misc.py | 2 +- frontend/templates/base/+left_drawer.html | 4 +- frontend/templates/base/topbar/_topbar.html | 4 +- .../modals/create_invoice_product.html | 2 +- .../templates/modals/create_reminder.html | 2 +- .../modals/invoices_add_service.html | 2 +- .../modals/invoices_edit_discount.html | 2 +- .../modals/invoices_from_destination.html | 2 +- .../modals/invoices_to_destination.html | 4 +- .../templates/modals/receipts_upload.html | 2 +- .../pages/clients/detail/dashboard.html | 4 +- .../create/services/+services_table.html | 2 +- .../pages/invoices/dashboard/_fetch_body.html | 30 +- .../pages/invoices/dashboard/manage.html | 16 +- .../recurring/dashboard/_fetch_body.html | 20 +- .../recurring/dashboard/_pause_button.html | 4 +- .../recurring/dashboard/dashboard.html | 4 +- .../invoices/recurring/dashboard/manage.html | 12 +- .../recurring/dashboard/poll_update.html | 2 +- .../recurring/edit/edit_recurring.html | 6 +- .../recurring/manage/next_invoice_block.html | 2 +- .../invoices/single/dashboard/dashboard.html | 6 +- .../single/manage_access/_table_row.html | 2 +- .../single/manage_access/manage_access.html | 4 +- .../schedules/reminders/_table_row.html | 2 +- .../single/schedules/reminders/container.html | 10 +- .../schedules/schedules/_table_row.html | 2 +- .../schedules/onetime_schedule_create.html | 2 +- .../schedules/onetime_schedule_list.html | 18 +- .../invoices/single/view/_banner/_banner.html | 2 +- .../_banner/_button_options_dropdown.html | 2 +- .../view/_banner/_button_options_top.html | 2 +- .../invoices/single/view/invoice_page.html | 2 +- .../invoices/structure/invoices_list.html | 4 +- .../pages/invoices/structure/toggler.html | 12 +- .../pages/products/fetched_items.html | 2 +- .../templates/pages/receipts/_search.html | 2 +- .../pages/receipts/_search_results.html | 12 +- .../templates/pages/receipts/dashboard.html | 2 +- settings/helpers.py | 5 +- settings/settings.py | 8 +- tests/api/test_account_settings.py | 2 +- tests/api/test_clients.py | 4 +- tests/api/test_invoices.py | 14 +- tests/api/test_receipts.py | 4 +- tests/handler.py | 2 +- tests/urls_INACTIVE/logged_in.json | 4 +- tests/urls_INACTIVE/unlogged_in.json | 4 +- tests/urls_INACTIVE/verify_urls.py | 2 +- tests/views/test_change_password.py | 2 +- tests/views/test_clients.py | 2 +- tests/views/test_dashboard.py | 2 +- tests/views/test_index.py | 2 +- tests/views/test_invoices.py | 10 +- tests/views/test_login.py | 2 +- tests/views/test_other.py | 2 +- tests/views/test_receipts.py | 6 +- tests/views/test_receipts_download.py | 10 +- tests/views/test_settings_teams.py | 2 +- tests/views/test_usersettings.py | 2 +- 402 files changed, 2037 insertions(+), 2102 deletions(-) delete mode 100644 WHERE_ARE_THINGS.md delete mode 100644 backend/api/urls.py rename backend/{api => clients}/__init__.py (100%) rename backend/{api/file_storage => clients/api}/__init__.py (100%) rename backend/{api/clients => clients/api}/delete.py (80%) rename backend/{api/clients => clients/api}/fetch.py (83%) rename backend/{api/clients => clients/api}/urls.py (88%) rename backend/{signals/core_signals => clients}/clients.py (95%) create mode 100644 backend/clients/models.py rename backend/{api/invoices => clients/views}/__init__.py (100%) rename backend/{views/core/clients => clients/views}/create.py (83%) rename backend/{views/core/clients => clients/views}/dashboard.py (84%) rename backend/{views/core/clients => clients/views}/detail.py (84%) rename backend/{views/core/clients => clients/views}/edit.py (100%) rename backend/{views/core/clients => clients/views}/urls.py (100%) rename backend/{api/invoices/create => core}/__init__.py (100%) rename backend/{api/invoices/create/services => core/api}/__init__.py (100%) rename backend/{api/invoices/recurring => core/api/base}/__init__.py (100%) rename backend/{ => core}/api/base/breadcrumbs.py (73%) rename backend/{ => core}/api/base/modal.py (94%) rename backend/{ => core}/api/base/notifications.py (96%) rename backend/{ => core}/api/base/urls.py (100%) rename backend/{api/invoices/single => core/api/emails}/__init__.py (100%) rename backend/{ => core}/api/emails/fetch.py (96%) rename backend/{ => core}/api/emails/send.py (98%) rename backend/{ => core}/api/emails/status.py (98%) rename backend/{ => core}/api/emails/urls.py (100%) rename backend/{api/maintenance => core/api/healthcheck}/__init__.py (100%) rename backend/{ => core}/api/healthcheck/healthcheck.py (100%) rename backend/{ => core}/api/healthcheck/urls.py (100%) rename backend/{api/reports => core/api/landing_page}/__init__.py (100%) rename backend/{ => core}/api/landing_page/email_waitlist.py (89%) rename backend/{ => core}/api/landing_page/urls.py (100%) rename backend/{service/asyn_tasks => core/api/maintenance}/__init__.py (100%) rename backend/{ => core}/api/maintenance/now.py (65%) rename backend/{ => core}/api/maintenance/urls.py (100%) rename backend/{ => core}/api/public/__init__.py (100%) rename backend/{ => core}/api/public/authentication.py (83%) rename backend/{ => core}/api/public/decorators.py (100%) rename backend/{service/boto3 => core/api/public/endpoints/Invoices}/__init__.py (100%) rename backend/{ => core}/api/public/endpoints/Invoices/create.py (92%) rename backend/{ => core}/api/public/endpoints/Invoices/delete.py (86%) rename backend/{ => core}/api/public/endpoints/Invoices/download_pdf.py (88%) rename backend/{ => core}/api/public/endpoints/Invoices/edit.py (97%) rename backend/{ => core}/api/public/endpoints/Invoices/get.py (87%) rename backend/{ => core}/api/public/endpoints/Invoices/list.py (89%) rename backend/{ => core}/api/public/endpoints/Invoices/urls.py (100%) rename backend/{service/boto3/scheduler => core/api/public/endpoints}/__init__.py (100%) rename backend/{service/file_storage => core/api/public/endpoints/clients}/__init__.py (100%) rename backend/{ => core}/api/public/endpoints/clients/create.py (85%) rename backend/{ => core}/api/public/endpoints/clients/delete.py (90%) rename backend/{ => core}/api/public/endpoints/clients/list.py (82%) rename backend/{ => core}/api/public/endpoints/clients/urls.py (100%) rename backend/{ => core}/api/public/endpoints/system_health.py (97%) rename backend/{service/invoices => core/api/public/endpoints/webhooks}/__init__.py (100%) rename backend/{ => core}/api/public/endpoints/webhooks/urls.py (100%) rename backend/{ => core}/api/public/endpoints/webhooks/webhook_task_queue_handler.py (92%) rename backend/{service/invoices/common => core/api/public/helpers}/__init__.py (100%) rename backend/{ => core}/api/public/helpers/deprecate.py (100%) rename backend/{ => core}/api/public/middleware.py (93%) rename backend/{ => core}/api/public/models.py (98%) rename backend/{ => core}/api/public/permissions.py (100%) rename backend/{service/invoices/common/create => core/api/public/serializers}/__init__.py (100%) rename backend/{ => core}/api/public/serializers/clients.py (82%) rename backend/{ => core}/api/public/serializers/invoices.py (93%) rename backend/{ => core}/api/public/swagger_ui.py (100%) rename backend/{ => core}/api/public/types.py (82%) rename backend/{ => core}/api/public/urls.py (62%) rename backend/{service/invoices/common/create/services => core/api/quotas}/__init__.py (100%) rename backend/{ => core}/api/quotas/fetch.py (94%) rename backend/{ => core}/api/quotas/requests.py (98%) rename backend/{ => core}/api/quotas/urls.py (100%) rename backend/{service/invoices/common/emails => core/api/settings}/__init__.py (100%) rename backend/{ => core}/api/settings/api_keys.py (84%) rename backend/{ => core}/api/settings/change_name.py (92%) rename backend/{ => core}/api/settings/defaults.py (91%) rename backend/{ => core}/api/settings/email_templates.py (81%) rename backend/{ => core}/api/settings/preferences.py (100%) rename backend/{ => core}/api/settings/profile_picture.py (80%) rename backend/{ => core}/api/settings/urls.py (100%) rename backend/{service/invoices/recurring => core/api/teams}/__init__.py (100%) rename backend/{ => core}/api/teams/create.py (96%) rename backend/{ => core}/api/teams/create_user.py (76%) rename backend/{ => core}/api/teams/edit_permissions.py (83%) rename backend/{ => core}/api/teams/invites.py (96%) rename backend/{ => core}/api/teams/kick.py (100%) rename backend/{ => core}/api/teams/leave.py (92%) rename backend/{ => core}/api/teams/switch_team.py (97%) rename backend/{ => core}/api/teams/urls.py (100%) create mode 100644 backend/core/api/urls.py rename backend/{service/invoices/recurring/create => core/data}/__init__.py (100%) rename backend/{ => core}/data/default_email_templates.py (100%) rename backend/{ => core}/data/default_feature_flags.py (100%) rename backend/{ => core}/data/default_quota_limits.py (100%) rename backend/{service/invoices/recurring/generation => core/management}/__init__.py (100%) rename backend/{service/invoices/recurring/schedules => core/management/commands}/__init__.py (100%) rename backend/{ => core}/management/commands/auto.py (79%) rename backend/{ => core}/management/commands/contributors.json (100%) rename backend/{ => core}/management/commands/contributors.py (100%) rename backend/{ => core}/management/commands/feature_flags.py (100%) rename backend/{ => core}/management/commands/generate_aws_scheduler_apikey.py (94%) rename backend/{ => core}/management/commands/lint.py (100%) rename backend/{ => core}/management/commands/navbar_refresh.py (100%) rename backend/{ => core}/management/commands/test_urls.py (100%) rename backend/{ => core}/management/commands/test_views.py (100%) rename backend/{service/invoices/recurring/validate => core/management/scheduled_tasks}/__init__.py (100%) rename backend/{ => core}/management/scheduled_tasks/update_all_schedules.py (81%) create mode 100644 backend/core/models.py create mode 100644 backend/core/service/__init__.py rename backend/{service/invoices/recurring/webhooks => core/service/api_keys}/__init__.py (100%) rename backend/{ => core}/service/api_keys/delete.py (83%) rename backend/{ => core}/service/api_keys/generate.py (92%) rename backend/{ => core}/service/api_keys/get.py (88%) rename backend/{service/invoices/single => core/service/asyn_tasks}/__init__.py (100%) rename backend/{ => core}/service/asyn_tasks/tasks.py (100%) rename backend/{service/invoices/single/create => core/service/base}/__init__.py (100%) rename backend/{ => core}/service/base/breadcrumbs.py (72%) rename backend/{service/reports => core/service/boto3}/__init__.py (100%) rename backend/{ => core}/service/boto3/handler.py (100%) rename backend/{service/webhooks => core/service/boto3/scheduler}/__init__.py (100%) rename backend/{ => core}/service/boto3/scheduler/create_schedule.py (94%) rename backend/{ => core}/service/boto3/scheduler/delete_schedule.py (87%) rename backend/{ => core}/service/boto3/scheduler/get.py (89%) rename backend/{ => core}/service/boto3/scheduler/pause.py (87%) rename backend/{ => core}/service/boto3/scheduler/update_schedule.py (92%) rename backend/{utils => core/service/clients}/__init__.py (100%) rename backend/{ => core}/service/clients/create.py (84%) rename backend/{ => core}/service/clients/delete.py (84%) rename backend/{ => core}/service/clients/get.py (84%) rename backend/{ => core}/service/clients/validate.py (100%) rename backend/{views => core/service/defaults}/__init__.py (100%) rename backend/{ => core}/service/defaults/get.py (79%) rename backend/{ => core}/service/defaults/update.py (95%) rename backend/{views/core/auth => core/service/file_storage}/__init__.py (100%) rename backend/{ => core}/service/file_storage/create.py (95%) rename backend/{ => core}/service/file_storage/utils.py (100%) rename backend/{views/core/auth/passwords => core/service/invoices}/__init__.py (100%) rename backend/{views/core/file_storage => core/service/invoices/common}/__init__.py (100%) rename backend/{views/core/invoices => core/service/invoices/common/create}/__init__.py (100%) rename backend/{ => core}/service/invoices/common/create/create.py (95%) rename backend/{ => core}/service/invoices/common/create/get_page.py (92%) rename backend/{views/core/invoices/recurring => core/service/invoices/common/create/services}/__init__.py (100%) rename backend/{ => core}/service/invoices/common/create/services/add.py (94%) rename backend/{views/core/invoices/single => core/service/invoices/common/emails}/__init__.py (100%) rename backend/{ => core}/service/invoices/common/emails/on_create.py (84%) rename backend/{ => core}/service/invoices/common/fetch.py (97%) rename backend/{ => core}/service/invoices/handler.py (100%) rename backend/{views/core/reports => core/service/invoices/recurring}/__init__.py (100%) rename backend/{service/webhooks/auth.py => core/service/invoices/recurring/create/__init__.py} (100%) rename backend/{ => core}/service/invoices/recurring/create/get_page.py (87%) rename backend/{ => core}/service/invoices/recurring/create/save.py (90%) create mode 100644 backend/core/service/invoices/recurring/generation/__init__.py rename backend/{ => core}/service/invoices/recurring/generation/next_invoice.py (95%) rename backend/{ => core}/service/invoices/recurring/get.py (82%) create mode 100644 backend/core/service/invoices/recurring/schedules/__init__.py rename backend/{ => core}/service/invoices/recurring/schedules/date_handlers.py (98%) create mode 100644 backend/core/service/invoices/recurring/validate/__init__.py rename backend/{ => core}/service/invoices/recurring/validate/frequencies.py (95%) create mode 100644 backend/core/service/invoices/recurring/webhooks/__init__.py rename backend/{ => core}/service/invoices/recurring/webhooks/webhook_apikey_auth.py (86%) create mode 100644 backend/core/service/invoices/single/__init__.py create mode 100644 backend/core/service/invoices/single/create/__init__.py rename backend/{ => core}/service/invoices/single/create/create.py (88%) rename backend/{ => core}/service/invoices/single/create/get_page.py (81%) rename backend/{ => core}/service/invoices/single/create_pdf.py (95%) rename backend/{ => core}/service/invoices/single/create_url.py (69%) rename backend/{ => core}/service/invoices/single/get_invoice.py (82%) create mode 100644 backend/core/service/maintenance/__init__.py create mode 100644 backend/core/service/maintenance/expire/__init__.py rename backend/{ => core}/service/maintenance/expire/run.py (100%) create mode 100644 backend/core/service/permissions/__init__.py rename backend/{ => core}/service/permissions/scopes.py (83%) create mode 100644 backend/core/service/reports/__init__.py rename backend/{ => core}/service/reports/generate.py (94%) rename backend/{ => core}/service/reports/get.py (87%) create mode 100644 backend/core/service/settings/__init__.py rename backend/{ => core}/service/settings/update.py (92%) rename backend/{ => core}/service/settings/view.py (90%) create mode 100644 backend/core/service/teams/__init__.py rename backend/{ => core}/service/teams/create_user.py (92%) rename backend/{ => core}/service/teams/fetch.py (53%) rename backend/{ => core}/service/teams/permissions.py (87%) create mode 100644 backend/core/service/webhooks/__init__.py create mode 100644 backend/core/service/webhooks/auth.py rename backend/{ => core}/service/webhooks/get_url.py (100%) create mode 100644 backend/core/signals/__init__.py rename backend/{ => core}/signals/migrations.py (94%) rename backend/{ => core}/signals/signals.py (96%) create mode 100644 backend/core/types/__init__.py rename backend/{ => core}/types/emails.py (90%) rename backend/{ => core}/types/htmx.py (100%) rename backend/{ => core}/types/requests.py (100%) create mode 100644 backend/core/utils/__init__.py rename backend/{ => core}/utils/calendar.py (100%) rename backend/{ => core}/utils/dataclasses.py (100%) rename backend/{ => core}/utils/feature_flags.py (100%) rename backend/{ => core}/utils/http_utils.py (100%) rename backend/{ => core}/utils/quota_limit_ops.py (100%) rename backend/{ => core}/utils/service_retry.py (81%) create mode 100644 backend/core/views/__init__.py create mode 100644 backend/core/views/auth/__init__.py rename backend/{views/core => core/views}/auth/create_account.py (97%) rename backend/{views/core => core/views}/auth/helpers.py (100%) rename backend/{views/core => core/views}/auth/login.py (96%) create mode 100644 backend/core/views/auth/passwords/__init__.py rename backend/{views/core => core/views}/auth/passwords/generate.py (95%) rename backend/{views/core => core/views}/auth/passwords/set.py (87%) rename backend/{views/core => core/views}/auth/passwords/view.py (72%) rename backend/{views/core => core/views}/auth/urls.py (100%) rename backend/{views/core => core/views}/auth/verify.py (97%) create mode 100644 backend/core/views/emails/__init__.py rename backend/{views/core => core/views}/emails/dashboard.py (88%) rename backend/{views/core => core/views}/emails/urls.py (100%) rename backend/{views/core => core/views}/other/__init__.py (100%) rename backend/{views/core => core/views}/other/errors.py (94%) rename backend/{views/core => core/views}/other/index.py (100%) create mode 100644 backend/core/views/quotas/__init__.py rename backend/{views/core => core/views}/quotas/view.py (90%) create mode 100644 backend/core/views/settings/__init__.py rename backend/{views/core => core/views}/settings/teams.py (91%) create mode 100644 backend/core/views/settings/urls.py rename backend/{views/core => core/views}/settings/view.py (93%) create mode 100644 backend/core/views/teams/__init__.py rename backend/{views/core => core/views}/teams/urls.py (50%) create mode 100644 backend/core/webhooks/__init__.py create mode 100644 backend/core/webhooks/invoices/__init__.py rename backend/{ => core}/webhooks/invoices/invoice_status.py (80%) rename backend/{ => core}/webhooks/invoices/recurring.py (77%) rename backend/{ => core}/webhooks/urls.py (57%) create mode 100644 backend/finance/__init__.py create mode 100644 backend/finance/api/__init__.py create mode 100644 backend/finance/api/invoices/__init__.py create mode 100644 backend/finance/api/invoices/create/__init__.py create mode 100644 backend/finance/api/invoices/create/services/__init__.py rename backend/{ => finance}/api/invoices/create/services/add_service.py (69%) rename backend/{ => finance}/api/invoices/create/set_destination.py (96%) rename backend/{ => finance}/api/invoices/delete.py (96%) rename backend/{ => finance}/api/invoices/edit.py (96%) rename backend/{ => finance}/api/invoices/fetch.py (81%) rename backend/{ => finance}/api/invoices/manage.py (86%) create mode 100644 backend/finance/api/invoices/recurring/__init__.py rename backend/{ => finance}/api/invoices/recurring/delete.py (84%) rename backend/{ => finance}/api/invoices/recurring/edit.py (92%) rename backend/{ => finance}/api/invoices/recurring/fetch.py (79%) rename backend/{ => finance}/api/invoices/recurring/generate_next_invoice_now.py (87%) rename backend/{ => finance}/api/invoices/recurring/poll.py (85%) rename backend/{ => finance}/api/invoices/recurring/update_status.py (89%) create mode 100644 backend/finance/api/invoices/reminders/__init__.py rename backend/{ => finance}/api/invoices/reminders/create.py (96%) rename backend/{ => finance}/api/invoices/reminders/delete.py (97%) rename backend/{ => finance}/api/invoices/reminders/fetch.py (97%) rename backend/{ => finance}/api/invoices/reminders/urls.py (100%) create mode 100644 backend/finance/api/invoices/single/__init__.py rename backend/{ => finance}/api/invoices/urls.py (95%) create mode 100644 backend/finance/api/products/__init__.py rename backend/{ => finance}/api/products/create.py (83%) rename backend/{ => finance}/api/products/fetch.py (87%) rename backend/{ => finance}/api/products/urls.py (100%) create mode 100644 backend/finance/api/receipts/__init__.py rename backend/{ => finance}/api/receipts/delete.py (95%) rename backend/{ => finance}/api/receipts/download.py (95%) rename backend/{ => finance}/api/receipts/edit.py (100%) rename backend/{ => finance}/api/receipts/fetch.py (95%) rename backend/{ => finance}/api/receipts/new.py (98%) rename backend/{ => finance}/api/receipts/urls.py (100%) create mode 100644 backend/finance/api/reports/__init__.py rename backend/{ => finance}/api/reports/fetch.py (92%) rename backend/{ => finance}/api/reports/generate.py (80%) rename backend/{ => finance}/api/reports/urls.py (100%) create mode 100644 backend/finance/api/urls.py create mode 100644 backend/finance/models.py create mode 100644 backend/finance/signals/__init__.py rename backend/{signals/core_signals/invoices => finance/signals}/schedules.py (75%) create mode 100644 backend/finance/views/__init__.py create mode 100644 backend/finance/views/invoices/__init__.py rename backend/{views/core => finance/views}/invoices/handler.py (93%) create mode 100644 backend/finance/views/invoices/recurring/__init__.py rename backend/{views/core => finance/views}/invoices/recurring/create.py (58%) rename backend/{views/core => finance/views}/invoices/recurring/dashboard.py (64%) rename backend/{views/core => finance/views}/invoices/recurring/edit.py (90%) rename backend/{views/core => finance/views}/invoices/recurring/overview.py (81%) create mode 100644 backend/finance/views/invoices/single/__init__.py rename backend/{views/core => finance/views}/invoices/single/create.py (64%) rename backend/{views/core => finance/views}/invoices/single/dashboard.py (71%) rename backend/{views/core => finance/views}/invoices/single/edit.py (95%) rename backend/{views/core => finance/views}/invoices/single/manage_access.py (77%) rename backend/{views/core => finance/views}/invoices/single/overview.py (81%) rename backend/{views/core => finance/views}/invoices/single/schedule.py (90%) rename backend/{views/core => finance/views}/invoices/single/view.py (87%) rename backend/{views/core => finance/views}/invoices/urls.py (64%) create mode 100644 backend/finance/views/receipts/__init__.py rename backend/{views/core => finance/views}/receipts/dashboard.py (86%) create mode 100644 backend/finance/views/receipts/urls.py create mode 100644 backend/finance/views/reports/__init__.py create mode 100644 backend/finance/views/reports/dashboard.py rename backend/{views/core => finance/views}/reports/urls.py (100%) rename backend/{views/core => finance/views}/reports/view.py (68%) create mode 100644 backend/finance/views/urls.py create mode 100644 backend/onboarding/__init__.py create mode 100644 backend/onboarding/api/__init__.py delete mode 100644 backend/service/__init__.py delete mode 100644 backend/signals/__init__.py create mode 100644 backend/storage/__init__.py create mode 100644 backend/storage/api/__init__.py rename backend/{api/file_storage => storage/api}/delete.py (91%) rename backend/{api/file_storage => storage/api}/fetch.py (71%) rename backend/{api/file_storage => storage/api}/urls.py (100%) rename backend/{signals/core_signals => storage}/file_storage.py (84%) create mode 100644 backend/storage/views/__init__.py rename backend/{views/core/file_storage => storage/views}/dashboard.py (80%) rename backend/{views/core/file_storage => storage/views}/upload.py (93%) rename backend/{views/core/file_storage => storage/views}/urls.py (81%) create mode 100644 backend/templatetags/__init__.py delete mode 100644 backend/views/core/__init__.py delete mode 100644 backend/views/core/reports/dashboard.py delete mode 100644 backend/views/core/settings/urls.py diff --git a/WHERE_ARE_THINGS.md b/WHERE_ARE_THINGS.md deleted file mode 100644 index 12c777fe6..000000000 --- a/WHERE_ARE_THINGS.md +++ /dev/null @@ -1,50 +0,0 @@ -# Where are things? -- [views](#views) -- [tests](#tests) -- [Page routes (URLS.py)](#page-routes-urlspy) -- [context_processors](#context-processors-c_ppy-variables-passed-into-every-page) -- [static files](#static-files) -- [templates](#templates-html-pages) - - - -### Views -``` -backend/
/views/.py -``` - -### Tests -``` -backend/tests/test_
.py -``` - -### Page Routes ([URLS.py](https://github.com/TreyWW/MyFinances/blob/main/backend/urls.py)) -``` -backend/urls.py -``` - -### Context Processors ([c_p.py](https://github.com/TreyWW/MyFinances/blob/main/backend/context_processors.py)) (variables passed into every page) -``` -backend/context_processors.py -``` - -### Static Files -``` -frontend/static/img/ -frontend/static/js/ -``` - - -### Templates (HTML Pages) -``` - frontend/templates/pages/
/.html -``` - -### Partial Templates -``` - frontend/templates/pages/
/_-.html - e.g. --> frontend/templates/pages/receipts/_dashboard_search_results.html -``` diff --git a/backend/admin.py b/backend/admin.py index 2020a2a1e..5cecd24cd 100644 --- a/backend/admin.py +++ b/backend/admin.py @@ -1,13 +1,8 @@ -from typing import Iterable, Any - from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from backend.models import ( - Client, - Invoice, - InvoiceURL, - InvoiceItem, +from backend.core.api.public import APIAuthToken +from backend.core.models import ( PasswordSecret, AuditLog, LoginLog, @@ -19,23 +14,30 @@ TeamInvitation, TeamMemberPermission, User, - InvoiceProduct, FeatureFlags, VerificationCodes, QuotaLimit, QuotaOverrides, QuotaUsage, QuotaIncreaseRequest, - Receipt, - ReceiptDownloadToken, EmailSendStatus, - InvoiceReminder, - InvoiceRecurringProfile, FileStorageFile, MultiFileUpload, ) -from backend.api.public.models import APIAuthToken +from backend.finance.models import ( + Invoice, + InvoiceURL, + InvoiceItem, + InvoiceReminder, + InvoiceRecurringProfile, + InvoiceProduct, + Receipt, + ReceiptDownloadToken, +) + +from backend.clients.models import Client + from settings.settings import BILLING_ENABLED # from django.contrib.auth.models imp/ort User diff --git a/backend/api/urls.py b/backend/api/urls.py deleted file mode 100644 index 32b95b9fd..000000000 --- a/backend/api/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from django.urls import include -from django.urls import path - -urlpatterns = [ - path("base/", include("backend.api.base.urls")), - path("teams/", include("backend.api.teams.urls")), - path("receipts/", include("backend.api.receipts.urls")), - path("invoices/", include("backend.api.invoices.urls")), - path("clients/", include("backend.api.clients.urls")), - path("settings/", include("backend.api.settings.urls")), - path("file_storage/", include("backend.api.file_storage.urls")), - path("products/", include("backend.api.products.urls")), - path("quotas/", include("backend.api.quotas.urls")), - path("emails/", include("backend.api.emails.urls")), - path("reports/", include("backend.api.reports.urls")), - path("maintenance/", include("backend.api.maintenance.urls")), - path("landing_page/", include("backend.api.landing_page.urls")), - path("public/", include("backend.api.public.urls")), -] - -app_name = "api" diff --git a/backend/apps.py b/backend/apps.py index 06ddbf5cd..b9b0f8795 100644 --- a/backend/apps.py +++ b/backend/apps.py @@ -5,6 +5,4 @@ class BackendConfig(AppConfig): name = "backend" def ready(self): - import backend.signals - pass diff --git a/backend/api/__init__.py b/backend/clients/__init__.py similarity index 100% rename from backend/api/__init__.py rename to backend/clients/__init__.py diff --git a/backend/api/file_storage/__init__.py b/backend/clients/api/__init__.py similarity index 100% rename from backend/api/file_storage/__init__.py rename to backend/clients/api/__init__.py diff --git a/backend/api/clients/delete.py b/backend/clients/api/delete.py similarity index 80% rename from backend/api/clients/delete.py rename to backend/clients/api/delete.py index 4d75bb048..2dc5ee734 100644 --- a/backend/api/clients/delete.py +++ b/backend/clients/api/delete.py @@ -3,8 +3,8 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.service.clients.delete import delete_client, DeleteClientServiceResponse -from backend.types.requests import WebRequest +from backend.core.service.clients.delete import delete_client, DeleteClientServiceResponse +from backend.core.types.requests import WebRequest @require_http_methods(["DELETE"]) diff --git a/backend/api/clients/fetch.py b/backend/clients/api/fetch.py similarity index 83% rename from backend/api/clients/fetch.py rename to backend/clients/api/fetch.py index 72407627f..abf4a7435 100644 --- a/backend/api/clients/fetch.py +++ b/backend/clients/api/fetch.py @@ -2,10 +2,10 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.models import Client -from backend.service.clients.get import fetch_clients, FetchClientServiceResponse -from backend.types.htmx import HtmxHttpRequest -from backend.types.requests import WebRequest +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 @require_http_methods(["GET"]) diff --git a/backend/api/clients/urls.py b/backend/clients/api/urls.py similarity index 88% rename from backend/api/clients/urls.py rename to backend/clients/api/urls.py index 08f3f5226..875f942af 100644 --- a/backend/api/clients/urls.py +++ b/backend/clients/api/urls.py @@ -1,6 +1,5 @@ from django.urls import path -from . import fetch, delete - +from backend.clients.api import fetch, delete urlpatterns = [ path( diff --git a/backend/signals/core_signals/clients.py b/backend/clients/clients.py similarity index 95% rename from backend/signals/core_signals/clients.py rename to backend/clients/clients.py index 419a903a3..adf7cbb68 100644 --- a/backend/signals/core_signals/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.models import Client, DefaultValues +from backend.clients.models import Client, DefaultValues logger = logging.getLogger(__name__) diff --git a/backend/clients/models.py b/backend/clients/models.py new file mode 100644 index 000000000..fad6da28f --- /dev/null +++ b/backend/clients/models.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from datetime import date, timedelta +from django.db import models +from backend.core.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, + ) diff --git a/backend/api/invoices/__init__.py b/backend/clients/views/__init__.py similarity index 100% rename from backend/api/invoices/__init__.py rename to backend/clients/views/__init__.py diff --git a/backend/views/core/clients/create.py b/backend/clients/views/create.py similarity index 83% rename from backend/views/core/clients/create.py rename to backend/clients/views/create.py index b2f3be1d6..97674d4a8 100644 --- a/backend/views/core/clients/create.py +++ b/backend/clients/views/create.py @@ -2,8 +2,8 @@ from django.shortcuts import render, redirect from backend.decorators import web_require_scopes -from backend.service.clients.create import create_client, CreateClientServiceResponse -from backend.types.requests import WebRequest +from backend.core.service.clients.create import create_client, CreateClientServiceResponse +from backend.core.types.requests import WebRequest @web_require_scopes("clients:write", False, False, "clients:dashboard") diff --git a/backend/views/core/clients/dashboard.py b/backend/clients/views/dashboard.py similarity index 84% rename from backend/views/core/clients/dashboard.py rename to backend/clients/views/dashboard.py index 6ec2f261e..362dde0ab 100644 --- a/backend/views/core/clients/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.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @web_require_scopes("clients:read", False, False, "dashboard") diff --git a/backend/views/core/clients/detail.py b/backend/clients/views/detail.py similarity index 84% rename from backend/views/core/clients/detail.py rename to backend/clients/views/detail.py index 72bbc2edd..e120442d9 100644 --- a/backend/views/core/clients/detail.py +++ b/backend/clients/views/detail.py @@ -1,5 +1,3 @@ -from typing import Literal - from django.contrib import messages from django.core.exceptions import ValidationError from django.http.response import HttpResponse @@ -7,10 +5,10 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.service.clients.delete import delete_client, DeleteClientServiceResponse -from backend.service.clients.validate import validate_client -from backend.models import Client -from backend.types.requests import WebRequest +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 @require_http_methods(["GET"]) diff --git a/backend/views/core/clients/edit.py b/backend/clients/views/edit.py similarity index 100% rename from backend/views/core/clients/edit.py rename to backend/clients/views/edit.py diff --git a/backend/views/core/clients/urls.py b/backend/clients/views/urls.py similarity index 100% rename from backend/views/core/clients/urls.py rename to backend/clients/views/urls.py diff --git a/backend/context_processors.py b/backend/context_processors.py index 008d78af5..6b81f2090 100644 --- a/backend/context_processors.py +++ b/backend/context_processors.py @@ -5,7 +5,7 @@ import calendar -from backend.service.base.breadcrumbs import get_breadcrumbs +from backend.core.service.base.breadcrumbs import get_breadcrumbs from settings.helpers import get_var diff --git a/backend/api/invoices/create/__init__.py b/backend/core/__init__.py similarity index 100% rename from backend/api/invoices/create/__init__.py rename to backend/core/__init__.py diff --git a/backend/api/invoices/create/services/__init__.py b/backend/core/api/__init__.py similarity index 100% rename from backend/api/invoices/create/services/__init__.py rename to backend/core/api/__init__.py diff --git a/backend/api/invoices/recurring/__init__.py b/backend/core/api/base/__init__.py similarity index 100% rename from backend/api/invoices/recurring/__init__.py rename to backend/core/api/base/__init__.py diff --git a/backend/api/base/breadcrumbs.py b/backend/core/api/base/breadcrumbs.py similarity index 73% rename from backend/api/base/breadcrumbs.py rename to backend/core/api/base/breadcrumbs.py index 69070341f..7dfbdbfee 100644 --- a/backend/api/base/breadcrumbs.py +++ b/backend/core/api/base/breadcrumbs.py @@ -1,8 +1,7 @@ -from django.http import HttpResponse from django.shortcuts import render -from backend.types.requests import WebRequest -from backend.service.base.breadcrumbs import get_breadcrumbs +from backend.core.types.requests import WebRequest +from backend.core.service.base.breadcrumbs import get_breadcrumbs def update_breadcrumbs_endpoint(request: WebRequest): diff --git a/backend/api/base/modal.py b/backend/core/api/base/modal.py similarity index 94% rename from backend/api/base/modal.py rename to backend/core/api/base/modal.py index 34a22bdb5..cc252fe3f 100644 --- a/backend/api/base/modal.py +++ b/backend/core/api/base/modal.py @@ -4,17 +4,15 @@ from django.http import HttpResponseBadRequest from django.shortcuts import render -from backend.api.public.permissions import SCOPE_DESCRIPTIONS -from backend.api.public.models import APIAuthToken -from backend.models import Client, Receipt, User, InvoiceURL -from backend.models import Invoice -from backend.models import QuotaLimit -from backend.models import Organization -from backend.models import UserSettings -from backend.types.htmx import HtmxHttpRequest -from backend.types.requests import WebRequest -from backend.utils.feature_flags import get_feature_status -from backend.service.defaults.get import get_account_defaults +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 # from backend.utils.quota_limit_ops import quota_usage_check_under diff --git a/backend/api/base/notifications.py b/backend/core/api/base/notifications.py similarity index 96% rename from backend/api/base/notifications.py rename to backend/core/api/base/notifications.py index 40d02e43a..9880b1210 100644 --- a/backend/api/base/notifications.py +++ b/backend/core/api/base/notifications.py @@ -3,7 +3,7 @@ from django.shortcuts import render from backend.models import Notification -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest def get_notification_html(request: HtmxHttpRequest): diff --git a/backend/api/base/urls.py b/backend/core/api/base/urls.py similarity index 100% rename from backend/api/base/urls.py rename to backend/core/api/base/urls.py diff --git a/backend/api/invoices/single/__init__.py b/backend/core/api/emails/__init__.py similarity index 100% rename from backend/api/invoices/single/__init__.py rename to backend/core/api/emails/__init__.py diff --git a/backend/api/emails/fetch.py b/backend/core/api/emails/fetch.py similarity index 96% rename from backend/api/emails/fetch.py rename to backend/core/api/emails/fetch.py index f33aea4c8..87c062c72 100644 --- a/backend/api/emails/fetch.py +++ b/backend/core/api/emails/fetch.py @@ -6,7 +6,7 @@ from backend.decorators import web_require_scopes from backend.models import EmailSendStatus -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @web_require_scopes("emails:read", True, True) diff --git a/backend/api/emails/send.py b/backend/core/api/emails/send.py similarity index 98% rename from backend/api/emails/send.py rename to backend/core/api/emails/send.py index f20e4290d..317a59ad0 100644 --- a/backend/api/emails/send.py +++ b/backend/core/api/emails/send.py @@ -15,20 +15,20 @@ from django.views.decorators.http import require_POST from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef -from backend.data.default_email_templates import email_footer +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 backend.models import Client from backend.models import EmailSendStatus from backend.models import QuotaLimit from backend.models import QuotaUsage -from backend.types.emails import ( +from backend.core.types.emails import ( BulkEmailEmailItem, ) -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest from settings.helpers import send_email, send_templated_bulk_email, get_var -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @dataclass diff --git a/backend/api/emails/status.py b/backend/core/api/emails/status.py similarity index 98% rename from backend/api/emails/status.py rename to backend/core/api/emails/status.py index 85e20278c..8b6dd95e3 100644 --- a/backend/api/emails/status.py +++ b/backend/core/api/emails/status.py @@ -10,7 +10,7 @@ from backend.decorators import htmx_only, feature_flag_check, web_require_scopes from backend.models import EmailSendStatus -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest from settings.helpers import EMAIL_CLIENT diff --git a/backend/api/emails/urls.py b/backend/core/api/emails/urls.py similarity index 100% rename from backend/api/emails/urls.py rename to backend/core/api/emails/urls.py diff --git a/backend/api/maintenance/__init__.py b/backend/core/api/healthcheck/__init__.py similarity index 100% rename from backend/api/maintenance/__init__.py rename to backend/core/api/healthcheck/__init__.py diff --git a/backend/api/healthcheck/healthcheck.py b/backend/core/api/healthcheck/healthcheck.py similarity index 100% rename from backend/api/healthcheck/healthcheck.py rename to backend/core/api/healthcheck/healthcheck.py diff --git a/backend/api/healthcheck/urls.py b/backend/core/api/healthcheck/urls.py similarity index 100% rename from backend/api/healthcheck/urls.py rename to backend/core/api/healthcheck/urls.py diff --git a/backend/api/reports/__init__.py b/backend/core/api/landing_page/__init__.py similarity index 100% rename from backend/api/reports/__init__.py rename to backend/core/api/landing_page/__init__.py diff --git a/backend/api/landing_page/email_waitlist.py b/backend/core/api/landing_page/email_waitlist.py similarity index 89% rename from backend/api/landing_page/email_waitlist.py rename to backend/core/api/landing_page/email_waitlist.py index 94359073e..25fedcd4b 100644 --- a/backend/api/landing_page/email_waitlist.py +++ b/backend/core/api/landing_page/email_waitlist.py @@ -1,11 +1,9 @@ from textwrap import dedent -from django.contrib import messages from login_required import login_not_required -from backend.models import InvoiceProduct -from backend.service import BOTO3_HANDLER -from backend.types.requests import WebRequest +from backend.core.service import BOTO3_HANDLER +from backend.core.types.requests import WebRequest from django.http import HttpResponse diff --git a/backend/api/landing_page/urls.py b/backend/core/api/landing_page/urls.py similarity index 100% rename from backend/api/landing_page/urls.py rename to backend/core/api/landing_page/urls.py diff --git a/backend/service/asyn_tasks/__init__.py b/backend/core/api/maintenance/__init__.py similarity index 100% rename from backend/service/asyn_tasks/__init__.py rename to backend/core/api/maintenance/__init__.py diff --git a/backend/api/maintenance/now.py b/backend/core/api/maintenance/now.py similarity index 65% rename from backend/api/maintenance/now.py rename to backend/core/api/maintenance/now.py index 0776bcb61..11777c2d0 100644 --- a/backend/api/maintenance/now.py +++ b/backend/core/api/maintenance/now.py @@ -1,19 +1,15 @@ -from datetime import datetime, timedelta - 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.models import InvoiceRecurringProfile, Invoice, DefaultValues, AuditLog -from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key +from backend.core.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key -from backend.service.maintenance.expire.run import expire_and_cleanup_objects +from backend.core.service.maintenance.expire.run import expire_and_cleanup_objects import logging -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest logger = logging.getLogger(__name__) diff --git a/backend/api/maintenance/urls.py b/backend/core/api/maintenance/urls.py similarity index 100% rename from backend/api/maintenance/urls.py rename to backend/core/api/maintenance/urls.py diff --git a/backend/api/public/__init__.py b/backend/core/api/public/__init__.py similarity index 100% rename from backend/api/public/__init__.py rename to backend/core/api/public/__init__.py diff --git a/backend/api/public/authentication.py b/backend/core/api/public/authentication.py similarity index 83% rename from backend/api/public/authentication.py rename to backend/core/api/public/authentication.py index bcd36e944..6b58901db 100644 --- a/backend/api/public/authentication.py +++ b/backend/core/api/public/authentication.py @@ -1,10 +1,9 @@ from typing import Type -from rest_framework import exceptions -from rest_framework.authentication import TokenAuthentication, get_authorization_header +from rest_framework.authentication import TokenAuthentication from rest_framework.exceptions import AuthenticationFailed -from backend.api.public import APIAuthToken +from backend.core.api.public.models import APIAuthToken from backend.models import User, Organization diff --git a/backend/api/public/decorators.py b/backend/core/api/public/decorators.py similarity index 100% rename from backend/api/public/decorators.py rename to backend/core/api/public/decorators.py diff --git a/backend/service/boto3/__init__.py b/backend/core/api/public/endpoints/Invoices/__init__.py similarity index 100% rename from backend/service/boto3/__init__.py rename to backend/core/api/public/endpoints/Invoices/__init__.py diff --git a/backend/api/public/endpoints/Invoices/create.py b/backend/core/api/public/endpoints/Invoices/create.py similarity index 92% rename from backend/api/public/endpoints/Invoices/create.py rename to backend/core/api/public/endpoints/Invoices/create.py index 093013e04..dec229a36 100644 --- a/backend/api/public/endpoints/Invoices/create.py +++ b/backend/core/api/public/endpoints/Invoices/create.py @@ -4,11 +4,12 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.serializers.invoices import InvoiceSerializer -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.models import Client, InvoiceProduct +from backend.clients.models import Client +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.finance.models import InvoiceProduct def get_client(request: APIRequest) -> Client | None: diff --git a/backend/api/public/endpoints/Invoices/delete.py b/backend/core/api/public/endpoints/Invoices/delete.py similarity index 86% rename from backend/api/public/endpoints/Invoices/delete.py rename to backend/core/api/public/endpoints/Invoices/delete.py index 90ccd9a9f..f2f3f0b49 100644 --- a/backend/api/public/endpoints/Invoices/delete.py +++ b/backend/core/api/public/endpoints/Invoices/delete.py @@ -1,11 +1,10 @@ from django.http import QueryDict -from django.urls import resolve, Resolver404 from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.types import APIRequest +from backend.core.api.public.decorators import require_scopes +from backend.core.api.public.types import APIRequest from backend.models import Invoice, QuotaLimit diff --git a/backend/api/public/endpoints/Invoices/download_pdf.py b/backend/core/api/public/endpoints/Invoices/download_pdf.py similarity index 88% rename from backend/api/public/endpoints/Invoices/download_pdf.py rename to backend/core/api/public/endpoints/Invoices/download_pdf.py index b77b3dee5..1d6a885e1 100644 --- a/backend/api/public/endpoints/Invoices/download_pdf.py +++ b/backend/core/api/public/endpoints/Invoices/download_pdf.py @@ -7,12 +7,12 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.helpers.deprecate import deprecated -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.models import Invoice -from backend.service.invoices.single.create_pdf import generate_pdf +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 backend.finance.models import Invoice +from backend.core.service.invoices.single.create_pdf import generate_pdf @swagger_auto_schema( diff --git a/backend/api/public/endpoints/Invoices/edit.py b/backend/core/api/public/endpoints/Invoices/edit.py similarity index 97% rename from backend/api/public/endpoints/Invoices/edit.py rename to backend/core/api/public/endpoints/Invoices/edit.py index 0772b4ebd..b8794d2fb 100644 --- a/backend/api/public/endpoints/Invoices/edit.py +++ b/backend/core/api/public/endpoints/Invoices/edit.py @@ -3,9 +3,9 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.types import APIRequest -from backend.models import Invoice +from backend.core.api.public.decorators import require_scopes +from backend.core.api.public.types import APIRequest +from backend.finance.models import Invoice @api_view(["POST"]) diff --git a/backend/api/public/endpoints/Invoices/get.py b/backend/core/api/public/endpoints/Invoices/get.py similarity index 87% rename from backend/api/public/endpoints/Invoices/get.py rename to backend/core/api/public/endpoints/Invoices/get.py index 4bd70aad7..d20caac88 100644 --- a/backend/api/public/endpoints/Invoices/get.py +++ b/backend/core/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.api.public.decorators import require_scopes -from backend.api.public.serializers.invoices import InvoiceSerializer -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.models import Invoice +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.finance.models import Invoice @swagger_auto_schema( diff --git a/backend/api/public/endpoints/Invoices/list.py b/backend/core/api/public/endpoints/Invoices/list.py similarity index 89% rename from backend/api/public/endpoints/Invoices/list.py rename to backend/core/api/public/endpoints/Invoices/list.py index 76b5fbca5..524aa44a8 100644 --- a/backend/api/public/endpoints/Invoices/list.py +++ b/backend/core/api/public/endpoints/Invoices/list.py @@ -7,12 +7,13 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.serializers.invoices import InvoiceSerializer -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.models import Invoice -from backend.service.invoices.common.fetch import get_context +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.finance.models import Invoice +from backend.core.service.invoices.common.fetch import get_context @swagger_auto_schema( diff --git a/backend/api/public/endpoints/Invoices/urls.py b/backend/core/api/public/endpoints/Invoices/urls.py similarity index 100% rename from backend/api/public/endpoints/Invoices/urls.py rename to backend/core/api/public/endpoints/Invoices/urls.py diff --git a/backend/service/boto3/scheduler/__init__.py b/backend/core/api/public/endpoints/__init__.py similarity index 100% rename from backend/service/boto3/scheduler/__init__.py rename to backend/core/api/public/endpoints/__init__.py diff --git a/backend/service/file_storage/__init__.py b/backend/core/api/public/endpoints/clients/__init__.py similarity index 100% rename from backend/service/file_storage/__init__.py rename to backend/core/api/public/endpoints/clients/__init__.py diff --git a/backend/api/public/endpoints/clients/create.py b/backend/core/api/public/endpoints/clients/create.py similarity index 85% rename from backend/api/public/endpoints/clients/create.py rename to backend/core/api/public/endpoints/clients/create.py index a9f30dadf..030cbcd8e 100644 --- a/backend/api/public/endpoints/clients/create.py +++ b/backend/core/api/public/endpoints/clients/create.py @@ -1,17 +1,13 @@ -from typing import Literal - from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.serializers.clients import ClientSerializer -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.models import Client -from backend.service.clients.create import create_client +from backend.core.api.public.decorators import require_scopes +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 @swagger_auto_schema( diff --git a/backend/api/public/endpoints/clients/delete.py b/backend/core/api/public/endpoints/clients/delete.py similarity index 90% rename from backend/api/public/endpoints/clients/delete.py rename to backend/core/api/public/endpoints/clients/delete.py index 62f00096f..66e997ec4 100644 --- a/backend/api/public/endpoints/clients/delete.py +++ b/backend/core/api/public/endpoints/clients/delete.py @@ -5,10 +5,11 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.service.clients.delete import delete_client, DeleteClientServiceResponse +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 backend.core.service.clients.delete import delete_client, DeleteClientServiceResponse @swagger_auto_schema( diff --git a/backend/api/public/endpoints/clients/list.py b/backend/core/api/public/endpoints/clients/list.py similarity index 82% rename from backend/api/public/endpoints/clients/list.py rename to backend/core/api/public/endpoints/clients/list.py index 448d331f5..75819b0da 100644 --- a/backend/api/public/endpoints/clients/list.py +++ b/backend/core/api/public/endpoints/clients/list.py @@ -4,12 +4,11 @@ from rest_framework.decorators import api_view from rest_framework.response import Response -from backend.api.public.decorators import require_scopes -from backend.api.public.serializers.clients import ClientSerializer -from backend.api.public.swagger_ui import TEAM_PARAMETER -from backend.api.public.types import APIRequest -from backend.models import Client -from backend.service.clients.get import fetch_clients, FetchClientServiceResponse +from backend.core.api.public.decorators import require_scopes +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 @swagger_auto_schema( diff --git a/backend/api/public/endpoints/clients/urls.py b/backend/core/api/public/endpoints/clients/urls.py similarity index 100% rename from backend/api/public/endpoints/clients/urls.py rename to backend/core/api/public/endpoints/clients/urls.py diff --git a/backend/api/public/endpoints/system_health.py b/backend/core/api/public/endpoints/system_health.py similarity index 97% rename from backend/api/public/endpoints/system_health.py rename to backend/core/api/public/endpoints/system_health.py index ead4e2e86..43d4a136b 100644 --- a/backend/api/public/endpoints/system_health.py +++ b/backend/core/api/public/endpoints/system_health.py @@ -1,12 +1,12 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi -import requests from django.db import connection, OperationalError from django.core.cache import cache from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response -from backend.api.public.permissions import IsSuperuser + +from backend.core.api.public.permissions import IsSuperuser @swagger_auto_schema( diff --git a/backend/service/invoices/__init__.py b/backend/core/api/public/endpoints/webhooks/__init__.py similarity index 100% rename from backend/service/invoices/__init__.py rename to backend/core/api/public/endpoints/webhooks/__init__.py diff --git a/backend/api/public/endpoints/webhooks/urls.py b/backend/core/api/public/endpoints/webhooks/urls.py similarity index 100% rename from backend/api/public/endpoints/webhooks/urls.py rename to backend/core/api/public/endpoints/webhooks/urls.py diff --git a/backend/api/public/endpoints/webhooks/webhook_task_queue_handler.py b/backend/core/api/public/endpoints/webhooks/webhook_task_queue_handler.py similarity index 92% rename from backend/api/public/endpoints/webhooks/webhook_task_queue_handler.py rename to backend/core/api/public/endpoints/webhooks/webhook_task_queue_handler.py index e0cd016fc..f9cfe2904 100644 --- a/backend/api/public/endpoints/webhooks/webhook_task_queue_handler.py +++ b/backend/core/api/public/endpoints/webhooks/webhook_task_queue_handler.py @@ -1,10 +1,9 @@ from rest_framework.response import Response -from backend.api.public.models import APIAuthToken -import json +from backend.core.api.public import APIAuthToken from rest_framework.decorators import api_view -from backend.service.asyn_tasks.tasks import Task +from backend.core.service.asyn_tasks.tasks import Task @api_view(["POST"]) diff --git a/backend/service/invoices/common/__init__.py b/backend/core/api/public/helpers/__init__.py similarity index 100% rename from backend/service/invoices/common/__init__.py rename to backend/core/api/public/helpers/__init__.py diff --git a/backend/api/public/helpers/deprecate.py b/backend/core/api/public/helpers/deprecate.py similarity index 100% rename from backend/api/public/helpers/deprecate.py rename to backend/core/api/public/helpers/deprecate.py diff --git a/backend/api/public/middleware.py b/backend/core/api/public/middleware.py similarity index 93% rename from backend/api/public/middleware.py rename to backend/core/api/public/middleware.py index 8d5e746d0..82aacbcbd 100644 --- a/backend/api/public/middleware.py +++ b/backend/core/api/public/middleware.py @@ -1,7 +1,6 @@ from django.utils.deprecation import MiddlewareMixin -from rest_framework.response import Response -from backend.api.public import APIAuthToken +from backend.core.api.public import APIAuthToken from backend.models import Organization diff --git a/backend/api/public/models.py b/backend/core/api/public/models.py similarity index 98% rename from backend/api/public/models.py rename to backend/core/api/public/models.py index 33ccedc17..71e1722ed 100644 --- a/backend/api/public/models.py +++ b/backend/core/api/public/models.py @@ -6,7 +6,7 @@ import os from django.utils import timezone -from backend.models import OwnerBase +from backend.core.models import OwnerBase class APIAuthToken(OwnerBase): diff --git a/backend/api/public/permissions.py b/backend/core/api/public/permissions.py similarity index 100% rename from backend/api/public/permissions.py rename to backend/core/api/public/permissions.py diff --git a/backend/service/invoices/common/create/__init__.py b/backend/core/api/public/serializers/__init__.py similarity index 100% rename from backend/service/invoices/common/create/__init__.py rename to backend/core/api/public/serializers/__init__.py diff --git a/backend/api/public/serializers/clients.py b/backend/core/api/public/serializers/clients.py similarity index 82% rename from backend/api/public/serializers/clients.py rename to backend/core/api/public/serializers/clients.py index 8081e4f83..fd721a322 100644 --- a/backend/api/public/serializers/clients.py +++ b/backend/core/api/public/serializers/clients.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from backend.models import Client +from backend.finance.models import Client class ClientSerializer(serializers.ModelSerializer): diff --git a/backend/api/public/serializers/invoices.py b/backend/core/api/public/serializers/invoices.py similarity index 93% rename from backend/api/public/serializers/invoices.py rename to backend/core/api/public/serializers/invoices.py index 923921a9e..b019ea706 100644 --- a/backend/api/public/serializers/invoices.py +++ b/backend/core/api/public/serializers/invoices.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from backend.models import InvoiceItem, Invoice +from backend.finance.models import InvoiceItem, Invoice class InvoiceItemSerializer(serializers.ModelSerializer): diff --git a/backend/api/public/swagger_ui.py b/backend/core/api/public/swagger_ui.py similarity index 100% rename from backend/api/public/swagger_ui.py rename to backend/core/api/public/swagger_ui.py diff --git a/backend/api/public/types.py b/backend/core/api/public/types.py similarity index 82% rename from backend/api/public/types.py rename to backend/core/api/public/types.py index 243dd0c85..05d937c70 100644 --- a/backend/api/public/types.py +++ b/backend/core/api/public/types.py @@ -1,6 +1,6 @@ from rest_framework.request import Request -from backend.api.public import APIAuthToken +from backend.core.api.public import APIAuthToken from backend.models import User, Organization diff --git a/backend/api/public/urls.py b/backend/core/api/public/urls.py similarity index 62% rename from backend/api/public/urls.py rename to backend/core/api/public/urls.py index aab801dc5..2ab400e50 100644 --- a/backend/api/public/urls.py +++ b/backend/core/api/public/urls.py @@ -10,9 +10,9 @@ urlpatterns = [ path("internal/", include(INTERNAL_URLS)), - path("clients/", include("backend.api.public.endpoints.clients.urls")), - path("invoices/", include("backend.api.public.endpoints.Invoices.urls")), - path("webhooks/", include("backend.api.public.endpoints.webhooks.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/service/invoices/common/create/services/__init__.py b/backend/core/api/quotas/__init__.py similarity index 100% rename from backend/service/invoices/common/create/services/__init__.py rename to backend/core/api/quotas/__init__.py diff --git a/backend/api/quotas/fetch.py b/backend/core/api/quotas/fetch.py similarity index 94% rename from backend/api/quotas/fetch.py rename to backend/core/api/quotas/fetch.py index a86e85973..f2520a030 100644 --- a/backend/api/quotas/fetch.py +++ b/backend/core/api/quotas/fetch.py @@ -2,7 +2,7 @@ from django.shortcuts import render, redirect from backend.models import QuotaLimit -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest def fetch_all_quotas(request: HtmxHttpRequest, group: str): diff --git a/backend/api/quotas/requests.py b/backend/core/api/quotas/requests.py similarity index 98% rename from backend/api/quotas/requests.py rename to backend/core/api/quotas/requests.py index d2ef407f8..a2c239895 100644 --- a/backend/api/quotas/requests.py +++ b/backend/core/api/quotas/requests.py @@ -8,7 +8,7 @@ from backend.decorators import superuser_only from backend.models import QuotaIncreaseRequest, QuotaLimit, QuotaUsage, QuotaOverrides -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest # from backend.utils.quota_limit_ops import quota_usage_check_under diff --git a/backend/api/quotas/urls.py b/backend/core/api/quotas/urls.py similarity index 100% rename from backend/api/quotas/urls.py rename to backend/core/api/quotas/urls.py diff --git a/backend/service/invoices/common/emails/__init__.py b/backend/core/api/settings/__init__.py similarity index 100% rename from backend/service/invoices/common/emails/__init__.py rename to backend/core/api/settings/__init__.py diff --git a/backend/api/settings/api_keys.py b/backend/core/api/settings/api_keys.py similarity index 84% rename from backend/api/settings/api_keys.py rename to backend/core/api/settings/api_keys.py index 9c5e3de04..b1c58ddba 100644 --- a/backend/api/settings/api_keys.py +++ b/backend/core/api/settings/api_keys.py @@ -3,12 +3,13 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.service.api_keys.delete import delete_api_key -from backend.service.api_keys.generate import generate_public_api_key -from backend.service.api_keys.get import get_api_key_by_id -from backend.api.public.models import APIAuthToken -from backend.service.permissions.scopes import get_permissions_from_request -from backend.types.requests import WebRequest +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 @require_http_methods(["POST"]) diff --git a/backend/api/settings/change_name.py b/backend/core/api/settings/change_name.py similarity index 92% rename from backend/api/settings/change_name.py rename to backend/core/api/settings/change_name.py index d550a228a..03afc3d88 100644 --- a/backend/api/settings/change_name.py +++ b/backend/core/api/settings/change_name.py @@ -1,10 +1,9 @@ from django.contrib import messages -from django.contrib.auth.models import AnonymousUser from django.http import HttpResponse from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @require_http_methods(["POST"]) diff --git a/backend/api/settings/defaults.py b/backend/core/api/settings/defaults.py similarity index 91% rename from backend/api/settings/defaults.py rename to backend/core/api/settings/defaults.py index 2a44cd7de..7d8859256 100644 --- a/backend/api/settings/defaults.py +++ b/backend/core/api/settings/defaults.py @@ -4,11 +4,11 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods -from backend.models import Client -from backend.service.clients.validate import validate_client -from backend.service.defaults.get import get_account_defaults -from backend.service.defaults.update import change_client_defaults -from backend.types.requests import WebRequest +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 # @require_http_methods(["GET", "PUT"]) diff --git a/backend/api/settings/email_templates.py b/backend/core/api/settings/email_templates.py similarity index 81% rename from backend/api/settings/email_templates.py rename to backend/core/api/settings/email_templates.py index b28c8e64c..83e3b2edf 100644 --- a/backend/api/settings/email_templates.py +++ b/backend/core/api/settings/email_templates.py @@ -1,7 +1,7 @@ from django.views.decorators.http import require_GET from backend.decorators import web_require_scopes -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest @require_GET diff --git a/backend/api/settings/preferences.py b/backend/core/api/settings/preferences.py similarity index 100% rename from backend/api/settings/preferences.py rename to backend/core/api/settings/preferences.py diff --git a/backend/api/settings/profile_picture.py b/backend/core/api/settings/profile_picture.py similarity index 80% rename from backend/api/settings/profile_picture.py rename to backend/core/api/settings/profile_picture.py index 3de9bf53e..27dd71121 100644 --- a/backend/api/settings/profile_picture.py +++ b/backend/core/api/settings/profile_picture.py @@ -2,9 +2,9 @@ from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods -from backend.service.settings.update import update_profile_picture, UpdateProfilePictureServiceResponse -from backend.service.settings.view import get_user_profile -from backend.types.requests import WebRequest +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"]) diff --git a/backend/api/settings/urls.py b/backend/core/api/settings/urls.py similarity index 100% rename from backend/api/settings/urls.py rename to backend/core/api/settings/urls.py diff --git a/backend/service/invoices/recurring/__init__.py b/backend/core/api/teams/__init__.py similarity index 100% rename from backend/service/invoices/recurring/__init__.py rename to backend/core/api/teams/__init__.py diff --git a/backend/api/teams/create.py b/backend/core/api/teams/create.py similarity index 96% rename from backend/api/teams/create.py rename to backend/core/api/teams/create.py index c5ce9fbc7..f01172589 100644 --- a/backend/api/teams/create.py +++ b/backend/core/api/teams/create.py @@ -4,7 +4,7 @@ from backend.decorators import has_entitlements from backend.models import Organization, QuotaUsage -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @require_POST diff --git a/backend/api/teams/create_user.py b/backend/core/api/teams/create_user.py similarity index 76% rename from backend/api/teams/create_user.py rename to backend/core/api/teams/create_user.py index fae882729..23c311b93 100644 --- a/backend/api/teams/create_user.py +++ b/backend/core/api/teams/create_user.py @@ -1,14 +1,11 @@ from django.contrib import messages from django.shortcuts import render -from django.template.defaultfilters import last -from django.urls import reverse -from django.utils.crypto import get_random_string from backend.decorators import web_require_scopes -from backend.models import Organization, User, TeamMemberPermission -from backend.service.permissions.scopes import get_permissions_from_request -from backend.service.teams.create_user import create_user_service -from backend.types.requests import WebRequest +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) diff --git a/backend/api/teams/edit_permissions.py b/backend/core/api/teams/edit_permissions.py similarity index 83% rename from backend/api/teams/edit_permissions.py rename to backend/core/api/teams/edit_permissions.py index 229bab274..532a57a06 100644 --- a/backend/api/teams/edit_permissions.py +++ b/backend/core/api/teams/edit_permissions.py @@ -5,9 +5,9 @@ from backend.decorators import web_require_scopes from backend.models import User -from backend.service.permissions.scopes import get_permissions_from_request -from backend.service.teams.permissions import edit_member_permissions -from backend.types.requests import WebRequest +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"]) diff --git a/backend/api/teams/invites.py b/backend/core/api/teams/invites.py similarity index 96% rename from backend/api/teams/invites.py rename to backend/core/api/teams/invites.py index a755742c0..7555fa4f0 100644 --- a/backend/api/teams/invites.py +++ b/backend/core/api/teams/invites.py @@ -1,9 +1,14 @@ from textwrap import dedent -from backend.decorators import * +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.types.emails import SingleEmailInput -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest from settings.helpers import send_email diff --git a/backend/api/teams/kick.py b/backend/core/api/teams/kick.py similarity index 100% rename from backend/api/teams/kick.py rename to backend/core/api/teams/kick.py diff --git a/backend/api/teams/leave.py b/backend/core/api/teams/leave.py similarity index 92% rename from backend/api/teams/leave.py rename to backend/core/api/teams/leave.py index 4e2a90af7..f5a717d97 100644 --- a/backend/api/teams/leave.py +++ b/backend/core/api/teams/leave.py @@ -2,8 +2,8 @@ from django.http import HttpResponse from django.shortcuts import render -from backend.models import * -from backend.types.htmx import HtmxHttpRequest +from backend.models import Organization +from backend.core.types.htmx import HtmxHttpRequest def return_error_notif(request: HtmxHttpRequest, message: str): diff --git a/backend/api/teams/switch_team.py b/backend/core/api/teams/switch_team.py similarity index 97% rename from backend/api/teams/switch_team.py rename to backend/core/api/teams/switch_team.py index c3b94dadc..b2a5a978c 100644 --- a/backend/api/teams/switch_team.py +++ b/backend/core/api/teams/switch_team.py @@ -3,7 +3,7 @@ from django.shortcuts import render from backend.models import Organization -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest def switch_team(request: HtmxHttpRequest, team_id: str | int | None = None): diff --git a/backend/api/teams/urls.py b/backend/core/api/teams/urls.py similarity index 100% rename from backend/api/teams/urls.py rename to backend/core/api/teams/urls.py diff --git a/backend/core/api/urls.py b/backend/core/api/urls.py new file mode 100644 index 000000000..1249767a7 --- /dev/null +++ b/backend/core/api/urls.py @@ -0,0 +1,19 @@ +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/service/invoices/recurring/create/__init__.py b/backend/core/data/__init__.py similarity index 100% rename from backend/service/invoices/recurring/create/__init__.py rename to backend/core/data/__init__.py diff --git a/backend/data/default_email_templates.py b/backend/core/data/default_email_templates.py similarity index 100% rename from backend/data/default_email_templates.py rename to backend/core/data/default_email_templates.py diff --git a/backend/data/default_feature_flags.py b/backend/core/data/default_feature_flags.py similarity index 100% rename from backend/data/default_feature_flags.py rename to backend/core/data/default_feature_flags.py diff --git a/backend/data/default_quota_limits.py b/backend/core/data/default_quota_limits.py similarity index 100% rename from backend/data/default_quota_limits.py rename to backend/core/data/default_quota_limits.py diff --git a/backend/service/invoices/recurring/generation/__init__.py b/backend/core/management/__init__.py similarity index 100% rename from backend/service/invoices/recurring/generation/__init__.py rename to backend/core/management/__init__.py diff --git a/backend/service/invoices/recurring/schedules/__init__.py b/backend/core/management/commands/__init__.py similarity index 100% rename from backend/service/invoices/recurring/schedules/__init__.py rename to backend/core/management/commands/__init__.py diff --git a/backend/management/commands/auto.py b/backend/core/management/commands/auto.py similarity index 79% rename from backend/management/commands/auto.py rename to backend/core/management/commands/auto.py index 0bc61f7c8..ff7659659 100644 --- a/backend/management/commands/auto.py +++ b/backend/core/management/commands/auto.py @@ -1,6 +1,5 @@ -import uuid from django.core.management.base import BaseCommand -from backend.service.maintenance.expire.run import expire_and_cleanup_objects +from backend.core.service.maintenance.expire.run import expire_and_cleanup_objects class Command(BaseCommand): diff --git a/backend/management/commands/contributors.json b/backend/core/management/commands/contributors.json similarity index 100% rename from backend/management/commands/contributors.json rename to backend/core/management/commands/contributors.json diff --git a/backend/management/commands/contributors.py b/backend/core/management/commands/contributors.py similarity index 100% rename from backend/management/commands/contributors.py rename to backend/core/management/commands/contributors.py diff --git a/backend/management/commands/feature_flags.py b/backend/core/management/commands/feature_flags.py similarity index 100% rename from backend/management/commands/feature_flags.py rename to backend/core/management/commands/feature_flags.py diff --git a/backend/management/commands/generate_aws_scheduler_apikey.py b/backend/core/management/commands/generate_aws_scheduler_apikey.py similarity index 94% rename from backend/management/commands/generate_aws_scheduler_apikey.py rename to backend/core/management/commands/generate_aws_scheduler_apikey.py index da4ee09d8..c502797ec 100644 --- a/backend/management/commands/generate_aws_scheduler_apikey.py +++ b/backend/core/management/commands/generate_aws_scheduler_apikey.py @@ -1,6 +1,6 @@ import uuid from django.core.management.base import BaseCommand -from backend.api.public.models import APIAuthToken +from backend.core.api.public import APIAuthToken class Command(BaseCommand): diff --git a/backend/management/commands/lint.py b/backend/core/management/commands/lint.py similarity index 100% rename from backend/management/commands/lint.py rename to backend/core/management/commands/lint.py diff --git a/backend/management/commands/navbar_refresh.py b/backend/core/management/commands/navbar_refresh.py similarity index 100% rename from backend/management/commands/navbar_refresh.py rename to backend/core/management/commands/navbar_refresh.py diff --git a/backend/management/commands/test_urls.py b/backend/core/management/commands/test_urls.py similarity index 100% rename from backend/management/commands/test_urls.py rename to backend/core/management/commands/test_urls.py diff --git a/backend/management/commands/test_views.py b/backend/core/management/commands/test_views.py similarity index 100% rename from backend/management/commands/test_views.py rename to backend/core/management/commands/test_views.py diff --git a/backend/service/invoices/recurring/validate/__init__.py b/backend/core/management/scheduled_tasks/__init__.py similarity index 100% rename from backend/service/invoices/recurring/validate/__init__.py rename to backend/core/management/scheduled_tasks/__init__.py diff --git a/backend/management/scheduled_tasks/update_all_schedules.py b/backend/core/management/scheduled_tasks/update_all_schedules.py similarity index 81% rename from backend/management/scheduled_tasks/update_all_schedules.py rename to backend/core/management/scheduled_tasks/update_all_schedules.py index c3fc74c39..02b55b1d6 100644 --- a/backend/management/scheduled_tasks/update_all_schedules.py +++ b/backend/core/management/scheduled_tasks/update_all_schedules.py @@ -1,6 +1,6 @@ import threading -from backend.models import InvoiceRecurringProfile -from backend.service.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)) diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 000000000..76c100f4d --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +import typing +from datetime import datetime, date, 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 + + +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 self.expires and self.expires > timezone.now(): + return self.expires - timezone.now() + return None + + 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.team_id == self.id: + return True + return False + + def is_authenticated(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") + + +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=100) + 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 new file mode 100644 index 000000000..36dfed184 --- /dev/null +++ b/backend/core/service/__init__.py @@ -0,0 +1 @@ +from backend.core.service.boto3.handler import BOTO3_HANDLER diff --git a/backend/service/invoices/recurring/webhooks/__init__.py b/backend/core/service/api_keys/__init__.py similarity index 100% rename from backend/service/invoices/recurring/webhooks/__init__.py rename to backend/core/service/api_keys/__init__.py diff --git a/backend/service/api_keys/delete.py b/backend/core/service/api_keys/delete.py similarity index 83% rename from backend/service/api_keys/delete.py rename to backend/core/service/api_keys/delete.py index 49cc0aae4..0b172694a 100644 --- a/backend/service/api_keys/delete.py +++ b/backend/core/service/api_keys/delete.py @@ -1,6 +1,6 @@ from backend.models import User, Organization -from backend.service.api_keys.get import get_api_key_by_name -from backend.api.public.models import APIAuthToken +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: diff --git a/backend/service/api_keys/generate.py b/backend/core/service/api_keys/generate.py similarity index 92% rename from backend/service/api_keys/generate.py rename to backend/core/service/api_keys/generate.py index 3693ac815..c7a6c605f 100644 --- a/backend/service/api_keys/generate.py +++ b/backend/core/service/api_keys/generate.py @@ -1,8 +1,6 @@ -from backend.api.public.models import APIAuthToken -from backend.api.public.permissions import SCOPE_DESCRIPTIONS, SCOPES +from backend.core.api.public import APIAuthToken from backend.models import User, Organization -from backend.service.permissions.scopes import validate_scopes -from backend.types.htmx import HtmxHttpRequest +from backend.core.service.permissions.scopes import validate_scopes def generate_public_api_key( diff --git a/backend/service/api_keys/get.py b/backend/core/service/api_keys/get.py similarity index 88% rename from backend/service/api_keys/get.py rename to backend/core/service/api_keys/get.py index 2db638c3f..7d2439962 100644 --- a/backend/service/api_keys/get.py +++ b/backend/core/service/api_keys/get.py @@ -1,7 +1,6 @@ +from backend.core.api.public import APIAuthToken from backend.models import User, Organization -from backend.api.public.models import APIAuthToken - 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() diff --git a/backend/service/invoices/single/__init__.py b/backend/core/service/asyn_tasks/__init__.py similarity index 100% rename from backend/service/invoices/single/__init__.py rename to backend/core/service/asyn_tasks/__init__.py diff --git a/backend/service/asyn_tasks/tasks.py b/backend/core/service/asyn_tasks/tasks.py similarity index 100% rename from backend/service/asyn_tasks/tasks.py rename to backend/core/service/asyn_tasks/tasks.py diff --git a/backend/service/invoices/single/create/__init__.py b/backend/core/service/base/__init__.py similarity index 100% rename from backend/service/invoices/single/create/__init__.py rename to backend/core/service/base/__init__.py diff --git a/backend/service/base/breadcrumbs.py b/backend/core/service/base/breadcrumbs.py similarity index 72% rename from backend/service/base/breadcrumbs.py rename to backend/core/service/base/breadcrumbs.py index f700568ed..a436c81d4 100644 --- a/backend/service/base/breadcrumbs.py +++ b/backend/core/service/base/breadcrumbs.py @@ -6,12 +6,12 @@ ALL_ITEMS: dict[str, tuple[str, Optional[str], Optional[str]]] = { "dashboard": ("Dashboard", "dashboard", "house"), - "invoices:dashboard": ("Invoices", "invoices:single:dashboard", "file-invoice"), - "invoices:single:dashboard": ("Single", "invoices:single:dashboard", "file-invoice"), - "invoices:single:create": ("Create (single)", "invoices:single:create", None), - "invoices:recurring:dashboard": ("Recurring", "invoices:recurring:dashboard", "refresh"), - "invoices:recurring:create": ("Create (recurring)", "invoices:recurring:create", None), - "invoices:single:edit": ("Edit", None, "pencil"), + "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"), "receipts dashboard": ("Receipts", "receipts dashboard", "file-invoice"), "teams:dashboard": ("Teams", "teams:dashboard", "users"), "settings:dashboard": ("Settings", "settings:dashboard", "gear"), @@ -24,11 +24,11 @@ "dashboard": "dashboard", "teams:dashboard": ("dashboard", "teams:dashboard"), "receipts dashboard": ("dashboard", "receipts dashboard"), - "invoices:single:dashboard": ("dashboard", "invoices:dashboard", "invoices:single:dashboard"), - "invoices:single:create": ("dashboard", "invoices:dashboard", "invoices:single:create"), - "invoices:recurring:dashboard": ("dashboard", "invoices:dashboard", "invoices:recurring:dashboard"), - "invoices:recurring:create": ("dashboard", "invoices:dashboard", "invoices:recurring:create"), - "invoices:single:edit": ("dashboard", "invoices:dashboard", "invoices:single:edit"), + "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"), "clients:dashboard": ("dashboard", "clients:dashboard"), "clients:create": ("dashboard", "clients:dashboard", "clients:create"), "settings:dashboard": ("dashboard", "settings:dashboard"), diff --git a/backend/service/reports/__init__.py b/backend/core/service/boto3/__init__.py similarity index 100% rename from backend/service/reports/__init__.py rename to backend/core/service/boto3/__init__.py diff --git a/backend/service/boto3/handler.py b/backend/core/service/boto3/handler.py similarity index 100% rename from backend/service/boto3/handler.py rename to backend/core/service/boto3/handler.py diff --git a/backend/service/webhooks/__init__.py b/backend/core/service/boto3/scheduler/__init__.py similarity index 100% rename from backend/service/webhooks/__init__.py rename to backend/core/service/boto3/scheduler/__init__.py diff --git a/backend/service/boto3/scheduler/create_schedule.py b/backend/core/service/boto3/scheduler/create_schedule.py similarity index 94% rename from backend/service/boto3/scheduler/create_schedule.py rename to backend/core/service/boto3/scheduler/create_schedule.py index 68f933e2d..2ce7aa153 100644 --- a/backend/service/boto3/scheduler/create_schedule.py +++ b/backend/core/service/boto3/scheduler/create_schedule.py @@ -5,9 +5,9 @@ from django.urls import reverse -from backend.models import InvoiceRecurringProfile -from backend.service.boto3.handler import BOTO3_HANDLER -from backend.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse +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 settings.helpers import get_var logger = logging.getLogger(__name__) diff --git a/backend/service/boto3/scheduler/delete_schedule.py b/backend/core/service/boto3/scheduler/delete_schedule.py similarity index 87% rename from backend/service/boto3/scheduler/delete_schedule.py rename to backend/core/service/boto3/scheduler/delete_schedule.py index 84177fbfa..5e232f6ab 100644 --- a/backend/service/boto3/scheduler/delete_schedule.py +++ b/backend/core/service/boto3/scheduler/delete_schedule.py @@ -8,9 +8,9 @@ from django.core.exceptions import ObjectDoesNotExist from django.urls import reverse -from backend.models import InvoiceRecurringProfile, BotoSchedule, InvoiceReminder -from backend.service.boto3.handler import BOTO3_HANDLER -from backend.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse +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 logger = logging.getLogger(__name__) diff --git a/backend/service/boto3/scheduler/get.py b/backend/core/service/boto3/scheduler/get.py similarity index 89% rename from backend/service/boto3/scheduler/get.py rename to backend/core/service/boto3/scheduler/get.py index b4fc10154..446d4b5e7 100644 --- a/backend/service/boto3/scheduler/get.py +++ b/backend/core/service/boto3/scheduler/get.py @@ -2,8 +2,8 @@ from mypy_boto3_scheduler.type_defs import GetScheduleOutputTypeDef -from backend.service.boto3.handler import BOTO3_HANDLER -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.service.boto3.handler import BOTO3_HANDLER +from backend.core.utils.dataclasses import BaseServiceResponse logger = logging.getLogger(__name__) diff --git a/backend/service/boto3/scheduler/pause.py b/backend/core/service/boto3/scheduler/pause.py similarity index 87% rename from backend/service/boto3/scheduler/pause.py rename to backend/core/service/boto3/scheduler/pause.py index ebb3e46e7..b60c2fb57 100644 --- a/backend/service/boto3/scheduler/pause.py +++ b/backend/core/service/boto3/scheduler/pause.py @@ -1,11 +1,10 @@ import logging -import time from mypy_boto3_scheduler.type_defs import UpdateScheduleOutputTypeDef -from backend.service.boto3.handler import BOTO3_HANDLER -from backend.service.boto3.scheduler.get import get_boto_schedule -from backend.utils.dataclasses import BaseServiceResponse +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 logger = logging.getLogger(__name__) diff --git a/backend/service/boto3/scheduler/update_schedule.py b/backend/core/service/boto3/scheduler/update_schedule.py similarity index 92% rename from backend/service/boto3/scheduler/update_schedule.py rename to backend/core/service/boto3/scheduler/update_schedule.py index dd13490cf..fe0a8e8bb 100644 --- a/backend/service/boto3/scheduler/update_schedule.py +++ b/backend/core/service/boto3/scheduler/update_schedule.py @@ -2,11 +2,11 @@ import logging from uuid import UUID -from backend.models import InvoiceRecurringProfile -from backend.service.boto3.handler import BOTO3_HANDLER -from backend.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.service.boto3.scheduler.get import get_boto_schedule -from backend.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse +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.core.service.invoices.recurring.schedules.date_handlers import get_schedule_cron, CronServiceResponse logger = logging.getLogger(__name__) diff --git a/backend/utils/__init__.py b/backend/core/service/clients/__init__.py similarity index 100% rename from backend/utils/__init__.py rename to backend/core/service/clients/__init__.py diff --git a/backend/service/clients/create.py b/backend/core/service/clients/create.py similarity index 84% rename from backend/service/clients/create.py rename to backend/core/service/clients/create.py index bff8a565a..788e65cec 100644 --- a/backend/service/clients/create.py +++ b/backend/core/service/clients/create.py @@ -1,9 +1,6 @@ -from dataclasses import dataclass -from typing import Optional - -from backend.models import Client -from backend.service.clients.validate import validate_client_create -from backend.utils.dataclasses import BaseServiceResponse +from backend.clients.models import Client +from backend.core.service.clients.validate import validate_client_create +from backend.core.utils.dataclasses import BaseServiceResponse class CreateClientServiceResponse(BaseServiceResponse[Client]): ... diff --git a/backend/service/clients/delete.py b/backend/core/service/clients/delete.py similarity index 84% rename from backend/service/clients/delete.py rename to backend/core/service/clients/delete.py index c117c6992..01265b0a4 100644 --- a/backend/service/clients/delete.py +++ b/backend/core/service/clients/delete.py @@ -1,11 +1,8 @@ -from dataclasses import dataclass -from typing import Literal - -from backend.service.clients.validate import validate_client +from backend.core.service.clients.validate import validate_client from django.core.exceptions import ValidationError, PermissionDenied from backend.models import Client, AuditLog -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse class DeleteClientServiceResponse(BaseServiceResponse[None]): diff --git a/backend/service/clients/get.py b/backend/core/service/clients/get.py similarity index 84% rename from backend/service/clients/get.py rename to backend/core/service/clients/get.py index 22d6179ac..8d87f2ec3 100644 --- a/backend/service/clients/get.py +++ b/backend/core/service/clients/get.py @@ -1,10 +1,7 @@ -from dataclasses import dataclass -from typing import Optional - from django.db.models import Q, QuerySet from backend.models import Client, Organization -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse class FetchClientServiceResponse(BaseServiceResponse[QuerySet[Client]]): ... diff --git a/backend/service/clients/validate.py b/backend/core/service/clients/validate.py similarity index 100% rename from backend/service/clients/validate.py rename to backend/core/service/clients/validate.py diff --git a/backend/views/__init__.py b/backend/core/service/defaults/__init__.py similarity index 100% rename from backend/views/__init__.py rename to backend/core/service/defaults/__init__.py diff --git a/backend/service/defaults/get.py b/backend/core/service/defaults/get.py similarity index 79% rename from backend/service/defaults/get.py rename to backend/core/service/defaults/get.py index 9d40cc177..813c47675 100644 --- a/backend/service/defaults/get.py +++ b/backend/core/service/defaults/get.py @@ -1,5 +1,5 @@ -from backend.models import DefaultValues, Client, User, Organization -from backend.types.requests import WebRequest +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: diff --git a/backend/service/defaults/update.py b/backend/core/service/defaults/update.py similarity index 95% rename from backend/service/defaults/update.py rename to backend/core/service/defaults/update.py index 176182473..55072044f 100644 --- a/backend/service/defaults/update.py +++ b/backend/core/service/defaults/update.py @@ -1,12 +1,8 @@ -from dataclasses import dataclass -from typing import Optional - from PIL import Image -from django.http import QueryDict -from backend.models import DefaultValues, Client -from backend.types.requests import WebRequest -from backend.utils.dataclasses import BaseServiceResponse +from backend.models import DefaultValues +from backend.core.types.requests import WebRequest +from backend.core.utils.dataclasses import BaseServiceResponse class ClientDefaultsServiceResponse(BaseServiceResponse[DefaultValues]): ... diff --git a/backend/views/core/auth/__init__.py b/backend/core/service/file_storage/__init__.py similarity index 100% rename from backend/views/core/auth/__init__.py rename to backend/core/service/file_storage/__init__.py diff --git a/backend/service/file_storage/create.py b/backend/core/service/file_storage/create.py similarity index 95% rename from backend/service/file_storage/create.py rename to backend/core/service/file_storage/create.py index e3b04b217..84c16cafc 100644 --- a/backend/service/file_storage/create.py +++ b/backend/core/service/file_storage/create.py @@ -1,6 +1,6 @@ from django.core.files.uploadedfile import UploadedFile -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse from backend.models import FileStorageFile, User, Organization diff --git a/backend/service/file_storage/utils.py b/backend/core/service/file_storage/utils.py similarity index 100% rename from backend/service/file_storage/utils.py rename to backend/core/service/file_storage/utils.py diff --git a/backend/views/core/auth/passwords/__init__.py b/backend/core/service/invoices/__init__.py similarity index 100% rename from backend/views/core/auth/passwords/__init__.py rename to backend/core/service/invoices/__init__.py diff --git a/backend/views/core/file_storage/__init__.py b/backend/core/service/invoices/common/__init__.py similarity index 100% rename from backend/views/core/file_storage/__init__.py rename to backend/core/service/invoices/common/__init__.py diff --git a/backend/views/core/invoices/__init__.py b/backend/core/service/invoices/common/create/__init__.py similarity index 100% rename from backend/views/core/invoices/__init__.py rename to backend/core/service/invoices/common/create/__init__.py diff --git a/backend/service/invoices/common/create/create.py b/backend/core/service/invoices/common/create/create.py similarity index 95% rename from backend/service/invoices/common/create/create.py rename to backend/core/service/invoices/common/create/create.py index fbd0bf584..8910d0beb 100644 --- a/backend/service/invoices/common/create/create.py +++ b/backend/core/service/invoices/common/create/create.py @@ -1,10 +1,8 @@ -from datetime import datetime - from django.contrib import messages from backend.models import Invoice, InvoiceRecurringProfile, InvoiceItem, Client, QuotaUsage, DefaultValues -from backend.service.defaults.get import get_account_defaults -from backend.types.requests import WebRequest +from backend.core.service.defaults.get import get_account_defaults +from backend.core.types.requests import WebRequest def create_invoice_items(request: WebRequest): diff --git a/backend/service/invoices/common/create/get_page.py b/backend/core/service/invoices/common/create/get_page.py similarity index 92% rename from backend/service/invoices/common/create/get_page.py rename to backend/core/service/invoices/common/create/get_page.py index 810b22b2f..a3d5cf63e 100644 --- a/backend/service/invoices/common/create/get_page.py +++ b/backend/core/service/invoices/common/create/get_page.py @@ -3,10 +3,10 @@ from django.core.exceptions import PermissionDenied, ValidationError from backend.models import Client, InvoiceProduct, DefaultValues -from backend.service.clients.validate import validate_client -from backend.service.defaults.get import get_account_defaults -from backend.types.requests import WebRequest -from backend.utils.dataclasses import BaseServiceResponse +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 class CreateInvoiceContextTuple(NamedTuple): diff --git a/backend/views/core/invoices/recurring/__init__.py b/backend/core/service/invoices/common/create/services/__init__.py similarity index 100% rename from backend/views/core/invoices/recurring/__init__.py rename to backend/core/service/invoices/common/create/services/__init__.py diff --git a/backend/service/invoices/common/create/services/add.py b/backend/core/service/invoices/common/create/services/add.py similarity index 94% rename from backend/service/invoices/common/create/services/add.py rename to backend/core/service/invoices/common/create/services/add.py index 9b006f254..c01cda5ce 100644 --- a/backend/service/invoices/common/create/services/add.py +++ b/backend/core/service/invoices/common/create/services/add.py @@ -1,8 +1,8 @@ from django.http import JsonResponse -from backend.api.public.types import APIRequest -from backend.models import InvoiceProduct -from backend.types.htmx import HtmxHttpRequest +from backend.core.api.public.types import APIRequest +from backend.finance.models import InvoiceProduct +from backend.core.types.htmx import HtmxHttpRequest def add(request: APIRequest | HtmxHttpRequest): diff --git a/backend/views/core/invoices/single/__init__.py b/backend/core/service/invoices/common/emails/__init__.py similarity index 100% rename from backend/views/core/invoices/single/__init__.py rename to backend/core/service/invoices/common/emails/__init__.py diff --git a/backend/service/invoices/common/emails/on_create.py b/backend/core/service/invoices/common/emails/on_create.py similarity index 84% rename from backend/service/invoices/common/emails/on_create.py rename to backend/core/service/invoices/common/emails/on_create.py index 50b0243ea..7c43400c3 100644 --- a/backend/service/invoices/common/emails/on_create.py +++ b/backend/core/service/invoices/common/emails/on_create.py @@ -1,14 +1,13 @@ from string import Template -from textwrap import dedent from django.urls import reverse -from backend.data.default_email_templates import email_footer -from backend.models import Invoice, InvoiceRecurringProfile, User, EmailSendStatus, InvoiceURL -from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.single.create_url import create_invoice_url -from backend.utils.dataclasses import BaseServiceResponse -from backend.utils.service_retry import retry_handler +from backend.core.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 settings.helpers import send_email, get_var """ diff --git a/backend/service/invoices/common/fetch.py b/backend/core/service/invoices/common/fetch.py similarity index 97% rename from backend/service/invoices/common/fetch.py rename to backend/core/service/invoices/common/fetch.py index decbc7d6f..f5a4314d7 100644 --- a/backend/service/invoices/common/fetch.py +++ b/backend/core/service/invoices/common/fetch.py @@ -1,7 +1,7 @@ from django.db.models import Prefetch, ExpressionWrapper, F, FloatField, Sum, Case, When, Q, Value, CharField, QuerySet from django.utils import timezone -from backend.models import Invoice, InvoiceItem +from backend.finance.models import Invoice, InvoiceItem def should_add_condition(was_previous_selection, has_just_been_selected): diff --git a/backend/service/invoices/handler.py b/backend/core/service/invoices/handler.py similarity index 100% rename from backend/service/invoices/handler.py rename to backend/core/service/invoices/handler.py diff --git a/backend/views/core/reports/__init__.py b/backend/core/service/invoices/recurring/__init__.py similarity index 100% rename from backend/views/core/reports/__init__.py rename to backend/core/service/invoices/recurring/__init__.py diff --git a/backend/service/webhooks/auth.py b/backend/core/service/invoices/recurring/create/__init__.py similarity index 100% rename from backend/service/webhooks/auth.py rename to backend/core/service/invoices/recurring/create/__init__.py diff --git a/backend/service/invoices/recurring/create/get_page.py b/backend/core/service/invoices/recurring/create/get_page.py similarity index 87% rename from backend/service/invoices/recurring/create/get_page.py rename to backend/core/service/invoices/recurring/create/get_page.py index cbb3207f9..32d48e872 100644 --- a/backend/service/invoices/recurring/create/get_page.py +++ b/backend/core/service/invoices/recurring/create/get_page.py @@ -1,7 +1,7 @@ from datetime import date -from backend.service.invoices.common.create.get_page import global_get_invoice_context -from backend.types.requests import WebRequest +from backend.core.service.invoices.common.create.get_page import global_get_invoice_context +from backend.core.types.requests import WebRequest def get_invoice_context(request: WebRequest) -> dict: diff --git a/backend/service/invoices/recurring/create/save.py b/backend/core/service/invoices/recurring/create/save.py similarity index 90% rename from backend/service/invoices/recurring/create/save.py rename to backend/core/service/invoices/recurring/create/save.py index a9e917ad2..f9c2fac60 100644 --- a/backend/service/invoices/recurring/create/save.py +++ b/backend/core/service/invoices/recurring/create/save.py @@ -4,10 +4,10 @@ from django.core.exceptions import ValidationError from backend.models import InvoiceRecurringProfile, QuotaUsage -from backend.service.invoices.common.create.create import save_invoice_common -from backend.service.invoices.recurring.validate.frequencies import validate_and_update_frequency -from backend.types.requests import WebRequest -from backend.utils.dataclasses import BaseServiceResponse +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 class SaveInvoiceServiceResponse(BaseServiceResponse[InvoiceRecurringProfile]): ... diff --git a/backend/core/service/invoices/recurring/generation/__init__.py b/backend/core/service/invoices/recurring/generation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/invoices/recurring/generation/next_invoice.py b/backend/core/service/invoices/recurring/generation/next_invoice.py similarity index 95% rename from backend/service/invoices/recurring/generation/next_invoice.py rename to backend/core/service/invoices/recurring/generation/next_invoice.py index 21f21299f..0c041af41 100644 --- a/backend/service/invoices/recurring/generation/next_invoice.py +++ b/backend/core/service/invoices/recurring/generation/next_invoice.py @@ -1,11 +1,11 @@ -from datetime import datetime, date, timedelta +from datetime import date from django.db import transaction, IntegrityError from backend.models import Invoice, InvoiceRecurringProfile, DefaultValues, AuditLog -from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.common.emails.on_create import on_create_invoice_email_service -from backend.utils.dataclasses import BaseServiceResponse +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 import logging diff --git a/backend/service/invoices/recurring/get.py b/backend/core/service/invoices/recurring/get.py similarity index 82% rename from backend/service/invoices/recurring/get.py rename to backend/core/service/invoices/recurring/get.py index 25f72b8df..a2cfde003 100644 --- a/backend/service/invoices/recurring/get.py +++ b/backend/core/service/invoices/recurring/get.py @@ -1,6 +1,6 @@ -from backend.models import InvoiceRecurringProfile, User, Organization -from backend.types.requests import WebRequest -from backend.utils.dataclasses import BaseServiceResponse +from backend.finance.models import InvoiceRecurringProfile +from backend.core.types.requests import WebRequest +from backend.core.utils.dataclasses import BaseServiceResponse class GetRecurringSetServiceResponse(BaseServiceResponse[InvoiceRecurringProfile]): ... diff --git a/backend/core/service/invoices/recurring/schedules/__init__.py b/backend/core/service/invoices/recurring/schedules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/invoices/recurring/schedules/date_handlers.py b/backend/core/service/invoices/recurring/schedules/date_handlers.py similarity index 98% rename from backend/service/invoices/recurring/schedules/date_handlers.py rename to backend/core/service/invoices/recurring/schedules/date_handlers.py index baed057be..bab910cf0 100644 --- a/backend/service/invoices/recurring/schedules/date_handlers.py +++ b/backend/core/service/invoices/recurring/schedules/date_handlers.py @@ -1,4 +1,4 @@ -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse from datetime import date as Date diff --git a/backend/core/service/invoices/recurring/validate/__init__.py b/backend/core/service/invoices/recurring/validate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/invoices/recurring/validate/frequencies.py b/backend/core/service/invoices/recurring/validate/frequencies.py similarity index 95% rename from backend/service/invoices/recurring/validate/frequencies.py rename to backend/core/service/invoices/recurring/validate/frequencies.py index 908b8d30d..515330f25 100644 --- a/backend/service/invoices/recurring/validate/frequencies.py +++ b/backend/core/service/invoices/recurring/validate/frequencies.py @@ -1,5 +1,5 @@ -from backend.models import InvoiceRecurringProfile -from backend.utils.dataclasses import BaseServiceResponse +from backend.finance.models import InvoiceRecurringProfile +from backend.core.utils.dataclasses import BaseServiceResponse class ValidateFrequencyServiceResponse(BaseServiceResponse[None]): diff --git a/backend/core/service/invoices/recurring/webhooks/__init__.py b/backend/core/service/invoices/recurring/webhooks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/invoices/recurring/webhooks/webhook_apikey_auth.py b/backend/core/service/invoices/recurring/webhooks/webhook_apikey_auth.py similarity index 86% rename from backend/service/invoices/recurring/webhooks/webhook_apikey_auth.py rename to backend/core/service/invoices/recurring/webhooks/webhook_apikey_auth.py index 3f2600952..0916fea6c 100644 --- a/backend/service/invoices/recurring/webhooks/webhook_apikey_auth.py +++ b/backend/core/service/invoices/recurring/webhooks/webhook_apikey_auth.py @@ -1,6 +1,6 @@ -from backend.api.public import APIAuthToken -from backend.types.requests import WebRequest -from backend.utils.dataclasses import BaseServiceResponse +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]): diff --git a/backend/core/service/invoices/single/__init__.py b/backend/core/service/invoices/single/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/core/service/invoices/single/create/__init__.py b/backend/core/service/invoices/single/create/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/invoices/single/create/create.py b/backend/core/service/invoices/single/create/create.py similarity index 88% rename from backend/service/invoices/single/create/create.py rename to backend/core/service/invoices/single/create/create.py index 617be9980..91d4dd293 100644 --- a/backend/service/invoices/single/create/create.py +++ b/backend/core/service/invoices/single/create/create.py @@ -3,11 +3,12 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied, ValidationError -from backend.models import Invoice, InvoiceItem, Client, QuotaUsage, InvoiceProduct, DefaultValues -from backend.service.clients.validate import validate_client -from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.common.create.create import save_invoice_common -from backend.types.requests import WebRequest +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 def get_invoice_context(request: WebRequest) -> dict: diff --git a/backend/service/invoices/single/create/get_page.py b/backend/core/service/invoices/single/create/get_page.py similarity index 81% rename from backend/service/invoices/single/create/get_page.py rename to backend/core/service/invoices/single/create/get_page.py index c47c67e6c..322551849 100644 --- a/backend/service/invoices/single/create/get_page.py +++ b/backend/core/service/invoices/single/create/get_page.py @@ -1,7 +1,7 @@ from datetime import date -from backend.service.invoices.common.create.get_page import global_get_invoice_context -from backend.types.requests import WebRequest +from backend.core.service.invoices.common.create.get_page import global_get_invoice_context +from backend.core.types.requests import WebRequest def get_invoice_context(request: WebRequest) -> dict: diff --git a/backend/service/invoices/single/create_pdf.py b/backend/core/service/invoices/single/create_pdf.py similarity index 95% rename from backend/service/invoices/single/create_pdf.py rename to backend/core/service/invoices/single/create_pdf.py index b3e0f754c..e84c1e58a 100644 --- a/backend/service/invoices/single/create_pdf.py +++ b/backend/core/service/invoices/single/create_pdf.py @@ -4,7 +4,7 @@ from django.template.loader import get_template from xhtml2pdf import pisa -from backend.models import UserSettings, Invoice +from backend.finance.models import UserSettings, Invoice def render_to_pdf(template_src: str, context_dict: dict) -> HttpResponse | None: diff --git a/backend/service/invoices/single/create_url.py b/backend/core/service/invoices/single/create_url.py similarity index 69% rename from backend/service/invoices/single/create_url.py rename to backend/core/service/invoices/single/create_url.py index 81234bdba..92250713d 100644 --- a/backend/service/invoices/single/create_url.py +++ b/backend/core/service/invoices/single/create_url.py @@ -1,5 +1,6 @@ -from backend.models import InvoiceURL, Invoice, User -from backend.utils.dataclasses import BaseServiceResponse +from backend.finance.models import InvoiceURL, Invoice +from backend.core.models import User +from backend.core.utils.dataclasses import BaseServiceResponse class CreateInvoiceURLServiceResponse(BaseServiceResponse[InvoiceURL]): ... diff --git a/backend/service/invoices/single/get_invoice.py b/backend/core/service/invoices/single/get_invoice.py similarity index 82% rename from backend/service/invoices/single/get_invoice.py rename to backend/core/service/invoices/single/get_invoice.py index 80c58869e..7789b93a2 100644 --- a/backend/service/invoices/single/get_invoice.py +++ b/backend/core/service/invoices/single/get_invoice.py @@ -1,5 +1,5 @@ -from backend.models import Invoice, Organization, User -from backend.utils.dataclasses import BaseServiceResponse +from backend.finance.models import Invoice, Organization, User +from backend.core.utils.dataclasses import BaseServiceResponse class GetInvoiceServiceResponse(BaseServiceResponse[Invoice]): ... diff --git a/backend/core/service/maintenance/__init__.py b/backend/core/service/maintenance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/core/service/maintenance/expire/__init__.py b/backend/core/service/maintenance/expire/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/maintenance/expire/run.py b/backend/core/service/maintenance/expire/run.py similarity index 100% rename from backend/service/maintenance/expire/run.py rename to backend/core/service/maintenance/expire/run.py diff --git a/backend/core/service/permissions/__init__.py b/backend/core/service/permissions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/permissions/scopes.py b/backend/core/service/permissions/scopes.py similarity index 83% rename from backend/service/permissions/scopes.py rename to backend/core/service/permissions/scopes.py index 62c251187..f9e4212d3 100644 --- a/backend/service/permissions/scopes.py +++ b/backend/core/service/permissions/scopes.py @@ -1,8 +1,6 @@ -from dataclasses import dataclass - -from backend.api.public.permissions import SCOPE_DESCRIPTIONS, SCOPES -from backend.types.requests import WebRequest -from backend.utils.dataclasses import BaseServiceResponse +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]): diff --git a/backend/core/service/reports/__init__.py b/backend/core/service/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/reports/generate.py b/backend/core/service/reports/generate.py similarity index 94% rename from backend/service/reports/generate.py rename to backend/core/service/reports/generate.py index bdef1be6a..22bfd027d 100644 --- a/backend/service/reports/generate.py +++ b/backend/core/service/reports/generate.py @@ -1,11 +1,10 @@ -from dataclasses import dataclass from datetime import date from decimal import Decimal from django.db import transaction from backend.models import User, Organization, Invoice, MonthlyReport, MonthlyReportRow -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse class GenerateReportServiceResponse(BaseServiceResponse[MonthlyReport]): ... diff --git a/backend/service/reports/get.py b/backend/core/service/reports/get.py similarity index 87% rename from backend/service/reports/get.py rename to backend/core/service/reports/get.py index 36fe23fa0..a9f139f99 100644 --- a/backend/service/reports/get.py +++ b/backend/core/service/reports/get.py @@ -1,5 +1,5 @@ from backend.models import MonthlyReport, User, Organization -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse class GetReportServiceResponse(BaseServiceResponse[MonthlyReport]): ... diff --git a/backend/core/service/settings/__init__.py b/backend/core/service/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/settings/update.py b/backend/core/service/settings/update.py similarity index 92% rename from backend/service/settings/update.py rename to backend/core/service/settings/update.py index 4089e88c1..ce7fda38c 100644 --- a/backend/service/settings/update.py +++ b/backend/core/service/settings/update.py @@ -1,10 +1,7 @@ -from dataclasses import dataclass -from typing import Optional - from backend.models import UserSettings from PIL import Image -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse class UpdateProfilePictureServiceResponse(BaseServiceResponse[str]): ... diff --git a/backend/service/settings/view.py b/backend/core/service/settings/view.py similarity index 90% rename from backend/service/settings/view.py rename to backend/core/service/settings/view.py index 496ec8d4e..433a88b1b 100644 --- a/backend/service/settings/view.py +++ b/backend/core/service/settings/view.py @@ -1,10 +1,9 @@ from django.db.models import QuerySet +from backend.core.api.public import APIAuthToken from backend.models import UserSettings -from backend.models import DefaultValues -from backend.api.public.models import APIAuthToken -from backend.service.defaults.get import get_account_defaults -from backend.types.requests import WebRequest +from backend.core.service.defaults.get import get_account_defaults +from backend.core.types.requests import WebRequest def validate_page(page: str | None) -> bool: diff --git a/backend/core/service/teams/__init__.py b/backend/core/service/teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/teams/create_user.py b/backend/core/service/teams/create_user.py similarity index 92% rename from backend/service/teams/create_user.py rename to backend/core/service/teams/create_user.py index 20db83132..72e40518d 100644 --- a/backend/service/teams/create_user.py +++ b/backend/core/service/teams/create_user.py @@ -3,9 +3,8 @@ from django.urls import reverse from django.utils.crypto import get_random_string -from backend.models import User, Organization, TeamMemberPermission -from backend.types.emails import SingleEmailInput -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.models import User, Organization, TeamMemberPermission +from backend.core.utils.dataclasses import BaseServiceResponse from settings.helpers import send_email diff --git a/backend/service/teams/fetch.py b/backend/core/service/teams/fetch.py similarity index 53% rename from backend/service/teams/fetch.py rename to backend/core/service/teams/fetch.py index c97b391b9..fa4bd177d 100644 --- a/backend/service/teams/fetch.py +++ b/backend/core/service/teams/fetch.py @@ -1,7 +1,7 @@ -from django.db.models import QuerySet, Q +from django.db.models import QuerySet -from backend.models import Organization, User -from backend.types.requests import WebRequest +from backend.models import Organization +from backend.core.types.requests import WebRequest def get_all_users_teams(request: WebRequest) -> QuerySet[Organization]: diff --git a/backend/service/teams/permissions.py b/backend/core/service/teams/permissions.py similarity index 87% rename from backend/service/teams/permissions.py rename to backend/core/service/teams/permissions.py index 62c6f8ff4..afc515195 100644 --- a/backend/service/teams/permissions.py +++ b/backend/core/service/teams/permissions.py @@ -1,9 +1,6 @@ -from dataclasses import dataclass - -from backend.api.public.models import APIAuthToken from backend.models import User, Organization, TeamMemberPermission -from backend.service.permissions.scopes import validate_scopes -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.service.permissions.scopes import validate_scopes +from backend.core.utils.dataclasses import BaseServiceResponse class EditMemberPermissionsServiceResponse(BaseServiceResponse[None]): diff --git a/backend/core/service/webhooks/__init__.py b/backend/core/service/webhooks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/core/service/webhooks/auth.py b/backend/core/service/webhooks/auth.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/webhooks/get_url.py b/backend/core/service/webhooks/get_url.py similarity index 100% rename from backend/service/webhooks/get_url.py rename to backend/core/service/webhooks/get_url.py diff --git a/backend/core/signals/__init__.py b/backend/core/signals/__init__.py new file mode 100644 index 000000000..6e946f549 --- /dev/null +++ b/backend/core/signals/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from . import migrations +from . import signals diff --git a/backend/signals/migrations.py b/backend/core/signals/migrations.py similarity index 94% rename from backend/signals/migrations.py rename to backend/core/signals/migrations.py index e843e5ca4..3adafca2f 100644 --- a/backend/signals/migrations.py +++ b/backend/core/signals/migrations.py @@ -5,8 +5,8 @@ from django.db.models.signals import post_migrate from django.dispatch import receiver -from backend.data.default_feature_flags import default_feature_flags -from backend.data.default_quota_limits import default_quota_limits +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 diff --git a/backend/signals/signals.py b/backend/core/signals/signals.py similarity index 96% rename from backend/signals/signals.py rename to backend/core/signals/signals.py index b658cae8d..751a51d3d 100644 --- a/backend/signals/signals.py +++ b/backend/core/signals/signals.py @@ -3,8 +3,6 @@ from django.core.cache import cache from django.core.cache.backends.redis import RedisCacheClient -from backend.types.emails import SingleEmailInput - 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 @@ -13,7 +11,7 @@ import settings.settings from backend.models import UserSettings, Receipt, User, FeatureFlags, VerificationCodes -from settings.helpers import ARE_EMAILS_ENABLED, send_email +from settings.helpers import send_email @receiver(pre_save, sender=UserSettings) diff --git a/backend/core/types/__init__.py b/backend/core/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/types/emails.py b/backend/core/types/emails.py similarity index 90% rename from backend/types/emails.py rename to backend/core/types/emails.py index 1e80d268a..cb1abc08c 100644 --- a/backend/types/emails.py +++ b/backend/core/types/emails.py @@ -1,9 +1,9 @@ from dataclasses import dataclass, field from typing import TypedDict -from mypy_boto3_sesv2.type_defs import SendEmailResponseTypeDef, SendBulkEmailResponseTypeDef, BulkEmailEntryResultTypeDef +from mypy_boto3_sesv2.type_defs import SendEmailResponseTypeDef, SendBulkEmailResponseTypeDef -from backend.utils.dataclasses import BaseServiceResponse +from backend.core.utils.dataclasses import BaseServiceResponse class SingleEmailSendServiceResponse(BaseServiceResponse[SendEmailResponseTypeDef]): ... diff --git a/backend/types/htmx.py b/backend/core/types/htmx.py similarity index 100% rename from backend/types/htmx.py rename to backend/core/types/htmx.py diff --git a/backend/types/requests.py b/backend/core/types/requests.py similarity index 100% rename from backend/types/requests.py rename to backend/core/types/requests.py diff --git a/backend/core/utils/__init__.py b/backend/core/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/utils/calendar.py b/backend/core/utils/calendar.py similarity index 100% rename from backend/utils/calendar.py rename to backend/core/utils/calendar.py diff --git a/backend/utils/dataclasses.py b/backend/core/utils/dataclasses.py similarity index 100% rename from backend/utils/dataclasses.py rename to backend/core/utils/dataclasses.py diff --git a/backend/utils/feature_flags.py b/backend/core/utils/feature_flags.py similarity index 100% rename from backend/utils/feature_flags.py rename to backend/core/utils/feature_flags.py diff --git a/backend/utils/http_utils.py b/backend/core/utils/http_utils.py similarity index 100% rename from backend/utils/http_utils.py rename to backend/core/utils/http_utils.py diff --git a/backend/utils/quota_limit_ops.py b/backend/core/utils/quota_limit_ops.py similarity index 100% rename from backend/utils/quota_limit_ops.py rename to backend/core/utils/quota_limit_ops.py diff --git a/backend/utils/service_retry.py b/backend/core/utils/service_retry.py similarity index 81% rename from backend/utils/service_retry.py rename to backend/core/utils/service_retry.py index 57e2bd115..8c7579382 100644 --- a/backend/utils/service_retry.py +++ b/backend/core/utils/service_retry.py @@ -1,5 +1,5 @@ -from typing import Callable, TypeVar, Generic -from backend.utils.dataclasses import BaseServiceResponse +from typing import Callable, TypeVar +from backend.core.utils.dataclasses import BaseServiceResponse T = TypeVar("T", bound=BaseServiceResponse) diff --git a/backend/core/views/__init__.py b/backend/core/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/core/views/auth/__init__.py b/backend/core/views/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/auth/create_account.py b/backend/core/views/auth/create_account.py similarity index 97% rename from backend/views/core/auth/create_account.py rename to backend/core/views/auth/create_account.py index 3ae9ca9e5..69822ee51 100644 --- a/backend/views/core/auth/create_account.py +++ b/backend/core/views/auth/create_account.py @@ -6,8 +6,8 @@ from django.shortcuts import redirect, render from django.views import View -from backend.models import User -from backend.utils.feature_flags import get_feature_status +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, diff --git a/backend/views/core/auth/helpers.py b/backend/core/views/auth/helpers.py similarity index 100% rename from backend/views/core/auth/helpers.py rename to backend/core/views/auth/helpers.py diff --git a/backend/views/core/auth/login.py b/backend/core/views/auth/login.py similarity index 96% rename from backend/views/core/auth/login.py rename to backend/core/views/auth/login.py index 28ae4974f..c8f607d55 100644 --- a/backend/views/core/auth/login.py +++ b/backend/core/views/auth/login.py @@ -1,23 +1,24 @@ 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 -from django.urls import resolve +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render, redirect +from django.urls import resolve, reverse from django.urls.exceptions import Resolver404 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 * +from backend.decorators import not_authenticated from backend.models import LoginLog, User, VerificationCodes, AuditLog -from backend.types.emails import SingleEmailInput -from backend.views.core.auth.verify import create_magic_link -from backend.types.htmx import HtmxAnyHttpRequest +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 ( diff --git a/backend/core/views/auth/passwords/__init__.py b/backend/core/views/auth/passwords/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/auth/passwords/generate.py b/backend/core/views/auth/passwords/generate.py similarity index 95% rename from backend/views/core/auth/passwords/generate.py rename to backend/core/views/auth/passwords/generate.py index 9e61ba9e8..9c8fa8b15 100644 --- a/backend/views/core/auth/passwords/generate.py +++ b/backend/core/views/auth/passwords/generate.py @@ -8,8 +8,9 @@ from django.urls import reverse, resolve, NoReverseMatch from django.utils import timezone -from backend.models import * -from backend.types.htmx import HtmxHttpRequest +from backend.models import User, PasswordSecret +from backend.core.models import RandomCode +from backend.core.types.htmx import HtmxHttpRequest from settings import settings diff --git a/backend/views/core/auth/passwords/set.py b/backend/core/views/auth/passwords/set.py similarity index 87% rename from backend/views/core/auth/passwords/set.py rename to backend/core/views/auth/passwords/set.py index 6ef5b1942..ad79e3893 100644 --- a/backend/views/core/auth/passwords/set.py +++ b/backend/core/views/auth/passwords/set.py @@ -1,11 +1,14 @@ +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 * -from backend.models import * +from backend.decorators import not_authenticated +from backend.models import PasswordSecret @not_authenticated diff --git a/backend/views/core/auth/passwords/view.py b/backend/core/views/auth/passwords/view.py similarity index 72% rename from backend/views/core/auth/passwords/view.py rename to backend/core/views/auth/passwords/view.py index 5edb152ee..24c50fe15 100644 --- a/backend/views/core/auth/passwords/view.py +++ b/backend/core/views/auth/passwords/view.py @@ -1,9 +1,11 @@ +from django.contrib import messages from django.contrib.auth.hashers import check_password from django.http import HttpRequest -from django.shortcuts import render +from django.shortcuts import render, redirect +from django.utils import timezone -from backend.decorators import * -from backend.models import * +from backend.core.models import PasswordSecret +from backend.decorators import not_authenticated @not_authenticated diff --git a/backend/views/core/auth/urls.py b/backend/core/views/auth/urls.py similarity index 100% rename from backend/views/core/auth/urls.py rename to backend/core/views/auth/urls.py diff --git a/backend/views/core/auth/verify.py b/backend/core/views/auth/verify.py similarity index 97% rename from backend/views/core/auth/verify.py rename to backend/core/views/auth/verify.py index 81a34b7ba..df2eafe39 100644 --- a/backend/views/core/auth/verify.py +++ b/backend/core/views/auth/verify.py @@ -4,12 +4,10 @@ from django.contrib.auth.hashers import check_password from django.shortcuts import redirect from django.urls import reverse -from django.utils import timezone from django.views.decorators.http import require_POST from django_ratelimit.decorators import ratelimit from backend.models import VerificationCodes, User, TracebackError -from backend.types.emails import SingleEmailInput from settings import settings from settings.helpers import send_email, ARE_EMAILS_ENABLED diff --git a/backend/core/views/emails/__init__.py b/backend/core/views/emails/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/emails/dashboard.py b/backend/core/views/emails/dashboard.py similarity index 88% rename from backend/views/core/emails/dashboard.py rename to backend/core/views/emails/dashboard.py index e4c46b363..1dffbc89e 100644 --- a/backend/views/core/emails/dashboard.py +++ b/backend/core/views/emails/dashboard.py @@ -4,7 +4,7 @@ from django.shortcuts import render from backend.decorators import feature_flag_check, web_require_scopes -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @feature_flag_check("areUserEmailsAllowed", status=True) diff --git a/backend/views/core/emails/urls.py b/backend/core/views/emails/urls.py similarity index 100% rename from backend/views/core/emails/urls.py rename to backend/core/views/emails/urls.py diff --git a/backend/views/core/other/__init__.py b/backend/core/views/other/__init__.py similarity index 100% rename from backend/views/core/other/__init__.py rename to backend/core/views/other/__init__.py diff --git a/backend/views/core/other/errors.py b/backend/core/views/other/errors.py similarity index 94% rename from backend/views/core/other/errors.py rename to backend/core/views/other/errors.py index 39336ab27..5cc555a7c 100644 --- a/backend/views/core/other/errors.py +++ b/backend/core/views/other/errors.py @@ -1,10 +1,11 @@ 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.decorators import * -from backend.models import * +from backend.models import TracebackError, AuditLog def universal(request: HttpRequest, exception=None): diff --git a/backend/views/core/other/index.py b/backend/core/views/other/index.py similarity index 100% rename from backend/views/core/other/index.py rename to backend/core/views/other/index.py diff --git a/backend/core/views/quotas/__init__.py b/backend/core/views/quotas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/quotas/view.py b/backend/core/views/quotas/view.py similarity index 90% rename from backend/views/core/quotas/view.py rename to backend/core/views/quotas/view.py index fcbe120bd..1671067e5 100644 --- a/backend/views/core/quotas/view.py +++ b/backend/core/views/quotas/view.py @@ -1,10 +1,9 @@ from django.http import HttpResponse from django.shortcuts import render -from django.views.decorators.cache import cache_page from backend.decorators import superuser_only from backend.models import QuotaIncreaseRequest, QuotaLimit -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest def quotas_page(request: HtmxHttpRequest) -> HttpResponse: diff --git a/backend/core/views/settings/__init__.py b/backend/core/views/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/settings/teams.py b/backend/core/views/settings/teams.py similarity index 91% rename from backend/views/core/settings/teams.py rename to backend/core/views/settings/teams.py index 8dd97b7b0..5134fe578 100644 --- a/backend/views/core/settings/teams.py +++ b/backend/core/views/settings/teams.py @@ -1,11 +1,11 @@ from typing import Optional -from django.db.models import When, Case, BooleanField +from django.db.models import When, Case, BooleanField, QuerySet from django.shortcuts import render -from backend.models import * -from backend.service.teams.fetch import get_all_users_teams -from backend.types.requests import WebRequest +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): diff --git a/backend/core/views/settings/urls.py b/backend/core/views/settings/urls.py new file mode 100644 index 000000000..a56449426 --- /dev/null +++ b/backend/core/views/settings/urls.py @@ -0,0 +1,15 @@ +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/views/core/settings/view.py b/backend/core/views/settings/view.py similarity index 93% rename from backend/views/core/settings/view.py rename to backend/core/views/settings/view.py index 340bb763c..9df835c65 100644 --- a/backend/views/core/settings/view.py +++ b/backend/core/views/settings/view.py @@ -4,17 +4,14 @@ from django.shortcuts import redirect from django.shortcuts import render -from backend.service.defaults.get import get_account_defaults -from backend.service.settings.view import ( +from backend.core.service.settings.view import ( validate_page, - get_user_profile, - get_api_keys, account_page_context, api_keys_page_context, account_defaults_context, email_templates_context, ) -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest @require_http_methods(["GET"]) diff --git a/backend/core/views/teams/__init__.py b/backend/core/views/teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/teams/urls.py b/backend/core/views/teams/urls.py similarity index 50% rename from backend/views/core/teams/urls.py rename to backend/core/views/teams/urls.py index 7c5c2fd75..4de008fd8 100644 --- a/backend/views/core/teams/urls.py +++ b/backend/core/views/teams/urls.py @@ -1,11 +1,11 @@ -from django.urls import include from django.urls import path -from backend.views.core import settings + +from backend.core.views.settings.teams import teams_dashboard_handler urlpatterns = [ path( "", - settings.teams.teams_dashboard_handler, + teams_dashboard_handler, name="dashboard", ), ] diff --git a/backend/core/webhooks/__init__.py b/backend/core/webhooks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/core/webhooks/invoices/__init__.py b/backend/core/webhooks/invoices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/webhooks/invoices/invoice_status.py b/backend/core/webhooks/invoices/invoice_status.py similarity index 80% rename from backend/webhooks/invoices/invoice_status.py rename to backend/core/webhooks/invoices/invoice_status.py index 63da8ad19..3f9e85d9f 100644 --- a/backend/webhooks/invoices/invoice_status.py +++ b/backend/core/webhooks/invoices/invoice_status.py @@ -1,18 +1,17 @@ -from datetime import datetime, timedelta +from datetime import datetime 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.models import InvoiceRecurringProfile, Invoice, DefaultValues, AuditLog -from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key +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 import logging -from backend.types.requests import WebRequest -from settings.settings import AWS_TAGS_APP_NAME +from backend.core.types.requests import WebRequest logger = logging.getLogger(__name__) diff --git a/backend/webhooks/invoices/recurring.py b/backend/core/webhooks/invoices/recurring.py similarity index 77% rename from backend/webhooks/invoices/recurring.py rename to backend/core/webhooks/invoices/recurring.py index 5a58550b3..3f0a1b627 100644 --- a/backend/webhooks/invoices/recurring.py +++ b/backend/core/webhooks/invoices/recurring.py @@ -1,20 +1,17 @@ -from datetime import datetime, timedelta +from datetime import datetime 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.decorators import feature_flag_check -from backend.models import InvoiceRecurringProfile, Invoice, DefaultValues, AuditLog -from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key +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 import logging -from backend.types.requests import WebRequest -from settings.settings import AWS_TAGS_APP_NAME +from backend.core.types.requests import WebRequest logger = logging.getLogger(__name__) diff --git a/backend/webhooks/urls.py b/backend/core/webhooks/urls.py similarity index 57% rename from backend/webhooks/urls.py rename to backend/core/webhooks/urls.py index 7a8eeb35b..2ceeab7d0 100644 --- a/backend/webhooks/urls.py +++ b/backend/core/webhooks/urls.py @@ -1,6 +1,6 @@ -from django.urls import path, include +from django.urls import path -from backend.webhooks.invoices.recurring import handle_recurring_invoice_webhook_endpoint +from backend.core.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/backend/decorators.py b/backend/decorators.py index 59ece7981..7cc91414c 100644 --- a/backend/decorators.py +++ b/backend/decorators.py @@ -1,9 +1,8 @@ from __future__ import annotations import logging -from dataclasses import dataclass from functools import wraps -from typing import Optional, TypedDict, List +from typing import TypedDict from django.contrib import messages from django.http import HttpResponse @@ -12,9 +11,9 @@ from django.shortcuts import render from django.urls import reverse -from backend.models import QuotaLimit, TeamMemberPermission -from backend.types.requests import WebRequest -from backend.utils.feature_flags import get_feature_status +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__) diff --git a/backend/finance/__init__.py b/backend/finance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/finance/api/__init__.py b/backend/finance/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/finance/api/invoices/__init__.py b/backend/finance/api/invoices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/finance/api/invoices/create/__init__.py b/backend/finance/api/invoices/create/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/finance/api/invoices/create/services/__init__.py b/backend/finance/api/invoices/create/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/invoices/create/services/add_service.py b/backend/finance/api/invoices/create/services/add_service.py similarity index 69% rename from backend/api/invoices/create/services/add_service.py rename to backend/finance/api/invoices/create/services/add_service.py index 507a2da52..4068b8f06 100644 --- a/backend/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.types.htmx import HtmxHttpRequest -from backend.service.invoices.common.create.services.add import add +from backend.core.types.htmx import HtmxHttpRequest +from backend.core.service.invoices.common.create.services.add import add @require_http_methods(["POST"]) diff --git a/backend/api/invoices/create/set_destination.py b/backend/finance/api/invoices/create/set_destination.py similarity index 96% rename from backend/api/invoices/create/set_destination.py rename to backend/finance/api/invoices/create/set_destination.py index cd19ba25f..e84f78564 100644 --- a/backend/api/invoices/create/set_destination.py +++ b/backend/finance/api/invoices/create/set_destination.py @@ -3,7 +3,7 @@ from django.views.decorators.http import require_http_methods from backend.models import Client -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest to_get = ["name", "address", "city", "country", "company", "is_representative", "email"] diff --git a/backend/api/invoices/delete.py b/backend/finance/api/invoices/delete.py similarity index 96% rename from backend/api/invoices/delete.py rename to backend/finance/api/invoices/delete.py index 175d391aa..16b85bafa 100644 --- a/backend/api/invoices/delete.py +++ b/backend/finance/api/invoices/delete.py @@ -7,7 +7,7 @@ from backend.decorators import web_require_scopes from backend.models import Invoice, QuotaLimit -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @require_http_methods(["DELETE"]) diff --git a/backend/api/invoices/edit.py b/backend/finance/api/invoices/edit.py similarity index 96% rename from backend/api/invoices/edit.py rename to backend/finance/api/invoices/edit.py index 83aca9226..a6561a85b 100644 --- a/backend/api/invoices/edit.py +++ b/backend/finance/api/invoices/edit.py @@ -6,8 +6,8 @@ from django.views.decorators.http import require_http_methods, require_POST from backend.decorators import web_require_scopes -from backend.models import Invoice -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import Invoice +from backend.core.types.htmx import HtmxHttpRequest @require_http_methods(["POST"]) @@ -79,7 +79,7 @@ def change_status(request: HtmxHttpRequest, invoice_id: int, status: str) -> Htt status = status.lower() if status else "" if not request.htmx: - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") try: invoice = Invoice.objects.get(id=invoice_id) @@ -111,7 +111,7 @@ def edit_discount(request: HtmxHttpRequest, invoice_id: str): percentage_amount_str: str = request.POST.get("percentage_amount", "") if not request.htmx: - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") try: invoice: Invoice = Invoice.objects.get(id=invoice_id) diff --git a/backend/api/invoices/fetch.py b/backend/finance/api/invoices/fetch.py similarity index 81% rename from backend/api/invoices/fetch.py rename to backend/finance/api/invoices/fetch.py index e15b56537..2a945a0e4 100644 --- a/backend/api/invoices/fetch.py +++ b/backend/finance/api/invoices/fetch.py @@ -2,9 +2,9 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.models import Invoice -from backend.types.htmx import HtmxHttpRequest -from backend.service.invoices.common.fetch import get_context +from backend.finance.models import Invoice +from backend.core.types.htmx import HtmxHttpRequest +from backend.core.service.invoices.common.fetch import get_context @require_http_methods(["GET"]) @@ -12,7 +12,7 @@ def fetch_all_invoices(request: HtmxHttpRequest): # Redirect if not an HTMX request if not request.htmx: - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") if request.user.logged_in_as_team: invoices = Invoice.objects.filter(organization=request.user.logged_in_as_team) diff --git a/backend/api/invoices/manage.py b/backend/finance/api/invoices/manage.py similarity index 86% rename from backend/api/invoices/manage.py rename to backend/finance/api/invoices/manage.py index 97829f2af..78bba95a9 100644 --- a/backend/api/invoices/manage.py +++ b/backend/finance/api/invoices/manage.py @@ -4,15 +4,11 @@ from typing import Literal from typing import TypedDict -from django.contrib import messages -from django.http import HttpRequest -from django.shortcuts import redirect -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 Invoice -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import Invoice +from backend.core.types.htmx import HtmxHttpRequest class PreviewContext(TypedDict): diff --git a/backend/finance/api/invoices/recurring/__init__.py b/backend/finance/api/invoices/recurring/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/invoices/recurring/delete.py b/backend/finance/api/invoices/recurring/delete.py similarity index 84% rename from backend/api/invoices/recurring/delete.py rename to backend/finance/api/invoices/recurring/delete.py index dcf559060..5157c6e50 100644 --- a/backend/api/invoices/recurring/delete.py +++ b/backend/finance/api/invoices/recurring/delete.py @@ -6,10 +6,10 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.models import QuotaLimit, InvoiceRecurringProfile -from backend.service.asyn_tasks.tasks import Task -from backend.service.boto3.scheduler.delete_schedule import delete_boto_schedule -from backend.types.requests import WebRequest +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 @require_http_methods(["DELETE"]) @@ -47,6 +47,6 @@ def delete_invoice_recurring_profile_endpoint(request: WebRequest): response["HX-Location"] = redirect return response except Resolver404: - return HttpResponseRedirect(reverse("invoices:recurring:dashboard")) + return HttpResponseRedirect(reverse("finance:invoices:recurring:dashboard")) return JsonResponse({"message": "Invoice successfully deleted"}, status=200) diff --git a/backend/api/invoices/recurring/edit.py b/backend/finance/api/invoices/recurring/edit.py similarity index 92% rename from backend/api/invoices/recurring/edit.py rename to backend/finance/api/invoices/recurring/edit.py index fb900fd7c..2bad09b29 100644 --- a/backend/api/invoices/recurring/edit.py +++ b/backend/finance/api/invoices/recurring/edit.py @@ -6,10 +6,10 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes, has_entitlements -from backend.models import InvoiceRecurringProfile -from backend.service.invoices.recurring.get import get_invoice_profile -from backend.service.invoices.recurring.validate.frequencies import validate_and_update_frequency -from backend.types.requests import WebRequest +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 @require_http_methods(["POST"]) diff --git a/backend/api/invoices/recurring/fetch.py b/backend/finance/api/invoices/recurring/fetch.py similarity index 79% rename from backend/api/invoices/recurring/fetch.py rename to backend/finance/api/invoices/recurring/fetch.py index 8ad43e3f3..abee2dbf4 100644 --- a/backend/api/invoices/recurring/fetch.py +++ b/backend/finance/api/invoices/recurring/fetch.py @@ -1,11 +1,11 @@ -from django.core.paginator import Paginator, EmptyPage +from django.core.paginator import Paginator from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.models import InvoiceRecurringProfile -from backend.service.invoices.common.fetch import get_context -from backend.types.requests import WebRequest +from backend.finance.models import InvoiceRecurringProfile +from backend.core.service.invoices.common.fetch import get_context +from backend.core.types.requests import WebRequest @require_http_methods(["GET"]) @@ -13,7 +13,7 @@ def fetch_all_recurring_invoices_endpoint(request: WebRequest): # Redirect if not an HTMX request if not request.htmx: - return redirect("invoices:recurring:dashboard") + return redirect("finance:invoices:recurring:dashboard") invoices = InvoiceRecurringProfile.filter_by_owner(owner=request.actor).filter(active=True) diff --git a/backend/api/invoices/recurring/generate_next_invoice_now.py b/backend/finance/api/invoices/recurring/generate_next_invoice_now.py similarity index 87% rename from backend/api/invoices/recurring/generate_next_invoice_now.py rename to backend/finance/api/invoices/recurring/generate_next_invoice_now.py index 58cb77abf..a51f168d5 100644 --- a/backend/api/invoices/recurring/generate_next_invoice_now.py +++ b/backend/finance/api/invoices/recurring/generate_next_invoice_now.py @@ -3,10 +3,10 @@ from django.views.decorators.http import require_POST from backend.decorators import web_require_scopes, htmx_only -from backend.models import InvoiceRecurringProfile, Invoice -from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service -from backend.types.requests import WebRequest +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 import logging @@ -14,7 +14,7 @@ @require_POST -@htmx_only("invoices:recurring:dashboard") +@htmx_only("finance:invoices:recurring:dashboard") @web_require_scopes("invoices:write", True, True) def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): context: dict = {} diff --git a/backend/api/invoices/recurring/poll.py b/backend/finance/api/invoices/recurring/poll.py similarity index 85% rename from backend/api/invoices/recurring/poll.py rename to backend/finance/api/invoices/recurring/poll.py index dd35d37b7..5f6b7b0be 100644 --- a/backend/api/invoices/recurring/poll.py +++ b/backend/finance/api/invoices/recurring/poll.py @@ -6,12 +6,12 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes, htmx_only -from backend.models import InvoiceRecurringProfile -from backend.service.asyn_tasks.tasks import Task -from backend.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.service.boto3.scheduler.get import get_boto_schedule +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.types.requests import WebRequest +from backend.core.types.requests import WebRequest def return_create_schedule(recurring_schedule): @@ -23,7 +23,7 @@ def return_create_schedule(recurring_schedule): @require_http_methods(["GET"]) -@htmx_only("invoices:recurring:dashboard") +@htmx_only("finance:invoices:recurring:dashboard") @web_require_scopes("invoices:read", False, False, "dashboard") def poll_recurring_schedule_update_endpoint(request: WebRequest, invoice_profile_id): try: diff --git a/backend/api/invoices/recurring/update_status.py b/backend/finance/api/invoices/recurring/update_status.py similarity index 89% rename from backend/api/invoices/recurring/update_status.py rename to backend/finance/api/invoices/recurring/update_status.py index 606a1381e..276d2ddd9 100644 --- a/backend/api/invoices/recurring/update_status.py +++ b/backend/finance/api/invoices/recurring/update_status.py @@ -5,15 +5,14 @@ from django.views.decorators.http import require_POST from backend.decorators import web_require_scopes -from backend.models import InvoiceRecurringProfile -from backend.service.asyn_tasks.tasks import Task -from backend.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.service.boto3.scheduler.get import get_boto_schedule, GetScheduleServiceResponse -from backend.service.boto3.scheduler.pause import pause_boto_schedule, PauseScheduleServiceResponse -from backend.types.requests import WebRequest +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 datetime import timedelta, datetime -from typing import Optional @require_POST @@ -22,7 +21,7 @@ def recurring_profile_change_status_endpoint(request: WebRequest, invoice_profil status = status.lower() if status else "" if not request.htmx: - return redirect("invoices:recurring:dashboard") + return redirect("finance:invoices:recurring:dashboard") if status not in ["pause", "unpause", "refresh"]: return return_message(request, "Invalid status. Please choose from: paused, ongoing, refresh") diff --git a/backend/finance/api/invoices/reminders/__init__.py b/backend/finance/api/invoices/reminders/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/invoices/reminders/create.py b/backend/finance/api/invoices/reminders/create.py similarity index 96% rename from backend/api/invoices/reminders/create.py rename to backend/finance/api/invoices/reminders/create.py index bbef4b07d..4ef2a309e 100644 --- a/backend/api/invoices/reminders/create.py +++ b/backend/finance/api/invoices/reminders/create.py @@ -7,7 +7,7 @@ # from django.utils import timezone # # from backend.decorators import web_require_scopes -# from backend.models import Invoice, InvoiceReminder, QuotaUsage +# 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 # @@ -30,7 +30,7 @@ # @web_require_scopes("invoices:write", True, True) # def create_reminder_view(request: HtmxHttpRequest) -> HttpResponse: # if not request.htmx: -# return redirect("invoices:single:dashboard") +# return redirect("finance:invoices:single:dashboard") # # check_usage = quota_usage_check_under(request, "invoices-schedules", api=True, htmx=True) # diff --git a/backend/api/invoices/reminders/delete.py b/backend/finance/api/invoices/reminders/delete.py similarity index 97% rename from backend/api/invoices/reminders/delete.py rename to backend/finance/api/invoices/reminders/delete.py index 2e2384e84..8935b1c77 100644 --- a/backend/api/invoices/reminders/delete.py +++ b/backend/finance/api/invoices/reminders/delete.py @@ -5,7 +5,7 @@ # from django.views.decorators.http import require_http_methods # # from backend.decorators import feature_flag_check, web_require_scopes -# from backend.models import InvoiceReminder +# from backend.finance.models import InvoiceReminder # # from backend.types.htmx import HtmxHttpRequest # diff --git a/backend/api/invoices/reminders/fetch.py b/backend/finance/api/invoices/reminders/fetch.py similarity index 97% rename from backend/api/invoices/reminders/fetch.py rename to backend/finance/api/invoices/reminders/fetch.py index 48bd3c74d..d4afe81db 100644 --- a/backend/api/invoices/reminders/fetch.py +++ b/backend/finance/api/invoices/reminders/fetch.py @@ -5,8 +5,8 @@ from django_ratelimit.core import is_ratelimited from backend.decorators import feature_flag_check, web_require_scopes -from backend.models import Invoice -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import Invoice +from backend.core.types.htmx import HtmxHttpRequest @require_GET diff --git a/backend/api/invoices/reminders/urls.py b/backend/finance/api/invoices/reminders/urls.py similarity index 100% rename from backend/api/invoices/reminders/urls.py rename to backend/finance/api/invoices/reminders/urls.py diff --git a/backend/finance/api/invoices/single/__init__.py b/backend/finance/api/invoices/single/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/invoices/urls.py b/backend/finance/api/invoices/urls.py similarity index 95% rename from backend/api/invoices/urls.py rename to backend/finance/api/invoices/urls.py index d1951ffbc..40103ed38 100644 --- a/backend/api/invoices/urls.py +++ b/backend/finance/api/invoices/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include -from . import fetch, delete, edit, manage +from . import fetch, delete, edit from .create import set_destination from .create.services import add_service from .recurring.delete import delete_invoice_recurring_profile_endpoint @@ -24,7 +24,7 @@ path("edit//set_status//", edit.change_status, name="edit status"), path("edit//discount/", edit.edit_discount, name="edit discount"), path("fetch/", fetch.fetch_all_invoices, name="fetch"), - # path("", include("backend.api.invoices.reminders.urls")), + # path("", include("backend.finance.api.invoices.reminders.urls")), ] RECURRING_INVOICE_URLS = [ diff --git a/backend/finance/api/products/__init__.py b/backend/finance/api/products/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/products/create.py b/backend/finance/api/products/create.py similarity index 83% rename from backend/api/products/create.py rename to backend/finance/api/products/create.py index 2d5a25ddc..a1ba8b7d8 100644 --- a/backend/api/products/create.py +++ b/backend/finance/api/products/create.py @@ -1,9 +1,9 @@ from django.contrib import messages -from backend.api.products.fetch import fetch_products +from backend.finance.api.products.fetch import fetch_products from backend.decorators import web_require_scopes -from backend.models import InvoiceProduct -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import InvoiceProduct +from backend.core.types.htmx import HtmxHttpRequest @web_require_scopes("invoices:write", True, True) diff --git a/backend/api/products/fetch.py b/backend/finance/api/products/fetch.py similarity index 87% rename from backend/api/products/fetch.py rename to backend/finance/api/products/fetch.py index 8b6000612..5de0d1c5f 100644 --- a/backend/api/products/fetch.py +++ b/backend/finance/api/products/fetch.py @@ -2,8 +2,8 @@ from django.shortcuts import render from backend.decorators import web_require_scopes -from backend.models import InvoiceProduct -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import InvoiceProduct +from backend.core.types.htmx import HtmxHttpRequest @web_require_scopes("invoices:read", True, True) diff --git a/backend/api/products/urls.py b/backend/finance/api/products/urls.py similarity index 100% rename from backend/api/products/urls.py rename to backend/finance/api/products/urls.py diff --git a/backend/finance/api/receipts/__init__.py b/backend/finance/api/receipts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/receipts/delete.py b/backend/finance/api/receipts/delete.py similarity index 95% rename from backend/api/receipts/delete.py rename to backend/finance/api/receipts/delete.py index 9ca59b149..748ddece1 100644 --- a/backend/api/receipts/delete.py +++ b/backend/finance/api/receipts/delete.py @@ -6,7 +6,7 @@ from backend.decorators import web_require_scopes from backend.models import Receipt -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest @require_http_methods(["DELETE"]) diff --git a/backend/api/receipts/download.py b/backend/finance/api/receipts/download.py similarity index 95% rename from backend/api/receipts/download.py rename to backend/finance/api/receipts/download.py index 114f8b00e..70e199c25 100644 --- a/backend/api/receipts/download.py +++ b/backend/finance/api/receipts/download.py @@ -58,7 +58,7 @@ def generate_download_link(request, receipt_id): except Receipt.DoesNotExist: return HttpResponse("Receipt not found", status=404) token = ReceiptDownloadToken.objects.create(user=request.user, file=receipt) - download_link = request.build_absolute_uri(reverse("api:receipts:download_receipt", args=[token.token])) + download_link = request.build_absolute_uri(reverse("api:finance:receipts:download_receipt", args=[token.token])) response_data = { "download_link": download_link, diff --git a/backend/api/receipts/edit.py b/backend/finance/api/receipts/edit.py similarity index 100% rename from backend/api/receipts/edit.py rename to backend/finance/api/receipts/edit.py diff --git a/backend/api/receipts/fetch.py b/backend/finance/api/receipts/fetch.py similarity index 95% rename from backend/api/receipts/fetch.py rename to backend/finance/api/receipts/fetch.py index bf48ae020..84f56a053 100644 --- a/backend/api/receipts/fetch.py +++ b/backend/finance/api/receipts/fetch.py @@ -2,8 +2,8 @@ from django.shortcuts import render, redirect from backend.decorators import web_require_scopes -from backend.models import Receipt, User -from backend.types.htmx import HtmxHttpRequest +from backend.models import Receipt +from backend.core.types.htmx import HtmxHttpRequest @web_require_scopes("receipts:read", True, True) diff --git a/backend/api/receipts/new.py b/backend/finance/api/receipts/new.py similarity index 98% rename from backend/api/receipts/new.py rename to backend/finance/api/receipts/new.py index ff70c0e87..b7b1e20e9 100644 --- a/backend/api/receipts/new.py +++ b/backend/finance/api/receipts/new.py @@ -6,7 +6,7 @@ from backend.decorators import web_require_scopes, has_entitlements from backend.models import Receipt, QuotaUsage -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest @require_http_methods(["POST"]) diff --git a/backend/api/receipts/urls.py b/backend/finance/api/receipts/urls.py similarity index 100% rename from backend/api/receipts/urls.py rename to backend/finance/api/receipts/urls.py diff --git a/backend/finance/api/reports/__init__.py b/backend/finance/api/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/reports/fetch.py b/backend/finance/api/reports/fetch.py similarity index 92% rename from backend/api/reports/fetch.py rename to backend/finance/api/reports/fetch.py index 43cf9d530..ab2528829 100644 --- a/backend/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.types.requests import WebRequest +from backend.core.types.requests import WebRequest def fetch_reports_endpoint(request: WebRequest): diff --git a/backend/api/reports/generate.py b/backend/finance/api/reports/generate.py similarity index 80% rename from backend/api/reports/generate.py rename to backend/finance/api/reports/generate.py index 51ae1520b..4968ef8d1 100644 --- a/backend/api/reports/generate.py +++ b/backend/finance/api/reports/generate.py @@ -1,10 +1,9 @@ from django.contrib import messages -from django.http import HttpResponse -from django.shortcuts import render, redirect +from django.shortcuts import render from backend.decorators import web_require_scopes -from backend.service.reports.generate import generate_report -from backend.types.requests import WebRequest +from backend.core.service.reports.generate import generate_report +from backend.core.types.requests import WebRequest @web_require_scopes("invoices:write", True, True) diff --git a/backend/api/reports/urls.py b/backend/finance/api/reports/urls.py similarity index 100% rename from backend/api/reports/urls.py rename to backend/finance/api/reports/urls.py diff --git a/backend/finance/api/urls.py b/backend/finance/api/urls.py new file mode 100644 index 000000000..981d2b025 --- /dev/null +++ b/backend/finance/api/urls.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from django.urls import include +from django.urls import path + +urlpatterns = [ + path("receipts/", include("backend.finance.api.receipts.urls")), + path("invoices/", include("backend.finance.api.invoices.urls")), + path("products/", include("backend.finance.api.products.urls")), + path("reports/", include("backend.finance.api.reports.urls")), +] + +app_name = "finance" diff --git a/backend/finance/models.py b/backend/finance/models.py new file mode 100644 index 000000000..849881537 --- /dev/null +++ b/backend/finance/models.py @@ -0,0 +1,396 @@ +from __future__ import annotations +from datetime import datetime, date, timedelta +from decimal import Decimal +from uuid import uuid4 +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.managers import InvoiceRecurringProfile_WithItemsManager + +from backend.core.models import OwnerBase, UserSettings, _private_storage, USER_OR_ORGANIZATION_CONSTRAINT, User, ExpiresBase, Organization + + +class BotoSchedule(models.Model): + class BotoStatusTypes(models.TextChoices): + PENDING = "pending", "Pending" + CREATING = "creating", "Creating" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + DELETING = "deleting", "Deleting" + CANCELLED = "cancelled", "Cancelled" + + created_at = models.DateTimeField(auto_now_add=True) + + boto_schedule_arn = models.CharField(max_length=2048, null=True, blank=True) + boto_schedule_uuid = models.UUIDField(default=None, null=True, blank=True) + boto_last_updated = models.DateTimeField(auto_now=True) + + received = models.BooleanField(default=False) + boto_schedule_status = models.CharField(max_length=100, choices=BotoStatusTypes.choices, default=BotoStatusTypes.PENDING) + + class Meta: + abstract = True + + def set_status(self, status, save=True): + self.status = status + if save: + self.save() + return self + + def set_received(self, status: bool = True, save=True): + self.received = status + if save: + self.save() + return self + + +class InvoiceProduct(OwnerBase): + name = models.CharField(max_length=50) + description = models.CharField(max_length=100) + quantity = models.IntegerField() + rate = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) + + +class InvoiceItem(models.Model): + # objects = InvoiceItemManager() + + name = models.CharField(max_length=50) + description = models.CharField(max_length=100) + is_service = models.BooleanField(default=True) + # from + # if service + hours = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) + price_per_hour = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) + # if product + price = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) + + def get_total_price(self): + return self.hours * self.price_per_hour if self.is_service else self.price + + def __str__(self): + return self.description + + +class InvoiceBase(OwnerBase): + client_to = models.ForeignKey(Client, on_delete=models.SET_NULL, blank=True, null=True) + + client_name = models.CharField(max_length=100, blank=True, null=True) + client_email = models.EmailField(blank=True, null=True) + client_company = models.CharField(max_length=100, blank=True, null=True) + client_address = models.CharField(max_length=100, blank=True, null=True) + client_city = models.CharField(max_length=100, blank=True, null=True) + client_county = models.CharField(max_length=100, blank=True, null=True) + client_country = models.CharField(max_length=100, blank=True, null=True) + client_is_representative = models.BooleanField(default=False) + + self_name = models.CharField(max_length=100, blank=True, null=True) + self_company = models.CharField(max_length=100, blank=True, null=True) + self_address = models.CharField(max_length=100, blank=True, null=True) + self_city = models.CharField(max_length=100, blank=True, null=True) + self_county = models.CharField(max_length=100, blank=True, null=True) + self_country = models.CharField(max_length=100, blank=True, null=True) + + sort_code = models.CharField(max_length=8, blank=True, null=True) # 12-34-56 + account_holder_name = models.CharField(max_length=100, blank=True, null=True) + account_number = models.CharField(max_length=100, blank=True, null=True) + reference = models.CharField(max_length=100, blank=True, null=True) + invoice_number = models.CharField(max_length=100, blank=True, null=True) + vat_number = models.CharField(max_length=100, blank=True, null=True) + logo = models.ImageField( + upload_to="invoice_logos", + storage=_private_storage, + blank=True, + null=True, + ) + notes = models.TextField(blank=True, null=True) + + items = models.ManyToManyField(InvoiceItem, blank=True) + currency = models.CharField( + max_length=3, + default="GBP", + choices=[(code, info["name"]) for code, info in UserSettings.CURRENCIES.items()], + ) + date_created = models.DateTimeField(auto_now_add=True) + date_issued = models.DateField(blank=True, null=True) + + discount_amount = models.DecimalField(max_digits=15, default=0, decimal_places=2) + discount_percentage = models.DecimalField(default=0, max_digits=5, decimal_places=2, validators=[MaxValueValidator(100)]) + + class Meta: + abstract = True + constraints = [USER_OR_ORGANIZATION_CONSTRAINT()] + + 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 + + def get_currency_symbol(self): + return UserSettings.CURRENCIES.get(self.currency, {}).get("symbol", "$") + + +class Invoice(InvoiceBase): + # objects = InvoiceManager() + + STATUS_CHOICES = ( + ("draft", "Draft"), + # ("ready", "Ready"), + ("pending", "Pending"), + ("paid", "Paid"), + ) + + invoice_id = models.IntegerField(unique=True, blank=True, null=True) # todo: add + date_due = models.DateField() + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft") + invoice_recurring_profile = models.ForeignKey( + "InvoiceRecurringProfile", related_name="generated_invoices", on_delete=models.SET_NULL, blank=True, null=True + ) + + def __str__(self): + invoice_id = self.invoice_id or self.id + if self.client_name: + client = self.client_name + elif self.client_to: + client = self.client_to.name + else: + client = "Unknown Client" + + return f"Invoice #{invoice_id} for {client}" + + @property + def dynamic_status(self): + if self.status == "pending" and self.is_overdue: + return "overdue" + else: + return self.status + + @property + def is_overdue(self): + return self.date_due and timezone.now().date() > self.date_due + + @property + def get_to_details(self) -> tuple[str, dict[str, str | None]] | tuple[str, Client]: + """ + Returns the client details for the invoice + "client" and Client object if client_to + "manual" and dict of details if client_name + """ + if self.client_to: + return "client", self.client_to + else: + return "manual", {"name": self.client_name, "company": self.client_company, "email": self.client_email} + + def get_subtotal(self) -> Decimal: + subtotal = 0 + for item in self.items.all(): + subtotal += item.get_total_price() + return Decimal(round(subtotal, 2)) + + def get_tax(self, amount: Decimal = Decimal(0.00)) -> Decimal: + amount = amount or self.get_subtotal() + if self.vat_number: + return Decimal(round(amount * Decimal(0.2), 2)) + return Decimal(0) + + def get_percentage_amount(self, subtotal: Decimal = Decimal(0.00)) -> Decimal: + total = subtotal or self.get_subtotal() + + if self.discount_percentage > 0: + return round(total * (self.discount_percentage / 100), 2) + return Decimal(0) + + def get_total_price(self) -> Decimal: + total = self.get_subtotal() or Decimal(0) + + total -= self.get_percentage_amount() + + discount_amount = self.discount_amount + + total -= discount_amount + + if 0 > total: + total = Decimal(0) + else: + total -= self.get_tax(total) + + return Decimal(round(total, 2)) + + +class InvoiceRecurringProfile(InvoiceBase, BotoSchedule): + with_items = InvoiceRecurringProfile_WithItemsManager() + + class Frequencies(models.TextChoices): + WEEKLY = "weekly", "Weekly" + MONTHLY = "monthly", "Monthly" + YEARLY = "yearly", "Yearly" + + STATUS_CHOICES = ( + ("ongoing", "Ongoing"), + ("paused", "paused"), + ("cancelled", "cancelled"), + ) + + active = models.BooleanField(default=True) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="paused") + + frequency = models.CharField(max_length=20, choices=Frequencies.choices, default=Frequencies.MONTHLY) + end_date = models.DateField(blank=True, null=True) + due_after_days = models.PositiveSmallIntegerField(default=7) + + day_of_week = models.PositiveSmallIntegerField(null=True, blank=True) + day_of_month = models.PositiveSmallIntegerField(null=True, blank=True) + month_of_year = models.PositiveSmallIntegerField(null=True, blank=True) + + def get_total_price(self) -> Decimal: + total = Decimal(0) + for invoice in self.generated_invoices.all(): + total += invoice.get_total_price() + return Decimal(round(total, 2)) + + def get_last_invoice(self) -> Invoice | None: + return self.generated_invoices.order_by("-id").first() + + def next_invoice_issue_date(self) -> date: + last_invoice = self.get_last_invoice() + + if not last_invoice: + if self.date_issued is None: + return datetime.now().date() + return max(self.date_issued, datetime.now().date()) + + last_invoice_date_issued: date = last_invoice.date_issued or datetime.now().date() + + match self.frequency: + case "weekly": + return last_invoice_date_issued + timedelta(days=7) + case "monthly": + return date(year=last_invoice_date_issued.year, month=last_invoice_date_issued.month + 1, day=last_invoice_date_issued.day) + case "yearly": + return date(year=last_invoice_date_issued.year + 1, month=last_invoice_date_issued.month, day=last_invoice_date_issued.day) + case _: + return datetime.now().date() + + def next_invoice_due_date(self, account_defaults: DefaultValues, 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) + case account_defaults.InvoiceDueDateType.date_following: + return datetime(from_date.year, from_date.month + 1, account_defaults.invoice_due_date_value) + case account_defaults.InvoiceDueDateType.date_current: + return datetime(from_date.year, from_date.month, account_defaults.invoice_due_date_value) + case _: + return from_date + timedelta(days=7) + + +class InvoiceURL(ExpiresBase): + uuid = ShortUUIDField(length=8, primary_key=True) + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="invoice_urls") + created_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + system_created = models.BooleanField(default=False) + created_on = models.DateTimeField(auto_now_add=True) + + @property + def get_created_by(self): + if self.created_by: + return self.created_by.first_name or f"USR #{self.created_by.id}" + else: + return "SYSTEM" + + def set_expires(self): + self.expires = timezone.now() + timezone.timedelta(days=7) + + def __str__(self): + return str(self.invoice.id) + + class Meta: + verbose_name = "Invoice URL" + verbose_name_plural = "Invoice URLs" + + +class InvoiceReminder(BotoSchedule): + class ReminderTypes(models.TextChoices): + BEFORE_DUE = "before_due", "Before Due" + AFTER_DUE = "after_due", "After Due" + ON_OVERDUE = "on_overdue", "On Overdue" + + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="invoice_reminders") + days = models.PositiveIntegerField(blank=True, null=True) + reminder_type = models.CharField(max_length=100, choices=ReminderTypes.choices, default=ReminderTypes.BEFORE_DUE) + + class Meta: + verbose_name = "Invoice Reminder" + verbose_name_plural = "Invoice Reminders" + + def __str__(self): + days = (str(self.days) + "d" if self.days else " ").center(8, "ㅤ") + return f"({self.id}) Reminder for (#{self.invoice_id}) {days} {self.reminder_type}" + + +class MonthlyReportRow(models.Model): + date = models.DateField() + reference_number = models.CharField(max_length=100) + item_type = models.CharField(max_length=100) + + client_name = models.CharField(max_length=64, blank=True, null=True) + client = models.ForeignKey(Client, on_delete=models.CASCADE, blank=True, null=True) + + paid_in = models.DecimalField(max_digits=15, decimal_places=2, default=0) + paid_out = models.DecimalField(max_digits=15, decimal_places=2, default=0) + + +class MonthlyReport(OwnerBase): + uuid = models.UUIDField(default=uuid4, editable=False, unique=True) + name = models.CharField(max_length=100, blank=True, null=True) + items = models.ManyToManyField(MonthlyReportRow, blank=True) + + profit = models.DecimalField(max_digits=15, decimal_places=2, default=0) + invoices_sent = models.PositiveIntegerField(default=0) + + start_date = models.DateField() + end_date = models.DateField() + + recurring_customers = models.PositiveIntegerField(default=0) + payments_in = models.DecimalField(max_digits=15, decimal_places=2, default=0) + payments_out = models.DecimalField(max_digits=15, decimal_places=2, default=0) + + currency = models.CharField( + max_length=3, + default="GBP", + choices=[(code, info["name"]) for code, info in UserSettings.CURRENCIES.items()], + ) + + def __str__(self): + return self.name or str(self.uuid)[:8] + + def get_currency_symbol(self): + return UserSettings.CURRENCIES.get(self.currency, {}).get("symbol", "$") + + +class Receipt(OwnerBase): + name = models.CharField(max_length=100) + image = models.ImageField(upload_to="receipts", storage=_private_storage) + total_price = models.FloatField(null=True, blank=True) + date = models.DateField(null=True, blank=True) + date_uploaded = models.DateTimeField(auto_now_add=True) + receipt_parsed = models.JSONField(null=True, blank=True) + merchant_store = models.CharField(max_length=255, blank=True, null=True) + purchase_category = models.CharField(max_length=200, blank=True, null=True) + + def __str__(self): + return f"{self.name} - {self.date} ({self.total_price})" + + def has_access(self, actor: User | Organization) -> bool: + return self.owner == actor + + +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) diff --git a/backend/finance/signals/__init__.py b/backend/finance/signals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/signals/core_signals/invoices/schedules.py b/backend/finance/signals/schedules.py similarity index 75% rename from backend/signals/core_signals/invoices/schedules.py rename to backend/finance/signals/schedules.py index a66bf6f0a..5a101fb7b 100644 --- a/backend/signals/core_signals/invoices/schedules.py +++ b/backend/finance/signals/schedules.py @@ -3,10 +3,10 @@ from django.dispatch import receiver from django.db.models.signals import post_save -from backend.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.service.boto3.scheduler.update_schedule import update_boto_schedule +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.models import InvoiceRecurringProfile +from backend.finance.models import InvoiceRecurringProfile logger = logging.getLogger(__name__) diff --git a/backend/finance/views/__init__.py b/backend/finance/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/finance/views/invoices/__init__.py b/backend/finance/views/invoices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/invoices/handler.py b/backend/finance/views/invoices/handler.py similarity index 93% rename from backend/views/core/invoices/handler.py rename to backend/finance/views/invoices/handler.py index 70d55ca9c..64801d347 100644 --- a/backend/views/core/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.types.requests import WebRequest +from backend.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/__init__.py b/backend/finance/views/invoices/recurring/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/invoices/recurring/create.py b/backend/finance/views/invoices/recurring/create.py similarity index 58% rename from backend/views/core/invoices/recurring/create.py rename to backend/finance/views/invoices/recurring/create.py index 361a893f5..abc4f086a 100644 --- a/backend/views/core/invoices/recurring/create.py +++ b/backend/finance/views/invoices/recurring/create.py @@ -1,18 +1,17 @@ from django.contrib import messages -from django.shortcuts import redirect, render -from django.urls import reverse +from django.shortcuts import redirect from django.views.decorators.http import require_http_methods -from backend.models import InvoiceRecurringProfile +from backend.finance.models import InvoiceRecurringProfile from backend.decorators import web_require_scopes -from backend.service import BOTO3_HANDLER -from backend.service.asyn_tasks.tasks import Task -from backend.service.boto3.scheduler.create_schedule import create_boto_schedule -from backend.service.invoices.common.create.create import create_invoice_items -from backend.service.invoices.recurring.create.get_page import get_invoice_context -from backend.service.invoices.recurring.create.save import save_invoice -from backend.types.requests import WebRequest -from backend.views.core.invoices.handler import invoices_core_handler +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 backend.finance.views.invoices.handler import invoices_core_handler @require_http_methods(["GET", "POST"]) @@ -23,18 +22,18 @@ def create_recurring_invoice_endpoint_handler(request: WebRequest): @require_http_methods(["GET"]) -@web_require_scopes("invoices:read", False, False, "invoices:recurring:dashboard") +@web_require_scopes("invoices:read", False, False, "finance:invoices:recurring:dashboard") def create_invoice_page_endpoint(request: WebRequest): if not BOTO3_HANDLER.initiated: messages.error(request, "Something went wrong with the recurring service, please try again later or contact an administrator.") - response = redirect("invoices:recurring:dashboard") + response = redirect("finance:invoices:recurring:dashboard") return response context = get_invoice_context(request) | {"InvoiceRecurringProfile": InvoiceRecurringProfile} return invoices_core_handler(request, "pages/invoices/create/create_recurring.html", context) @require_http_methods(["POST"]) -@web_require_scopes("invoices:write", False, False, "invoices:recurring:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:recurring:dashboard") def create_invoice_post_endpoint(request: WebRequest): invoice_items = create_invoice_items(request) invoice_response = save_invoice(request, invoice_items) @@ -42,4 +41,4 @@ def create_invoice_post_endpoint(request: WebRequest): messages.error(request, invoice_response.error_message) return invoices_core_handler(request, "pages/invoices/create/create_recurring.html", {"autohide": False}) Task().queue_task(create_boto_schedule, invoice_response.response.pk) - return redirect("invoices:recurring:dashboard") + return redirect("finance:invoices:recurring:dashboard") diff --git a/backend/views/core/invoices/recurring/dashboard.py b/backend/finance/views/invoices/recurring/dashboard.py similarity index 64% rename from backend/views/core/invoices/recurring/dashboard.py rename to backend/finance/views/invoices/recurring/dashboard.py index 65e256177..8ff75ff2b 100644 --- a/backend/views/core/invoices/recurring/dashboard.py +++ b/backend/finance/views/invoices/recurring/dashboard.py @@ -1,11 +1,8 @@ -from django.contrib import messages -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 Invoice -from backend.types.requests import WebRequest -from backend.views.core.invoices.handler import invoices_core_handler +from backend.core.types.requests import WebRequest +from backend.finance.views.invoices.handler import invoices_core_handler @require_http_methods(["GET"]) diff --git a/backend/views/core/invoices/recurring/edit.py b/backend/finance/views/invoices/recurring/edit.py similarity index 90% rename from backend/views/core/invoices/recurring/edit.py rename to backend/finance/views/invoices/recurring/edit.py index 6b85a72ea..a3493c534 100644 --- a/backend/views/core/invoices/recurring/edit.py +++ b/backend/finance/views/invoices/recurring/edit.py @@ -3,10 +3,9 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes, has_entitlements -from backend.models import InvoiceRecurringProfile -from backend.service.invoices.recurring.create.get_page import get_invoice_context -from backend.service.invoices.recurring.get import get_invoice_profile, GetRecurringSetServiceResponse -from backend.views.core.invoices.handler import invoices_core_handler +from backend.finance.models import InvoiceRecurringProfile +from backend.core.service.invoices.recurring.get import get_invoice_profile +from backend.finance.views.invoices.handler import invoices_core_handler # RELATED PATH FILES : \frontend\templates\pages\invoices\dashboard\_fetch_body.html, \backend\urls.py @@ -66,7 +65,7 @@ def invoice_get_existing_data(invoice_obj: InvoiceRecurringProfile): # gets invoice object from invoice id, convert obj to dict, and renders edit.html while passing the stored invoice values to frontend @require_http_methods(["GET"]) @has_entitlements("invoice-schedules") -@web_require_scopes("invoices:write", False, False, "invoices:recurring:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:recurring:dashboard") def invoice_edit_page_endpoint(request, invoice_profile_id): get_response = get_invoice_profile(request, invoice_profile_id) diff --git a/backend/views/core/invoices/recurring/overview.py b/backend/finance/views/invoices/recurring/overview.py similarity index 81% rename from backend/views/core/invoices/recurring/overview.py rename to backend/finance/views/invoices/recurring/overview.py index 54fa1eef3..8b2bb9010 100644 --- a/backend/views/core/invoices/recurring/overview.py +++ b/backend/finance/views/invoices/recurring/overview.py @@ -1,10 +1,7 @@ -from django.db.models import Subquery - from backend.decorators import * from backend.models import * -from backend.service.defaults.get import get_account_defaults -from backend.types.htmx import HtmxHttpRequest -from backend.views.core.invoices.handler import invoices_core_handler +from backend.core.service.defaults.get import get_account_defaults +from backend.finance.views.invoices.handler import invoices_core_handler @web_require_scopes("invoices:read", False, False, "dashboard") @@ -12,13 +9,13 @@ def invoices_dashboard(request: WebRequest): return render(request, "pages/invoices/recurring/dashboard/dashboard.html") -@web_require_scopes("invoices:read", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:read", False, False, "finance:invoices:single:dashboard") def manage_recurring_invoice_profile_endpoint(request: WebRequest, invoice_profile_id: str): context: dict = {} if not invoice_profile_id.isnumeric(): messages.error(request, "Invalid invoice ID") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") invoice_profile = InvoiceRecurringProfile.with_items.get(id=invoice_profile_id, active=True) @@ -49,10 +46,10 @@ def manage_recurring_invoice_profile_endpoint(request: WebRequest, invoice_profi if not invoice_profile: messages.error(request, "Invalid invoice profile") - return redirect("invoices:recurring:dashboard") + return redirect("finance:invoices:recurring:dashboard") if not invoice_profile.has_access(request.user): messages.error(request, "You do not have access to this invoice profile") - return redirect("invoices:recurring:dashboard") + return redirect("finance:invoices:recurring:dashboard") return invoices_core_handler(request, "pages/invoices/recurring/dashboard/manage.html", context | {"invoiceProfile": invoice_profile}) diff --git a/backend/finance/views/invoices/single/__init__.py b/backend/finance/views/invoices/single/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/invoices/single/create.py b/backend/finance/views/invoices/single/create.py similarity index 64% rename from backend/views/core/invoices/single/create.py rename to backend/finance/views/invoices/single/create.py index 1652621f7..b77db574d 100644 --- a/backend/views/core/invoices/single/create.py +++ b/backend/finance/views/invoices/single/create.py @@ -2,10 +2,10 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes, has_entitlements -from backend.service.invoices.single.create.create import create_invoice_items, save_invoice -from backend.service.invoices.single.create.get_page import get_invoice_context -from backend.types.requests import WebRequest -from backend.views.core.invoices.handler import invoices_core_handler +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 backend.finance.views.invoices.handler import invoices_core_handler @require_http_methods(["GET", "POST"]) @@ -17,7 +17,7 @@ def create_single_invoice_endpoint_handler(request: WebRequest): @require_http_methods(["GET"]) @has_entitlements("invoices") -@web_require_scopes("invoices:read", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:read", False, False, "finance:invoices:single:dashboard") def create_invoice_page_endpoint(request: WebRequest): context = get_invoice_context(request) return invoices_core_handler(request, "pages/invoices/create/create_single.html", context) @@ -25,10 +25,10 @@ def create_invoice_page_endpoint(request: WebRequest): @require_http_methods(["POST"]) @has_entitlements("invoices") -@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:single:dashboard") def create_invoice_post_endpoint(request: WebRequest): invoice_items = create_invoice_items(request) invoice = save_invoice(request, invoice_items) if not invoice: return invoices_core_handler(request, "pages/invoices/create/create_single.html") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") diff --git a/backend/views/core/invoices/single/dashboard.py b/backend/finance/views/invoices/single/dashboard.py similarity index 71% rename from backend/views/core/invoices/single/dashboard.py rename to backend/finance/views/invoices/single/dashboard.py index 2ad4f67f4..96c9f51f6 100644 --- a/backend/views/core/invoices/single/dashboard.py +++ b/backend/finance/views/invoices/single/dashboard.py @@ -3,9 +3,9 @@ from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.models import Invoice -from backend.types.requests import WebRequest -from backend.views.core.invoices.handler import invoices_core_handler +from backend.finance.models import Invoice +from backend.core.types.requests import WebRequest +from backend.finance.views.invoices.handler import invoices_core_handler @require_http_methods(["GET"]) @@ -17,13 +17,13 @@ def invoices_single_dashboard_endpoint(request: WebRequest): @web_require_scopes("invoices:read", False, False, "dashboard") def invoices_dashboard_id(request: WebRequest, invoice_id): if invoice_id == "create": - return redirect("invoices:single:create") + return redirect("finance:invoices:single:create") elif not isinstance(invoice_id, int): messages.error(request, "Invalid invoice ID") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") try: Invoice.objects.get(id=invoice_id) except Invoice.DoesNotExist: - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") return render(request, "pages/invoices/single/dashboard/dashboard.html") diff --git a/backend/views/core/invoices/single/edit.py b/backend/finance/views/invoices/single/edit.py similarity index 95% rename from backend/views/core/invoices/single/edit.py rename to backend/finance/views/invoices/single/edit.py index 8fc761a58..353a0df9d 100644 --- a/backend/views/core/invoices/single/edit.py +++ b/backend/finance/views/invoices/single/edit.py @@ -2,13 +2,12 @@ from django.contrib import messages from django.http import JsonResponse -from django.http.response import HttpResponse from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods from backend.decorators import web_require_scopes -from backend.models import Invoice, Client, InvoiceItem -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import Invoice, Client, InvoiceItem +from backend.core.types.htmx import HtmxHttpRequest # RELATED PATH FILES : \frontend\templates\pages\invoices\dashboard\_fetch_body.html, \backend\urls.py @@ -67,10 +66,10 @@ def invoice_edit_page_get(request, invoice_id): if not invoice.has_access(request.user): messages.error(request, "You are not permitted to edit this invoice") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") except Invoice.DoesNotExist: messages.error(request, "Invoice not found") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") # use to populate fields with existing data in edit_from_destination.html AND edit_to_destination.html data_to_populate = invoice_get_existing_data(invoice) @@ -158,7 +157,7 @@ def edit_invoice(request: HtmxHttpRequest, invoice_id): # decorator & view function for rendering page and updating invoice items in the backend @require_http_methods(["GET", "POST"]) -@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:single:dashboard") def edit_invoice_page(request: HtmxHttpRequest, invoice_id): if request.method == "POST": return edit_invoice(request, invoice_id) diff --git a/backend/views/core/invoices/single/manage_access.py b/backend/finance/views/invoices/single/manage_access.py similarity index 77% rename from backend/views/core/invoices/single/manage_access.py rename to backend/finance/views/invoices/single/manage_access.py index c62000a71..e5f3ea413 100644 --- a/backend/views/core/invoices/single/manage_access.py +++ b/backend/finance/views/invoices/single/manage_access.py @@ -3,18 +3,18 @@ from django.shortcuts import redirect, render from backend.decorators import web_require_scopes -from backend.models import Invoice, InvoiceURL, QuotaLimit -from backend.service.invoices.single.get_invoice import get_invoice_by_actor -from backend.types.htmx import HtmxHttpRequest -from backend.types.requests import WebRequest +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 -@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:single:dashboard") def manage_access(request: WebRequest, invoice_id): invoice_resp = get_invoice_by_actor(request.actor, invoice_id, ["invoice_urls"]) if invoice_resp.failed: messages.error(request, "Invoice not found") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") all_access_codes = invoice_resp.response.invoice_urls.values_list("uuid", "created_on").order_by("-created_on") @@ -25,10 +25,10 @@ def manage_access(request: WebRequest, invoice_id): ) -@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:single:dashboard") def create_code(request: WebRequest, invoice_id): if not request.htmx: - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") if request.method != "POST": return HttpResponse("Invalid request", status=400) @@ -36,7 +36,7 @@ def create_code(request: WebRequest, invoice_id): invoice_resp = get_invoice_by_actor(request.actor, invoice_id, ["invoice_urls"]) if invoice_resp.failed: messages.error(request, "Invoice not found") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") code = InvoiceURL.objects.create(invoice=invoice_resp.response, created_by=request.user) @@ -49,7 +49,7 @@ def create_code(request: WebRequest, invoice_id): ) -@web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:write", False, False, "finance:invoices:single:dashboard") def delete_code(request: HtmxHttpRequest, code): if request.method != "DELETE" or not request.htmx: return HttpResponse("Request invalid", status=400) diff --git a/backend/views/core/invoices/single/overview.py b/backend/finance/views/invoices/single/overview.py similarity index 81% rename from backend/views/core/invoices/single/overview.py rename to backend/finance/views/invoices/single/overview.py index 6411eed43..f7ca164ac 100644 --- a/backend/views/core/invoices/single/overview.py +++ b/backend/finance/views/invoices/single/overview.py @@ -2,8 +2,7 @@ from backend.decorators import * from backend.models import * -from backend.types.htmx import HtmxHttpRequest -from backend.views.core.invoices.handler import invoices_core_handler +from backend.finance.views.invoices.handler import invoices_core_handler @web_require_scopes("invoices:read", False, False, "dashboard") @@ -11,24 +10,24 @@ def invoices_dashboard(request: WebRequest): return render(request, "pages/invoices/single/dashboard/dashboard.html") -@web_require_scopes("invoices:read", False, False, "invoices:single:dashboard") +@web_require_scopes("invoices:read", False, False, "finance:invoices:single:dashboard") def manage_invoice(request: WebRequest, invoice_id: str): context: dict = {} if not invoice_id.isnumeric(): messages.error(request, "Invalid invoice ID") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") invoice = Invoice.objects.get(id=invoice_id) if not invoice: - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") if not invoice.has_access(request.user): - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") # "clone to recurring profile" url builder - base_url = reverse("invoices:recurring:create") + base_url = reverse("finance:invoices:recurring:create") query_params = { "frequency": "monthly", "day_of_month": 15, diff --git a/backend/views/core/invoices/single/schedule.py b/backend/finance/views/invoices/single/schedule.py similarity index 90% rename from backend/views/core/invoices/single/schedule.py rename to backend/finance/views/invoices/single/schedule.py index acbebe3cf..39317965c 100644 --- a/backend/views/core/invoices/single/schedule.py +++ b/backend/finance/views/invoices/single/schedule.py @@ -3,7 +3,7 @@ # from django.shortcuts import render, redirect # # from backend.decorators import feature_flag_check, web_require_scopes -# from backend.models import Invoice, QuotaLimit +# from backend.finance.models import Invoice, QuotaLimit # from backend.types.htmx import HtmxHttpRequest # # @@ -16,7 +16,7 @@ # context["invoice"] = invoice # except Invoice.DoesNotExist: # messages.error(request, "Invoice not found") -# return redirect("invoices:single:dashboard") +# return redirect("finance:invoices:single:dashboard") # # context["schedules"] = invoice.onetime_invoice_schedules.order_by("due").only("id", "due", "status") # diff --git a/backend/views/core/invoices/single/view.py b/backend/finance/views/invoices/single/view.py similarity index 87% rename from backend/views/core/invoices/single/view.py rename to backend/finance/views/invoices/single/view.py index cf8e3b4b6..a12d4e992 100644 --- a/backend/views/core/invoices/single/view.py +++ b/backend/finance/views/invoices/single/view.py @@ -8,9 +8,8 @@ from login_required import login_not_required from backend.decorators import web_require_scopes -from backend.models import Invoice -from backend.models import InvoiceURL -from backend.types.htmx import HtmxHttpRequest +from backend.finance.models import Invoice, InvoiceURL +from backend.core.types.htmx import HtmxHttpRequest @web_require_scopes("invoices:read", False, False, "dashboard") @@ -19,11 +18,11 @@ def preview(request: HtmxHttpRequest, invoice_id: str) -> HttpResponse: if not invoice: messages.error(request, "Invoice not found") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") if not invoice.has_access(request.user): messages.error(request, "You don't have access to this invoice") - return redirect("invoices:single:dashboard") + return redirect("finance:invoices:single:dashboard") # if response := generate_pdf(invoice, "inline"): # return response diff --git a/backend/views/core/invoices/urls.py b/backend/finance/views/invoices/urls.py similarity index 64% rename from backend/views/core/invoices/urls.py rename to backend/finance/views/invoices/urls.py index cc9918cdb..fe9ca7100 100644 --- a/backend/views/core/invoices/urls.py +++ b/backend/finance/views/invoices/urls.py @@ -1,13 +1,16 @@ from django.urls import path from django.urls.conf import include -from .recurring.create import create_recurring_invoice_endpoint_handler -from .recurring.dashboard import invoices_recurring_dashboard_endpoint -from .recurring.edit import invoice_edit_page_endpoint -from .single import schedule, edit, create, view, manage_access -from .single.dashboard import invoices_single_dashboard_endpoint -from .single.overview import manage_invoice -from .recurring.overview import manage_recurring_invoice_profile_endpoint +from backend.finance.views.invoices.recurring.create import create_recurring_invoice_endpoint_handler +from backend.finance.views.invoices.recurring.dashboard import invoices_recurring_dashboard_endpoint +from backend.finance.views.invoices.recurring.edit import invoice_edit_page_endpoint +from backend.finance.views.invoices.single.create import create_single_invoice_endpoint_handler +from backend.finance.views.invoices.single.dashboard import invoices_single_dashboard_endpoint +from backend.finance.views.invoices.recurring.overview import manage_recurring_invoice_profile_endpoint +from backend.finance.views.invoices.single.edit import edit_invoice_page +from backend.finance.views.invoices.single import manage_access +from backend.finance.views.invoices.single.overview import manage_invoice +from backend.finance.views.invoices.single.view import preview SINGLE_INVOICE_URLS = [ # path( @@ -32,19 +35,19 @@ ), path( "/preview/", - view.preview, + preview, name="preview", ), path( "/edit/", - edit.edit_invoice_page, + edit_invoice_page, # invoices.edit.invoice_edit_page_get, name="edit", ), path("", invoices_single_dashboard_endpoint, name="dashboard"), path( "create/", - create.create_single_invoice_endpoint_handler, + create_single_invoice_endpoint_handler, name="create", ), path( diff --git a/backend/finance/views/receipts/__init__.py b/backend/finance/views/receipts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/receipts/dashboard.py b/backend/finance/views/receipts/dashboard.py similarity index 86% rename from backend/views/core/receipts/dashboard.py rename to backend/finance/views/receipts/dashboard.py index 3448c87be..b810764e3 100644 --- a/backend/views/core/receipts/dashboard.py +++ b/backend/finance/views/receipts/dashboard.py @@ -2,7 +2,7 @@ from django.shortcuts import render from backend.decorators import web_require_scopes -from backend.types.htmx import HtmxHttpRequest +from backend.core.types.htmx import HtmxHttpRequest @login_required diff --git a/backend/finance/views/receipts/urls.py b/backend/finance/views/receipts/urls.py new file mode 100644 index 000000000..45f1998b0 --- /dev/null +++ b/backend/finance/views/receipts/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .dashboard import receipts_dashboard + +urlpatterns = [path("", receipts_dashboard, name="dashboard")] + +app_name = "receipts" diff --git a/backend/finance/views/reports/__init__.py b/backend/finance/views/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/finance/views/reports/dashboard.py b/backend/finance/views/reports/dashboard.py new file mode 100644 index 000000000..5ce098bd1 --- /dev/null +++ b/backend/finance/views/reports/dashboard.py @@ -0,0 +1,6 @@ +from backend.core.types.requests import WebRequest +from django.shortcuts import render + + +def view_reports_endpoint(request: WebRequest): + return render(request, "pages/reports/dashboard.html") diff --git a/backend/views/core/reports/urls.py b/backend/finance/views/reports/urls.py similarity index 100% rename from backend/views/core/reports/urls.py rename to backend/finance/views/reports/urls.py diff --git a/backend/views/core/reports/view.py b/backend/finance/views/reports/view.py similarity index 68% rename from backend/views/core/reports/view.py rename to backend/finance/views/reports/view.py index d9c4ba6a5..1041ab29d 100644 --- a/backend/views/core/reports/view.py +++ b/backend/finance/views/reports/view.py @@ -1,10 +1,7 @@ -from datetime import date - from django.contrib import messages -from backend.service.reports.generate import generate_report -from backend.service.reports.get import get_report -from backend.types.requests import WebRequest +from backend.core.service.reports.get import get_report +from backend.core.types.requests import WebRequest from django.shortcuts import render, redirect diff --git a/backend/finance/views/urls.py b/backend/finance/views/urls.py new file mode 100644 index 000000000..4b01e7c82 --- /dev/null +++ b/backend/finance/views/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from django.urls.conf import include + +from backend.finance.views.invoices.single.dashboard import invoices_single_dashboard_endpoint + +urlpatterns = [ + path("invoices/", include("backend.finance.views.invoices.urls")), + path("reports/", include("backend.finance.views.reports.urls")), + path("receipts/", include("backend.finance.views.receipts.urls")), + path("", invoices_single_dashboard_endpoint), +] + +app_name = "finance" diff --git a/backend/middleware.py b/backend/middleware.py index 9b4b89190..2e5ec6908 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -4,10 +4,9 @@ from django.db import connection, OperationalError from django.http import HttpResponse -from backend.models import User, Organization -from backend.types.htmx import HtmxAnyHttpRequest -from backend.types.requests import WebRequest -import re +from backend.models import User +from backend.core.types.htmx import HtmxAnyHttpRequest +from backend.core.types.requests import WebRequest class HealthCheckMiddleware: diff --git a/backend/migrations/0001_initial.py b/backend/migrations/0001_initial.py index b6572b794..f9cff14bb 100644 --- a/backend/migrations/0001_initial.py +++ b/backend/migrations/0001_initial.py @@ -114,7 +114,7 @@ class Migration(migrations.Migration): "abstract": False, }, managers=[ - ("objects", backend.models.CustomUserManager()), + ("objects", backend.core.models.CustomUserManager()), ], ), migrations.CreateModel( 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 index ae25f4c2c..a192c24d6 100644 --- a/backend/migrations/0022_loginlog_service_alter_verificationcodes_expiry_and_more.py +++ b/backend/migrations/0022_loginlog_service_alter_verificationcodes_expiry_and_more.py @@ -20,11 +20,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="verificationcodes", name="expiry", - field=models.DateTimeField(default=backend.models.add_3hrs_from_now), + field=models.DateTimeField(default=backend.core.models.add_3hrs_from_now), ), migrations.AlterField( model_name="verificationcodes", name="token", - field=models.TextField(default=backend.models.RandomCode, editable=False), + 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 index f472a2c9f..5af19f8c8 100644 --- a/backend/migrations/0023_apikey_invoiceonetimeschedule.py +++ b/backend/migrations/0023_apikey_invoiceonetimeschedule.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): 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.models.RandomAPICode, max_length=100)), + ("key", models.CharField(default=backend.core.models.RandomAPICode, max_length=100)), ("last_used", models.DateTimeField(auto_now_add=True)), ], options={ 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 index 332f04292..6b0548fbb 100644 --- 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 @@ -46,12 +46,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="invoice", name="logo", - field=models.ImageField(blank=True, null=True, storage=backend.models._private_storage, upload_to="invoice_logos"), + 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.models._private_storage, upload_to="receipts"), + field=models.ImageField(storage=backend.core.models._private_storage, upload_to="receipts"), ), migrations.AlterField( model_name="teammemberpermission", @@ -63,7 +63,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="usersettings", name="profile_picture", - field=models.ImageField(blank=True, null=True, storage=backend.models._public_storage, upload_to="profile_pictures/"), + field=models.ImageField(blank=True, null=True, storage=backend.core.models._public_storage, upload_to="profile_pictures/"), ), migrations.CreateModel( name="InvoiceRecurringProfile", @@ -109,7 +109,7 @@ class Migration(migrations.Migration): ("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.models._private_storage, upload_to="invoice_logos")), + ("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", diff --git a/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py b/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py index 5d7565f7e..e00e2effb 100644 --- a/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py +++ b/backend/migrations/0048_alter_defaultvalues_default_invoice_logo.py @@ -3,6 +3,8 @@ import backend.models from django.db import migrations, models +from backend.core.models import _private_storage + class Migration(migrations.Migration): @@ -14,6 +16,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="defaultvalues", name="default_invoice_logo", - field=models.ImageField(blank=True, null=True, storage=backend.models._private_storage, upload_to="invoice_logos/"), + 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 index 19b90a444..31578e422 100644 --- a/backend/migrations/0049_filestoragefile.py +++ b/backend/migrations/0049_filestoragefile.py @@ -19,7 +19,9 @@ class Migration(migrations.Migration): ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ( "file", - models.FileField(storage=backend.models._private_storage, upload_to=backend.models.upload_to_user_separate_folder), + 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)), 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 index 5aefa7ed3..4f30d311f 100644 --- 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 @@ -1,6 +1,6 @@ # Generated by Django 5.1 on 2024-09-28 18:46 -import backend.data.default_email_templates +import backend.core.data.default_email_templates from django.db import migrations, models @@ -15,18 +15,22 @@ class Migration(migrations.Migration): model_name="defaultvalues", name="email_template_recurring_invoices_invoice_cancelled", field=models.TextField( - default=backend.data.default_email_templates.recurring_invoices_invoice_cancelled_default_email_template + 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.data.default_email_templates.recurring_invoices_invoice_created_default_email_template), + 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.data.default_email_templates.recurring_invoices_invoice_overdue_default_email_template), + field=models.TextField( + default=backend.core.data.default_email_templates.recurring_invoices_invoice_overdue_default_email_template + ), ), migrations.AddField( model_name="defaultvalues", diff --git a/backend/models.py b/backend/models.py index 0045215aa..6b354acfe 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,1194 +1,37 @@ -from __future__ import annotations - -import typing -from datetime import datetime, date, timedelta -from decimal import Decimal -from typing import Literal, Union -from uuid import uuid4 - -from dateutil.relativedelta import relativedelta -from django.contrib.auth.hashers import check_password, make_password -from django.contrib.auth.models import AbstractUser, UserManager -from django.contrib.contenttypes.models import ContentType -from django.core.files.storage import storages, FileSystemStorage -from django.core.validators import MaxValueValidator -from django.db import models, transaction -from django.db.models import Count, QuerySet -from django.utils import timezone -from django.utils.crypto import get_random_string -from shortuuid.django_fields import ShortUUIDField -from storages.backends.s3 import S3Storage - -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 ( + PasswordSecret, + AuditLog, + LoginLog, + Error, + TracebackError, + UserSettings, + Notification, + Organization, + TeamInvitation, + TeamMemberPermission, + User, + FeatureFlags, + VerificationCodes, + QuotaLimit, + QuotaOverrides, + QuotaUsage, + QuotaIncreaseRequest, + EmailSendStatus, + FileStorageFile, + MultiFileUpload, ) -from backend.managers import InvoiceRecurringProfile_WithItemsManager - - -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 - - -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 self.expires and self.expires > timezone.now(): - return self.expires - timezone.now() - return None - - 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.team_id == self.id: - return True - return False - - def is_authenticated(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") - - -class Receipt(OwnerBase): - name = models.CharField(max_length=100) - image = models.ImageField(upload_to="receipts", storage=_private_storage) - total_price = models.FloatField(null=True, blank=True) - date = models.DateField(null=True, blank=True) - date_uploaded = models.DateTimeField(auto_now_add=True) - receipt_parsed = models.JSONField(null=True, blank=True) - merchant_store = models.CharField(max_length=255, blank=True, null=True) - purchase_category = models.CharField(max_length=200, blank=True, null=True) - - def __str__(self): - return f"{self.name} - {self.date} ({self.total_price})" - - def has_access(self, actor: User | Organization) -> bool: - return self.owner == actor - - -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 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, - ) - - -class BotoSchedule(models.Model): - class BotoStatusTypes(models.TextChoices): - PENDING = "pending", "Pending" - CREATING = "creating", "Creating" - COMPLETED = "completed", "Completed" - FAILED = "failed", "Failed" - DELETING = "deleting", "Deleting" - CANCELLED = "cancelled", "Cancelled" - - created_at = models.DateTimeField(auto_now_add=True) - - boto_schedule_arn = models.CharField(max_length=2048, null=True, blank=True) - boto_schedule_uuid = models.UUIDField(default=None, null=True, blank=True) - boto_last_updated = models.DateTimeField(auto_now=True) - - received = models.BooleanField(default=False) - boto_schedule_status = models.CharField(max_length=100, choices=BotoStatusTypes.choices, default=BotoStatusTypes.PENDING) - - class Meta: - abstract = True - - def set_status(self, status, save=True): - self.status = status - if save: - self.save() - return self - - def set_received(self, status: bool = True, save=True): - self.received = status - if save: - self.save() - return self - - -class InvoiceProduct(OwnerBase): - name = models.CharField(max_length=50) - description = models.CharField(max_length=100) - quantity = models.IntegerField() - rate = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) - - -class InvoiceItem(models.Model): - # objects = InvoiceItemManager() - - name = models.CharField(max_length=50) - description = models.CharField(max_length=100) - is_service = models.BooleanField(default=True) - # from - # if service - hours = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) - price_per_hour = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) - # if product - price = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True) - - def get_total_price(self): - return self.hours * self.price_per_hour if self.is_service else self.price - - def __str__(self): - return self.description - - -class InvoiceBase(OwnerBase): - client_to = models.ForeignKey(Client, on_delete=models.SET_NULL, blank=True, null=True) - - client_name = models.CharField(max_length=100, blank=True, null=True) - client_email = models.EmailField(blank=True, null=True) - client_company = models.CharField(max_length=100, blank=True, null=True) - client_address = models.CharField(max_length=100, blank=True, null=True) - client_city = models.CharField(max_length=100, blank=True, null=True) - client_county = models.CharField(max_length=100, blank=True, null=True) - client_country = models.CharField(max_length=100, blank=True, null=True) - client_is_representative = models.BooleanField(default=False) - - self_name = models.CharField(max_length=100, blank=True, null=True) - self_company = models.CharField(max_length=100, blank=True, null=True) - self_address = models.CharField(max_length=100, blank=True, null=True) - self_city = models.CharField(max_length=100, blank=True, null=True) - self_county = models.CharField(max_length=100, blank=True, null=True) - self_country = models.CharField(max_length=100, blank=True, null=True) - - sort_code = models.CharField(max_length=8, blank=True, null=True) # 12-34-56 - account_holder_name = models.CharField(max_length=100, blank=True, null=True) - account_number = models.CharField(max_length=100, blank=True, null=True) - reference = models.CharField(max_length=100, blank=True, null=True) - invoice_number = models.CharField(max_length=100, blank=True, null=True) - vat_number = models.CharField(max_length=100, blank=True, null=True) - logo = models.ImageField( - upload_to="invoice_logos", - storage=_private_storage, - blank=True, - null=True, - ) - notes = models.TextField(blank=True, null=True) - - items = models.ManyToManyField(InvoiceItem, blank=True) - currency = models.CharField( - max_length=3, - default="GBP", - choices=[(code, info["name"]) for code, info in UserSettings.CURRENCIES.items()], - ) - date_created = models.DateTimeField(auto_now_add=True) - date_issued = models.DateField(blank=True, null=True) - - discount_amount = models.DecimalField(max_digits=15, default=0, decimal_places=2) - discount_percentage = models.DecimalField(default=0, max_digits=5, decimal_places=2, validators=[MaxValueValidator(100)]) - - class Meta: - abstract = True - constraints = [USER_OR_ORGANIZATION_CONSTRAINT()] - - 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 - - def get_currency_symbol(self): - return UserSettings.CURRENCIES.get(self.currency, {}).get("symbol", "$") - - -class Invoice(InvoiceBase): - # objects = InvoiceManager() - - STATUS_CHOICES = ( - ("draft", "Draft"), - # ("ready", "Ready"), - ("pending", "Pending"), - ("paid", "Paid"), - ) - - invoice_id = models.IntegerField(unique=True, blank=True, null=True) # todo: add - date_due = models.DateField() - status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft") - invoice_recurring_profile = models.ForeignKey( - "InvoiceRecurringProfile", related_name="generated_invoices", on_delete=models.SET_NULL, blank=True, null=True - ) - - def __str__(self): - invoice_id = self.invoice_id or self.id - if self.client_name: - client = self.client_name - elif self.client_to: - client = self.client_to.name - else: - client = "Unknown Client" - - return f"Invoice #{invoice_id} for {client}" - - @property - def dynamic_status(self): - if self.status == "pending" and self.is_overdue: - return "overdue" - else: - return self.status - - @property - def is_overdue(self): - return self.date_due and timezone.now().date() > self.date_due - - @property - def get_to_details(self) -> tuple[str, dict[str, str | None]] | tuple[str, Client]: - """ - Returns the client details for the invoice - "client" and Client object if client_to - "manual" and dict of details if client_name - """ - if self.client_to: - return "client", self.client_to - else: - return "manual", {"name": self.client_name, "company": self.client_company, "email": self.client_email} - - def get_subtotal(self) -> Decimal: - subtotal = 0 - for item in self.items.all(): - subtotal += item.get_total_price() - return Decimal(round(subtotal, 2)) - - def get_tax(self, amount: Decimal = Decimal(0.00)) -> Decimal: - amount = amount or self.get_subtotal() - if self.vat_number: - return Decimal(round(amount * Decimal(0.2), 2)) - return Decimal(0) - - def get_percentage_amount(self, subtotal: Decimal = Decimal(0.00)) -> Decimal: - total = subtotal or self.get_subtotal() - - if self.discount_percentage > 0: - return round(total * (self.discount_percentage / 100), 2) - return Decimal(0) - - def get_total_price(self) -> Decimal: - total = self.get_subtotal() or Decimal(0) - - total -= self.get_percentage_amount() - - discount_amount = self.discount_amount - - total -= discount_amount - - if 0 > total: - total = Decimal(0) - else: - total -= self.get_tax(total) - - return Decimal(round(total, 2)) - - -class InvoiceRecurringProfile(InvoiceBase, BotoSchedule): - with_items = InvoiceRecurringProfile_WithItemsManager() - - class Frequencies(models.TextChoices): - WEEKLY = "weekly", "Weekly" - MONTHLY = "monthly", "Monthly" - YEARLY = "yearly", "Yearly" - - STATUS_CHOICES = ( - ("ongoing", "Ongoing"), - ("paused", "paused"), - ("cancelled", "cancelled"), - ) - - active = models.BooleanField(default=True) - status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="paused") - - frequency = models.CharField(max_length=20, choices=Frequencies.choices, default=Frequencies.MONTHLY) - end_date = models.DateField(blank=True, null=True) - due_after_days = models.PositiveSmallIntegerField(default=7) - - day_of_week = models.PositiveSmallIntegerField(null=True, blank=True) - day_of_month = models.PositiveSmallIntegerField(null=True, blank=True) - month_of_year = models.PositiveSmallIntegerField(null=True, blank=True) - - def get_total_price(self) -> Decimal: - total = Decimal(0) - for invoice in self.generated_invoices.all(): - total += invoice.get_total_price() - return Decimal(round(total, 2)) - - def get_last_invoice(self) -> Invoice | None: - return self.generated_invoices.order_by("-id").first() - - def next_invoice_issue_date(self) -> date: - last_invoice = self.get_last_invoice() - - if not last_invoice: - if self.date_issued is None: - return datetime.now().date() - return max(self.date_issued, datetime.now().date()) - - last_invoice_date_issued: date = last_invoice.date_issued or datetime.now().date() - - match self.frequency: - case "weekly": - return last_invoice_date_issued + timedelta(days=7) - case "monthly": - return date(year=last_invoice_date_issued.year, month=last_invoice_date_issued.month + 1, day=last_invoice_date_issued.day) - case "yearly": - return date(year=last_invoice_date_issued.year + 1, month=last_invoice_date_issued.month, day=last_invoice_date_issued.day) - case _: - return datetime.now().date() - - def next_invoice_due_date(self, account_defaults: "DefaultValues", 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) - case account_defaults.InvoiceDueDateType.date_following: - return datetime(from_date.year, from_date.month + 1, account_defaults.invoice_due_date_value) - case account_defaults.InvoiceDueDateType.date_current: - return datetime(from_date.year, from_date.month, account_defaults.invoice_due_date_value) - case _: - return from_date + timedelta(days=7) - - -class InvoiceURL(ExpiresBase): - uuid = ShortUUIDField(length=8, primary_key=True) - invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="invoice_urls") - created_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) - system_created = models.BooleanField(default=False) - created_on = models.DateTimeField(auto_now_add=True) - - @property - def get_created_by(self): - if self.created_by: - return self.created_by.first_name or f"USR #{self.created_by.id}" - else: - return "SYSTEM" - - def set_expires(self): - self.expires = timezone.now() + timezone.timedelta(days=7) - - def __str__(self): - return str(self.invoice.id) - - class Meta: - verbose_name = "Invoice URL" - verbose_name_plural = "Invoice URLs" - - -class InvoiceReminder(BotoSchedule): - class ReminderTypes(models.TextChoices): - BEFORE_DUE = "before_due", "Before Due" - AFTER_DUE = "after_due", "After Due" - ON_OVERDUE = "on_overdue", "On Overdue" - - invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="invoice_reminders") - days = models.PositiveIntegerField(blank=True, null=True) - reminder_type = models.CharField(max_length=100, choices=ReminderTypes.choices, default=ReminderTypes.BEFORE_DUE) - - class Meta: - verbose_name = "Invoice Reminder" - verbose_name_plural = "Invoice Reminders" - - def __str__(self): - days = (str(self.days) + "d" if self.days else " ").center(8, "ㅤ") - return f"({self.id}) Reminder for (#{self.invoice_id}) {days} {self.reminder_type}" - - -class MonthlyReportRow(models.Model): - date = models.DateField() - reference_number = models.CharField(max_length=100) - item_type = models.CharField(max_length=100) - - client_name = models.CharField(max_length=64, blank=True, null=True) - client = models.ForeignKey(Client, on_delete=models.CASCADE, blank=True, null=True) - - paid_in = models.DecimalField(max_digits=15, decimal_places=2, default=0) - paid_out = models.DecimalField(max_digits=15, decimal_places=2, default=0) - - -class MonthlyReport(OwnerBase): - uuid = models.UUIDField(default=uuid4, editable=False, unique=True) - name = models.CharField(max_length=100, blank=True, null=True) - items = models.ManyToManyField(MonthlyReportRow, blank=True) - - profit = models.DecimalField(max_digits=15, decimal_places=2, default=0) - invoices_sent = models.PositiveIntegerField(default=0) - - start_date = models.DateField() - end_date = models.DateField() - - recurring_customers = models.PositiveIntegerField(default=0) - payments_in = models.DecimalField(max_digits=15, decimal_places=2, default=0) - payments_out = models.DecimalField(max_digits=15, decimal_places=2, default=0) - - currency = models.CharField( - max_length=3, - default="GBP", - choices=[(code, info["name"]) for code, info in UserSettings.CURRENCIES.items()], - ) - - def __str__(self): - return self.name or str(self.uuid)[:8] - - def get_currency_symbol(self): - return UserSettings.CURRENCIES.get(self.currency, {}).get("symbol", "$") - - -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=100) - 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) +from backend.finance.models import ( + Invoice, + InvoiceURL, + InvoiceItem, + InvoiceReminder, + InvoiceRecurringProfile, + InvoiceProduct, + Receipt, + ReceiptDownloadToken, + MonthlyReport, + MonthlyReportRow, +) - def is_finished(self): - return self.finished_at is not None +from backend.clients.models import Client, DefaultValues diff --git a/backend/onboarding/__init__.py b/backend/onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/onboarding/api/__init__.py b/backend/onboarding/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/service/__init__.py b/backend/service/__init__.py deleted file mode 100644 index 4037f08d7..000000000 --- a/backend/service/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from backend.service.boto3.handler import BOTO3_HANDLER diff --git a/backend/signals/__init__.py b/backend/signals/__init__.py deleted file mode 100644 index 6b7b0db31..000000000 --- a/backend/signals/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import annotations - -from . import migrations -from . import signals -from .core_signals import clients, file_storage -from .core_signals.invoices import schedules diff --git a/backend/storage/__init__.py b/backend/storage/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/storage/api/__init__.py b/backend/storage/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/file_storage/delete.py b/backend/storage/api/delete.py similarity index 91% rename from backend/api/file_storage/delete.py rename to backend/storage/api/delete.py index 0637aa50f..a2ac79f14 100644 --- a/backend/api/file_storage/delete.py +++ b/backend/storage/api/delete.py @@ -1,12 +1,12 @@ from django.contrib import messages from django.db.models import QuerySet -from django.http import HttpResponse, QueryDict, JsonResponse +from django.http import HttpResponse, QueryDict from django.shortcuts import render from django.views.decorators.http import require_http_methods from backend.decorators import htmx_only from backend.models import FileStorageFile -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest @require_http_methods(["DELETE"]) diff --git a/backend/api/file_storage/fetch.py b/backend/storage/api/fetch.py similarity index 71% rename from backend/api/file_storage/fetch.py rename to backend/storage/api/fetch.py index 32b84d100..e8b0e33da 100644 --- a/backend/api/file_storage/fetch.py +++ b/backend/storage/api/fetch.py @@ -1,20 +1,15 @@ -from django.contrib import messages -from django.http import HttpResponse -from django.utils import timezone from django.utils.html import escape from django.views.decorators.http import require_GET from backend.decorators import htmx_only from backend.models import FileStorageFile -# from backend.service.billing.calculate.test import generate_monthly_billing_summary -from backend.service.file_storage.utils import format_file_size -from backend.types.requests import WebRequest +# 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 django.shortcuts import render -from backend.utils.calendar import get_months_text, timezone_now - @require_GET @htmx_only("file_storage:dashboard") diff --git a/backend/api/file_storage/urls.py b/backend/storage/api/urls.py similarity index 100% rename from backend/api/file_storage/urls.py rename to backend/storage/api/urls.py diff --git a/backend/signals/core_signals/file_storage.py b/backend/storage/file_storage.py similarity index 84% rename from backend/signals/core_signals/file_storage.py rename to backend/storage/file_storage.py index 47b969834..4c01777c1 100644 --- a/backend/signals/core_signals/file_storage.py +++ b/backend/storage/file_storage.py @@ -3,7 +3,8 @@ from django.db.models.signals import pre_delete, post_save from django.dispatch import receiver -from backend.models import FileStorageFile, MultiFileUpload, _private_storage +from backend.core.models import _private_storage +from backend.models import FileStorageFile logger = logging.getLogger(__name__) diff --git a/backend/storage/views/__init__.py b/backend/storage/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/views/core/file_storage/dashboard.py b/backend/storage/views/dashboard.py similarity index 80% rename from backend/views/core/file_storage/dashboard.py rename to backend/storage/views/dashboard.py index a27801bb0..47d9a79b4 100644 --- a/backend/views/core/file_storage/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.models import FileStorageFile -from backend.service.file_storage.utils import format_file_size -from backend.types.requests import WebRequest +from backend.core.models import FileStorageFile +from backend.core.service.file_storage.utils import format_file_size +from backend.core.types.requests import WebRequest def file_storage_dashboard_endpoint(request: WebRequest): diff --git a/backend/views/core/file_storage/upload.py b/backend/storage/views/upload.py similarity index 93% rename from backend/views/core/file_storage/upload.py rename to backend/storage/views/upload.py index ecf7a79b6..408d80d04 100644 --- a/backend/views/core/file_storage/upload.py +++ b/backend/storage/views/upload.py @@ -3,15 +3,16 @@ from django.contrib import messages from django.core.files.base import ContentFile from django.core.files.uploadedfile import UploadedFile -from django.http import HttpResponse, JsonResponse, QueryDict +from django.http import HttpResponse, JsonResponse from django.shortcuts import render, redirect from django.utils import timezone from django.views.decorators.http import require_http_methods -from backend.types.requests import WebRequest -from backend.models import FileStorageFile, MultiFileUpload, _private_storage, upload_to_user_separate_folder +from backend.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.service.file_storage.create import parse_files_for_creation +from backend.core.service.file_storage.create import parse_files_for_creation from django.urls import reverse diff --git a/backend/views/core/file_storage/urls.py b/backend/storage/views/urls.py similarity index 81% rename from backend/views/core/file_storage/urls.py rename to backend/storage/views/urls.py index b02666658..f6b407c04 100644 --- a/backend/views/core/file_storage/urls.py +++ b/backend/storage/views/urls.py @@ -2,12 +2,9 @@ from django.urls.conf import include from django.views.generic import RedirectView -from backend.views.core.file_storage.dashboard import file_storage_dashboard_endpoint -from backend.views.core.file_storage.upload import ( - upload_file_dashboard_endpoints, +from backend.storage.views.upload import ( upload_file_via_batch_endpoint, end_file_upload_batch_endpoint, - start_file_upload_batch_endpoint, ) upload_paths = [ diff --git a/backend/templatetags/__init__.py b/backend/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/templatetags/feature_enabled.py b/backend/templatetags/feature_enabled.py index d6073b869..3d00c8ed2 100644 --- a/backend/templatetags/feature_enabled.py +++ b/backend/templatetags/feature_enabled.py @@ -2,7 +2,7 @@ from django.urls import NoReverseMatch from backend.models import User, Organization -from backend.utils.feature_flags import get_feature_status +from backend.core.utils.feature_flags import get_feature_status from django.conf import settings diff --git a/backend/urls.py b/backend/urls.py index a702f28dc..494ea5dc6 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -9,14 +9,13 @@ from django.views.generic import RedirectView from django.views.static import serve -from backend.api.public.swagger_ui import get_swagger_ui, get_swagger_endpoints -from backend.views.core import receipts -from backend.views.core.invoices.single.view import view_invoice_with_uuid_endpoint -from backend.views.core.other.index import dashboard -from backend.views.core.other.index import index, pricing -from backend.views.core.quotas.view import quotas_list -from backend.views.core.quotas.view import quotas_page -from backend.views.core.quotas.view import view_quota_increase_requests +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 url( @@ -26,25 +25,25 @@ ) urlpatterns = [ path("tz_detect/", include("tz_detect.urls")), - path("api/", include("backend.api.urls")), - path("webhooks/", include("backend.webhooks.urls")), + path("webhooks/", include("backend.core.webhooks.urls")), path("", index, name="index"), path("pricing", pricing, name="pricing"), path("dashboard/", dashboard, name="dashboard"), - path("dashboard/settings/", include("backend.views.core.settings.urls")), - path("dashboard/teams/", include("backend.views.core.teams.urls")), - path("dashboard/invoices/", include("backend.views.core.invoices.urls")), + 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.views.core.emails.urls")), - path("dashboard/reports/", include("backend.views.core.reports.urls")), + path("dashboard/emails/", include("backend.core.views.emails.urls")), + 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/file_storage/", include("backend.views.core.file_storage.urls")), + 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")), path( "dashboard/receipts/", - receipts.dashboard.receipts_dashboard, + receipts_dashboard, name="receipts dashboard", ), path( @@ -53,8 +52,8 @@ name="invoices view invoice", ), path("login/external/", include("social_django.urls", namespace="social")), - path("auth/", include("backend.views.core.auth.urls")), - path("dashboard/clients/", include("backend.views.core.clients.urls")), + path("auth/", include("backend.core.views.auth.urls")), + path("api/", include("backend.core.api.urls")), path("admin/", admin.site.urls), ] + static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0]) @@ -71,6 +70,6 @@ schema_view = get_swagger_ui() urlpatterns += get_swagger_endpoints(settings.DEBUG) -handler500 = "backend.views.core.other.errors.universal" -handler404 = "backend.views.core.other.errors.universal" -handler403 = "backend.views.core.other.errors.e_403" +handler500 = "backend.core.views.other.errors.universal" +handler404 = "backend.core.views.other.errors.universal" +handler403 = "backend.core.views.other.errors.e_403" diff --git a/backend/views/core/__init__.py b/backend/views/core/__init__.py deleted file mode 100644 index 818c497f5..000000000 --- a/backend/views/core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .auth.passwords import set -from .other import index, errors -from .auth import login -from .settings import view as settings_view, teams -from .invoices.single import manage_access -from .clients import dashboard as clients_dashboard, create as create_client -from .receipts import dashboard as receipts_dashboard diff --git a/backend/views/core/reports/dashboard.py b/backend/views/core/reports/dashboard.py deleted file mode 100644 index d74cf4192..000000000 --- a/backend/views/core/reports/dashboard.py +++ /dev/null @@ -1,12 +0,0 @@ -from datetime import date - -from django.contrib import messages - -from backend.models import MonthlyReport -from backend.service.reports.generate import generate_report -from backend.types.requests import WebRequest -from django.shortcuts import render, redirect - - -def view_reports_endpoint(request: WebRequest): - return render(request, "pages/reports/dashboard.html") diff --git a/backend/views/core/settings/urls.py b/backend/views/core/settings/urls.py deleted file mode 100644 index 753df65c3..000000000 --- a/backend/views/core/settings/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import include -from django.urls import path -from django.views.generic import RedirectView - -from backend.views.core import settings - -urlpatterns = [ - path("", settings.view.view_settings_page_endpoint, name="dashboard"), - path("/", settings.view.view_settings_page_endpoint, name="dashboard with page"), - path( - "profile/change_password/", - settings.view.change_password, - name="change_password", - ), -] - -app_name = "settings" diff --git a/billing/billing_settings.py b/billing/billing_settings.py index b7716048b..124cb9c77 100644 --- a/billing/billing_settings.py +++ b/billing/billing_settings.py @@ -18,25 +18,25 @@ "file_storage:upload:end_batch", "file_storage:upload:add_to_batch", "file_storage:upload:dashboard", - # "invoices:single:manage_access", - "invoices:single:manage_access create", - # "invoices:single:manage_access delete", - "invoices:single:edit", - "invoices:single:create", - "invoices:recurring:create", - "invoices:recurring:edit", + # "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", - "invoices:single:edit", - "invoices:single:edit discount", - "invoices:recurring:generate next invoice", - "invoices:recurring:edit", - "invoices:create:set_destination from", - "invoices:create:set_destination to", - "invoices:create:services add", + "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/middleware.py b/billing/middleware.py index 84b81fe9a..6873b5cd6 100644 --- a/billing/middleware.py +++ b/billing/middleware.py @@ -1,14 +1,10 @@ -import os - -import stripe from django.contrib import messages -from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import redirect, render -from django.urls import reverse, resolve +from django.urls import resolve -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest from billing.billing_settings import NO_SUBSCRIPTION_PLAN_DENY_VIEW_NAMES -from billing.models import UserSubscription, SubscriptionPlan +from billing.models import UserSubscription # middleware to check if user is subscribed to a plan yet diff --git a/billing/models.py b/billing/models.py index a159e1974..65a184cd1 100644 --- a/billing/models.py +++ b/billing/models.py @@ -2,7 +2,7 @@ from django.db import models -from backend.models import OwnerBase +from backend.core.models import OwnerBase from django.utils import timezone diff --git a/billing/service/checkout_completed.py b/billing/service/checkout_completed.py index 401541fe0..62d90d97f 100644 --- a/billing/service/checkout_completed.py +++ b/billing/service/checkout_completed.py @@ -1,6 +1,6 @@ import stripe -from backend.utils.calendar import timezone_now +from backend.core.utils.calendar import timezone_now from billing.models import StripeCheckoutSession, StripeWebhookEvent, UserSubscription diff --git a/billing/service/subscription_ended.py b/billing/service/subscription_ended.py index c7678ab6a..253e013f9 100644 --- a/billing/service/subscription_ended.py +++ b/billing/service/subscription_ended.py @@ -1,6 +1,6 @@ import stripe -from backend.models import User, Organization +from backend.core.models import User, Organization from billing.models import StripeWebhookEvent, UserSubscription diff --git a/billing/signals/quotas.py b/billing/signals/quotas.py index f560fa2f6..d79ba6bd1 100644 --- a/billing/signals/quotas.py +++ b/billing/signals/quotas.py @@ -1,7 +1,7 @@ # from django.db.models.signals import post_save # from django.dispatch import receiver # -# from backend.models import Invoice, Usage +# from backend.finance.models import Invoice, Usage # # # @receiver(post_save, sender=Invoice) diff --git a/billing/signals/usage.py b/billing/signals/usage.py index 00c542057..f67c51a40 100644 --- a/billing/signals/usage.py +++ b/billing/signals/usage.py @@ -2,11 +2,11 @@ from datetime import datetime import stripe -from django.db.models.signals import pre_delete, post_save +from django.db.models.signals import post_save from django.dispatch import receiver -from django.utils.timezone import make_aware -from backend.models import Invoice, User +from backend.finance.models import Invoice +from backend.core.models import User from billing.models import BillingUsage logger = logging.getLogger(__name__) diff --git a/billing/views/change_plan.py b/billing/views/change_plan.py index dbc77491b..c35735d0a 100644 --- a/billing/views/change_plan.py +++ b/billing/views/change_plan.py @@ -4,14 +4,12 @@ from django.contrib import messages from django.db.models import QuerySet from django.http import HttpResponse -from django.shortcuts import redirect, render +from django.shortcuts import render from django.urls import reverse -from backend.api.public.decorators import require_scopes from backend.decorators import htmx_only, web_require_scopes -from backend.models import User -from backend.types.requests import WebRequest -from backend.utils.calendar import timezone_now +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 diff --git a/billing/views/dashboard.py b/billing/views/dashboard.py index c9192f2c2..f7db39ed8 100644 --- a/billing/views/dashboard.py +++ b/billing/views/dashboard.py @@ -2,7 +2,7 @@ from backend.decorators import web_require_scopes from billing.models import UserSubscription, SubscriptionPlan -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest @web_require_scopes("billing:manage", api=True, htmx=True) diff --git a/billing/views/return_urls/failed.py b/billing/views/return_urls/failed.py index 814df1826..c03d4a888 100644 --- a/billing/views/return_urls/failed.py +++ b/billing/views/return_urls/failed.py @@ -1,7 +1,7 @@ from django.contrib import messages from django.shortcuts import redirect -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest def stripe_failed_return_endpoint(request: WebRequest): diff --git a/billing/views/return_urls/success.py b/billing/views/return_urls/success.py index 08e0749ba..dd0d328a8 100644 --- a/billing/views/return_urls/success.py +++ b/billing/views/return_urls/success.py @@ -1,7 +1,6 @@ -from django.contrib import messages from django.shortcuts import redirect -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest def stripe_success_return_endpoint(request: WebRequest): diff --git a/billing/views/stripe_misc.py b/billing/views/stripe_misc.py index 28267b274..c7d0905f8 100644 --- a/billing/views/stripe_misc.py +++ b/billing/views/stripe_misc.py @@ -3,7 +3,7 @@ from django.urls import reverse, resolve, NoReverseMatch from backend.decorators import web_require_scopes -from backend.types.requests import WebRequest +from backend.core.types.requests import WebRequest from billing.service.stripe_customer import get_or_create_customer_id diff --git a/frontend/templates/base/+left_drawer.html b/frontend/templates/base/+left_drawer.html index 640736be1..3c8a986db 100644 --- a/frontend/templates/base/+left_drawer.html +++ b/frontend/templates/base/+left_drawer.html @@ -31,7 +31,7 @@ {# {% if feature_enabled_invoices %}#}
  • - {% with i_url="invoices:single:dashboard" %} + {% with i_url="finance:invoices:single:dashboard" %} Single Invoices @@ -39,7 +39,7 @@ {% endwith %}
  • - {% with i_url="invoices:recurring:dashboard" %} + {% with i_url="finance:invoices:recurring:dashboard" %} Recurring Invoices diff --git a/frontend/templates/base/topbar/_topbar.html b/frontend/templates/base/topbar/_topbar.html index 85dfaa9fe..ac7591bd5 100644 --- a/frontend/templates/base/topbar/_topbar.html +++ b/frontend/templates/base/topbar/_topbar.html @@ -65,7 +65,7 @@ {% if feature_enabled_invoices %}
  • - Invoices @@ -74,7 +74,7 @@ {% endif %}
  • - Clients diff --git a/frontend/templates/modals/create_invoice_product.html b/frontend/templates/modals/create_invoice_product.html index ee60ed677..622dda898 100644 --- a/frontend/templates/modals/create_invoice_product.html +++ b/frontend/templates/modals/create_invoice_product.html @@ -2,7 +2,7 @@ {% fill "content" %}
  • @@ -97,7 +97,7 @@ + hx-get="{% url "api:finance:invoices:single:single:schedules onetime fetch" invoice_id=invoice.id %}"> diff --git a/frontend/templates/pages/invoices/single/view/_banner/_banner.html b/frontend/templates/pages/invoices/single/view/_banner/_banner.html index e70bc91a5..b5f097ef9 100644 --- a/frontend/templates/pages/invoices/single/view/_banner/_banner.html +++ b/frontend/templates/pages/invoices/single/view/_banner/_banner.html @@ -12,7 +12,7 @@ {% if type == "preview" %} -
    diff --git a/frontend/templates/pages/invoices/single/view/_banner/_button_options_dropdown.html b/frontend/templates/pages/invoices/single/view/_banner/_button_options_dropdown.html index 922ba0acb..a21ba79b4 100644 --- a/frontend/templates/pages/invoices/single/view/_banner/_button_options_dropdown.html +++ b/frontend/templates/pages/invoices/single/view/_banner/_button_options_dropdown.html @@ -13,6 +13,6 @@ {% if request.user.is_authenticated %}
  • Share Invoice + href="{% url "finance:invoices:single:manage_access" invoice_id=invoice.id %}">Share Invoice
  • {% endif %} diff --git a/frontend/templates/pages/invoices/single/view/_banner/_button_options_top.html b/frontend/templates/pages/invoices/single/view/_banner/_button_options_top.html index f023d13a1..f6aad83c4 100644 --- a/frontend/templates/pages/invoices/single/view/_banner/_button_options_top.html +++ b/frontend/templates/pages/invoices/single/view/_banner/_button_options_top.html @@ -13,6 +13,6 @@ {% if request.user.is_authenticated %}
  • + href="{% url "finance:invoices:single:manage_access" invoice_id=invoice.id %}">Share Invoice
  • {% endif %} diff --git a/frontend/templates/pages/invoices/single/view/invoice_page.html b/frontend/templates/pages/invoices/single/view/invoice_page.html index 7fef6842d..3d653fc43 100644 --- a/frontend/templates/pages/invoices/single/view/invoice_page.html +++ b/frontend/templates/pages/invoices/single/view/invoice_page.html @@ -9,7 +9,7 @@
    {% if request.user.is_authenticated %} - Back to invoice overview {% endif %} diff --git a/frontend/templates/pages/invoices/structure/invoices_list.html b/frontend/templates/pages/invoices/structure/invoices_list.html index 797164b66..86c68db4f 100644 --- a/frontend/templates/pages/invoices/structure/invoices_list.html +++ b/frontend/templates/pages/invoices/structure/invoices_list.html @@ -7,8 +7,8 @@ hx-vals='{"invoice_structure_main": "True"}'> {% for _ in "x"|rjust:"20" %}
  • -
    diff --git a/frontend/templates/pages/invoices/structure/toggler.html b/frontend/templates/pages/invoices/structure/toggler.html index 228caddc0..26e686dea 100644 --- a/frontend/templates/pages/invoices/structure/toggler.html +++ b/frontend/templates/pages/invoices/structure/toggler.html @@ -1,8 +1,8 @@ diff --git a/frontend/templates/pages/products/fetched_items.html b/frontend/templates/pages/products/fetched_items.html index 372edae87..e8b109c97 100644 --- a/frontend/templates/pages/products/fetched_items.html +++ b/frontend/templates/pages/products/fetched_items.html @@ -2,7 +2,7 @@ {% component "messages_list" custom_oob='afterbegin:div[data-card="services_card_body"]' %} {% for product in products %}
  • -
  • @@ -80,12 +80,12 @@ diff --git a/frontend/templates/pages/receipts/dashboard.html b/frontend/templates/pages/receipts/dashboard.html index 00c60dac1..08ba23bcd 100644 --- a/frontend/templates/pages/receipts/dashboard.html +++ b/frontend/templates/pages/receipts/dashboard.html @@ -21,7 +21,7 @@

    Receipts

    + hx-get="{% url 'api:finance:receipts:fetch' %}">
    {% include 'components/table/skeleton_table.html' %}
    diff --git a/settings/helpers.py b/settings/helpers.py index cd0069fc2..2b225ad80 100644 --- a/settings/helpers.py +++ b/settings/helpers.py @@ -15,7 +15,7 @@ SendBulkEmailResponseTypeDef, ) -from backend.types.emails import ( +from backend.core.types.emails import ( SingleEmailInput, BulkTemplatedEmailInput, SingleTemplatedEmailContent, @@ -110,7 +110,8 @@ def send_email( ) if get_var("DEBUG", "").lower() == "true": - print(data) + if not "test" in sys.argv[1:]: + print(data) return SingleEmailSendServiceResponse( True, response=SendEmailResponseTypeDef( diff --git a/settings/settings.py b/settings/settings.py index 91180bb75..5a8c88446 100644 --- a/settings/settings.py +++ b/settings/settings.py @@ -74,7 +74,7 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ # "rest_framework.authentication.TokenAuthentication", - "backend.api.public.authentication.CustomBearerAuthentication" # also adds custom model + "backend.core.api.public.authentication.CustomBearerAuthentication" # also adds custom model ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", @@ -88,7 +88,7 @@ SWAGGER_SETTINGS = { "USE_SESSION_AUTH": False, - "DEFAULT_INFO": "backend.api.public.swagger_ui.INFO", + "DEFAULT_INFO": "backend.core.api.public.swagger_ui.INFO", "SECURITY_DEFINITIONS": {"Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}}, } @@ -233,8 +233,8 @@ "social_django.middleware.SocialAuthExceptionMiddleware", "tz_detect.middleware.TimezoneMiddleware", "backend.middleware.HTMXPartialLoadMiddleware", - # "backend.api.public.middleware.AttachTokenMiddleware", - "backend.api.public.middleware.HandleTeamContextMiddleware", + # "backend.core.api.public.middleware.AttachTokenMiddleware", + "backend.core.api.public.middleware.HandleTeamContextMiddleware", ] if DEBUG: diff --git a/tests/api/test_account_settings.py b/tests/api/test_account_settings.py index 628f1ede0..1a272ea1c 100644 --- a/tests/api/test_account_settings.py +++ b/tests/api/test_account_settings.py @@ -10,7 +10,7 @@ def setUp(self): super().setUp() self.url_path = "/api/settings/account_preferences/" self.url_name = "api:settings:account_preferences" - self.view_function_path = "backend.api.settings.preferences.update_account_preferences" + self.view_function_path = "backend.core.api.settings.preferences.update_account_preferences" # Test that the URL resolves to the correct view function def test_url_matches_api(self): diff --git a/tests/api/test_clients.py b/tests/api/test_clients.py index 4d4dbe200..e9e030b36 100644 --- a/tests/api/test_clients.py +++ b/tests/api/test_clients.py @@ -12,7 +12,7 @@ def setUp(self): super().setUp() self.url_path = "/api/clients/fetch/" self.url_name = "api:clients:fetch" - self.view_function_path = "backend.api.clients.fetch.fetch_all_clients" + self.view_function_path = "backend.clients.api.fetch.fetch_all_clients" def test_clients_view_302_for_all_normal_get_requests(self): # Ensure that non-HTMX GET requests are redirected to the login page @@ -118,7 +118,7 @@ def setUp(self): self.id = 1 self.url_path = f"/api/clients/delete/{self.id}/" self.url_name = "api:clients:delete" - self.view_function_path = "backend.api.clients.delete.client_delete" + self.view_function_path = "backend.core.api.clients.delete.client_delete" def test_client_delete_view_matches_with_urls_view(self): self.assertEqual(reverse(self.url_name, args=[self.id]), self.url_path) diff --git a/tests/api/test_invoices.py b/tests/api/test_invoices.py index 99cbba4c4..01f42a71b 100644 --- a/tests/api/test_invoices.py +++ b/tests/api/test_invoices.py @@ -3,7 +3,7 @@ from django.urls import reverse, resolve from model_bakery import baker -from backend.models import Invoice +from backend.finance.models import Invoice from tests.handler import ViewTestCase, assert_url_matches_view @@ -11,8 +11,8 @@ class InvoicesAPIFetch(ViewTestCase): def setUp(self): super().setUp() self.url_path = "/api/invoices/single/fetch/" - self.url_name = "api:invoices:single:fetch" - self.view_function_path = "backend.api.invoices.fetch.fetch_all_invoices" + self.url_name = "api:finance:invoices:single:fetch" + self.view_function_path = "backend.finance.api.invoices.fetch.fetch_all_invoices" def test_302_for_all_normal_get_requests(self): # Ensure that non-HTMX GET requests are redirected to the login page @@ -82,8 +82,8 @@ class InvoicesAPIDelete(ViewTestCase): def setUp(self): super().setUp() self.url_path = "/api/invoices/single/delete/" - self.url_name = "api:invoices:single:delete" - self.view_function_path = "backend.api.invoices.delete.delete_invoice" + self.url_name = "api:finance:invoices:single:delete" + self.view_function_path = "backend.finance.api.invoices.delete.delete_invoice" def test_302_for_all_normal_get_requests(self): # Ensure that non-HTMX GET requests are redirected to the login page @@ -119,8 +119,8 @@ class InvoicesEditDiscount(ViewTestCase): def setUp(self): super().setUp() self.url_path = "/api/invoices/single/edit/discount/" - self.url_name = "api:invoices:single:edit discount" - self.view_function_path = "backend.api.invoices.edit.edit_discount" + self.url_name = "api:finance:invoices:single:edit discount" + self.view_function_path = "backend.finance.api.invoices.edit.edit_discount" self.invoice: Invoice = baker.make("backend.Invoice", user=self.log_in_user) def test_302_for_all_normal_get_requests(self): diff --git a/tests/api/test_receipts.py b/tests/api/test_receipts.py index e7b186e20..e7fb9d533 100644 --- a/tests/api/test_receipts.py +++ b/tests/api/test_receipts.py @@ -10,8 +10,8 @@ class ReceiptsAPIFetch(ViewTestCase): def setUp(self): super().setUp() self.url_path = "/api/receipts/fetch/" - self.url_name = "api:receipts:fetch" - self.view_function_path = "backend.api.receipts.fetch.fetch_all_receipts" + self.url_name = "api:finance:receipts:fetch" + self.view_function_path = "backend.finance.api.receipts.fetch.fetch_all_receipts" def test_302_for_all_normal_get_requests(self): # Ensure that non-HTMX GET requests are redirected to the login page diff --git a/tests/handler.py b/tests/handler.py index a1753b46f..de5ea3d2a 100644 --- a/tests/handler.py +++ b/tests/handler.py @@ -17,7 +17,7 @@ def assert_url_matches_view(url_path, url_name, view_function_path): Args: url_path (str): The full URL path of the view. (e.g. api//clients/fetch/) url_name (str): The name of the URL to reverse. (e.g. api:clients:fetch) - view_function_path (str): The expected path of the view function (e.g., "backend.api.clients.fetch.fetch_all_clients"). + view_function_path (str): The expected path of the view function (e.g., "backend.core.api.clients.fetch.fetch_all_clients"). """ resolved_func = resolve(url_path).func resolved_func_name = f"{resolved_func.__module__}.{resolved_func.__name__}" diff --git a/tests/urls_INACTIVE/logged_in.json b/tests/urls_INACTIVE/logged_in.json index c79fee781..c2a3a9057 100644 --- a/tests/urls_INACTIVE/logged_in.json +++ b/tests/urls_INACTIVE/logged_in.json @@ -23,13 +23,13 @@ "settings:change_password change_password": [ 200 ], - "invoices:single:dashboard": [ + "finance:invoices:single:dashboard": [ 200 ], "receipts dashboard": [ 200 ], - "api:receipts:new": [ + "api:finance:receipts:new": [ 405 ] } diff --git a/tests/urls_INACTIVE/unlogged_in.json b/tests/urls_INACTIVE/unlogged_in.json index 3c9e0d4e9..5ea5a1d8f 100644 --- a/tests/urls_INACTIVE/unlogged_in.json +++ b/tests/urls_INACTIVE/unlogged_in.json @@ -23,13 +23,13 @@ "settings:change_password": [ 302 ], - "invoices:single:dashboard": [ + "finance:invoices:single:dashboard": [ 302 ], "receipts dashboard": [ 302 ], - "api:receipts:new": [ + "api:finance:receipts:new": [ 405 ] } diff --git a/tests/urls_INACTIVE/verify_urls.py b/tests/urls_INACTIVE/verify_urls.py index a3ee680eb..11206414e 100644 --- a/tests/urls_INACTIVE/verify_urls.py +++ b/tests/urls_INACTIVE/verify_urls.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.urls import reverse -from backend.models import * +from backend.models import User class UrlTestCase(TestCase): diff --git a/tests/views/test_change_password.py b/tests/views/test_change_password.py index 91a39cbbc..0e3682054 100644 --- a/tests/views/test_change_password.py +++ b/tests/views/test_change_password.py @@ -145,4 +145,4 @@ def test_change_password_view_matches_with_urls_view(self): "/dashboard/settings/profile/change_password/", reverse("settings:change_password"), ) - self.assertEqual("backend.views.core.settings.view.change_password", func_name) + self.assertEqual("backend.core.views.settings.view.change_password", func_name) diff --git a/tests/views/test_clients.py b/tests/views/test_clients.py index 09625f761..e30f11c07 100644 --- a/tests/views/test_clients.py +++ b/tests/views/test_clients.py @@ -24,7 +24,7 @@ def test_clients_view_matches_with_urls_view(self): func = resolve("/dashboard/clients/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/dashboard/clients/", reverse("clients:dashboard")) - self.assertEqual("backend.views.core.clients.dashboard.clients_dashboard_endpoint", func_name) + self.assertEqual("backend.clients.views.dashboard.clients_dashboard_endpoint", func_name) def test_clients_view_doesnt_create_invalid_client_no_first_name(self): self.login_user() diff --git a/tests/views/test_dashboard.py b/tests/views/test_dashboard.py index 617a58a03..52886fa67 100644 --- a/tests/views/test_dashboard.py +++ b/tests/views/test_dashboard.py @@ -16,4 +16,4 @@ def test_dashboard_view_matches_with_urls_view(self): func = resolve("/dashboard/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/dashboard/", reverse("dashboard")) - self.assertEqual("backend.views.core.other.index.dashboard", func_name) + self.assertEqual("backend.core.views.other.index.dashboard", func_name) diff --git a/tests/views/test_index.py b/tests/views/test_index.py index 79b1f6707..0acfccab2 100644 --- a/tests/views/test_index.py +++ b/tests/views/test_index.py @@ -16,4 +16,4 @@ def test_clients_view_matches_with_urls_view(self): func = resolve("/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/", reverse("index")) - self.assertEqual("backend.views.core.other.index.index", func_name) + self.assertEqual("backend.core.views.other.index.index", func_name) diff --git a/tests/views/test_invoices.py b/tests/views/test_invoices.py index aae1bf2d9..4a7f2e682 100644 --- a/tests/views/test_invoices.py +++ b/tests/views/test_invoices.py @@ -2,7 +2,7 @@ from django.urls import reverse, resolve -from backend.models import Invoice +from backend.finance.models import Invoice from tests.handler import ViewTestCase @@ -10,7 +10,7 @@ class InvoicesViewTestCase(ViewTestCase): def setUp(self): super().setUp() - self._invoices_dashboard_url = reverse("invoices:single:dashboard") + self._invoices_dashboard_url = reverse("finance:invoices:single:dashboard") def test_invoices_view_302_for_non_authenticated_users(self): response = self.client.get(self._invoices_dashboard_url) @@ -33,14 +33,14 @@ def test_invoices_view_matches_with_urls_view(self): func = resolve("/dashboard/invoices/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/dashboard/invoices/single/", self._invoices_dashboard_url) - self.assertEqual("backend.views.core.invoices.single.dashboard.invoices_single_dashboard_endpoint", func_name) + self.assertEqual("backend.finance.views.invoices.single.dashboard.invoices_single_dashboard_endpoint", func_name) class InvoicesCreateTestCase(ViewTestCase): def setUp(self): super().setUp() - self._invoices_create_url = reverse("invoices:single:create") + self._invoices_create_url = reverse("finance:invoices:single:create") self.data = { "service_name[]": ["Service 1", "Service 2"], "hours[]": [2, 3], @@ -90,7 +90,7 @@ def test_invoices_create_matches_with_urls_view(self): func = resolve("/dashboard/invoices/single/create/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/dashboard/invoices/single/create/", self._invoices_create_url) - self.assertEqual("backend.views.core.invoices.single.create.create_single_invoice_endpoint_handler", func_name) + self.assertEqual("backend.finance.views.invoices.single.create.create_single_invoice_endpoint_handler", func_name) def test_invoices_create_invoice_from_post_data(self): self.login_user() diff --git a/tests/views/test_login.py b/tests/views/test_login.py index 3b15adfab..7830b364d 100644 --- a/tests/views/test_login.py +++ b/tests/views/test_login.py @@ -10,7 +10,7 @@ def setUp(self): super().setUp() self.url_path = "/api/invoices/fetch/" self.url_name = "auth:login" - self.view_function_path = "backend.api.invoices.fetch.fetch_all_invoices" + self.view_function_path = "backend.finance.api.invoices.fetch.fetch_all_invoices" self.login_rev = reverse("auth:login") def test_login_redirects_for_auth_user(self): diff --git a/tests/views/test_other.py b/tests/views/test_other.py index f613113a1..0e9417715 100644 --- a/tests/views/test_other.py +++ b/tests/views/test_other.py @@ -1,6 +1,6 @@ from django.urls import reverse, resolve -from backend.views.core.auth.login import logout_view +from backend.core.views.auth.login import logout_view from tests.handler import ViewTestCase diff --git a/tests/views/test_receipts.py b/tests/views/test_receipts.py index cad4a8595..05380b18c 100644 --- a/tests/views/test_receipts.py +++ b/tests/views/test_receipts.py @@ -30,7 +30,7 @@ def test_receipts_dashboard_view_matches_with_urls_view(self): func = resolve("/dashboard/receipts/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/dashboard/receipts/", self._receipts_dashboard_url) - self.assertEqual("backend.views.core.receipts.dashboard.receipts_dashboard", func_name) + self.assertEqual("backend.finance.views.receipts.dashboard.receipts_dashboard", func_name) def test_search_functionality(self): self.login_user() @@ -47,7 +47,7 @@ def test_search_functionality(self): receipts = [baker.make("backend.Receipt", user=self.log_in_user, **attrs) for attrs in receipt_attributes] # Define the URL with the search query parameter - url = reverse("api:receipts:fetch") + url = reverse("api:finance:receipts:fetch") headers = {"HTTP_HX-Request": "true"} # Define search queries to cover various edge cases @@ -104,7 +104,7 @@ def setUp(self): super().setUp() # Define URLs for the receipts API - self._receipts_api_create_url = reverse("api:receipts:new") + self._receipts_api_create_url = reverse("api:finance:receipts:new") def test_receipt_create_post_with_valid(self): # Test creating a receipt with valid data diff --git a/tests/views/test_receipts_download.py b/tests/views/test_receipts_download.py index a67a136e1..26f274dd2 100644 --- a/tests/views/test_receipts_download.py +++ b/tests/views/test_receipts_download.py @@ -32,8 +32,8 @@ def setUp(self): user=self.log_in_user, image=SimpleUploadedFile("mock_image.jpg", b"image_content", "image/jpeg") ) self.token = ReceiptDownloadToken.objects.create(user=self.log_in_user, file=self.receipt) - self.download_receipt_url = reverse("api:receipts:download_receipt", args=[self.token.token]) - self.generate_download_link_url = reverse("api:receipts:generate_download_link", args=[self.receipt.id]) + self.download_receipt_url = reverse("api:finance:receipts:download_receipt", args=[self.token.token]) + self.generate_download_link_url = reverse("api:finance:receipts:generate_download_link", args=[self.receipt.id]) def test_download_receipt_valid_token(self): response = self.client.get(self.download_receipt_url) @@ -42,7 +42,7 @@ def test_download_receipt_valid_token(self): def test_download_receipt_invalid_token(self): invalid_token = uuid.uuid4() # Generate a valid UUID - invalid_url = reverse("api:receipts:download_receipt", args=[invalid_token]) + invalid_url = reverse("api:finance:receipts:download_receipt", args=[invalid_token]) response = self.client.get(invalid_url) self.assertEqual(response.status_code, 404) # Update expected status code @@ -58,7 +58,7 @@ def test_generate_download_link_valid_receipt(self): self.client.logout() def test_generate_download_link_invalid_receipt(self): - invalid_url = reverse("api:receipts:generate_download_link", args=[9999]) # Assuming 9999 is an invalid receipt id + invalid_url = reverse("api:finance:receipts:generate_download_link", args=[9999]) # Assuming 9999 is an invalid receipt id response = self.client.get(invalid_url) self.assertEqual(response.status_code, 404) self.client.logout() @@ -68,7 +68,7 @@ def test_download_receipt_user_mismatch(self): another_token = ReceiptDownloadToken.objects.create(user=another_user, file=self.receipt) self.client.login(username="user@example.com", password="user") - download_receipt_url = reverse("api:receipts:download_receipt", args=[another_token.token]) + download_receipt_url = reverse("api:finance:receipts:download_receipt", args=[another_token.token]) response = self.client.get(download_receipt_url) self.assertEqual(response.status_code, 403) self.assertEqual(response.content, b"Forbidden") diff --git a/tests/views/test_settings_teams.py b/tests/views/test_settings_teams.py index 8dfd6c896..ce6f80154 100644 --- a/tests/views/test_settings_teams.py +++ b/tests/views/test_settings_teams.py @@ -17,5 +17,5 @@ def test_teams_view_matches_with_urls_view(self): path = "/dashboard/teams/" func = resolve(path).func func_name = f"{func.__module__}.{func.__name__}" - self.assertEqual("backend.views.core.settings.teams.teams_dashboard_handler", func_name) + self.assertEqual("backend.core.views.settings.teams.teams_dashboard_handler", func_name) self.assertEqual(path, reverse("teams:dashboard")) diff --git a/tests/views/test_usersettings.py b/tests/views/test_usersettings.py index e2833635b..a88ce503a 100644 --- a/tests/views/test_usersettings.py +++ b/tests/views/test_usersettings.py @@ -19,4 +19,4 @@ def test_usersettings_view_matches_with_urls_view(self): func = resolve("/dashboard/settings/").func func_name = f"{func.__module__}.{func.__name__}" self.assertEqual("/dashboard/settings/profile/", reverse("settings:dashboard with page", args=("profile",))) - self.assertEqual("backend.views.core.settings.view.view_settings_page_endpoint", func_name) + self.assertEqual("backend.core.views.settings.view.view_settings_page_endpoint", func_name)