From ed17a07cd8f33c53737988b6f4716d8a29ca71b3 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:52:31 +0530 Subject: [PATCH 01/93] chore(saas): remove in-desk billing related codes (#2283) Removing in-desk billing panel, as we are moving frappecloud billing in framework - https://github.com/frappe/frappe/pull/28459 --- dashboard/src2/App.vue | 7 +- .../BuyPrepaidCreditsForm.vue | 147 ------- .../BuyPrepaidCreditsRazorpay.vue | 142 ------- .../BuyPrepaidCreditsStripe.vue | 183 --------- .../ChangePaymentModeDialog.vue | 99 ----- .../in_desk_checkout/InvoiceTable.vue | 178 -------- .../components/in_desk_checkout/PlanCard.vue | 57 --- .../in_desk_checkout/SitePlanCards.vue | 106 ----- .../in_desk_checkout/SitePlanChangeDialog.vue | 97 ----- .../in_desk_checkout/StripeCard.vue | 317 --------------- .../in_desk_checkout/UpdateAddressForm.vue | 245 ----------- dashboard/src2/pages/saas/InDeskBilling.vue | 132 ------ .../pages/saas/in_desk_billing/Invoices.vue | 298 -------------- .../pages/saas/in_desk_billing/Onboarding.vue | 316 --------------- .../pages/saas/in_desk_billing/Overview.vue | 379 ------------------ dashboard/src2/router.js | 30 -- 16 files changed, 2 insertions(+), 2731 deletions(-) delete mode 100644 dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsForm.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsRazorpay.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsStripe.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/ChangePaymentModeDialog.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/InvoiceTable.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/PlanCard.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/SitePlanCards.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/SitePlanChangeDialog.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/StripeCard.vue delete mode 100644 dashboard/src2/components/in_desk_checkout/UpdateAddressForm.vue delete mode 100644 dashboard/src2/pages/saas/InDeskBilling.vue delete mode 100644 dashboard/src2/pages/saas/in_desk_billing/Invoices.vue delete mode 100644 dashboard/src2/pages/saas/in_desk_billing/Onboarding.vue delete mode 100644 dashboard/src2/pages/saas/in_desk_billing/Overview.vue diff --git a/dashboard/src2/App.vue b/dashboard/src2/App.vue index 09201c3e7a4..a18a40a4f38 100644 --- a/dashboard/src2/App.vue +++ b/dashboard/src2/App.vue @@ -10,8 +10,7 @@ v-if=" $session.user && !$route.name?.startsWith('SaaSSignup') && - $route.name != 'SaaSLogin' && - !$route.name?.startsWith('IntegratedBilling') + $route.name != 'SaaSLogin' " /> @@ -22,15 +21,13 @@ !isHideSidebar && $session.user && !$route.name?.startsWith('SaaSSignup') && - $route.name != 'SaaSLogin' && - !$route.name?.startsWith('IntegratedBilling') + $route.name != 'SaaSLogin' " />
-
- - - - - - -
- -
-
Select Payment Gateway
-
- - -
-
- - - - - - diff --git a/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsRazorpay.vue b/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsRazorpay.vue deleted file mode 100644 index 44f469a43c2..00000000000 --- a/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsRazorpay.vue +++ /dev/null @@ -1,142 +0,0 @@ - - diff --git a/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsStripe.vue b/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsStripe.vue deleted file mode 100644 index 55bc2c5e92a..00000000000 --- a/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsStripe.vue +++ /dev/null @@ -1,183 +0,0 @@ - - diff --git a/dashboard/src2/components/in_desk_checkout/ChangePaymentModeDialog.vue b/dashboard/src2/components/in_desk_checkout/ChangePaymentModeDialog.vue deleted file mode 100644 index 89c2a8ad57b..00000000000 --- a/dashboard/src2/components/in_desk_checkout/ChangePaymentModeDialog.vue +++ /dev/null @@ -1,99 +0,0 @@ - - diff --git a/dashboard/src2/components/in_desk_checkout/InvoiceTable.vue b/dashboard/src2/components/in_desk_checkout/InvoiceTable.vue deleted file mode 100644 index 5c482a6681b..00000000000 --- a/dashboard/src2/components/in_desk_checkout/InvoiceTable.vue +++ /dev/null @@ -1,178 +0,0 @@ - - diff --git a/dashboard/src2/components/in_desk_checkout/PlanCard.vue b/dashboard/src2/components/in_desk_checkout/PlanCard.vue deleted file mode 100644 index fbe079063a1..00000000000 --- a/dashboard/src2/components/in_desk_checkout/PlanCard.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/dashboard/src2/components/in_desk_checkout/SitePlanCards.vue b/dashboard/src2/components/in_desk_checkout/SitePlanCards.vue deleted file mode 100644 index 62a53e8da63..00000000000 --- a/dashboard/src2/components/in_desk_checkout/SitePlanCards.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - diff --git a/dashboard/src2/components/in_desk_checkout/SitePlanChangeDialog.vue b/dashboard/src2/components/in_desk_checkout/SitePlanChangeDialog.vue deleted file mode 100644 index cc59362d62b..00000000000 --- a/dashboard/src2/components/in_desk_checkout/SitePlanChangeDialog.vue +++ /dev/null @@ -1,97 +0,0 @@ - - - diff --git a/dashboard/src2/components/in_desk_checkout/StripeCard.vue b/dashboard/src2/components/in_desk_checkout/StripeCard.vue deleted file mode 100644 index 60631844afd..00000000000 --- a/dashboard/src2/components/in_desk_checkout/StripeCard.vue +++ /dev/null @@ -1,317 +0,0 @@ - - - diff --git a/dashboard/src2/components/in_desk_checkout/UpdateAddressForm.vue b/dashboard/src2/components/in_desk_checkout/UpdateAddressForm.vue deleted file mode 100644 index 4f02b148103..00000000000 --- a/dashboard/src2/components/in_desk_checkout/UpdateAddressForm.vue +++ /dev/null @@ -1,245 +0,0 @@ - - - diff --git a/dashboard/src2/pages/saas/InDeskBilling.vue b/dashboard/src2/pages/saas/InDeskBilling.vue deleted file mode 100644 index 7cb26ffb473..00000000000 --- a/dashboard/src2/pages/saas/InDeskBilling.vue +++ /dev/null @@ -1,132 +0,0 @@ - - - diff --git a/dashboard/src2/pages/saas/in_desk_billing/Invoices.vue b/dashboard/src2/pages/saas/in_desk_billing/Invoices.vue deleted file mode 100644 index 1b5eb8d1693..00000000000 --- a/dashboard/src2/pages/saas/in_desk_billing/Invoices.vue +++ /dev/null @@ -1,298 +0,0 @@ - - diff --git a/dashboard/src2/pages/saas/in_desk_billing/Onboarding.vue b/dashboard/src2/pages/saas/in_desk_billing/Onboarding.vue deleted file mode 100644 index c58932746de..00000000000 --- a/dashboard/src2/pages/saas/in_desk_billing/Onboarding.vue +++ /dev/null @@ -1,316 +0,0 @@ - - diff --git a/dashboard/src2/pages/saas/in_desk_billing/Overview.vue b/dashboard/src2/pages/saas/in_desk_billing/Overview.vue deleted file mode 100644 index 3c7be33b375..00000000000 --- a/dashboard/src2/pages/saas/in_desk_billing/Overview.vue +++ /dev/null @@ -1,379 +0,0 @@ - - diff --git a/dashboard/src2/router.js b/dashboard/src2/router.js index a6833979002..1a95c3d669d 100644 --- a/dashboard/src2/router.js +++ b/dashboard/src2/router.js @@ -58,31 +58,6 @@ let router = createRouter({ isLoginPage: true } }, - { - path: '/in-desk-billing/:accessToken', - name: 'IntegratedBilling', - component: () => import('./pages/saas/InDeskBilling.vue'), - children: [ - { - path: '', - redirect: { name: 'IntegratedBillingOverview' } - }, - { - path: 'overview', - name: 'IntegratedBillingOverview', - component: () => import('./pages/saas/in_desk_billing/Overview.vue') - }, - { - path: 'invoices', - name: 'IntegratedBillingInvoices', - component: () => import('./pages/saas/in_desk_billing/Invoices.vue') - } - ], - props: false, - meta: { - isLoginPage: true - } - }, { path: '/subscription/:site?', name: 'Subscription', @@ -322,11 +297,6 @@ router.beforeEach(async (to, from, next) => { !document.cookie.includes('user_id=Guest'); let goingToLoginPage = to.matched.some(record => record.meta.isLoginPage); - if (to.name.startsWith('IntegratedBilling')) { - next(); - return; - } - // if user is trying to access saas login page, allow irrespective of login status if (to.name == 'SaaSLogin') { next(); From 07c5cc91347e410b353b3cea7daf16882b25ed80 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Thu, 14 Nov 2024 14:25:59 +0530 Subject: [PATCH 02/93] perf(aws): Set snapshot status if a Virtual Disk Snapshot already exists Affects Virtual Disk Snapshot incorrectly marked as Unavailable --- .../virtual_disk_snapshot.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/press/press/doctype/virtual_disk_snapshot/virtual_disk_snapshot.py b/press/press/doctype/virtual_disk_snapshot/virtual_disk_snapshot.py index 8a147a9758c..5a220524f0a 100644 --- a/press/press/doctype/virtual_disk_snapshot/virtual_disk_snapshot.py +++ b/press/press/doctype/virtual_disk_snapshot/virtual_disk_snapshot.py @@ -184,12 +184,8 @@ def sync_all_snapshots_from_aws(): if _should_skip_snapshot(snapshot): continue try: - frappe.db.set_value( - "Virtual Disk Snapshot", - {"snapshot_id": snapshot["SnapshotId"]}, - "status", - random_snapshot.get_aws_status_map(snapshot["State"]), - ) + if _update_snapshot_if_exists(snapshot, random_snapshot): + continue tag_name = next(tag["Value"] for tag in snapshot["Tags"] if tag["Key"] == "Name") virtual_machine = tag_name.split(" - ")[1] _insert_snapshot(snapshot, virtual_machine, random_snapshot) @@ -240,3 +236,15 @@ def _should_skip_snapshot(snapshot): return True return False + + +def _update_snapshot_if_exists(snapshot, random_snapshot): + snapshot_id = snapshot["SnapshotId"] + if frappe.db.exists("Virtual Disk Snapshot", {"snapshot_id": snapshot_id}): + frappe.db.set_value( + "Virtual Disk Snapshot", + {"snapshot_id": snapshot_id}, + "status", + random_snapshot.get_aws_status_map(snapshot["State"]), + ) + return False From 7612bfde57b1dfb060e2f557fabb5cbbce7809c5 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:54:03 +0530 Subject: [PATCH 03/93] fix(SiteAPI): use get_value instead of get_doc for fetching few values in frappe latest --- press/api/site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/press/api/site.py b/press/api/site.py index bc47e40a36e..c9ec143f463 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -1295,7 +1295,7 @@ def get_installed_apps(site, query_filters: dict | None = None): "enabled": 1, }, ): - subscription = frappe.get_doc( + subscription = frappe.get_value( "Subscription", { "site": site.name, @@ -1304,6 +1304,7 @@ def get_installed_apps(site, query_filters: dict | None = None): "enabled": 1, }, ["document_name as app", "plan"], + as_dict=True, ) app_source.subscription = subscription marketplace_app_info = frappe.db.get_value( From ce8dc730a24de0fe74aadc03934cd4f778eaefe5 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:05:23 +0530 Subject: [PATCH 04/93] fix(SiteAPI): 'get_installed_apps fn use column's alias for accessing data --- press/api/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/api/site.py b/press/api/site.py index c9ec143f463..57e29912d5b 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -1308,7 +1308,7 @@ def get_installed_apps(site, query_filters: dict | None = None): ) app_source.subscription = subscription marketplace_app_info = frappe.db.get_value( - "Marketplace App", subscription.document_name, ["title", "image"], as_dict=True + "Marketplace App", subscription.app, ["title", "image"], as_dict=True ) app_source.app_title = marketplace_app_info.title From 86598e79917bfe30dafd5e943dde38117f8a4ea6 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Fri, 15 Nov 2024 16:21:02 +0530 Subject: [PATCH 05/93] fix(GroupSites): send empty dict as args to avoid `unexpected keyword argument` error https://trace.frappe.cloud/organizations/frappe/issues/30562/?project=2&query=frappe_trace_id%3A09e1a470-20f6-404b-9069-2357b2832eee&referrer=issue-stream&statsPeriod=14d&stream_index=0 --- dashboard/src2/pages/ReleaseGroupBenchSites.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src2/pages/ReleaseGroupBenchSites.vue b/dashboard/src2/pages/ReleaseGroupBenchSites.vue index 876b0b4abe6..2cb2b02ab26 100644 --- a/dashboard/src2/pages/ReleaseGroupBenchSites.vue +++ b/dashboard/src2/pages/ReleaseGroupBenchSites.vue @@ -323,7 +323,7 @@ export default { variant: 'solid', theme: 'red', onClick: ({ hide }) => { - toast.promise(this.$bench(bench.name).restart.submit(), { + toast.promise(this.$bench(bench.name).restart.submit({}), { loading: 'Restarting bench...', success: () => { hide(); From bac441d22aa99f86c9f0129f1329445a80f777b9 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Fri, 15 Nov 2024 16:54:40 +0530 Subject: [PATCH 06/93] perf: Add site index on Site Migration DocType --- press/press/doctype/site_migration/site_migration.json | 5 +++-- press/press/doctype/site_migration/site_migration.py | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/press/press/doctype/site_migration/site_migration.json b/press/press/doctype/site_migration/site_migration.json index 6ff1ba34713..b3d69acaa3f 100644 --- a/press/press/doctype/site_migration/site_migration.json +++ b/press/press/doctype/site_migration/site_migration.json @@ -31,7 +31,8 @@ "in_standard_filter": 1, "label": "Site", "options": "Site", - "reqd": 1 + "reqd": 1, + "search_index": 1 }, { "fetch_from": "site.bench", @@ -155,7 +156,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-06-14 16:45:36.956416", + "modified": "2024-11-15 16:53:44.667863", "modified_by": "Administrator", "module": "Press", "name": "Site Migration", diff --git a/press/press/doctype/site_migration/site_migration.py b/press/press/doctype/site_migration/site_migration.py index 839f49ccca1..5c2f4a4cf18 100644 --- a/press/press/doctype/site_migration/site_migration.py +++ b/press/press/doctype/site_migration/site_migration.py @@ -55,9 +55,7 @@ class SiteMigration(Document): if TYPE_CHECKING: from frappe.types import DF - from press.press.doctype.site_migration_step.site_migration_step import ( - SiteMigrationStep, - ) + from press.press.doctype.site_migration_step.site_migration_step import SiteMigrationStep backup: DF.Link | None destination_bench: DF.Link From b335065f4c49c597af78522a60fd3179d96e5415 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Mon, 18 Nov 2024 13:16:33 +0530 Subject: [PATCH 07/93] fix(oci): Sync OCI machines in batches --- .../virtual_machine/virtual_machine.py | 63 ++++++++++++------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/press/press/doctype/virtual_machine/virtual_machine.py b/press/press/doctype/virtual_machine/virtual_machine.py index 58d3c0a1c5b..da58314f4b0 100644 --- a/press/press/doctype/virtual_machine/virtual_machine.py +++ b/press/press/doctype/virtual_machine/virtual_machine.py @@ -1013,7 +1013,7 @@ def reboot_with_serial_console(self): def bulk_sync_aws(cls): for cluster in frappe.get_all( "Virtual Machine", - ["cluster", "max(`index`) as max_index"], + ["cluster", "cloud_provider", "max(`index`) as max_index"], { "status": ("not in", ("Terminated", "Draft")), "cloud_provider": "AWS EC2", @@ -1028,7 +1028,9 @@ def bulk_sync_aws(cls): for start, end in chunks: # Pick a random machine # TODO: This probably should be a method on the Cluster - machines = cls._get_active_aws_machines_within_chunk_range(cluster.cluster, start, end) + machines = cls._get_active_machines_within_chunk_range( + cluster.cloud_provider, cluster.cluster, start, end + ) if not machines: # There might not be any running machines in the chunk range continue @@ -1046,7 +1048,9 @@ def bulk_sync_aws(cls): def bulk_sync_aws_cluster(self, start, end): client = self.client() - machines = self.__class__._get_active_aws_machines_within_chunk_range(self.cluster, start, end) + machines = self.__class__._get_active_machines_within_chunk_range( + self.cloud_provider, self.cluster, start, end + ) instance_ids = [machine.instance_id for machine in machines] response = client.describe_instances(Filters=[{"Name": "instance-id", "Values": instance_ids}]) for reservation in response["Reservations"]: @@ -1062,13 +1066,13 @@ def bulk_sync_aws_cluster(self, start, end): frappe.db.rollback() @classmethod - def _get_active_aws_machines_within_chunk_range(cls, cluster, start, end): + def _get_active_machines_within_chunk_range(cls, provider, cluster, start, end): return frappe.get_all( "Virtual Machine", fields=["name", "instance_id"], filters=[ ["status", "not in", ("Terminated", "Draft")], - ["cloud_provider", "=", "AWS EC2"], + ["cloud_provider", "=", provider], ["cluster", "=", cluster], ["instance_id", "is", "set"], ["index", ">=", start], @@ -1080,34 +1084,49 @@ def _get_active_aws_machines_within_chunk_range(cls, cluster, start, end): def bulk_sync_oci(cls): for cluster in frappe.get_all( "Virtual Machine", - ["cluster"], - {"status": ("not in", ("Terminated", "Draft")), "cloud_provider": "OCI"}, + ["cluster", "cloud_provider", "max(`index`) as max_index"], + { + "status": ("not in", ("Terminated", "Draft")), + "cloud_provider": "OCI", + }, group_by="cluster", - pluck="cluster", ): - # Pick a random machine - # TODO: This probably should be a method on the Cluster - machine = frappe.get_doc( - "Virtual Machine", - { - "status": ("not in", ("Terminated", "Draft")), - "cloud_provider": "OCI", - "cluster": cluster, - }, - ) + CHUNK_SIZE = 15 # Each call will pick up ~30 machines (2 x CHUNK_SIZE) + # Generate closed bounds for 15 indexes at a time + # (1, 15), (16, 30), (31, 45), ... + # We might have uneven chunks because of missing indexes + chunks = [(ii, ii + CHUNK_SIZE - 1) for ii in range(1, cluster.max_index, CHUNK_SIZE)] + for start, end in chunks: + # Pick a random machine + # TODO: This probably should be a method on the Cluster + machines = cls._get_active_machines_within_chunk_range( + cluster.cloud_provider, cluster.cluster, start, end + ) + if not machines: + # There might not be any running machines in the chunk range + continue + frappe.enqueue_doc( - machine.doctype, - machine.name, + "Virtual Machine", + machines[0].name, method="bulk_sync_oci_cluster", + start=start, + end=end, queue="sync", - job_id=f"bulk_sync_oci:{machine.cluster}", + job_id=f"bulk_sync_oci:{machine.cluster}:{start}-{end}", deduplicate=True, ) - def bulk_sync_oci_cluster(self): + def bulk_sync_oci_cluster(self, start, end): cluster = frappe.get_doc("Cluster", self.cluster) + machines = self.__class__._get_active_machines_within_chunk_range( + self.cloud_provider, self.cluster, start, end + ) + instance_ids = set([machine.instance_id for machine in machines]) response = self.client().list_instances(compartment_id=cluster.oci_tenancy).data for instance in response: + if instance.id not in instance_ids: + continue machine: VirtualMachine = frappe.get_doc("Virtual Machine", {"instance_id": instance.id}) if has_job_timeout_exceeded(): return From 7a9dede07c8cedb605030f39015af7f59e988720 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Mon, 18 Nov 2024 13:21:38 +0530 Subject: [PATCH 08/93] fix(oci): Typo --- press/press/doctype/virtual_machine/virtual_machine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/press/doctype/virtual_machine/virtual_machine.py b/press/press/doctype/virtual_machine/virtual_machine.py index da58314f4b0..d9b8b5a0045 100644 --- a/press/press/doctype/virtual_machine/virtual_machine.py +++ b/press/press/doctype/virtual_machine/virtual_machine.py @@ -1113,7 +1113,7 @@ def bulk_sync_oci(cls): start=start, end=end, queue="sync", - job_id=f"bulk_sync_oci:{machine.cluster}:{start}-{end}", + job_id=f"bulk_sync_oci:{cluster.cluster}:{start}-{end}", deduplicate=True, ) From 5b9f9714a04730bcc246f2e46782d8d0867ebf40 Mon Sep 17 00:00:00 2001 From: Shariq Ansari <30859809+shariquerik@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:24:03 +0530 Subject: [PATCH 09/93] fix: added some more missing api's (#2285) --- press/api/account.py | 2 +- press/api/site.py | 1 + press/saas/api/billing.py | 10 ++++++++++ press/saas/api/site.py | 17 ++++++++++++++--- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/press/api/account.py b/press/api/account.py index d5b4a3c1c0d..e2b5929aabf 100644 --- a/press/api/account.py +++ b/press/api/account.py @@ -477,7 +477,7 @@ def signup_settings(product=None, fetch_countries=False, timezone=None): product_trial = frappe.db.get_value( "Product Trial", {"name": product, "published": 1}, - ["title", "description", "logo"], + ["title", "logo"], as_dict=1, ) diff --git a/press/api/site.py b/press/api/site.py index 57e29912d5b..6ea525cee77 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -785,6 +785,7 @@ def get_site_plans(): "private_benches", "monitor_access", "dedicated_server_plan", + "is_trial_plan", "allow_downgrading_from_other_plan", ], # TODO: Remove later, temporary change because site plan has all document_type plans diff --git a/press/saas/api/billing.py b/press/saas/api/billing.py index 8901a9ca6b4..8e8044b21ba 100644 --- a/press/saas/api/billing.py +++ b/press/saas/api/billing.py @@ -91,6 +91,7 @@ def get_invoices(): "stripe_payment_failed", ], filters={"team": frappe.local.team_name}, + order_by="due_date desc, creation desc", ) @@ -99,6 +100,15 @@ def upcoming_invoice(): return billing_api.upcoming_invoice() +@whitelist_saas_api +def get_unpaid_invoices(): + invoices = billing_api.unpaid_invoices() + unpaid_invoices = [invoice for invoice in invoices if invoice.status == "Unpaid"] + if len(unpaid_invoices) == 1: + return get_invoice(unpaid_invoices[0].name) + return unpaid_invoices + + @whitelist_saas_api def total_unpaid_amount(): return billing_api.total_unpaid_amount() diff --git a/press/saas/api/site.py b/press/saas/api/site.py index c1d5fa3e675..13bae69cf4b 100644 --- a/press/saas/api/site.py +++ b/press/saas/api/site.py @@ -1,10 +1,10 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe and contributors # For license information, please see license.txt import frappe -from press.saas.api import whitelist_saas_api + from press.api import site as site_api +from press.saas.api import whitelist_saas_api @whitelist_saas_api @@ -13,14 +13,25 @@ def info(): return { "name": frappe.local.site_name, "trial_end_date": frappe.get_value("Site", frappe.local.site_name, "trial_end_date"), - "plan": frappe.get_doc("Site Plan", site.plan) + "plan": frappe.get_doc("Site Plan", site.plan), } + @whitelist_saas_api def change_plan(plan: str): site = frappe.local.get_site() site.set_plan(plan) + @whitelist_saas_api def get_plans(): return site_api.get_site_plans() + + +@whitelist_saas_api +def get_first_support_plan(): + plans = get_plans() + for plan in plans: + if plan.support_included and not plan.is_trial_plan: + return plan + return None From bea3b4e0a854ef1ea009a3042de75feb72001875 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:26:35 +0530 Subject: [PATCH 10/93] chore(DripEmail): add flag to skip sites having paid plan (#2278) --- .../press/doctype/drip_email/drip_email.json | 9 ++- press/press/doctype/drip_email/drip_email.py | 33 +++++---- .../doctype/drip_email/test_drip_email.py | 68 ++++++++++++++++--- .../press/doctype/site_plan/test_site_plan.py | 2 + 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/press/press/doctype/drip_email/drip_email.json b/press/press/doctype/drip_email/drip_email.json index cdd5216981e..7bcaefa9d06 100644 --- a/press/press/doctype/drip_email/drip_email.json +++ b/press/press/doctype/drip_email/drip_email.json @@ -21,6 +21,7 @@ "section_break_9", "message", "section_break_4", + "skip_sites_with_paid_plan", "send_after", "send_after_payment", "minimum_activation_level", @@ -201,11 +202,17 @@ "fieldtype": "Link", "label": "Saas App", "options": "Marketplace App" + }, + { + "default": "0", + "fieldname": "skip_sites_with_paid_plan", + "fieldtype": "Check", + "label": "Skip Sites With Paid Plan" } ], "icon": "icon-envelope", "links": [], - "modified": "2022-08-24 17:58:28.497406", + "modified": "2024-11-13 17:55:29.705979", "modified_by": "Administrator", "module": "Press", "name": "Drip Email", diff --git a/press/press/doctype/drip_email/drip_email.py b/press/press/doctype/drip_email/drip_email.py index 55126cded40..6c94b47baac 100644 --- a/press/press/doctype/drip_email/drip_email.py +++ b/press/press/doctype/drip_email/drip_email.py @@ -1,17 +1,17 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2015, Web Notes and contributors # For license information, please see license.txt +from __future__ import annotations from datetime import timedelta -from typing import Dict, List -import rq import frappe -from frappe.model.document import Document -from frappe.utils.make_random import get_random +import rq import rq.exceptions import rq.timeouts +from frappe.model.document import Document +from frappe.utils.make_random import get_random + from press.utils import log_error @@ -50,6 +50,7 @@ class DripEmail(Document): sender: DF.Data sender_name: DF.Data services: DF.Check + skip_sites_with_paid_plan: DF.Check subject: DF.SmallText # end: auto-generated types @@ -119,7 +120,7 @@ def select_consultant(self, site) -> str: self.sender_name = consultant.full_name return consultant - def get_setup_guides(self, account_request) -> List[Dict[str, str]]: + def get_setup_guides(self, account_request) -> list[dict[str, str]]: if not account_request: return [] @@ -127,9 +128,7 @@ def get_setup_guides(self, account_request) -> List[Dict[str, str]]: for guide in self.module_setup_guide: if account_request.industry == guide.industry: attachments.append( - frappe.db.get_value( - "File", {"file_url": guide.setup_guide}, ["name as fid"], as_dict=1 - ) + frappe.db.get_value("File", {"file_url": guide.setup_guide}, ["name as fid"], as_dict=1) ) return attachments @@ -143,6 +142,15 @@ def sites_to_send_drip(self): if self.saas_app: conditions += f'AND site.standby_for = "{self.saas_app}"' + if self.skip_sites_with_paid_plan: + paid_site_plans = frappe.get_all( + "Site Plan", {"enabled": True, "is_trial_plan": False, "document_type": "Site"}, pluck="name" + ) + + if paid_site_plans: + paid_site_plans_str = ", ".join(f"'{plan}'" for plan in paid_site_plans) + conditions += f" AND site.plan NOT IN ({paid_site_plans_str})" + sites = frappe.db.sql( f""" SELECT @@ -159,8 +167,7 @@ def sites_to_send_drip(self): {conditions} """ ) - sites = [t[0] for t in sites] - return sites + return [t[0] for t in sites] # site names def send_to_sites(self): sites = self.sites_to_send_drip @@ -201,9 +208,7 @@ def send_drip_emails(): def send_welcome_email(): """Send welcome email to sites created in last 15 minutes.""" - welcome_drips = frappe.db.get_all( - "Drip Email", {"email_type": "Sign Up", "enabled": 1}, pluck="name" - ) + welcome_drips = frappe.db.get_all("Drip Email", {"email_type": "Sign Up", "enabled": 1}, pluck="name") for drip in welcome_drips: welcome_email = frappe.get_doc("Drip Email", drip) _15_mins_ago = frappe.utils.add_to_date(None, minutes=-15) diff --git a/press/press/doctype/drip_email/test_drip_email.py b/press/press/doctype/drip_email/test_drip_email.py index eb93acde6da..f7a1b1cdda4 100644 --- a/press/press/doctype/drip_email/test_drip_email.py +++ b/press/press/doctype/drip_email/test_drip_email.py @@ -1,11 +1,11 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2015, Web Notes and Contributors # See license.txt +from __future__ import annotations import unittest from datetime import date, timedelta -from typing import Optional +from typing import TYPE_CHECKING import frappe @@ -13,15 +13,18 @@ create_test_account_request, ) from press.press.doctype.app.test_app import create_test_app -from press.press.doctype.drip_email.drip_email import DripEmail from press.press.doctype.marketplace_app.test_marketplace_app import ( create_test_marketplace_app, ) from press.press.doctype.site.test_site import create_test_site +from press.press.doctype.site_plan_change.test_site_plan_change import create_test_plan + +if TYPE_CHECKING: + from press.press.doctype.drip_email.drip_email import DripEmail def create_test_drip_email( - send_after: int, saas_app: Optional[str] = None + send_after: int, saas_app: str | None = None, skip_sites_with_paid_plan: bool = False ) -> DripEmail: drip_email = frappe.get_doc( { @@ -32,6 +35,7 @@ def create_test_drip_email( "message": "Drip Top, Drop Top", "send_after": send_after, "saas_app": saas_app, + "skip_sites_with_paid_plan": skip_sites_with_paid_plan, } ).insert(ignore_if_duplicate=True) drip_email.reload() @@ -39,6 +43,10 @@ def create_test_drip_email( class TestDripEmail(unittest.TestCase): + def setUp(self) -> None: + self.trial_site_plan = create_test_plan("Site", is_trial_plan=True) + self.paid_site_plan = create_test_plan("Site", is_trial_plan=False) + def tearDown(self): frappe.db.rollback() @@ -57,9 +65,7 @@ def test_correct_sites_are_selected_for_drip_email(self): ) site1.save() - site2 = create_test_site( - "site2", account_request=create_test_account_request("site2").name - ) + site2 = create_test_site("site2", account_request=create_test_account_request("site2").name) site2.save() create_test_site("site3") # Note: site is not created @@ -69,8 +75,50 @@ def test_correct_sites_are_selected_for_drip_email(self): def test_older_site_isnt_selected(self): drip_email = create_test_drip_email(0) site = create_test_site("site1") - site.account_request = create_test_account_request( - "site1", creation=date.today() - timedelta(1) - ).name + site.account_request = create_test_account_request("site1", creation=date.today() - timedelta(1)).name site.save() self.assertNotEqual(drip_email.sites_to_send_drip, [site.name]) + + def test_drip_emails_not_sent_to_sites_with_paid_plan_having_special_flag(self): + """ + If you enable `skip_sites_with_paid_plan` flag, drip emails should not be sent to sites with paid plan set + No matter whether they have paid for any invoice or not + """ + test_app = create_test_app() + test_marketplace_app = create_test_marketplace_app(test_app.name) + + drip_email = create_test_drip_email( + 0, saas_app=test_marketplace_app.name, skip_sites_with_paid_plan=True + ) + + site1 = create_test_site( + "site1", + standby_for=test_marketplace_app.name, + account_request=create_test_account_request( + "site1", saas=True, saas_app=test_marketplace_app.name + ).name, + plan=self.trial_site_plan.name, + ) + site1.save() + + site2 = create_test_site( + "site2", + standby_for=test_marketplace_app.name, + account_request=create_test_account_request( + "site2", saas=True, saas_app=test_marketplace_app.name + ).name, + plan=self.paid_site_plan.name, + ) + site2.save() + + site3 = create_test_site( + "site3", + standby_for=test_marketplace_app.name, + account_request=create_test_account_request( + "site3", saas=True, saas_app=test_marketplace_app.name + ).name, + plan=self.trial_site_plan.name, + ) + site3.save() + + self.assertEqual(drip_email.sites_to_send_drip, [site1.name, site3.name]) diff --git a/press/press/doctype/site_plan/test_site_plan.py b/press/press/doctype/site_plan/test_site_plan.py index 646caf6e399..523426c03d2 100644 --- a/press/press/doctype/site_plan/test_site_plan.py +++ b/press/press/doctype/site_plan/test_site_plan.py @@ -22,6 +22,7 @@ def create_test_plan( allowed_apps: list[str] | None = None, release_groups: list[str] | None = None, private_benches: bool = False, + is_trial_plan: bool = False, ): """Create test Plan doc.""" plan_name = plan_name or f"Test {document_type} plan {make_autoname('.#')}" @@ -39,6 +40,7 @@ def create_test_plan( "disk": 50, "instance_type": "t2.micro", "private_benches": private_benches, + "is_trial_plan": is_trial_plan, } ) if allowed_apps: From 7c029fb6b49f36fe8d5b29d59c8dd354dfdc1ec5 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:30:06 +0530 Subject: [PATCH 11/93] feat(saas): google oauth login and signup for new saas flow (#2286) --- dashboard/src2/pages/saas/Login.vue | 28 +++- .../src2/pages/saas/OAuthSetupAccount.vue | 152 ++++++++++++++++++ dashboard/src2/pages/saas/Signup.vue | 21 ++- dashboard/src2/router.js | 7 + press/api/account.py | 4 +- press/api/google.py | 124 +++++++------- press/api/product_trial.py | 12 +- .../account_request/account_request.py | 18 +-- 8 files changed, 284 insertions(+), 82 deletions(-) create mode 100644 dashboard/src2/pages/saas/OAuthSetupAccount.vue diff --git a/dashboard/src2/pages/saas/Login.vue b/dashboard/src2/pages/saas/Login.vue index aa3ba5eb599..21ed7dfff6e 100644 --- a/dashboard/src2/pages/saas/Login.vue +++ b/dashboard/src2/pages/saas/Login.vue @@ -59,13 +59,20 @@ > Login with email - +
@@ -150,6 +157,9 @@ export default { computed: { saasProduct() { return this.$resources.signupSettings.data?.product_trial || {}; + }, + isGoogleOAuthEnabled() { + return this.$resources.signupSettings.data?.enable_google_oauth || false; } }, resources: { @@ -198,6 +208,18 @@ export default { this.moveToSiteLoginPage(data); } }; + }, + signupWithOAuth() { + return { + url: 'press.api.google.login', + params: { + product: this.productId + }, + auto: false, + onSuccess(url) { + window.location.href = url; + } + }; } }, methods: { diff --git a/dashboard/src2/pages/saas/OAuthSetupAccount.vue b/dashboard/src2/pages/saas/OAuthSetupAccount.vue new file mode 100644 index 00000000000..e399d3dea80 --- /dev/null +++ b/dashboard/src2/pages/saas/OAuthSetupAccount.vue @@ -0,0 +1,152 @@ + + diff --git a/dashboard/src2/pages/saas/Signup.vue b/dashboard/src2/pages/saas/Signup.vue index d19bf80824b..32542391374 100644 --- a/dashboard/src2/pages/saas/Signup.vue +++ b/dashboard/src2/pages/saas/Signup.vue @@ -94,9 +94,11 @@ > Create Account -

or

+

or

+
+ + Already have an account? Log in. + +
diff --git a/dashboard/src2/router.js b/dashboard/src2/router.js index de0244ba03f..aad1182b031 100644 --- a/dashboard/src2/router.js +++ b/dashboard/src2/router.js @@ -214,7 +214,9 @@ let router = createRouter({ path: ':productId/login', component: () => import('./pages/saas/Login.vue'), props: true, - meta: { isLoginPage: true } + meta: { + isLoginPage: true + } }, { name: 'SaaSSignup', @@ -305,7 +307,14 @@ router.beforeEach(async (to, from, next) => { let goingToLoginPage = to.matched.some(record => record.meta.isLoginPage); // if user is trying to access saas login page, allow irrespective of login status - if (to.name == 'SaaSLogin') { + if ( + [ + 'SaaSLogin', + 'SaaSSignup', + 'SaaSSignupVerifyEmail', + 'SaaSSignupOAuthSetupAccount' + ].includes(to.name) + ) { next(); return; } @@ -328,12 +337,6 @@ router.beforeEach(async (to, from, next) => { } } - // If user is logged in and was moving to app trial signup, redirect to app trial setup - if (to.name == 'SaaSSignup') { - next({ name: 'SaaSSignupSetup', params: to.params }); - return; - } - // if team owner/admin enforce 2fa and user has not enabled 2fa, redirect to enable 2fa const Enable2FARoute = 'Enable2FA'; if ( diff --git a/press/api/google.py b/press/api/google.py index e3d081b40e3..cc08461311d 100644 --- a/press/api/google.py +++ b/press/api/google.py @@ -14,7 +14,7 @@ from googleapiclient.discovery import build from oauthlib.oauth2 import AccessDeniedError -from press.api.product_trial import _get_active_site as get_active_site_product_trial +from press.api.product_trial import _get_active_site as get_active_site_of_product_trial from press.utils import log_error @@ -47,16 +47,6 @@ def _redirect_to_login_on_failed_authentication(): else: frappe.local.response.location = "/dashboard/login" - def _redirect_to_target_on_successful_authentication(team_name: str | None = None): - frappe.local.response.type = "redirect" - if product_trial: - if get_active_site_product_trial(product_trial.name, team_name): - frappe.local.response.location = f"/dashboard/saas/{product_trial.name}/login-to-site" - else: - frappe.local.response.location = f"/dashboard/saas/{product_trial.name}/setup" - else: - frappe.local.response.location = "/dashboard" - try: flow = google_oauth_flow() flow.fetch_token(authorization_response=frappe.request.url) @@ -97,10 +87,12 @@ def _redirect_to_target_on_successful_authentication(team_name: str | None = Non frappe.throw(_("Account {0} has been deactivated").format(email)) return None - if team_name: + # if team exitst and oauth is not using in saas login/signup flow + if team_name and not product_trial: # login to existing account frappe.local.login_manager.login_as(email) - _redirect_to_target_on_successful_authentication() + frappe.local.response.type = "redirect" + frappe.local.response.location = "/dashboard" return None # create account request @@ -112,16 +104,28 @@ def _redirect_to_target_on_successful_authentication(team_name: str | None = Non phone_number=phone_number, role="Press Admin", oauth_signup=True, + product_trial=product_trial.name if product_trial else None, ) - if product_trial: - account_request.product_trial = product_trial.name - account_request.insert(ignore_permissions=True) - frappe.db.commit() - frappe.local.response.type = "redirect" - frappe.local.response.location = account_request.get_verification_url() + if team_name and product_trial: + frappe.local.login_manager.login_as(email) + active_site = get_active_site_of_product_trial(product_trial.name, team_name) + frappe.local.response.type = "redirect" + if active_site: + product_trial_request = frappe.get_value( + "Product Trial Request", {"site": active_site, "product_trial": product}, ["name"], as_dict=1 + ) + frappe.local.response.location = f"/dashboard/saas/{product_trial.name}/login-to-site?product_trial_request={product_trial_request.name}" + else: + frappe.local.response.location = ( + f"/dashboard/saas/{product_trial.name}/setup?account_request={account_request.name}" + ) + else: + # create/setup account + frappe.local.response.type = "redirect" + frappe.local.response.location = account_request.get_verification_url() return None diff --git a/press/api/product_trial.py b/press/api/product_trial.py index a181bb5e4b1..60bd6e08db3 100644 --- a/press/api/product_trial.py +++ b/press/api/product_trial.py @@ -189,31 +189,34 @@ def setup_account(key: str, country: str | None = None): frappe.local.login_manager.login_as(ar.email) if _get_active_site(ar.product_trial, team.name): return { - "location": f"/dashboard/saas/{ar.product_trial}/login-to-site", + "account_request": ar.name, + "location": f"/dashboard/saas/{ar.product_trial}/login-to-site?account_request={ar.name}", } return { - "location": f"/dashboard/saas/{ar.product_trial}/setup", + "account_request": ar.name, + "location": f"/dashboard/saas/{ar.product_trial}/setup?account_request={ar.name}", } @frappe.whitelist(methods=["POST"]) -def get_request(product): +def get_request(product: str, account_request: str | None = None): team = frappe.local.team() # validate if there is already a site site = _get_active_site(product, team.name) if site: site_request = frappe.get_doc( - "Product Trial Request", - {"product_trial": product, "team": team, "site": site}, - pluck="site", + "Product Trial Request", {"product_trial": product, "team": team, "site": site} ) else: + # check if account request is valid + is_valid_account_request = frappe.get_value("Account Request", account_request, "email") == team.user # create a new one site_request = frappe.new_doc( "Product Trial Request", product_trial=product, team=team.name, + account_request=account_request if is_valid_account_request else None, ).insert(ignore_permissions=True) return { From 15cbb7354c67383ea16feedc8505ad2d88c52c0a Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Tue, 19 Nov 2024 14:59:13 +0530 Subject: [PATCH 13/93] fix(SitePlan): Show Product Warranty on supported plans only --- dashboard/src2/components/SitePlansCards.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src2/components/SitePlansCards.vue b/dashboard/src2/components/SitePlansCards.vue index 949ef1ce166..d63530b600c 100644 --- a/dashboard/src2/components/SitePlansCards.vue +++ b/dashboard/src2/components/SitePlansCards.vue @@ -106,7 +106,7 @@ export default { value: this.$format.bytes(plan.max_storage_usage, 1, 2) }, { - value: 'Product Warranty' + value: plan.support_included ? 'Product Warranty' : '' }, { value: plan.support_included ? 'Support Included' : '' From fee9e13373ab4ff7d2eb277dc681fbead31a6b6d Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Tue, 19 Nov 2024 12:31:35 +0530 Subject: [PATCH 14/93] fix: ignore `RateLimitError` these are thrown when users get ratelimited for endpoints like login/signup --- dashboard/src2/main.js | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/src2/main.js b/dashboard/src2/main.js index 2fd7dd7c2bc..2a9555ab5ef 100644 --- a/dashboard/src2/main.js +++ b/dashboard/src2/main.js @@ -94,6 +94,7 @@ getInitialData().then(() => { 'SecurityException', 'AAAARecordExists', 'AuthenticationError', + 'RateLimitExceededError', 'InsufficientSpaceOnServer' ]; const error = hint.originalException; From d0a4b523db48fa57ba0e475d74b3014096ca5ea2 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Tue, 19 Nov 2024 14:47:10 +0530 Subject: [PATCH 15/93] fix(LinkControl): ensure currentValue is not null before copying https://trace.frappe.cloud/organizations/frappe/issues/34678/events/e1e2b3b373bf4593977a70e003031fa8/?project=14 --- dashboard/src2/components/LinkControl.vue | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dashboard/src2/components/LinkControl.vue b/dashboard/src2/components/LinkControl.vue index ffe69b655e6..dc4d23a51b7 100644 --- a/dashboard/src2/components/LinkControl.vue +++ b/dashboard/src2/components/LinkControl.vue @@ -64,8 +64,8 @@ export default { }, computed: { autocompleteOptions() { - let options = this.$resources.options.data || []; - let currentValueInOptions = options.find( + const options = this.$resources.options.data || []; + const currentValueInOptions = options.find( o => o.value === this.modelValue ); @@ -73,9 +73,14 @@ export default { this.currentValidValueInOptions = currentValueInOptions; } - if (this.modelValue && !currentValueInOptions) { + if ( + this.modelValue && + !currentValueInOptions && + this.currentValidValueInOptions + ) { options = [this.currentValidValueInOptions, ...options]; } + return options; } } From b3a6771b65f299de25cff7d04dda2f0c89450e84 Mon Sep 17 00:00:00 2001 From: Shadrak Gurupnor Date: Tue, 19 Nov 2024 15:03:28 +0530 Subject: [PATCH 16/93] v0.0.0 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 395e72fc990..6b0c3466795 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,6 @@ "frappe-ui": "^0.1.70", "fuse.js": "^6.6.2", "libarchive.js": "^1.3.0" - } + }, + "version": "0.0.0" } From 59f2015f87af572cefd88e65e454ba9c384f865f Mon Sep 17 00:00:00 2001 From: Shadrak Gurupnor Date: Tue, 19 Nov 2024 15:50:10 +0530 Subject: [PATCH 17/93] fix(ux): update billing address before adding credits --- dashboard/src2/pages/BillingOverview.vue | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/dashboard/src2/pages/BillingOverview.vue b/dashboard/src2/pages/BillingOverview.vue index 89fcc80ca7a..41e92022c0f 100644 --- a/dashboard/src2/pages/BillingOverview.vue +++ b/dashboard/src2/pages/BillingOverview.vue @@ -38,7 +38,16 @@
Credits Available
\nIf you are not using Setup Wizard Autocompletion, you can add a password field (fieldname - user_login_password) to set it to the new user of site.", "fieldname": "signup_fields", "fieldtype": "Table", "label": "Signup Fields", @@ -160,6 +161,7 @@ "options": "manual\nauto" }, { + "depends_on": "eval: doc.setup_wizard_completion_mode == \"auto\"", "description": "You can write python script to generate the payload for setup wizard\n
\n
\n\nAvailable Variables -
\na. signup_details : This is a dictionary and it will contain user submitted data for app trial. If the user hasn't provided any value for specific info, then the value will be null.
\nb. team: This is dictionary and will contain information regarding team.\n\n
{\n  \"name\" : \"jhd8dsw\",\n  \"user\" : {\n    \"email\" : \"test@example.com\",\n    \"full_name\" : \"Rahul Roy\",\n    \"first_name\" : \"Rahul\",\n    \"last_name\" : \"Roy\",\n  },\n  \"country\" : \"India\",\n  \"currency\" : \"INR\"\n}\n
\n\nExpected Result - \nWrite the final result (dictionary) in a variable payload. It will be send to site for setup wizard completion.\n
\n
\nNote -
\na. Use decrypt_password(..) to decrypt password signup field.", "fieldname": "setup_wizard_payload_generator_script", "fieldtype": "Code", @@ -207,6 +209,14 @@ { "fieldname": "column_break_cdhw", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval: doc.setup_wizard_completion_mode == \"manual\"", + "description": "Only configurable while using manual setup wizard completion mode", + "fieldname": "create_additional_system_user", + "fieldtype": "Check", + "label": "Create Additional System User" } ], "image_field": "logo", @@ -221,7 +231,7 @@ "link_fieldname": "product_trial" } ], - "modified": "2024-08-22 11:20:54.136031", + "modified": "2024-11-19 16:12:29.471858", "modified_by": "Administrator", "module": "SaaS", "name": "Product Trial", diff --git a/press/saas/doctype/product_trial/product_trial.py b/press/saas/doctype/product_trial/product_trial.py index 0f809e02e57..5cf043ec6d6 100644 --- a/press/saas/doctype/product_trial/product_trial.py +++ b/press/saas/doctype/product_trial/product_trial.py @@ -28,6 +28,7 @@ class ProductTrial(Document): ) apps: DF.Table[ProductTrialApp] + create_additional_system_user: DF.Check domain: DF.Link email_account: DF.Link | None email_full_logo: DF.AttachImage | None @@ -55,6 +56,8 @@ class ProductTrial(Document): "trial_plan", ) + USER_LOGIN_PASSWORD_FIELD = "user_login_password" + def get_doc(self, doc): if not self.published: frappe.throw("Not permitted") @@ -88,7 +91,14 @@ def validate(self): if not plan.is_trial_plan: frappe.throw("Selected plan is not a trial plan") - def setup_trial_site(self, team, plan, cluster=None): + for field in self.signup_fields: + if field.fieldname == self.USER_LOGIN_PASSWORD_FIELD: + if not field.required: + frappe.throw(f"{self.USER_LOGIN_PASSWORD_FIELD} field should be marked as required") + if field.fieldtype != "Password": + frappe.throw(f"{self.USER_LOGIN_PASSWORD_FIELD} field should be of type Password") + + def setup_trial_site(self, team, plan, cluster=None, account_request=None): standby_site = self.get_standby_site(cluster) team_record = frappe.get_doc("Team", team) trial_end_date = frappe.utils.add_days(None, self.trial_days or 14) @@ -105,6 +115,7 @@ def setup_trial_site(self, team, plan, cluster=None): site.is_standby = False site.team = team_record.name site.trial_end_date = trial_end_date + site.account_request = account_request site.save(ignore_permissions=True) agent_job_name = None site.create_subscription(plan) @@ -120,6 +131,7 @@ def setup_trial_site(self, team, plan, cluster=None): domain=self.domain, group=self.release_group, cluster=cluster, + account_request=account_request, is_standby=False, standby_for_product=self.name, subscription_plan=plan, @@ -127,6 +139,9 @@ def setup_trial_site(self, team, plan, cluster=None): apps=apps, trial_end_date=trial_end_date, ) + if self.setup_wizard_completion_mode == "auto" or not self.create_additional_system_user: + site.flags.ignore_additional_system_user_creation = True + # set flag to ignore user site.insert(ignore_permissions=True) agent_job_name = site.flags.get("new_site_agent_job_name", None) @@ -134,7 +149,7 @@ def setup_trial_site(self, team, plan, cluster=None): site.reload() site.generate_saas_communication_secret(create_agent_job=True) site.flags.ignore_permissions = True - if standby_site: + if standby_site and self.create_additional_system_user: agent_job_name = site.create_user_with_team_info() return site, agent_job_name, bool(standby_site) diff --git a/press/saas/doctype/product_trial_request/product_trial_request.json b/press/saas/doctype/product_trial_request/product_trial_request.json index 54b5515cd41..7247b6562c5 100644 --- a/press/saas/doctype/product_trial_request/product_trial_request.json +++ b/press/saas/doctype/product_trial_request/product_trial_request.json @@ -34,7 +34,8 @@ "fieldname": "account_request", "fieldtype": "Link", "label": "Account Request", - "options": "Account Request" + "options": "Account Request", + "search_index": 1 }, { "fieldname": "column_break_cubd", @@ -45,6 +46,7 @@ "fieldname": "status", "fieldtype": "Select", "in_list_view": 1, + "in_standard_filter": 1, "label": "Status", "options": "Pending\nWait for Site\nCompleting Setup Wizard\nSite Created\nError\nExpired" }, @@ -53,13 +55,15 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Site", - "options": "Site" + "options": "Site", + "search_index": 1 }, { "fieldname": "agent_job", "fieldtype": "Link", "label": "Agent Job", - "options": "Agent Job" + "options": "Agent Job", + "search_index": 1 }, { "fieldname": "product_trial", @@ -101,7 +105,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-09-12 11:32:08.901183", + "modified": "2024-11-19 15:17:20.958670", "modified_by": "Administrator", "module": "SaaS", "name": "Product Trial Request", diff --git a/press/saas/doctype/product_trial_request/product_trial_request.py b/press/saas/doctype/product_trial_request/product_trial_request.py index a3a9483de5f..0e9e6333329 100644 --- a/press/saas/doctype/product_trial_request/product_trial_request.py +++ b/press/saas/doctype/product_trial_request/product_trial_request.py @@ -21,6 +21,8 @@ from press.agent import Agent from press.api.client import dashboard_whitelist +from press.saas.doctype.product_trial.product_trial import ProductTrial +from press.utils import log_error if TYPE_CHECKING: from press.press.doctype.site.site import Site @@ -43,12 +45,7 @@ class ProductTrialRequest(Document): site_creation_completed_on: DF.Datetime | None site_creation_started_on: DF.Datetime | None status: DF.Literal[ - "Pending", - "Wait for Site", - "Completing Setup Wizard", - "Site Created", - "Error", - "Expired", + "Pending", "Wait for Site", "Completing Setup Wizard", "Site Created", "Error", "Expired" ] team: DF.Link | None # end: auto-generated types @@ -156,6 +153,22 @@ def get_setup_wizard_payload(self): frappe.log_error(title="Product Trial Reqeust Setup Wizard Payload Generation Error") frappe.throw(f"Failed to generate payload for Setup Wizard: {e}") + def get_user_login_password_from_signup_details(self) -> str | None: + """ + Handling the exception because without the password also + the site can be created and user can login through saas flow + + Better than failing the site creation process + """ + try: + signup_details = json.loads(self.signup_details) + encrypted_password = signup_details.get(ProductTrial.USER_LOGIN_PASSWORD_FIELD) + if encrypted_password: + return decrypt_password(encrypted_password) + except Exception as e: + log_error("Failed to get user login password from signup details", data=e) + return None + def validate_signup_fields(self): signup_values = json.loads(self.signup_details) product = frappe.get_doc("Product Trial", self.product_trial) @@ -204,7 +217,9 @@ def create_site(self, cluster: str | None = None, signup_values: dict | None = N self.site_creation_started_on = now_datetime() self.save(ignore_permissions=True) self.reload() - site, agent_job_name, _ = product.setup_trial_site(self.team, product.trial_plan, cluster) + site, agent_job_name, _ = product.setup_trial_site( + self.team, product.trial_plan, cluster=cluster, account_request=self.account_request + ) self.agent_job = agent_job_name self.site = site.name self.save(ignore_permissions=True) @@ -268,8 +283,7 @@ def complete_setup_wizard(self): @dashboard_whitelist() def get_login_sid(self): site: Site = frappe.get_doc("Site", self.site) - is_secondary_user_created = site.additional_system_user_created - if is_secondary_user_created: + if site.additional_system_user_created: email = frappe.db.get_value("Team", self.team, "user") return site.get_login_sid(user=email) From 3994c81b132586a3758d435f4bd73512efb78b6c Mon Sep 17 00:00:00 2001 From: Shadrak Gurupnor Date: Tue, 19 Nov 2024 18:02:21 +0530 Subject: [PATCH 19/93] chore: show payment mode description on card --- dashboard/src2/pages/BillingOverview.vue | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/dashboard/src2/pages/BillingOverview.vue b/dashboard/src2/pages/BillingOverview.vue index 41e92022c0f..fc0454e0641 100644 --- a/dashboard/src2/pages/BillingOverview.vue +++ b/dashboard/src2/pages/BillingOverview.vue @@ -66,13 +66,18 @@
{{ $team?.doc?.payment_mode || 'Not set' }}
+
+

+ {{ paymentModeDescription }} +

+
Billing Details
-
+
{{ billingDetailsSummary }} @@ -240,6 +245,13 @@ export default { return [billing_name, address_line1, city, state, country, pincode, gstin] .filter(Boolean) .join(', '); + }, + paymentModeDescription() { + return { + Card: `Your card will be charged for monthly subscription`, + 'Prepaid Credits': `You will be charged from your account balance for monthly subscription`, + 'Paid By Partner': `Your partner will be charged for monthly subscription` + }[this.$team?.doc?.payment_mode]; } } }; From c7b794ee7809df025c314ce0b37b7564ad11d4d0 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Wed, 20 Nov 2024 10:54:27 +0530 Subject: [PATCH 20/93] fix(release-group): pass http_timeout to bench_config on config update --- press/press/doctype/release_group/release_group.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/press/press/doctype/release_group/release_group.py b/press/press/doctype/release_group/release_group.py index 233f2d8158e..3594459c3a2 100644 --- a/press/press/doctype/release_group/release_group.py +++ b/press/press/doctype/release_group/release_group.py @@ -311,6 +311,8 @@ def update_config(self, config): sanitized_common_site_config = [ {"key": c.key, "type": c.type, "value": c.value} for c in self.common_site_config_table ] + sanitized_bench_config = [] + bench_config_keys = ["http_timeout"] config = frappe.parse_json(config) @@ -330,6 +332,9 @@ def update_config(self, config): self.name, ) + if key in bench_config_keys: + sanitized_bench_config.append({"key": key, "value": value, "type": config_type}) + # update existing key for row in sanitized_common_site_config: if row["key"] == key: @@ -339,9 +344,7 @@ def update_config(self, config): else: sanitized_common_site_config.append({"key": key, "value": value, "type": config_type}) - # using a tuple to avoid updating bench_config - # TODO: remove tuple when bench_config is removed and field for http_timeout is added - self.update_config_in_release_group(sanitized_common_site_config, ()) + self.update_config_in_release_group(sanitized_common_site_config, sanitized_bench_config) self.update_benches_config() def update_config_in_release_group(self, common_site_config, bench_config): @@ -369,9 +372,9 @@ def update_config_in_release_group(self, common_site_config, bench_config): self.append("common_site_config_table", {"key": d.key, "value": value, "type": d.type}) for d in bench_config: - if d.key == "http_timeout": + if d["key"] == "http_timeout": # http_timeout should be the only thing configurable in bench_config - self.bench_config = json.dumps({"http_timeout": int(d.value)}, indent=4) + self.bench_config = json.dumps({"http_timeout": int(d["value"])}, indent=4) if bench_config == []: self.bench_config = json.dumps({}) From d5a60ddbf7bb7c0a212d35573eea4185f96bc101 Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Wed, 20 Nov 2024 14:35:05 +0530 Subject: [PATCH 21/93] fix(SiteUpdate): Reallocate workers regardless for skipped_backups Required for case of dedicated server users doing manual updates/testing --- press/press/doctype/site_update/site_update.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/site_update/site_update.py b/press/press/doctype/site_update/site_update.py index 680e6ce637f..19995186b6a 100644 --- a/press/press/doctype/site_update/site_update.py +++ b/press/press/doctype/site_update/site_update.py @@ -534,7 +534,7 @@ def process_update_site_job_update(job): # noqa: C901 "status", ) if site_enable_step_status == "Success": - frappe.get_doc("Site Update", site_update.name).reallocate_workers() + SiteUpdate("Site Update", site_update.name).reallocate_workers() frappe.db.set_value("Site Update", site_update.name, "status", updated_status) if updated_status == "Running": @@ -549,6 +549,7 @@ def process_update_site_job_update(job): # noqa: C901 trigger_recovery_job(site_update.name) else: frappe.db.set_value("Site Update", site_update.name, "status", "Fatal") + SiteUpdate("Site Update", site_update.name).reallocate_workers() def process_update_site_recover_job_update(job): From 3fd3282ac40a463554a9c2b8bd763c17407210ba Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Wed, 20 Nov 2024 22:54:35 +0530 Subject: [PATCH 22/93] fix(saas-signup): checkbox alignment --- press/templates/saas/signup.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/press/templates/saas/signup.html b/press/templates/saas/signup.html index 1a83c36c480..f46d66d84d2 100644 --- a/press/templates/saas/signup.html +++ b/press/templates/saas/signup.html @@ -59,7 +59,8 @@
- +

I agree to Frappe Terms of Service, @@ -69,8 +70,9 @@

- + {% if enable_google_oauth %} @@ -257,4 +259,4 @@

Verification email sent

} -{%- endblock -%} +{%- endblock -%} \ No newline at end of file From 59af954bf5af5140ce8f1181b6af43b31a707e39 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:44:13 +0530 Subject: [PATCH 23/93] chore(saas): misc saas flow fixes and tweaks (#2293) --- press/press/doctype/site/site.py | 4 +- press/saas/api/site.py | 20 ++++++- .../doctype/product_trial/product_trial.py | 53 ++++++++++++++++--- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/press/press/doctype/site/site.py b/press/press/doctype/site/site.py index 1c21a123fce..9bd026d1dee 100644 --- a/press/press/doctype/site/site.py +++ b/press/press/doctype/site/site.py @@ -510,7 +510,7 @@ def on_update(self): if self.has_value_changed("status"): create_site_status_update_webhook_event(self.name) - def generate_saas_communication_secret(self, create_agent_job=False): + def generate_saas_communication_secret(self, create_agent_job=False, save=True): if not self.standby_for and not self.standby_for_product: return if not self.saas_communication_secret: @@ -521,7 +521,7 @@ def generate_saas_communication_secret(self, create_agent_job=False): if create_agent_job: self.update_site_config(config) else: - self._update_configuration(config=config, save=True) + self._update_configuration(config=config, save=save) def rename_upstream(self, new_name: str): proxy_server = frappe.db.get_value("Server", self.server, "proxy_server") diff --git a/press/saas/api/site.py b/press/saas/api/site.py index 13bae69cf4b..8ecb2b4c437 100644 --- a/press/saas/api/site.py +++ b/press/saas/api/site.py @@ -25,7 +25,25 @@ def change_plan(plan: str): @whitelist_saas_api def get_plans(): - return site_api.get_site_plans() + site = frappe.get_value("Site", frappe.local.site_name, ["server", "group", "plan"], as_dict=True) + is_site_on_private_bench = frappe.db.get_value("Release Group", site.group, "public") is False + is_site_on_shared_server = frappe.db.get_value("Server", site.server, "public") + plans = site_api.get_site_plans() + filtered_plans = [] + + for plan in plans: + if plan.name != site.plan: + if plan.restricted_plan or plan.is_frappe_plan or plan.is_trial_plan: + continue + if is_site_on_private_bench and not plan.private_benches: + continue + if plan.dedicated_server_plan and is_site_on_shared_server: + continue + if not plan.dedicated_server_plan and not is_site_on_shared_server: + continue + filtered_plans.append(plan) + + return filtered_plans @whitelist_saas_api diff --git a/press/saas/doctype/product_trial/product_trial.py b/press/saas/doctype/product_trial/product_trial.py index 5cf043ec6d6..d7b8d4d6865 100644 --- a/press/saas/doctype/product_trial/product_trial.py +++ b/press/saas/doctype/product_trial/product_trial.py @@ -3,12 +3,15 @@ from __future__ import annotations +import json + import frappe import frappe.utils from frappe.model.document import Document from frappe.utils.data import get_url from frappe.utils.momentjs import get_all_timezones +from press.press.doctype.site.site import get_plan_config from press.utils import log_error from press.utils.unique_name_generator import generate as generate_random_name @@ -105,6 +108,7 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): site = None agent_job_name = None current_user = frappe.session.user + apps_site_config = get_app_subscriptions_site_config([d.app for d in self.apps]) """ We have set the current user to "Administrator" temporarily to bypass the site creation validation @@ -116,9 +120,13 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): site.team = team_record.name site.trial_end_date = trial_end_date site.account_request = account_request + site._update_configuration(apps_site_config, save=False) + site._update_configuration(get_plan_config(plan), save=False) site.save(ignore_permissions=True) - agent_job_name = None site.create_subscription(plan) + site.generate_saas_communication_secret(create_agent_job=True, save=True) + if self.create_additional_system_user: + agent_job_name = site.create_user_with_team_info() else: # Create a site in the cluster, if standby site is not available apps = [{"app": d.app} for d in self.apps] @@ -139,18 +147,15 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): apps=apps, trial_end_date=trial_end_date, ) + site._update_configuration(apps_site_config, save=False) + site._update_configuration(get_plan_config(plan), save=False) + site.generate_saas_communication_secret(create_agent_job=False, save=False) if self.setup_wizard_completion_mode == "auto" or not self.create_additional_system_user: site.flags.ignore_additional_system_user_creation = True - # set flag to ignore user site.insert(ignore_permissions=True) agent_job_name = site.flags.get("new_site_agent_job_name", None) frappe.set_user(current_user) - site.reload() - site.generate_saas_communication_secret(create_agent_job=True) - site.flags.ignore_permissions = True - if standby_site and self.create_additional_system_user: - agent_job_name = site.create_user_with_team_info() return site, agent_job_name, bool(standby_site) def get_proxy_servers_for_available_clusters(self): @@ -284,6 +289,40 @@ def get_unique_site_name(self): return subdomain +def get_app_subscriptions_site_config(apps: list[str]): + subscriptions = [] + site_config = {} + + for app in apps: + free_plan = frappe.get_all( + "Marketplace App Plan", + {"enabled": 1, "price_usd": ("<=", 0), "app": app}, + pluck="name", + ) + if free_plan: + new_subscription = frappe.get_doc( + { + "doctype": "Subscription", + "document_type": "Marketplace App", + "document_name": app, + "plan_type": "Marketplace App Plan", + "plan": free_plan[0], + "enabled": 0, + "team": frappe.get_value("Team", {"user": "Administrator"}, "name"), + } + ).insert(ignore_permissions=True) + + subscriptions.append(new_subscription) + config = frappe.db.get_value("Marketplace App", app, "site_config") + config = json.loads(config) if config else {} + site_config.update(config) + + for s in subscriptions: + site_config.update({"sk_" + s.document_name: s.secret_key}) + + return site_config + + def replenish_standby_sites(): """Create standby sites for all products with pooling enabled. This is called by the scheduler.""" products = frappe.get_all("Product Trial", {"enable_pooling": 1}, pluck="name") From ee50d2cfe1503d78f8d6c2327bd8964fdb1bd985 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:00:48 +0530 Subject: [PATCH 24/93] Revert "chore(saas): misc saas flow fixes and tweaks (#2293)" (#2294) This reverts commit 59af954bf5af5140ce8f1181b6af43b31a707e39. --- press/press/doctype/site/site.py | 4 +- press/saas/api/site.py | 20 +------ .../doctype/product_trial/product_trial.py | 53 +++---------------- 3 files changed, 10 insertions(+), 67 deletions(-) diff --git a/press/press/doctype/site/site.py b/press/press/doctype/site/site.py index 9bd026d1dee..1c21a123fce 100644 --- a/press/press/doctype/site/site.py +++ b/press/press/doctype/site/site.py @@ -510,7 +510,7 @@ def on_update(self): if self.has_value_changed("status"): create_site_status_update_webhook_event(self.name) - def generate_saas_communication_secret(self, create_agent_job=False, save=True): + def generate_saas_communication_secret(self, create_agent_job=False): if not self.standby_for and not self.standby_for_product: return if not self.saas_communication_secret: @@ -521,7 +521,7 @@ def generate_saas_communication_secret(self, create_agent_job=False, save=True): if create_agent_job: self.update_site_config(config) else: - self._update_configuration(config=config, save=save) + self._update_configuration(config=config, save=True) def rename_upstream(self, new_name: str): proxy_server = frappe.db.get_value("Server", self.server, "proxy_server") diff --git a/press/saas/api/site.py b/press/saas/api/site.py index 8ecb2b4c437..13bae69cf4b 100644 --- a/press/saas/api/site.py +++ b/press/saas/api/site.py @@ -25,25 +25,7 @@ def change_plan(plan: str): @whitelist_saas_api def get_plans(): - site = frappe.get_value("Site", frappe.local.site_name, ["server", "group", "plan"], as_dict=True) - is_site_on_private_bench = frappe.db.get_value("Release Group", site.group, "public") is False - is_site_on_shared_server = frappe.db.get_value("Server", site.server, "public") - plans = site_api.get_site_plans() - filtered_plans = [] - - for plan in plans: - if plan.name != site.plan: - if plan.restricted_plan or plan.is_frappe_plan or plan.is_trial_plan: - continue - if is_site_on_private_bench and not plan.private_benches: - continue - if plan.dedicated_server_plan and is_site_on_shared_server: - continue - if not plan.dedicated_server_plan and not is_site_on_shared_server: - continue - filtered_plans.append(plan) - - return filtered_plans + return site_api.get_site_plans() @whitelist_saas_api diff --git a/press/saas/doctype/product_trial/product_trial.py b/press/saas/doctype/product_trial/product_trial.py index d7b8d4d6865..5cf043ec6d6 100644 --- a/press/saas/doctype/product_trial/product_trial.py +++ b/press/saas/doctype/product_trial/product_trial.py @@ -3,15 +3,12 @@ from __future__ import annotations -import json - import frappe import frappe.utils from frappe.model.document import Document from frappe.utils.data import get_url from frappe.utils.momentjs import get_all_timezones -from press.press.doctype.site.site import get_plan_config from press.utils import log_error from press.utils.unique_name_generator import generate as generate_random_name @@ -108,7 +105,6 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): site = None agent_job_name = None current_user = frappe.session.user - apps_site_config = get_app_subscriptions_site_config([d.app for d in self.apps]) """ We have set the current user to "Administrator" temporarily to bypass the site creation validation @@ -120,13 +116,9 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): site.team = team_record.name site.trial_end_date = trial_end_date site.account_request = account_request - site._update_configuration(apps_site_config, save=False) - site._update_configuration(get_plan_config(plan), save=False) site.save(ignore_permissions=True) + agent_job_name = None site.create_subscription(plan) - site.generate_saas_communication_secret(create_agent_job=True, save=True) - if self.create_additional_system_user: - agent_job_name = site.create_user_with_team_info() else: # Create a site in the cluster, if standby site is not available apps = [{"app": d.app} for d in self.apps] @@ -147,15 +139,18 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): apps=apps, trial_end_date=trial_end_date, ) - site._update_configuration(apps_site_config, save=False) - site._update_configuration(get_plan_config(plan), save=False) - site.generate_saas_communication_secret(create_agent_job=False, save=False) if self.setup_wizard_completion_mode == "auto" or not self.create_additional_system_user: site.flags.ignore_additional_system_user_creation = True + # set flag to ignore user site.insert(ignore_permissions=True) agent_job_name = site.flags.get("new_site_agent_job_name", None) frappe.set_user(current_user) + site.reload() + site.generate_saas_communication_secret(create_agent_job=True) + site.flags.ignore_permissions = True + if standby_site and self.create_additional_system_user: + agent_job_name = site.create_user_with_team_info() return site, agent_job_name, bool(standby_site) def get_proxy_servers_for_available_clusters(self): @@ -289,40 +284,6 @@ def get_unique_site_name(self): return subdomain -def get_app_subscriptions_site_config(apps: list[str]): - subscriptions = [] - site_config = {} - - for app in apps: - free_plan = frappe.get_all( - "Marketplace App Plan", - {"enabled": 1, "price_usd": ("<=", 0), "app": app}, - pluck="name", - ) - if free_plan: - new_subscription = frappe.get_doc( - { - "doctype": "Subscription", - "document_type": "Marketplace App", - "document_name": app, - "plan_type": "Marketplace App Plan", - "plan": free_plan[0], - "enabled": 0, - "team": frappe.get_value("Team", {"user": "Administrator"}, "name"), - } - ).insert(ignore_permissions=True) - - subscriptions.append(new_subscription) - config = frappe.db.get_value("Marketplace App", app, "site_config") - config = json.loads(config) if config else {} - site_config.update(config) - - for s in subscriptions: - site_config.update({"sk_" + s.document_name: s.secret_key}) - - return site_config - - def replenish_standby_sites(): """Create standby sites for all products with pooling enabled. This is called by the scheduler.""" products = frappe.get_all("Product Trial", {"enable_pooling": 1}, pluck="name") From 72dde5f27ac78345ebe8a6213e8d5b07ab0e6b12 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:40:24 +0530 Subject: [PATCH 25/93] chore(saas): misc saas flow fixes and tweaks (#2295) - dont remove site access from proxy while suspending new saas sites - create app subscriptions and add app related site config on allocation of site - modify saas site plan api to send only specific plans based on site config + add trial plan in list as well - add site plan related configs on site allocation - cyclic import fixed (original pr - https://github.com/frappe/press/pull/2293) --- press/press/doctype/site/site.py | 4 +- press/saas/api/site.py | 20 ++++++- .../doctype/product_trial/product_trial.py | 54 ++++++++++++++++--- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/press/press/doctype/site/site.py b/press/press/doctype/site/site.py index 1c21a123fce..9bd026d1dee 100644 --- a/press/press/doctype/site/site.py +++ b/press/press/doctype/site/site.py @@ -510,7 +510,7 @@ def on_update(self): if self.has_value_changed("status"): create_site_status_update_webhook_event(self.name) - def generate_saas_communication_secret(self, create_agent_job=False): + def generate_saas_communication_secret(self, create_agent_job=False, save=True): if not self.standby_for and not self.standby_for_product: return if not self.saas_communication_secret: @@ -521,7 +521,7 @@ def generate_saas_communication_secret(self, create_agent_job=False): if create_agent_job: self.update_site_config(config) else: - self._update_configuration(config=config, save=True) + self._update_configuration(config=config, save=save) def rename_upstream(self, new_name: str): proxy_server = frappe.db.get_value("Server", self.server, "proxy_server") diff --git a/press/saas/api/site.py b/press/saas/api/site.py index 13bae69cf4b..8ecb2b4c437 100644 --- a/press/saas/api/site.py +++ b/press/saas/api/site.py @@ -25,7 +25,25 @@ def change_plan(plan: str): @whitelist_saas_api def get_plans(): - return site_api.get_site_plans() + site = frappe.get_value("Site", frappe.local.site_name, ["server", "group", "plan"], as_dict=True) + is_site_on_private_bench = frappe.db.get_value("Release Group", site.group, "public") is False + is_site_on_shared_server = frappe.db.get_value("Server", site.server, "public") + plans = site_api.get_site_plans() + filtered_plans = [] + + for plan in plans: + if plan.name != site.plan: + if plan.restricted_plan or plan.is_frappe_plan or plan.is_trial_plan: + continue + if is_site_on_private_bench and not plan.private_benches: + continue + if plan.dedicated_server_plan and is_site_on_shared_server: + continue + if not plan.dedicated_server_plan and not is_site_on_shared_server: + continue + filtered_plans.append(plan) + + return filtered_plans @whitelist_saas_api diff --git a/press/saas/doctype/product_trial/product_trial.py b/press/saas/doctype/product_trial/product_trial.py index 5cf043ec6d6..c8553caab08 100644 --- a/press/saas/doctype/product_trial/product_trial.py +++ b/press/saas/doctype/product_trial/product_trial.py @@ -3,6 +3,8 @@ from __future__ import annotations +import json + import frappe import frappe.utils from frappe.model.document import Document @@ -99,12 +101,15 @@ def validate(self): frappe.throw(f"{self.USER_LOGIN_PASSWORD_FIELD} field should be of type Password") def setup_trial_site(self, team, plan, cluster=None, account_request=None): + from press.press.doctype.site.site import get_plan_config + standby_site = self.get_standby_site(cluster) team_record = frappe.get_doc("Team", team) trial_end_date = frappe.utils.add_days(None, self.trial_days or 14) site = None agent_job_name = None current_user = frappe.session.user + apps_site_config = get_app_subscriptions_site_config([d.app for d in self.apps]) """ We have set the current user to "Administrator" temporarily to bypass the site creation validation @@ -116,9 +121,13 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): site.team = team_record.name site.trial_end_date = trial_end_date site.account_request = account_request + site._update_configuration(apps_site_config, save=False) + site._update_configuration(get_plan_config(plan), save=False) site.save(ignore_permissions=True) - agent_job_name = None site.create_subscription(plan) + site.generate_saas_communication_secret(create_agent_job=True, save=True) + if self.create_additional_system_user: + agent_job_name = site.create_user_with_team_info() else: # Create a site in the cluster, if standby site is not available apps = [{"app": d.app} for d in self.apps] @@ -139,18 +148,15 @@ def setup_trial_site(self, team, plan, cluster=None, account_request=None): apps=apps, trial_end_date=trial_end_date, ) + site._update_configuration(apps_site_config, save=False) + site._update_configuration(get_plan_config(plan), save=False) + site.generate_saas_communication_secret(create_agent_job=False, save=False) if self.setup_wizard_completion_mode == "auto" or not self.create_additional_system_user: site.flags.ignore_additional_system_user_creation = True - # set flag to ignore user site.insert(ignore_permissions=True) agent_job_name = site.flags.get("new_site_agent_job_name", None) frappe.set_user(current_user) - site.reload() - site.generate_saas_communication_secret(create_agent_job=True) - site.flags.ignore_permissions = True - if standby_site and self.create_additional_system_user: - agent_job_name = site.create_user_with_team_info() return site, agent_job_name, bool(standby_site) def get_proxy_servers_for_available_clusters(self): @@ -284,6 +290,40 @@ def get_unique_site_name(self): return subdomain +def get_app_subscriptions_site_config(apps: list[str]): + subscriptions = [] + site_config = {} + + for app in apps: + free_plan = frappe.get_all( + "Marketplace App Plan", + {"enabled": 1, "price_usd": ("<=", 0), "app": app}, + pluck="name", + ) + if free_plan: + new_subscription = frappe.get_doc( + { + "doctype": "Subscription", + "document_type": "Marketplace App", + "document_name": app, + "plan_type": "Marketplace App Plan", + "plan": free_plan[0], + "enabled": 0, + "team": frappe.get_value("Team", {"user": "Administrator"}, "name"), + } + ).insert(ignore_permissions=True) + + subscriptions.append(new_subscription) + config = frappe.db.get_value("Marketplace App", app, "site_config") + config = json.loads(config) if config else {} + site_config.update(config) + + for s in subscriptions: + site_config.update({"sk_" + s.document_name: s.secret_key}) + + return site_config + + def replenish_standby_sites(): """Create standby sites for all products with pooling enabled. This is called by the scheduler.""" products = frappe.get_all("Product Trial", {"enable_pooling": 1}, pluck="name") From a4422c5d653450560dad2a95a4811021f3f89150 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Thu, 21 Nov 2024 11:14:40 +0530 Subject: [PATCH 26/93] refactor: don't fetch team doc for perm check --- press/overrides.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/press/overrides.py b/press/overrides.py index a6743423f39..6531bcfa0f6 100644 --- a/press/overrides.py +++ b/press/overrides.py @@ -118,9 +118,9 @@ def has_permission(doc, ptype, user): if has_role("Press Support Agent", user) and ptype == "read": return True - team = get_current_team(True) - child_team_members = [d.name for d in frappe.db.get_all("Team", {"parent_team": team.name}, ["name"])] - if doc.team == team.name or doc.team in child_team_members: + team = get_current_team() + child_team_members = [d.name for d in frappe.db.get_all("Team", {"parent_team": team}, ["name"])] + if doc.team == team or doc.team in child_team_members: return True return False From 2b7b2f98d3cd52116d8a6c2c7b57aab40391bbc5 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Thu, 21 Nov 2024 11:14:48 +0530 Subject: [PATCH 27/93] fix(release-group): better error message --- press/press/doctype/release_group/release_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/press/doctype/release_group/release_group.py b/press/press/doctype/release_group/release_group.py index 3594459c3a2..ef4bab7f282 100644 --- a/press/press/doctype/release_group/release_group.py +++ b/press/press/doctype/release_group/release_group.py @@ -785,7 +785,7 @@ def send_change_team_request(self, team_mail_id: str, reason: str): old_team = frappe.db.get_value("Team", self.team, "user") if old_team == team_mail_id: - frappe.throw(f"Bench is already owned by the team {team_mail_id}") + frappe.throw(f"Bench group is already owned by the team {team_mail_id}") team_change = frappe.get_doc( { From b4caa35ec586b3978f73f163b1fa7d64bc39d0b7 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:30:30 +0530 Subject: [PATCH 28/93] chore(saas): add trial plan explicitly in saas.api.site.get_plans api response --- press/saas/api/site.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/press/saas/api/site.py b/press/saas/api/site.py index 8ecb2b4c437..99ee337cfb4 100644 --- a/press/saas/api/site.py +++ b/press/saas/api/site.py @@ -43,6 +43,39 @@ def get_plans(): continue filtered_plans.append(plan) + """ + plans `site_api.get_site_plans()` doesn't include trial plan, as we don't have any roles specfied for trial plan + because from backend only we set the trial plan, end-user can't subscribe to trial plan directly + If the site is on a trial plan, add it to the starting of the list + """ + + current_plan = frappe.get_doc("Site Plan", site.plan) + if current_plan.is_trial_plan: + filtered_plans.insert( + 0, + { + "name": current_plan.name, + "plan_title": current_plan.plan_title, + "price_usd": current_plan.price_usd, + "price_inr": current_plan.price_inr, + "cpu_time_per_day": current_plan.cpu_time_per_day, + "max_storage_usage": current_plan.max_storage_usage, + "max_database_usage": current_plan.max_database_usage, + "database_access": current_plan.database_access, + "support_included": current_plan.support_included, + "offsite_backups": current_plan.offsite_backups, + "private_benches": current_plan.private_benches, + "monitor_access": current_plan.monitor_access, + "dedicated_server_plan": current_plan.dedicated_server_plan, + "is_trial_plan": current_plan.is_trial_plan, + "allow_downgrading_from_other_plan": False, + "clusters": [], + "allowed_apps": [], + "bench_versions": [], + "restricted_plan": False, + }, + ) + return filtered_plans From 3da6a0a3a66a7846a45749521f97e3b4983afa47 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Thu, 21 Nov 2024 11:38:23 +0530 Subject: [PATCH 29/93] fix(notifications): reduce notification count on reading - also fetch only if user logged in --- dashboard/src2/data/notifications.js | 3 +-- dashboard/src2/main.js | 2 ++ dashboard/src2/objects/notification.js | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dashboard/src2/data/notifications.js b/dashboard/src2/data/notifications.js index 95f92ff7b48..0b791411716 100644 --- a/dashboard/src2/data/notifications.js +++ b/dashboard/src2/data/notifications.js @@ -3,6 +3,5 @@ import { createResource } from 'frappe-ui'; export const unreadNotificationsCount = createResource({ cache: 'Unread Notifications Count', url: 'press.api.notifications.get_unread_count', - initialData: 0, - auto: true + initialData: 0 }); diff --git a/dashboard/src2/main.js b/dashboard/src2/main.js index 2a9555ab5ef..14d5d53e3e8 100644 --- a/dashboard/src2/main.js +++ b/dashboard/src2/main.js @@ -12,6 +12,7 @@ import { subscribeToJobUpdates } from './utils/agentJob'; import { fetchPlans } from './data/plans.js'; import * as Sentry from '@sentry/vue'; import { session } from './data/session.js'; +import { unreadNotificationsCount } from './data/notifications.js'; import './vendor/posthog.js'; const request = options => { @@ -48,6 +49,7 @@ getInitialData().then(() => { if (session.isLoggedIn) { fetchPlans(); session.roles.fetch(); + unreadNotificationsCount.fetch(); } if (window.press_dashboard_sentry_dsn.includes('https://')) { diff --git a/dashboard/src2/objects/notification.js b/dashboard/src2/objects/notification.js index 0e58e943d1c..13c168ad88b 100644 --- a/dashboard/src2/objects/notification.js +++ b/dashboard/src2/objects/notification.js @@ -1,6 +1,7 @@ import { h } from 'vue'; import router from '../router'; import { getDocResource } from '../utils/resource'; +import { unreadNotificationsCount } from '../data/notifications'; import { Tooltip, frappeRequest } from 'frappe-ui'; import { icon } from '../utils/components'; import { getTeam } from '../data/team'; @@ -52,6 +53,7 @@ export default { const notification = getNotification(row.name); notification.markNotificationAsRead.submit().then(() => { + unreadNotificationsCount.setData(data => data - 1); if (row.route) router.push(row.route); }); }, From 66b1d2899da36fe50172486f58dade9ddd32ddc1 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:44:36 +0530 Subject: [PATCH 30/93] chore(saas): update default site build status label --- dashboard/src2/pages/saas/LoginToSite.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src2/pages/saas/LoginToSite.vue b/dashboard/src2/pages/saas/LoginToSite.vue index 8ae16e3b448..b097d4fb2bc 100644 --- a/dashboard/src2/pages/saas/LoginToSite.vue +++ b/dashboard/src2/pages/saas/LoginToSite.vue @@ -89,7 +89,7 @@ export default { product_trial_request: this.$route.query.product_trial_request, progressCount: 0, isRedirectingToSite: false, - currentBuildStep: 'Waiting for build to be started' + currentBuildStep: 'Preparing for build' }; }, resources: { From 18b063b295c64f74bbd33f1685622c2b58d42cd1 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:06:27 +0530 Subject: [PATCH 31/93] fix: fetch_column_stats_update function should look into request_data for doc_name - Sentry Ticket - PRESS-7AR --- press/api/dboptimize.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/press/api/dboptimize.py b/press/api/dboptimize.py index 02ae9aee3a1..cdc8c9b425c 100644 --- a/press/api/dboptimize.py +++ b/press/api/dboptimize.py @@ -82,8 +82,9 @@ def check_if_all_fetch_column_stats_was_sucessful(doc): def fetch_column_stats_update(job, response_data): - doc_name = response_data["data"]["doc_name"] - table = json.loads(job.request_data)["table"] + request_data_json = json.loads(job.request_data) + doc_name = request_data_json["doc_name"] + table = request_data_json["table"] if job.status == "Success": column_statistics = response_data["steps"][0]["data"]["output"] @@ -148,9 +149,7 @@ def get_status_of_mariadb_analyze_query(name, query): def mariadb_analyze_query_already_exists(site, normalized_query): - if frappe.db.exists( - "MariaDB Analyze Query", {"site": site, "normalized_query": normalized_query} - ): + if frappe.db.exists("MariaDB Analyze Query", {"site": site, "normalized_query": normalized_query}): return True return False From f80ef55be558d66f523d76787ddcf292ea21de20 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:39:24 +0530 Subject: [PATCH 32/93] fix(saas): in otp based fc login from desk, dont allowe to login if team of site is not website user - the restriction added because - sometimes due to some db locks, site gets allocated to user but team info has failed to update - in those case, it can cause some security risk - Error Log ID : hi515ma874 (for ref) --- press/api/developer/saas.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/press/api/developer/saas.py b/press/api/developer/saas.py index 4624c54b871..5730f4074e8 100644 --- a/press/api/developer/saas.py +++ b/press/api/developer/saas.py @@ -130,6 +130,11 @@ def request_login_to_fc(domain: str): frappe.throw( "Sorry, you cannot login with this method as 2FA is enabled. Please visit https://frappecloud.com/dashboard to login." ) + if ( + team_info.get("user") == "Administrator" + or frappe.db.get_value("User", team_info.get("user"), "user_type") != "Website User" + ): + frappe.throw("Sorry, you cannot login with this method. Please contact support for more details.") # restrict to SaaS Site if not (site_info.get("standby_for") or site_info.get("standby_for_product")): From d2afcf55f51921404941225b3e1991ed89e45525 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Thu, 21 Nov 2024 14:35:43 +0530 Subject: [PATCH 33/93] fix(oci): Typo --- .../virtual_machine/virtual_machine.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/press/press/doctype/virtual_machine/virtual_machine.py b/press/press/doctype/virtual_machine/virtual_machine.py index d9b8b5a0045..1ace6b9d2dc 100644 --- a/press/press/doctype/virtual_machine/virtual_machine.py +++ b/press/press/doctype/virtual_machine/virtual_machine.py @@ -1106,16 +1106,16 @@ def bulk_sync_oci(cls): # There might not be any running machines in the chunk range continue - frappe.enqueue_doc( - "Virtual Machine", - machines[0].name, - method="bulk_sync_oci_cluster", - start=start, - end=end, - queue="sync", - job_id=f"bulk_sync_oci:{cluster.cluster}:{start}-{end}", - deduplicate=True, - ) + frappe.enqueue_doc( + "Virtual Machine", + machines[0].name, + method="bulk_sync_oci_cluster", + start=start, + end=end, + queue="sync", + job_id=f"bulk_sync_oci:{cluster.cluster}:{start}-{end}", + deduplicate=True, + ) def bulk_sync_oci_cluster(self, start, end): cluster = frappe.get_doc("Cluster", self.cluster) From 7b15b59652fc3af23f798894698336d04e75b312 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Wed, 4 Sep 2024 09:17:15 +0530 Subject: [PATCH 34/93] feat: provision to configure app group for all mandatory apps --- dashboard/src2/pages/NewReleaseGroup.vue | 91 ++++++++++++++++--- press/api/bench.py | 13 +++ press/press/doctype/app_group/__init__.py | 0 press/press/doctype/app_group/app_group.json | 46 ++++++++++ press/press/doctype/app_group/app_group.py | 24 +++++ .../press_settings/press_settings.json | 19 ++++ .../doctype/press_settings/press_settings.py | 9 +- 7 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 press/press/doctype/app_group/__init__.py create mode 100644 press/press/doctype/app_group/app_group.json create mode 100644 press/press/doctype/app_group/app_group.py diff --git a/dashboard/src2/pages/NewReleaseGroup.vue b/dashboard/src2/pages/NewReleaseGroup.vue index 34dd8a08860..3107db032b9 100644 --- a/dashboard/src2/pages/NewReleaseGroup.vue +++ b/dashboard/src2/pages/NewReleaseGroup.vue @@ -64,6 +64,9 @@
+
+ +
v.name === benchVersion) - .apps.find(app => app.name === 'frappe') - ].map(app => { - return { - name: app.name, - source: app.source.name - }; - }), + apps: getAppsToInstall(), server: server || null } }) @@ -156,12 +149,16 @@ import Summary from '../components/Summary.vue'; import Header from '../components/Header.vue'; import { DashboardError } from '../utils/error'; +import { h } from 'vue'; +import { Badge } from 'frappe-ui'; +import ObjectList from '../components/ObjectList.vue'; export default { name: 'NewReleaseGroup', components: { Summary, - Header + Header, + ObjectList }, props: ['server'], data() { @@ -208,10 +205,80 @@ export default { }; } }, + methods: { + getAppsToInstall() { + let apps = [ + this.options.versions + .find(v => v.name === this.benchVersion) + .apps.find(app => app.name === 'frappe') + ].map(app => { + return { + name: app.name, + source: app.source.name + }; + }); + + // add default apps + this.defaultApps.forEach(app => { + apps.push({ + name: app.name, + source: app.source + }); + }); + + return apps; + } + }, computed: { options() { return this.$resources.options.data; }, + defaultApps() { + let defaultApps = []; + this.options.versions.forEach(version => { + version.apps.forEach(app => { + if (app.is_default) { + let d = { + name: app.name, + source: app.source.name, + app_title: app.title, + route: app.source.repository_url + }; + if ( + defaultApps.filter(app => app.app_title === d.app_title) + .length === 0 + ) { + defaultApps.push(d); + } + } + }); + }); + + return defaultApps; + }, + defaultAppsList() { + return { + data: () => this.defaultApps, + columns: [ + { + label: 'Default Apps', + fieldname: 'app_title', + type: 'Component', + component: ({ row }) => { + return h( + 'a', + { + class: 'flex items-center text-sm', + href: `${row.route}`, + target: '_blank' + }, + [h('span', { class: 'ml-2' }, row.app_title)] + ); + } + } + ] + }; + }, summaryOptions() { return [ { diff --git a/press/api/bench.py b/press/api/bench.py index 447ed5b1f2e..69f4af2e1d9 100644 --- a/press/api/bench.py +++ b/press/api/bench.py @@ -224,15 +224,25 @@ def options(): .run(as_dict=True) ) + approved_apps = frappe.get_all( + "Marketplace App", filters={"frappe_approved": 1}, pluck="app" + ) + + press_settings = frappe.get_single("Press Settings") + apps_group = press_settings.get_app_group() + version_list = unique(rows, lambda x: x.version) versions = [] for d in version_list: version_dict = {"name": d.version, "status": d.status, "default": d.default} version_rows = find_all(rows, lambda x: x.version == d.version) app_list = frappe.utils.unique([row.app for row in version_rows]) + app_list = sorted(app_list, key=lambda x: x not in approved_apps) + for app in app_list: app_rows = find_all(version_rows, lambda x: x.app == app) app_dict = {"name": app, "title": app_rows[0].title} + for source in app_rows: source_dict = { "name": source.source, @@ -242,7 +252,10 @@ def options(): "repository_owner": source.repository_owner, } app_dict.setdefault("sources", []).append(source_dict) + app_dict["source"] = app_dict["sources"][0] + app_dict["is_default"] = True if app in apps_group else False + version_dict.setdefault("apps", []).append(app_dict) versions.append(version_dict) diff --git a/press/press/doctype/app_group/__init__.py b/press/press/doctype/app_group/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/press/press/doctype/app_group/app_group.json b/press/press/doctype/app_group/app_group.json new file mode 100644 index 00000000000..f1a0bb594de --- /dev/null +++ b/press/press/doctype/app_group/app_group.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-03 15:07:30.256352", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "app", + "column_break_jvou", + "app_title" + ], + "fields": [ + { + "fieldname": "app", + "fieldtype": "Link", + "in_list_view": 1, + "label": "App", + "options": "App", + "reqd": 1 + }, + { + "fieldname": "column_break_jvou", + "fieldtype": "Column Break" + }, + { + "fetch_from": "app.title", + "fieldname": "app_title", + "fieldtype": "Read Only", + "in_list_view": 1, + "label": "App Title" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-09-04 12:02:25.463128", + "modified_by": "Administrator", + "module": "Press", + "name": "App Group", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/press/press/doctype/app_group/app_group.py b/press/press/doctype/app_group/app_group.py new file mode 100644 index 00000000000..e3aeca40771 --- /dev/null +++ b/press/press/doctype/app_group/app_group.py @@ -0,0 +1,24 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AppGroup(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + app: DF.Link + app_title: DF.ReadOnly | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/press/press/doctype/press_settings/press_settings.json b/press/press/doctype/press_settings/press_settings.json index 5ac010d495e..71c8c7e90a3 100644 --- a/press/press/doctype/press_settings/press_settings.json +++ b/press/press/doctype/press_settings/press_settings.json @@ -193,6 +193,9 @@ "column_break_rdlr", "disable_auto_retry", "disable_agent_job_deduplication", + "section_break_jstu", + "enable_app_grouping", + "default_apps", "code_spaces_tab", "spaces_domain", "hybrid_server_tab", @@ -1246,6 +1249,22 @@ "fieldtype": "Link", "label": "Press Trial Plan", "options": "Site Plan" + }, + { + "fieldname": "section_break_jstu", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "enable_app_grouping", + "fieldtype": "Check", + "label": "Enable App Grouping" + }, + { + "fieldname": "default_apps", + "fieldtype": "Table", + "label": "Default Apps", + "options": "App Group" } ], "issingle": 1, diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index bd99a7db4f9..211dfd0c0a5 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -22,7 +22,7 @@ class PressSettings(Document): if TYPE_CHECKING: from frappe.types import DF - + from press.press.doctype.app_group.app_group import AppGroup from press.press.doctype.erpnext_app.erpnext_app import ERPNextApp agent_github_access_token: DF.Data | None @@ -51,6 +51,7 @@ class PressSettings(Document): commission: DF.Float compress_app_cache: DF.Check data_40: DF.Data | None + default_apps: DF.Table[AppGroup] default_outgoing_id: DF.Data | None default_outgoing_pass: DF.Data | None disable_agent_job_deduplication: DF.Check @@ -61,6 +62,7 @@ class PressSettings(Document): docker_registry_username: DF.Data | None domain: DF.Link | None eff_registration_email: DF.Data + enable_app_grouping: DF.Check enable_google_oauth: DF.Check enable_site_pooling: DF.Check enforce_storage_limits: DF.Check @@ -245,3 +247,8 @@ def twilio_client(self) -> Client: api_key_sid = self.twilio_api_key_sid api_key_secret = self.get_password("twilio_api_key_secret") return Client(api_key_sid, api_key_secret, account_sid) + + def get_app_group(self): + if self.enable_app_grouping: + return [app.app for app in self.default_apps] + return [] From 0a130ec4f0bdee4175884dfb053899ee94d525cd Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 17 Oct 2024 14:52:13 +0530 Subject: [PATCH 35/93] feat: site creation api rewrite --- dashboard/src2/components/SitePlansCards.vue | 1 + dashboard/src2/pages/NewSite.vue | 2 + press/api/site.py | 209 ++++++++++--------- 3 files changed, 118 insertions(+), 94 deletions(-) diff --git a/dashboard/src2/components/SitePlansCards.vue b/dashboard/src2/components/SitePlansCards.vue index d63530b600c..e95bbe909ce 100644 --- a/dashboard/src2/components/SitePlansCards.vue +++ b/dashboard/src2/components/SitePlansCards.vue @@ -32,6 +32,7 @@ export default { }, plans() { let plans = getPlans(); + if (this.isPrivateBenchSite) { plans = plans.filter(plan => plan.private_benches); } diff --git a/dashboard/src2/pages/NewSite.vue b/dashboard/src2/pages/NewSite.vue index 36dc65496b9..91f3ffe79dc 100644 --- a/dashboard/src2/pages/NewSite.vue +++ b/dashboard/src2/pages/NewSite.vue @@ -544,9 +544,11 @@ export default { else apps = this.selectedVersion.group.bench_app_sources.map(app_source => { let app_source_details = this.options.app_source_details[app_source]; + console.log(app_source_details); let marketplace_details = app_source_details ? this.options.marketplace_details[app_source_details.app] : {}; + console.log(marketplace_details); return { app_title: app_source, ...app_source_details, diff --git a/press/api/site.py b/press/api/site.py index 6ea525cee77..1b56a55c100 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -481,7 +481,9 @@ def app_details_for_new_public_site(): filters={"status": "Published", "show_for_site_creation": 1}, ).run(as_dict=True) - marketplace_app_sources = [app["sources"][0]["source"] for app in marketplace_apps if app["sources"]] + marketplace_app_sources = [ + app["sources"][0]["source"] for app in marketplace_apps if app["sources"] + ] if not marketplace_app_sources: return [] @@ -519,102 +521,15 @@ def app_details_for_new_public_site(): return marketplace_apps +def _options_for_new(for_bench: str = None): + versions = get_available_versions(for_bench) + return versions + + @frappe.whitelist() def options_for_new(for_bench: str | None = None): # noqa: C901 for_bench = str(for_bench) if for_bench else None - if for_bench: - version = frappe.db.get_value("Release Group", for_bench, "version") - versions = frappe.db.get_all( - "Frappe Version", - ["name", "default", "status", "number"], - {"name": version}, - order_by="number desc", - ) - else: - versions = frappe.db.get_all( - "Frappe Version", - ["name", "default", "status", "number"], - {"public": True, "status": ("!=", "End of Life")}, - order_by="number desc", - ) - available_versions = [] - restricted_release_group_names = frappe.db.get_all( - "Site Plan Release Group", - pluck="release_group", - filters={"parenttype": "Site Plan", "parentfield": "release_groups"}, - ) - for version in versions: - filters = ( - {"name": for_bench} - if for_bench - else { - "enabled": 1, - "public": 1, - "version": version.name, - "name": ("not in", restricted_release_group_names), - "saas_bench": 0, - } - ) - release_group = frappe.db.get_value( - "Release Group", - fieldname=["name", "`default`", "title", "public"], - filters=filters, - order_by="creation asc", - as_dict=1, - ) - version.group = release_group - if version.group: - if for_bench: - version.group.is_dedicated_server = is_dedicated_server( - frappe.get_all( - "Release Group Server", - filters={"parent": release_group.name, "parenttype": "Release Group"}, - pluck="server", - limit=1, - )[0] - ) - - # here we get the last created bench for the release group - # assuming the last created bench is the latest one - bench = frappe.db.get_value( - "Bench", - filters={"status": "Active", "group": version.group.name}, - order_by="creation desc", - ) - if bench: - version.group.bench = bench - version.group.bench_app_sources = frappe.db.get_all( - "Bench App", {"parent": bench, "app": ("!=", "frappe")}, pluck="source" - ) - cluster_names = unique( - frappe.db.get_all( - "Bench", - filters={"candidate": frappe.db.get_value("Bench", bench, "candidate")}, - pluck="cluster", - ) - ) - clusters = frappe.db.get_all( - "Cluster", - filters={"name": ("in", cluster_names)}, - fields=["name", "title", "image", "beta"], - ) - if not for_bench: - proxy_servers = frappe.db.get_all( - "Proxy Server", - { - "cluster": ("in", cluster_names), - "is_primary": 1, - }, - ["name", "cluster"], - ) - - for cluster in clusters: - cluster.proxy_server = find(proxy_servers, lambda x: x.cluster == cluster.name) - - version.group.clusters = clusters - - if version.group and version.group.bench and version.group.clusters: - available_versions.append(version) + available_versions = get_available_versions(for_bench) unique_app_sources = [] for version in available_versions: @@ -673,6 +588,112 @@ def options_for_new(for_bench: str | None = None): # noqa: C901 } +def get_available_versions(for_bench: str = None): + available_versions = [] + restricted_release_group_names = get_restricted_release_group_names() + + if for_bench: + version = frappe.db.get_value("Release Group", for_bench, "version") + filters = {"name": version} + + release_group_filters = {"name": for_bench} + else: + filters = {"public": True, "status": ("!=", "End of Life")} + release_group_filters = { + "public": 1, + "enabled": 1, + "name": ( + "not in", + restricted_release_group_names, + ), # filter out restricted release groups + } + + versions = frappe.db.get_all( + "Frappe Version", + ["name", "default", "status", "number"], + filters, + order_by="number desc", + ) + + for version in versions: + release_group_filters["version"] = version.name + release_group = frappe.db.get_value( + "Release Group", + fieldname=["name", "`default`", "title", "public"], + filters=release_group_filters, + order_by="creation desc", + as_dict=1, + ) + + if release_group: + version.group = release_group + if for_bench: + version.group.is_dedicated_server = is_dedicated_server( + frappe.get_all( + "Release Group Server", + filters={"parent": release_group.name, "parenttype": "Release Group"}, + pluck="server", + limit=1, + )[0] + ) + + set_bench_and_clusters(version, for_bench) + + if version.group and version.group.bench and version.group.clusters: + available_versions.append(version) + + return available_versions + + +def get_restricted_release_group_names(): + return frappe.db.get_all( + "Site Plan Release Group", + pluck="release_group", + filters={"parenttype": "Site Plan", "parentfield": "release_groups"}, + ) + + +def set_bench_and_clusters(version, for_bench): + # here we get the last created bench for the release group + # assuming the last created bench is the latest one + bench = frappe.db.get_value( + "Bench", + filters={"status": "Active", "group": version.group.name}, + order_by="creation desc", + ) + if bench: + version.group.bench = bench + version.group.bench_app_sources = frappe.db.get_all( + "Bench App", {"parent": bench, "app": ("!=", "frappe")}, pluck="source" + ) + cluster_names = unique( + frappe.db.get_all( + "Bench", + filters={"candidate": frappe.db.get_value("Bench", bench, "candidate")}, + pluck="cluster", + ) + ) + clusters = frappe.db.get_all( + "Cluster", + filters={"name": ("in", cluster_names)}, + fields=["name", "title", "image", "beta"], + ) + if not for_bench: + proxy_servers = frappe.db.get_all( + "Proxy Server", + { + "cluster": ("in", cluster_names), + "is_primary": 1, + }, + ["name", "cluster"], + ) + + for cluster in clusters: + cluster.proxy_server = find(proxy_servers, lambda x: x.cluster == cluster.name) + + version.group.clusters = clusters + + @frappe.whitelist() def get_domain(): return frappe.db.get_value("Press Settings", "Press Settings", ["domain"]) From 8e53e36ae8450ea59fd48d9b2f81600f3291ce04 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Tue, 19 Nov 2024 19:09:32 +0530 Subject: [PATCH 36/93] fix: ux and api for default apps --- dashboard/src2/pages/NewReleaseGroup.vue | 51 +++++++++---------- press/api/bench.py | 44 ++++++++++------ .../doctype/press_settings/press_settings.py | 3 +- 3 files changed, 54 insertions(+), 44 deletions(-) diff --git a/dashboard/src2/pages/NewReleaseGroup.vue b/dashboard/src2/pages/NewReleaseGroup.vue index 3107db032b9..aba3f828a0c 100644 --- a/dashboard/src2/pages/NewReleaseGroup.vue +++ b/dashboard/src2/pages/NewReleaseGroup.vue @@ -170,6 +170,13 @@ export default { }; }, resources: { + defaultApps() { + return { + url: 'press.api.bench.get_default_apps', + initialData: {}, + auto: true + }; + }, options() { return { url: 'press.api.bench.options', @@ -219,13 +226,14 @@ export default { }); // add default apps - this.defaultApps.forEach(app => { - apps.push({ - name: app.name, - source: app.source - }); - }); - + apps.push( + ...this.defaultApps[this.benchVersion].map(app => { + return { + name: app.app, + source: app.source + }; + }) + ); return apps; } }, @@ -234,27 +242,7 @@ export default { return this.$resources.options.data; }, defaultApps() { - let defaultApps = []; - this.options.versions.forEach(version => { - version.apps.forEach(app => { - if (app.is_default) { - let d = { - name: app.name, - source: app.source.name, - app_title: app.title, - route: app.source.repository_url - }; - if ( - defaultApps.filter(app => app.app_title === d.app_title) - .length === 0 - ) { - defaultApps.push(d); - } - } - }); - }); - - return defaultApps; + return this.$resources.defaultApps.data; }, defaultAppsList() { return { @@ -285,6 +273,13 @@ export default { label: 'Frappe Framework Version', value: this.benchVersion }, + { + label: 'Default Apps', + value: this.defaultApps[this.benchVersion] + .map(app => app.title) + .join(', '), + condition: () => this.defaultApps[this.benchVersion].length + }, { label: 'Region', value: this.benchRegion, diff --git a/press/api/bench.py b/press/api/bench.py index 69f4af2e1d9..23f332d7a7b 100644 --- a/press/api/bench.py +++ b/press/api/bench.py @@ -191,7 +191,22 @@ def exists(title): @frappe.whitelist() -def options(): +def get_default_apps(): + press_settings = frappe.get_single("Press Settings") + default_apps = press_settings.get_default_apps() + + versions, rows = get_app_versions_list() + + version_based_default_apps = {v.version: [] for v in versions} + + for row in rows: + if row.app in default_apps: + version_based_default_apps[row.version].append(row) + + return version_based_default_apps + + +def get_app_versions_list(only_frappe=False): AppSource = frappe.qb.DocType("App Source") FrappeVersion = frappe.qb.DocType("Frappe Version") AppSourceVersion = frappe.qb.DocType("App Source Version") @@ -201,12 +216,7 @@ def options(): .on(AppSourceVersion.parent == AppSource.name) .left_join(FrappeVersion) .on(AppSourceVersion.version == FrappeVersion.name) - .where( - (AppSource.enabled == 1) - & (AppSource.public == 1) - & (FrappeVersion.public == 1) - & (AppSource.frappe == 1) - ) + .where((AppSource.enabled == 1) & (AppSource.public == 1) & (FrappeVersion.public == 1)) .select( FrappeVersion.name.as_("version"), FrappeVersion.status, @@ -221,17 +231,23 @@ def options(): AppSource.frappe, ) .orderby(AppSource.creation) - .run(as_dict=True) ) - approved_apps = frappe.get_all( - "Marketplace App", filters={"frappe_approved": 1}, pluck="app" - ) + if only_frappe: + rows = rows.where(AppSource.frappe == 1) - press_settings = frappe.get_single("Press Settings") - apps_group = press_settings.get_app_group() + rows = rows.run(as_dict=True) version_list = unique(rows, lambda x: x.version) + + return version_list, rows + + +@frappe.whitelist() +def options(): + version_list, rows = get_app_versions_list(only_frappe=True) + approved_apps = frappe.get_all("Marketplace App", filters={"frappe_approved": 1}, pluck="app") + versions = [] for d in version_list: version_dict = {"name": d.version, "status": d.status, "default": d.default} @@ -254,8 +270,6 @@ def options(): app_dict.setdefault("sources", []).append(source_dict) app_dict["source"] = app_dict["sources"][0] - app_dict["is_default"] = True if app in apps_group else False - version_dict.setdefault("apps", []).append(app_dict) versions.append(version_dict) diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index 211dfd0c0a5..29f4de3dab2 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -22,6 +22,7 @@ class PressSettings(Document): if TYPE_CHECKING: from frappe.types import DF + from press.press.doctype.app_group.app_group import AppGroup from press.press.doctype.erpnext_app.erpnext_app import ERPNextApp @@ -248,7 +249,7 @@ def twilio_client(self) -> Client: api_key_secret = self.get_password("twilio_api_key_secret") return Client(api_key_sid, api_key_secret, account_sid) - def get_app_group(self): + def get_default_apps(self): if self.enable_app_grouping: return [app.app for app in self.default_apps] return [] From c8e66245fe2642837354361c8e6c06894c958597 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Wed, 20 Nov 2024 17:31:33 +0530 Subject: [PATCH 37/93] feat: install default apps to the site --- .../components/site/NewSiteAppSelector.vue | 6 ++++- dashboard/src2/pages/NewSite.vue | 4 ++-- press/api/site.py | 23 +++++++++++-------- press/press/doctype/app_group/app_group.py | 2 ++ 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/dashboard/src2/components/site/NewSiteAppSelector.vue b/dashboard/src2/components/site/NewSiteAppSelector.vue index 0ab5cdcd127..a1f93f7dd0a 100644 --- a/dashboard/src2/components/site/NewSiteAppSelector.vue +++ b/dashboard/src2/components/site/NewSiteAppSelector.vue @@ -72,6 +72,8 @@ export default { if (!publicApps.length) return; + this.apps = this.availableApps.filter(app => app.is_default === true); + return { data: () => publicApps, columns: [ @@ -192,7 +194,9 @@ export default { }, methods: { toggleApp(app) { - if (this.apps.map(a => a.app).includes(app.app)) { + if (app.is_default) { + throw new Error('Cannot remove default app'); + } else if (this.apps.map(a => a.app).includes(app.app)) { this.apps = this.apps.filter(a => a.app !== app.app); } else { if (app.subscription_type && app.subscription_type !== 'Free') { diff --git a/dashboard/src2/pages/NewSite.vue b/dashboard/src2/pages/NewSite.vue index 91f3ffe79dc..846c4a3582d 100644 --- a/dashboard/src2/pages/NewSite.vue +++ b/dashboard/src2/pages/NewSite.vue @@ -544,11 +544,11 @@ export default { else apps = this.selectedVersion.group.bench_app_sources.map(app_source => { let app_source_details = this.options.app_source_details[app_source]; - console.log(app_source_details); + let marketplace_details = app_source_details ? this.options.marketplace_details[app_source_details.app] : {}; - console.log(marketplace_details); + return { app_title: app_source, ...app_source_details, diff --git a/press/api/site.py b/press/api/site.py index 1b56a55c100..ba3c63319de 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -481,9 +481,7 @@ def app_details_for_new_public_site(): filters={"status": "Published", "show_for_site_creation": 1}, ).run(as_dict=True) - marketplace_app_sources = [ - app["sources"][0]["source"] for app in marketplace_apps if app["sources"] - ] + marketplace_app_sources = [app["sources"][0]["source"] for app in marketplace_apps if app["sources"]] if not marketplace_app_sources: return [] @@ -521,11 +519,6 @@ def app_details_for_new_public_site(): return marketplace_apps -def _options_for_new(for_bench: str = None): - versions = get_available_versions(for_bench) - return versions - - @frappe.whitelist() def options_for_new(for_bench: str | None = None): # noqa: C901 for_bench = str(for_bench) if for_bench else None @@ -569,12 +562,15 @@ def options_for_new(for_bench: str | None = None): # noqa: C901 ) total_installs_by_app = get_total_installs_by_app() marketplace_details = {} + for app in unique_apps: details = find(marketplace_apps, lambda x: x.app == app) if details: details["plans"] = get_plans_for_app(app) details["total_installs"] = total_installs_by_app.get(app, 0) marketplace_details[app] = details + + set_default_apps(app_source_details_grouped) else: app_source_details_grouped = app_details_for_new_public_site() # app source details are all fetched from marketplace apps for public sites @@ -588,7 +584,16 @@ def options_for_new(for_bench: str | None = None): # noqa: C901 } -def get_available_versions(for_bench: str = None): +def set_default_apps(app_source_details_grouped): + press_settings = frappe.get_single("Press Settings") + default_apps = press_settings.get_default_apps() + + for app_source in app_source_details_grouped.values(): + if app_source["app"] in default_apps: + app_source["is_default"] = True + + +def get_available_versions(for_bench: str = None): # noqa available_versions = [] restricted_release_group_names = get_restricted_release_group_names() diff --git a/press/press/doctype/app_group/app_group.py b/press/press/doctype/app_group/app_group.py index e3aeca40771..f0b0ad7e463 100644 --- a/press/press/doctype/app_group/app_group.py +++ b/press/press/doctype/app_group/app_group.py @@ -1,6 +1,8 @@ # Copyright (c) 2024, Frappe and contributors # For license information, please see license.txt +from __future__ import annotations + # import frappe from frappe.model.document import Document From 55474381fd06c4e5206e0e341cda46fd8f2f5f66 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 21 Nov 2024 13:27:02 +0530 Subject: [PATCH 38/93] feat: use toast, badge for notification and use preinstalled as a key --- .../src2/components/site/NewSiteAppSelector.vue | 14 +++++++++++--- dashboard/src2/pages/NewReleaseGroup.vue | 2 +- press/api/site.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/dashboard/src2/components/site/NewSiteAppSelector.vue b/dashboard/src2/components/site/NewSiteAppSelector.vue index a1f93f7dd0a..fec1081fc25 100644 --- a/dashboard/src2/components/site/NewSiteAppSelector.vue +++ b/dashboard/src2/components/site/NewSiteAppSelector.vue @@ -41,6 +41,7 @@ import SiteAppPlanSelectorDialog from './SiteAppPlanSelectorDialog.vue'; import { Badge } from 'frappe-ui'; import { icon } from '../../utils/components'; import ObjectList from '../ObjectList.vue'; +import { toast } from 'vue-sonner'; export default { props: ['availableApps', 'siteOnPublicBench', 'modelValue'], @@ -72,7 +73,7 @@ export default { if (!publicApps.length) return; - this.apps = this.availableApps.filter(app => app.is_default === true); + this.apps = this.availableApps.filter(app => app.preinstalled === true); return { data: () => publicApps, @@ -95,6 +96,13 @@ export default { src: row.image }), h('span', { class: 'ml-2' }, row.title || row.app_title), + row?.preinstalled + ? h(Badge, { + class: 'ml-2', + theme: 'green', + label: 'Pre-Installed' + }) + : '', row.subscription_type !== 'Free' ? h(Badge, { class: 'ml-2', @@ -194,8 +202,8 @@ export default { }, methods: { toggleApp(app) { - if (app.is_default) { - throw new Error('Cannot remove default app'); + if (app.preinstalled) { + toast.error(app.title + ' is pre-installed and cannot be removed'); } else if (this.apps.map(a => a.app).includes(app.app)) { this.apps = this.apps.filter(a => a.app !== app.app); } else { diff --git a/dashboard/src2/pages/NewReleaseGroup.vue b/dashboard/src2/pages/NewReleaseGroup.vue index aba3f828a0c..b70d290664f 100644 --- a/dashboard/src2/pages/NewReleaseGroup.vue +++ b/dashboard/src2/pages/NewReleaseGroup.vue @@ -274,7 +274,7 @@ export default { value: this.benchVersion }, { - label: 'Default Apps', + label: 'Preinstalled Apps', value: this.defaultApps[this.benchVersion] .map(app => app.title) .join(', '), diff --git a/press/api/site.py b/press/api/site.py index ba3c63319de..13b56fdaa7f 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -590,7 +590,7 @@ def set_default_apps(app_source_details_grouped): for app_source in app_source_details_grouped.values(): if app_source["app"] in default_apps: - app_source["is_default"] = True + app_source["preinstalled"] = True def get_available_versions(for_bench: str = None): # noqa From d8eeb41fd442b728922bcb000364875128184976 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 21 Nov 2024 13:37:20 +0530 Subject: [PATCH 39/93] fix: naming --- dashboard/src2/pages/NewReleaseGroup.vue | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/dashboard/src2/pages/NewReleaseGroup.vue b/dashboard/src2/pages/NewReleaseGroup.vue index b70d290664f..1e4621f5b55 100644 --- a/dashboard/src2/pages/NewReleaseGroup.vue +++ b/dashboard/src2/pages/NewReleaseGroup.vue @@ -64,9 +64,6 @@
-
- -
{ + ...this.preInstalledApps[this.benchVersion].map(app => { return { name: app.app, source: app.source @@ -241,12 +238,12 @@ export default { options() { return this.$resources.options.data; }, - defaultApps() { - return this.$resources.defaultApps.data; + preInstalledApps() { + return this.$resources.preInstalledApps.data; }, - defaultAppsList() { + preInstalledAppsList() { return { - data: () => this.defaultApps, + data: () => this.preInstalledApps, columns: [ { label: 'Default Apps', @@ -275,10 +272,10 @@ export default { }, { label: 'Preinstalled Apps', - value: this.defaultApps[this.benchVersion] + value: this.preInstalledApps[this.benchVersion] .map(app => app.title) .join(', '), - condition: () => this.defaultApps[this.benchVersion].length + condition: () => this.preInstalledApps[this.benchVersion].length }, { label: 'Region', From 99382ede70297f50bc15912cfc5d8a0c9add547d Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 21 Nov 2024 14:14:15 +0530 Subject: [PATCH 40/93] fix: validate if fields are exists --- press/press/doctype/press_settings/press_settings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/press/press/doctype/press_settings/press_settings.py b/press/press/doctype/press_settings/press_settings.py index 29f4de3dab2..cedb08f91c5 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -250,6 +250,7 @@ def twilio_client(self) -> Client: return Client(api_key_sid, api_key_secret, account_sid) def get_default_apps(self): - if self.enable_app_grouping: - return [app.app for app in self.default_apps] + if hasattr(self, "enable_app_grouping") and hasattr(self, "default_apps"): # noqa + if self.enable_app_grouping: + return [app.app for app in self.default_apps] return [] From a2555bce319206d9c3da18f8380e05684bac10e7 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Thu, 21 Nov 2024 15:02:18 +0530 Subject: [PATCH 41/93] fix(SiteList): fixed width for status badge --- dashboard/src2/objects/site.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src2/objects/site.js b/dashboard/src2/objects/site.js index d803c64f0b8..159d9a43002 100644 --- a/dashboard/src2/objects/site.js +++ b/dashboard/src2/objects/site.js @@ -133,7 +133,7 @@ export default { return value || row.name; } }, - { label: 'Status', fieldname: 'status', type: 'Badge', width: 0.7 }, + { label: 'Status', fieldname: 'status', type: 'Badge', width: '140px' }, { label: 'Plan', fieldname: 'plan', From 393d5a4925c16c42ddd6873777a993565048d8f5 Mon Sep 17 00:00:00 2001 From: Bread Genie Date: Thu, 21 Nov 2024 15:21:34 +0530 Subject: [PATCH 42/93] fix(site-api): fetch subscription name for changing plan --- press/api/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/api/site.py b/press/api/site.py index 13b56fdaa7f..0440b9c7878 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -1330,7 +1330,7 @@ def get_installed_apps(site, query_filters: dict | None = None): "document_name": app.app, "enabled": 1, }, - ["document_name as app", "plan"], + ["document_name as app", "plan", "name"], as_dict=True, ) app_source.subscription = subscription From 5eeb975bd24b673ffd69ccdb434f4df784078287 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:08:03 +0530 Subject: [PATCH 43/93] chore(saas): make terms and privacy policy acceptance label in saas signup pages responsive --- .../src2/pages/saas/OAuthSetupAccount.vue | 43 ++++++++++--------- dashboard/src2/pages/saas/Signup.vue | 42 +++++++++--------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/dashboard/src2/pages/saas/OAuthSetupAccount.vue b/dashboard/src2/pages/saas/OAuthSetupAccount.vue index e399d3dea80..5e3788b3e2e 100644 --- a/dashboard/src2/pages/saas/OAuthSetupAccount.vue +++ b/dashboard/src2/pages/saas/OAuthSetupAccount.vue @@ -41,27 +41,28 @@ v-model="country" required /> - -
- - I agree to Frappe  - - TC ,  - - Privacy Policy - -  &  - - Cookie Policy - +
+
-
- - I agree to Frappe  - - TC ,  - - Privacy Policy - -  &  - - Cookie Policy - +
+
Date: Thu, 21 Nov 2024 16:55:03 +0530 Subject: [PATCH 44/93] chore(saas): remove verify button from email for new saas flow --- press/templates/emails/product_trial_verify_account.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/press/templates/emails/product_trial_verify_account.html b/press/templates/emails/product_trial_verify_account.html index 60e9b08f777..6d326ff5684 100644 --- a/press/templates/emails/product_trial_verify_account.html +++ b/press/templates/emails/product_trial_verify_account.html @@ -15,14 +15,8 @@ {{ header_content }} {% endautoescape %} {% endif %} - {% if otp %}

Verification Code

{{ otp }}
-

Or click on the button to verify your account

- {% else %} -

Click on the button to verify your account

- {% endif %} - {{ utils.button('Verify Account', link, true) }} {{ utils.separator() }}

Team Frappe

From 23e017b97e073ac6e5ba7ef127d9b78041b09e5f Mon Sep 17 00:00:00 2001 From: Shadrak Gurupnor <30501401+shadrak98@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:02:19 +0530 Subject: [PATCH 45/93] fix: ignore cancelled invoices --- press/api/billing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/api/billing.py b/press/api/billing.py index 4bcea50b240..6c09f2219d3 100644 --- a/press/api/billing.py +++ b/press/api/billing.py @@ -640,7 +640,7 @@ def total_unpaid_amount(): return ( frappe.get_all( "Invoice", - {"status": "Unpaid", "team": team.name, "type": "Subscription"}, + {"status": "Unpaid", "team": team.name, "type": "Subscription", "docstatus": ("!=", 2)}, ["sum(amount_due) as total"], pluck="total", )[0] From 61ae7112c7183e9fe6fec129f1c2f2a4e157882f Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:03:51 +0530 Subject: [PATCH 46/93] chore(saas): rename headers of saas site setup page --- dashboard/src2/pages/saas/SetupSite.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src2/pages/saas/SetupSite.vue b/dashboard/src2/pages/saas/SetupSite.vue index c474436ce26..caaefd90344 100644 --- a/dashboard/src2/pages/saas/SetupSite.vue +++ b/dashboard/src2/pages/saas/SetupSite.vue @@ -9,8 +9,8 @@
From 656e9faeea795929b613a5aa210df3f55d67792c Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:16:37 +0000 Subject: [PATCH 48/93] docs(saas): added documentation for new saas flow --- press/saas/README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/press/saas/README.md b/press/saas/README.md index e69de29bb2d..2da0948ee35 100644 --- a/press/saas/README.md +++ b/press/saas/README.md @@ -0,0 +1,62 @@ +### New SaaS Flow (Product Trial) + +It has 2 doctypes. + +1. **Product Trial** - Hold the configuration for a specific product. +2. **Product Trial Request** - This holds the records of request for a specific product from a user. + +#### Configure a new Product Trial +- Create a new record in `Product Trial` doctype +- **Details Tab** + - **Name** - should be a unique one and will be used as a id in signup/login flows. e.g. For `Frappe CRM` it could be `crm` + - **Published**, **Title**, **Logo**, **Domain**, **Release Group**, **Trial Duration (days)**, **Trial Plan** - as the name implies, all fields are mandatory. + - **Apps** - List of apps those will be installed on the site. First app should be `Frappe` in the list. +- **Pooling Tab** + - **Enable Pooling** - Checkbox to enable/disable pooling. If you enable pooling, you will have standby sites and will be quick to provision sites. + - **Standby Pool Size** - The total number of sites that will be maintained in the pool. + - **Standby Queue Size** - Number of standby sites that will be queued at a time. +- **Sign-up Details Tab** + - **Sign-up Fields** - If you need some information from user at the time of sign-up, you can configure this. Check the field description of this field in doctype. + - **E-mail Account** - If you want to use some specific e-mail account for the saas sign-up, you can configure it here + - **E-mail Full Logo** - This logo will be sent in verification e-mails. + - **E-mail Subject** - Subject of verification e-mail. You can put `{otp}` to insert the value in subject. Example - `{otp} - OTP for CRM Registration` + - **E-mail Header Content** - Header part of e-mail. + ```html +

You're almost done!

+

Just one quick step left to get you started with Frappe CRM!

+ ``` +- **Setup Wizard Tab**- + - **Setup Wizard Completion Mode** - + - **auto** - setup wizard of site will be completed in background and after signup + setup, user will get direct access to desk or portal of app + - **manual** - after signup, user will be logged in to the site and user need to complete the setup wizard of framework + - **Setup Wizard Payload Generator Script** [only for **auto** mode] - Check the field description in doctype. + + Sample Payload Script - + ```python + payload = { + "language":"English", + "country": team.country, + "timezone":"Asia/Kolkata", + "currency": team.currency, + "full_name": team.user.full_name, + "email": team.user.email, + "password": decrypt_password(signup_details.login_password) + } + ``` + - **Create Additional System User** [only for **manual** mode] - If this is checked, we will add an additional system user with the team's information after creating a new site. + +#### FC Dashboard +- UI/UX - The pages are available in https://github.com/frappe/press/tree/master/dashboard/src2/pages/saas +- The required apis for these pages are available in https://github.com/frappe/press/blob/master/press/api/product_trial.py + +#### Billing APIs for Integration in Framework + +> [!CAUTION] +> Changes in any of these APIs can cause disruption in on-site billing system. + +- All the required APIs for billing in site is available in https://github.com/frappe/press/tree/master/press/saas/api +- These APIs use a different type of authentication mechanism. Check this readme for more info https://github.com/frappe/press/blob/master/press/saas/api/readme.md +- Reference of integration in framework + - https://github.com/frappe/frappe/tree/develop/billing + - https://github.com/frappe/frappe/blob/develop/frappe/integrations/frappe_providers/frappecloud_billing.py + From 40647f941d7bdc5ffc7f6c5a760332f8c3682db9 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:40:29 +0530 Subject: [PATCH 49/93] feat(saas): support to configure the redirection url for site after login/signup (#2302) --- dashboard/src2/pages/saas/LoginToSite.vue | 5 ++++- press/saas/README.md | 1 + press/saas/doctype/product_trial/product_trial.json | 12 ++++++++++-- press/saas/doctype/product_trial/product_trial.py | 5 +++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/dashboard/src2/pages/saas/LoginToSite.vue b/dashboard/src2/pages/saas/LoginToSite.vue index b097d4fb2bc..bbf2ed77ed5 100644 --- a/dashboard/src2/pages/saas/LoginToSite.vue +++ b/dashboard/src2/pages/saas/LoginToSite.vue @@ -152,7 +152,10 @@ export default { method: 'get_login_sid', onSuccess(data) { let sid = data; - let loginURL = `https://${this.$resources.siteRequest.doc.site}/desk?sid=${sid}`; + let redirectRoute = + this.$resources?.saasProduct?.doc?.redirect_to_after_login ?? + '/desk'; + let loginURL = `https://${this.$resources.siteRequest.doc.site}${redirectRoute}?sid=${sid}`; this.isRedirectingToSite = true; window.open(loginURL, '_self'); } diff --git a/press/saas/README.md b/press/saas/README.md index 2da0948ee35..470555f37ee 100644 --- a/press/saas/README.md +++ b/press/saas/README.md @@ -44,6 +44,7 @@ It has 2 doctypes. } ``` - **Create Additional System User** [only for **manual** mode] - If this is checked, we will add an additional system user with the team's information after creating a new site. + - **Redirect To After Login** - After SaaS signup/login, user is directly logged-in to his site. By default, we redirect the user to desk of site. With this option, we can configure the redirect path. For example, for gameplan the path would be `/g` #### FC Dashboard - UI/UX - The pages are available in https://github.com/frappe/press/tree/master/dashboard/src2/pages/saas diff --git a/press/saas/doctype/product_trial/product_trial.json b/press/saas/doctype/product_trial/product_trial.json index 6064ce17317..4139b4f9cb6 100644 --- a/press/saas/doctype/product_trial/product_trial.json +++ b/press/saas/doctype/product_trial/product_trial.json @@ -36,8 +36,9 @@ "email_header_content", "setup_wizard_tab", "setup_wizard_completion_mode", + "setup_wizard_payload_generator_script", "create_additional_system_user", - "setup_wizard_payload_generator_script" + "redirect_to_after_login" ], "fields": [ { @@ -217,6 +218,13 @@ "fieldname": "create_additional_system_user", "fieldtype": "Check", "label": "Create Additional System User" + }, + { + "default": "/desk", + "fieldname": "redirect_to_after_login", + "fieldtype": "Data", + "label": "Redirect To After Login", + "reqd": 1 } ], "image_field": "logo", @@ -231,7 +239,7 @@ "link_fieldname": "product_trial" } ], - "modified": "2024-11-19 16:12:29.471858", + "modified": "2024-11-21 22:13:33.724250", "modified_by": "Administrator", "module": "SaaS", "name": "Product Trial", diff --git a/press/saas/doctype/product_trial/product_trial.py b/press/saas/doctype/product_trial/product_trial.py index c8553caab08..b54b0907bee 100644 --- a/press/saas/doctype/product_trial/product_trial.py +++ b/press/saas/doctype/product_trial/product_trial.py @@ -39,6 +39,7 @@ class ProductTrial(Document): enable_pooling: DF.Check logo: DF.AttachImage | None published: DF.Check + redirect_to_after_login: DF.Data release_group: DF.Link setup_wizard_completion_mode: DF.Literal["manual", "auto"] setup_wizard_payload_generator_script: DF.Code | None @@ -56,6 +57,7 @@ class ProductTrial(Document): "domain", "trial_days", "trial_plan", + "redirect_to_after_login", ) USER_LOGIN_PASSWORD_FIELD = "user_login_password" @@ -100,6 +102,9 @@ def validate(self): if field.fieldtype != "Password": frappe.throw(f"{self.USER_LOGIN_PASSWORD_FIELD} field should be of type Password") + if not self.redirect_to_after_login.startswith("/"): + frappe.throw("Redirection route after login should start with /") + def setup_trial_site(self, team, plan, cluster=None, account_request=None): from press.press.doctype.site.site import get_plan_config From 685d38048db83e94eed83b8292e6afff56c53dbd Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Thu, 21 Nov 2024 23:01:08 +0530 Subject: [PATCH 50/93] chore(saas): update the body of login e-mail --- press/saas/doctype/product_trial/product_trial.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/press/saas/doctype/product_trial/product_trial.py b/press/saas/doctype/product_trial/product_trial.py index b54b0907bee..2bb72292e89 100644 --- a/press/saas/doctype/product_trial/product_trial.py +++ b/press/saas/doctype/product_trial/product_trial.py @@ -350,15 +350,11 @@ def send_verification_mail_for_login(email: str, product: str, code: str): print(f"Code : {code}") print() return - product_trial = frappe.get_doc("Product Trial", product) + product_trial: ProductTrial = frappe.get_doc("Product Trial", product) sender = "" - subject = ( - product_trial.email_subject.format(otp=code) - if product_trial.email_subject - else "Verify your email for Frappe" - ) + subject = f"{code} - Verification Code for {product_trial.title} Login" args = { - "header_content": product_trial.email_header_content or "", + "header_content": f"

You have requested a verification code to login to your {product_trial.title} site. The code is valid for 5 minutes.

", "otp": code, } if product_trial.email_full_logo: @@ -370,7 +366,7 @@ def send_verification_mail_for_login(email: str, product: str, code: str): sender=sender, recipients=email, subject=subject, - template="saas_verify_account", + template="product_trial_verify_account", args=args, now=True, ) From ef2b1ff3e55a9a637db26f432b55d462ec196180 Mon Sep 17 00:00:00 2001 From: Shadrak Gurupnor <30501401+shadrak98@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:50:52 +0530 Subject: [PATCH 51/93] fix: update next attempt date if exists --- press/press/doctype/stripe_webhook_log/stripe_webhook_log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/press/doctype/stripe_webhook_log/stripe_webhook_log.py b/press/press/doctype/stripe_webhook_log/stripe_webhook_log.py index cf3991032ed..39ae096bbec 100644 --- a/press/press/doctype/stripe_webhook_log/stripe_webhook_log.py +++ b/press/press/doctype/stripe_webhook_log/stripe_webhook_log.py @@ -61,7 +61,7 @@ def before_insert(self): "name", ) - if self.event_type == "invoice.payment_failed" and self.invoice: + if self.event_type == "invoice.payment_failed" and self.invoice and payload.get("data", {}).get("object", {}).get("next_payment_attempt"): next_payment_attempt_date = datetime.fromtimestamp( payload.get("data", {}).get("object", {}).get("next_payment_attempt") ).strftime("%Y-%m-%d") From 85c9f246f0aa84dd14abd07419b03390f7be1ec6 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:10:01 +0530 Subject: [PATCH 52/93] chore(saas): update docs --- press/saas/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/press/saas/README.md b/press/saas/README.md index 470555f37ee..3764f5624a7 100644 --- a/press/saas/README.md +++ b/press/saas/README.md @@ -5,6 +5,11 @@ It has 2 doctypes. 1. **Product Trial** - Hold the configuration for a specific product. 2. **Product Trial Request** - This holds the records of request for a specific product from a user. +#### How to know, which site is available for allocation to user ? + +In **Site** doctype, there will be a field `standby_for_product`, this field should have the link to the product trial (e.g. erpnext, crm) +If `is_standby` field is checked, that site can be allocated to a user. + #### Configure a new Product Trial - Create a new record in `Product Trial` doctype - **Details Tab** From d285e33e2078804d6d59a37aee1658e5dfa205f0 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:03:06 +0530 Subject: [PATCH 53/93] fix(site-api): if app source is none, don't include it - Sentry - PRESS-7WP - While fetching app sources with repository_owner, branch filter, some apps can be in the installed_bench_apps list but may not available in app_sources due to filter. That raises the error. --- press/api/site.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/press/api/site.py b/press/api/site.py index 0440b9c7878..0e7620af78c 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -1296,6 +1296,8 @@ def get_installed_apps(site, query_filters: dict | None = None): installed_apps = [] for app in installed_bench_apps: app_source = find(sources, lambda x: x.name == app.source) + if not app_source: + continue app_source.hash = app.hash app_source.commit_message = frappe.db.get_value("App Release", {"hash": app_source.hash}, "message") app_tags = frappe.db.get_value( @@ -1341,16 +1343,16 @@ def get_installed_apps(site, query_filters: dict | None = None): app_source.app_title = marketplace_app_info.title app_source.app_image = marketplace_app_info.image - app_source.plan_info = frappe.db.get_value( - "Marketplace App Plan", - subscription.plan, - ["price_usd", "price_inr", "name", "plan"], - as_dict=True, - ) + # app_source.plan_info = frappe.db.get_value( + # "Marketplace App Plan", + # subscription.plan, + # ["price_usd", "price_inr", "name", "plan"], + # as_dict=True, + # ) app_source.plans = get_plans_for_app(app.app) - app_source.is_free = app_source.plan_info.price_usd <= 0 + # app_source.is_free = app_source.plan_info.price_usd <= 0 else: app_source.subscription = {} From 46bddaa314151a30c5f68887c8b74797604b0d69 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:04:28 +0530 Subject: [PATCH 54/93] chore(site-api): revert the changes of local environment --- press/api/site.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/press/api/site.py b/press/api/site.py index 0e7620af78c..ca5397a78d8 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -1343,16 +1343,16 @@ def get_installed_apps(site, query_filters: dict | None = None): app_source.app_title = marketplace_app_info.title app_source.app_image = marketplace_app_info.image - # app_source.plan_info = frappe.db.get_value( - # "Marketplace App Plan", - # subscription.plan, - # ["price_usd", "price_inr", "name", "plan"], - # as_dict=True, - # ) + app_source.plan_info = frappe.db.get_value( + "Marketplace App Plan", + subscription.plan, + ["price_usd", "price_inr", "name", "plan"], + as_dict=True, + ) app_source.plans = get_plans_for_app(app.app) - # app_source.is_free = app_source.plan_info.price_usd <= 0 + app_source.is_free = app_source.plan_info.price_usd <= 0 else: app_source.subscription = {} From a74f3d74e94e6fd69965537202ae88fcfbcbc338 Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Thu, 21 Nov 2024 11:02:54 +0530 Subject: [PATCH 55/93] chore(SiteMigration): Correct docstring for plan adjustment --- press/press/doctype/site_migration/site_migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/press/press/doctype/site_migration/site_migration.py b/press/press/doctype/site_migration/site_migration.py index 5c2f4a4cf18..8415331fb70 100644 --- a/press/press/doctype/site_migration/site_migration.py +++ b/press/press/doctype/site_migration/site_migration.py @@ -604,7 +604,7 @@ def downgrade_plan(self, site: "Site", dest_server: Server): return None def adjust_plan_if_required(self): - """Change Plan to Unlimited if Migrated to Dedicated Server""" + """Update site plan from/to Unlimited""" site: "Site" = frappe.get_doc("Site", self.site) dest_server: Server = frappe.get_doc("Server", self.destination_server) plan_change = None From a08b7c775ae0ac7945ce5d1a295702864f66004b Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Fri, 22 Nov 2024 12:34:58 +0530 Subject: [PATCH 56/93] fix(DatabaseServer): Set myisam-recover-options to FORCE This ensures no .BAK files are created for when Error Log and other myisam tables crash. We don't use them anyway. The stray .BAK files prevent archive jobs from completing. Also use newer `myisam-recover-options` instead of `myisam-recover` https://mariadb.com/kb/en/myisam-system-variables/#myisam_recover_options --- press/playbooks/roles/convert/templates/mariadb.cnf | 2 +- press/playbooks/roles/mariadb/templates/mariadb.cnf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/press/playbooks/roles/convert/templates/mariadb.cnf b/press/playbooks/roles/convert/templates/mariadb.cnf index 307d3e9d472..dcf6d39931e 100644 --- a/press/playbooks/roles/convert/templates/mariadb.cnf +++ b/press/playbooks/roles/convert/templates/mariadb.cnf @@ -5,7 +5,7 @@ default-storage-engine = InnoDB # MyISAM # key-buffer-size = 32M -myisam-recover = FORCE,BACKUP +myisam-recover-options = FORCE # SAFETY # max-allowed-packet = 256M diff --git a/press/playbooks/roles/mariadb/templates/mariadb.cnf b/press/playbooks/roles/mariadb/templates/mariadb.cnf index 67cf36ee740..ea1d1d45182 100644 --- a/press/playbooks/roles/mariadb/templates/mariadb.cnf +++ b/press/playbooks/roles/mariadb/templates/mariadb.cnf @@ -5,7 +5,7 @@ default-storage-engine = InnoDB # MyISAM # key-buffer-size = 32M -myisam-recover = FORCE,BACKUP +myisam-recover-options = FORCE # SAFETY # max-allowed-packet = 256M From 6769938f1933fc5283374b381a6b612ea9a58ca3 Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Fri, 22 Nov 2024 12:44:08 +0530 Subject: [PATCH 57/93] chore(MariaDBVariable): Add myisam_recover_options This overrides the (previously) set default myisam_recover variable when both are set in the config --- press/fixtures/mariadb_variable.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/press/fixtures/mariadb_variable.json b/press/fixtures/mariadb_variable.json index 10822cc71bc..eb538590221 100644 --- a/press/fixtures/mariadb_variable.json +++ b/press/fixtures/mariadb_variable.json @@ -370,5 +370,17 @@ "name": "net_write_timeout", "set_on_new_servers": 0, "skippable": 0 + }, + { + "datatype": "Str", + "default_value": "FORCE", + "doc_section": "server", + "docstatus": 0, + "doctype": "MariaDB Variable", + "dynamic": 0, + "modified": "2024-11-22 12:31:31.593757", + "name": "myisam_recover_options", + "set_on_new_servers": 0, + "skippable": 0 } ] \ No newline at end of file From d747e563bfc6fa933d66834dbb4f2aa3d3d41c2b Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Fri, 22 Nov 2024 12:53:02 +0530 Subject: [PATCH 58/93] refactor(MariaDBVariable): Remove set_on_new_servers check from fixture Cloud-init will update from config anyway - Update convert playbook conf with same vars from mariadb.cnf --- press/fixtures/mariadb_variable.json | 28 +++++++++---------- .../roles/convert/templates/mariadb.cnf | 10 +++++-- .../roles/mariadb/templates/mariadb.cnf | 7 +++-- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/press/fixtures/mariadb_variable.json b/press/fixtures/mariadb_variable.json index eb538590221..5d1b0e7d963 100644 --- a/press/fixtures/mariadb_variable.json +++ b/press/fixtures/mariadb_variable.json @@ -138,9 +138,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 1, - "modified": "2023-09-22 08:38:16.807229", + "modified": "2024-11-22 12:51:29.101315", "name": "tmp_disk_table_size", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { @@ -150,9 +150,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 1, - "modified": "2024-03-05 12:55:47.689706", + "modified": "2024-11-22 12:52:00.473677", "name": "extra_max_connections", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { @@ -162,9 +162,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 0, - "modified": "2024-03-05 12:55:54.950776", + "modified": "2024-11-22 12:52:35.958089", "name": "extra_port", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { @@ -174,9 +174,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 1, - "modified": "2024-07-29 13:26:57.153685", + "modified": "2024-11-22 12:50:54.084797", "name": "max_connections", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { @@ -270,9 +270,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 1, - "modified": "2023-09-22 14:02:04.157025", + "modified": "2024-11-22 12:50:46.547631", "name": "innodb_old_blocks_time", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { @@ -282,9 +282,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 1, - "modified": "2023-09-22 08:38:25.704420", + "modified": "2024-11-22 12:51:34.994866", "name": "max_allowed_packet", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { @@ -294,9 +294,9 @@ "docstatus": 0, "doctype": "MariaDB Variable", "dynamic": 1, - "modified": "2024-03-28 15:23:30.635417", + "modified": "2024-11-22 12:50:58.514076", "name": "max_statement_time", - "set_on_new_servers": 1, + "set_on_new_servers": 0, "skippable": 0 }, { diff --git a/press/playbooks/roles/convert/templates/mariadb.cnf b/press/playbooks/roles/convert/templates/mariadb.cnf index dcf6d39931e..e8ce58ee685 100644 --- a/press/playbooks/roles/convert/templates/mariadb.cnf +++ b/press/playbooks/roles/convert/templates/mariadb.cnf @@ -8,7 +8,6 @@ key-buffer-size = 32M myisam-recover-options = FORCE # SAFETY # -max-allowed-packet = 256M max-connect-errors = 1000000 innodb = FORCE @@ -27,11 +26,15 @@ tmp-table-size = 32M max-heap-table-size = 32M query-cache-type = 0 query-cache-size = 0 -max-connections = 500 +max-connections = 200 thread-cache-size = 50 open-files-limit = 65535 table-definition-cache = 4096 table-open-cache = 10240 +tmp-disk-table-size = 5120M +max-statement-time = 3600 +extra_port = 3307 +extra_max_connections = 5 # INNODB # innodb-flush-method = O_DIRECT @@ -39,9 +42,10 @@ innodb-log-files-in-group = 2 innodb-log-file-size = 512M innodb-flush-log-at-trx-commit = 1 innodb-file-per-table = 1 -innodb-buffer-pool-size = {{ (ansible_memtotal_mb * 0.685)|round|int }}M +innodb-buffer-pool-size = {{ (ansible_memtotal_mb * 0.6)|round|int }}M innodb-file-format = barracuda innodb-large-prefix = 1 +innodb-old-blocks-time = 5000 collation-server = utf8mb4_unicode_ci character-set-server = utf8mb4 character-set-client-handshake = FALSE diff --git a/press/playbooks/roles/mariadb/templates/mariadb.cnf b/press/playbooks/roles/mariadb/templates/mariadb.cnf index ea1d1d45182..9086a4228eb 100644 --- a/press/playbooks/roles/mariadb/templates/mariadb.cnf +++ b/press/playbooks/roles/mariadb/templates/mariadb.cnf @@ -8,7 +8,6 @@ key-buffer-size = 32M myisam-recover-options = FORCE # SAFETY # -max-allowed-packet = 256M max-connect-errors = 1000000 innodb = FORCE @@ -29,13 +28,15 @@ tmp-table-size = 32M max-heap-table-size = 32M query-cache-type = 0 query-cache-size = 0 -max-connections = 500 +max-connections = 200 thread-cache-size = 50 open-files-limit = 65535 table-definition-cache = 4096 table-open-cache = 10240 tmp-disk-table-size = 5120M -max-statement-time = 10800 +max-statement-time = 3600 +extra_port = 3307 +extra_max_connections = 5 # INNODB # innodb-flush-method = O_DIRECT From 820918ff44ad97addc26379720c3bd25d06e68ad Mon Sep 17 00:00:00 2001 From: Shadrak Gurupnor <30501401+shadrak98@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:42:24 +0530 Subject: [PATCH 59/93] fix: remove additional storage check --- press/press/doctype/subscription/subscription.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/press/press/doctype/subscription/subscription.py b/press/press/doctype/subscription/subscription.py index 371cddd6c96..e85364a2be4 100644 --- a/press/press/doctype/subscription/subscription.py +++ b/press/press/doctype/subscription/subscription.py @@ -137,9 +137,6 @@ def create_usage_record(self): team = frappe.get_cached_doc("Team", self.team) - if self.additional_storage: - return None - if team.parent_team: team = frappe.get_cached_doc("Team", team.parent_team) From bbeb77664e4f3568f2c7c2b574932622779abc01 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:46:13 +0530 Subject: [PATCH 60/93] chore(saas): add notes for important saas related apis --- press/api/developer/saas.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/press/api/developer/saas.py b/press/api/developer/saas.py index 5730f4074e8..60d73d0edd7 100644 --- a/press/api/developer/saas.py +++ b/press/api/developer/saas.py @@ -112,6 +112,17 @@ def get_trial_expiry(secret_key): return api_handler.get_trial_expiry() +""" +NOTE: These mentioned apis are used for all type of saas sites to allow login to frappe cloud +- request_login_to_fc +- validate_login_to_fc +- login_to_fc + +Don't change the file name or the method names +It can potentially break the integrations. +""" + + @frappe.whitelist(allow_guest=True, methods=["POST"]) @rate_limit(limit=5, seconds=60) def request_login_to_fc(domain: str): From 9806b0fc970880fdda1b2bef672f1259379dc9e4 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 23 Nov 2024 15:46:22 +0530 Subject: [PATCH 61/93] fix(spamd): Request Entity Too Large (#2276) Co-authored-by: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> --- press/api/email.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/press/api/email.py b/press/api/email.py index 4c56fd6bf8f..2bfd4a8dbed 100644 --- a/press/api/email.py +++ b/press/api/email.py @@ -145,10 +145,10 @@ def validate_plan(secret_key): ) -def check_spam(message: str): +def check_spam(message: bytes): resp = requests.post( "https://server.frappemail.com/spamd/score", - {"message": message}, + files={"message": message}, ) if resp.status_code == 200: data = resp.json() @@ -158,7 +158,7 @@ def check_spam(message: str): SpamDetectionError, ) else: - log_error("Spam Detection: Error", data=resp.text, message=message) + log_error("Spam Detection: Error", data=resp.text, message=message.decode("utf-8")) @frappe.whitelist(allow_guest=True) @@ -173,8 +173,8 @@ def send_mime_mail(**data): api_key, domain = frappe.db.get_value("Press Settings", None, ["mailgun_api_key", "root_domain"]) - message = files["mime"].read() - check_spam(message.decode("utf-8")) + message: bytes = files["mime"].read() + check_spam(message) resp = requests.post( f"https://api.mailgun.net/v3/{domain}/messages.mime", From 21a3f767afd01682ced368ce360f3ce39c604486 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Sat, 23 Nov 2024 16:15:53 +0530 Subject: [PATCH 62/93] chore(email): ignore spam check errors in case of mail server update --- press/api/email.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/press/api/email.py b/press/api/email.py index 2bfd4a8dbed..e7789e5ca9e 100644 --- a/press/api/email.py +++ b/press/api/email.py @@ -146,19 +146,22 @@ def validate_plan(secret_key): def check_spam(message: bytes): - resp = requests.post( - "https://server.frappemail.com/spamd/score", - files={"message": message}, - ) - if resp.status_code == 200: + try: + resp = requests.post( + "https://server.frappemail.com/spamd/score", + files={"message": message}, + ) + resp.raise_for_status() data = resp.json() if data["message"] > 3.5: frappe.throw( "This email was blocked as it was flagged as spam by our system. Please review the contents and try again.", SpamDetectionError, ) - else: - log_error("Spam Detection: Error", data=resp.text, message=message.decode("utf-8")) + except requests.exceptions.HTTPError as e: + # Ignore error, if server.frappemail.com is being updated. + if e.response.status_code != 503: + log_error("Spam Detection : Error", data=e) @frappe.whitelist(allow_guest=True) From 04ee6c577f4acf66bf5fb191d4a73ad658236ca6 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Mon, 25 Nov 2024 12:13:27 +0530 Subject: [PATCH 63/93] feat: database user and permission manager (#2245) --- dashboard/src/components/global/Badge.vue | 1 + dashboard/src2/components/SiteActionCell.vue | 2 +- .../components/SiteDatabaseAccessDialog.vue | 327 +++++++++------ .../SiteDatabaseAddEditUserDialog.vue | 393 ++++++++++++++++++ .../SiteDatabaseColumnsSelector.vue | 92 ++++ .../SiteDatabaseUserCredentialDialog.vue | 101 +++++ press/agent.py | 71 +++- press/api/client.py | 1 + press/fixtures/agent_job_type.json | 54 +++ press/hooks.py | 2 + press/patches.txt | 1 + ...db_access_users_to_site_db_perm_manager.py | 33 ++ press/press/doctype/agent_job/agent_job.py | 17 +- .../agent_job/agent_job_notifications.py | 3 + press/press/doctype/site/site.json | 7 +- press/press/doctype/site/site.py | 13 +- .../__init__.py | 0 .../site_database_table_permission.json | 72 ++++ .../site_database_table_permission.py | 26 ++ .../doctype/site_database_user/__init__.py | 0 .../site_database_user/site_database_user.js | 70 ++++ .../site_database_user.json | 184 ++++++++ .../site_database_user/site_database_user.py | 299 +++++++++++++ .../test_site_database_user.py | 20 + 24 files changed, 1641 insertions(+), 148 deletions(-) create mode 100644 dashboard/src2/components/site_database_user/SiteDatabaseAddEditUserDialog.vue create mode 100644 dashboard/src2/components/site_database_user/SiteDatabaseColumnsSelector.vue create mode 100644 dashboard/src2/components/site_database_user/SiteDatabaseUserCredentialDialog.vue create mode 100644 press/patches/v0_7_0/move_site_db_access_users_to_site_db_perm_manager.py create mode 100644 press/press/doctype/site_database_table_permission/__init__.py create mode 100644 press/press/doctype/site_database_table_permission/site_database_table_permission.json create mode 100644 press/press/doctype/site_database_table_permission/site_database_table_permission.py create mode 100644 press/press/doctype/site_database_user/__init__.py create mode 100644 press/press/doctype/site_database_user/site_database_user.js create mode 100644 press/press/doctype/site_database_user/site_database_user.json create mode 100644 press/press/doctype/site_database_user/site_database_user.py create mode 100644 press/press/doctype/site_database_user/test_site_database_user.py diff --git a/dashboard/src/components/global/Badge.vue b/dashboard/src/components/global/Badge.vue index 4cfdf13db5a..193a2b303c3 100644 --- a/dashboard/src/components/global/Badge.vue +++ b/dashboard/src/components/global/Badge.vue @@ -29,6 +29,7 @@ export default { Running: 'blue', Pending: 'orange', Failure: 'red', + Failed: 'red', 'Update Available': 'blue', Enabled: 'blue', 'Awaiting Approval': 'orange', diff --git a/dashboard/src2/components/SiteActionCell.vue b/dashboard/src2/components/SiteActionCell.vue index 2963d572d11..3c2af195942 100644 --- a/dashboard/src2/components/SiteActionCell.vue +++ b/dashboard/src2/components/SiteActionCell.vue @@ -48,7 +48,7 @@ function getSiteActionHandler(action) { 'Restore from an existing site': defineAsyncComponent(() => import('./site/SiteDatabaseRestoreFromURLDialog.vue') ), - 'Access site database': defineAsyncComponent(() => + 'Manage database users': defineAsyncComponent(() => import('./SiteDatabaseAccessDialog.vue') ), 'Version upgrade': defineAsyncComponent(() => diff --git a/dashboard/src2/components/SiteDatabaseAccessDialog.vue b/dashboard/src2/components/SiteDatabaseAccessDialog.vue index 73563742611..6676cb4a1eb 100644 --- a/dashboard/src2/components/SiteDatabaseAccessDialog.vue +++ b/dashboard/src2/components/SiteDatabaseAccessDialog.vue @@ -1,5 +1,8 @@ diff --git a/dashboard/src2/components/site_database_user/SiteDatabaseAddEditUserDialog.vue b/dashboard/src2/components/site_database_user/SiteDatabaseAddEditUserDialog.vue new file mode 100644 index 00000000000..98edd1dbc44 --- /dev/null +++ b/dashboard/src2/components/site_database_user/SiteDatabaseAddEditUserDialog.vue @@ -0,0 +1,393 @@ + + diff --git a/dashboard/src2/components/site_database_user/SiteDatabaseColumnsSelector.vue b/dashboard/src2/components/site_database_user/SiteDatabaseColumnsSelector.vue new file mode 100644 index 00000000000..4b193185df3 --- /dev/null +++ b/dashboard/src2/components/site_database_user/SiteDatabaseColumnsSelector.vue @@ -0,0 +1,92 @@ + + diff --git a/dashboard/src2/components/site_database_user/SiteDatabaseUserCredentialDialog.vue b/dashboard/src2/components/site_database_user/SiteDatabaseUserCredentialDialog.vue new file mode 100644 index 00000000000..3ad51f5b8f1 --- /dev/null +++ b/dashboard/src2/components/site_database_user/SiteDatabaseUserCredentialDialog.vue @@ -0,0 +1,101 @@ + + diff --git a/press/agent.py b/press/agent.py index 2e815631e1b..4e5f1044f86 100644 --- a/press/agent.py +++ b/press/agent.py @@ -619,14 +619,23 @@ def remove_ssh_user(self, bench): upstream=bench.server, ) - def add_proxysql_user(self, site, database, username, password, database_server): + def add_proxysql_user( + self, site, database, username, password, database_server, reference_doctype=None, reference_name=None + ): data = { "username": username, "password": password, "database": database, "backend": {"ip": database_server.private_ip, "id": database_server.server_id}, } - return self.create_agent_job("Add User to ProxySQL", "proxysql/users", data, site=site.name) + return self.create_agent_job( + "Add User to ProxySQL", + "proxysql/users", + data, + site=site.name, + reference_name=reference_name, + reference_doctype=reference_doctype, + ) def add_proxysql_backend(self, database_server): data = { @@ -634,12 +643,14 @@ def add_proxysql_backend(self, database_server): } return self.create_agent_job("Add Backend to ProxySQL", "proxysql/backends", data) - def remove_proxysql_user(self, site, username): + def remove_proxysql_user(self, site, username, reference_doctype=None, reference_name=None): return self.create_agent_job( "Remove User from ProxySQL", f"proxysql/users/{username}", method="DELETE", site=site.name, + reference_doctype=reference_doctype, + reference_name=reference_name, ) def create_database_access_credentials(self, site, mode): @@ -662,6 +673,60 @@ def revoke_database_access_credentials(self, site): } return self.post(f"benches/{site.bench}/sites/{site.name}/credentials/revoke", data=data) + def create_database_user(self, site, username, password, reference_name): + database_server = frappe.db.get_value("Bench", site.bench, "database_server") + data = { + "username": username, + "password": password, + "mariadb_root_password": get_decrypted_password( + "Database Server", database_server, "mariadb_root_password" + ), + } + return self.create_agent_job( + "Create Database User", + f"benches/{site.bench}/sites/{site.name}/database/users", + data, + site=site.name, + reference_doctype="Site Database User", + reference_name=reference_name, + ) + + def remove_database_user(self, site, username, reference_name): + database_server = frappe.db.get_value("Bench", site.bench, "database_server") + data = { + "mariadb_root_password": get_decrypted_password( + "Database Server", database_server, "mariadb_root_password" + ) + } + return self.create_agent_job( + "Remove Database User", + f"benches/{site.bench}/sites/{site.name}/database/users/{username}", + method="DELETE", + data=data, + site=site.name, + reference_doctype="Site Database User", + reference_name=reference_name, + ) + + def modify_database_user_permissions(self, site, username, mode, permissions: dict, reference_name): + database_server = frappe.db.get_value("Bench", site.bench, "database_server") + data = { + "mode": mode, + "permissions": permissions, + "mariadb_root_password": get_decrypted_password( + "Database Server", database_server, "mariadb_root_password" + ), + } + return self.create_agent_job( + "Modify Database User Permissions", + f"benches/{site.bench}/sites/{site.name}/database/users/{username}/permissions", + method="POST", + data=data, + site=site.name, + reference_doctype="Site Database User", + reference_name=reference_name, + ) + def update_site_status(self, server, site, status, skip_reload=False): data = {"status": status, "skip_reload": skip_reload} _server = frappe.get_doc("Server", server) diff --git a/press/api/client.py b/press/api/client.py index 81d8f8acc1e..149eedc44bc 100644 --- a/press/api/client.py +++ b/press/api/client.py @@ -74,6 +74,7 @@ "App Release Approval Request", "Press Webhook", "SQL Playground Log", + "Site Database User", ] ALLOWED_DOCTYPES_FOR_SUPPORT = [ diff --git a/press/fixtures/agent_job_type.json b/press/fixtures/agent_job_type.json index 64d7776cae5..83a118e1468 100644 --- a/press/fixtures/agent_job_type.json +++ b/press/fixtures/agent_job_type.json @@ -2200,5 +2200,59 @@ "step_name": "Fetch Database Table Schema" } ] + }, + { + "disabled_auto_retry": 1, + "docstatus": 0, + "doctype": "Agent Job Type", + "max_retry_count": 1, + "modified": "2024-11-04 14:49:18.592247", + "name": "Create Database User", + "request_method": "POST", + "request_path": "/benches/{bench}/sites/{site}/database/users", + "steps": [ + { + "parent": "Create Database User", + "parentfield": "steps", + "parenttype": "Agent Job Type", + "step_name": "Create Database User" + } + ] + }, + { + "disabled_auto_retry": 1, + "docstatus": 0, + "doctype": "Agent Job Type", + "max_retry_count": 1, + "modified": "2024-11-04 14:49:18.592247", + "name": "Remove Database User", + "request_method": "DELETE", + "request_path": "/benches/{bench}/sites/{site}/database/users/{username}", + "steps": [ + { + "parent": "Remove Database User", + "parentfield": "steps", + "parenttype": "Agent Job Type", + "step_name": "Remove Database User" + } + ] + }, + { + "disabled_auto_retry": 1, + "docstatus": 0, + "doctype": "Agent Job Type", + "max_retry_count": 1, + "modified": "2024-11-04 14:49:18.592247", + "name": "Modify Database User Permissions", + "request_method": "POST", + "request_path": "/benches/{bench}/sites/{site}/database/users/{db_user}/permissions", + "steps": [ + { + "parent": "Modify Database User Permissions", + "parentfield": "steps", + "parenttype": "Agent Job Type", + "step_name": "Modify Database User Permissions" + } + ] } ] \ No newline at end of file diff --git a/press/hooks.py b/press/hooks.py index b803afe6991..a7fc1819d49 100644 --- a/press/hooks.py +++ b/press/hooks.py @@ -124,6 +124,7 @@ "Press Webhook": "press.press.doctype.press_webhook.press_webhook.get_permission_query_conditions", "Press Webhook Log": "press.press.doctype.press_webhook_log.press_webhook_log.get_permission_query_conditions", "SQL Playground Log": "press.press.doctype.sql_playground_log.sql_playground_log.get_permission_query_conditions", + "Site Database User": "press.press.doctype.site_database_user.site_database_user.get_permission_query_conditions", } has_permission = { "Site": "press.overrides.has_permission", @@ -147,6 +148,7 @@ "Press Webhook Log": "press.overrides.has_permission", "Press Webhook Attempt": "press.press.doctype.press_webhook_attempt.press_webhook_attempt.has_permission", "SQL Playground Log": "press.overrides.has_permission", + "Site Database User": "press.overrides.has_permission", } # Document Events diff --git a/press/patches.txt b/press/patches.txt index 5bc2dbd9ca2..30182834ae3 100644 --- a/press/patches.txt +++ b/press/patches.txt @@ -132,3 +132,4 @@ press.marketplace.doctype.app_user_review.patches.add_rating_values_to_apps press.press.doctype.site.patches.set_status_wizard_check_next_retry_datetime_in_site press.patches.v0_7_0.update_enable_performance_tuning press.press.doctype.server.patches.set_plan_and_subscription +press.patches.v0_7_0.move_site_db_access_users_to_site_db_perm_manager \ No newline at end of file diff --git a/press/patches/v0_7_0/move_site_db_access_users_to_site_db_perm_manager.py b/press/patches/v0_7_0/move_site_db_access_users_to_site_db_perm_manager.py new file mode 100644 index 00000000000..25c43e143cd --- /dev/null +++ b/press/patches/v0_7_0/move_site_db_access_users_to_site_db_perm_manager.py @@ -0,0 +1,33 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt +import frappe + + +def execute(): + sites = frappe.get_all( + "Site", + filters={ + "status": ("!=", "Archived"), + "is_database_access_enabled": 1, + "database_access_mode": ["in", ("read_only", "read_write")], + }, + pluck="name", + ) + if sites: + for site_name in sites: + site = frappe.get_doc("Site", site_name) + db_user = frappe.get_doc( + { + "doctype": "Site Database User", + "site": site.name, + "team": site.team, + "mode": site.database_access_mode, + "user_created_in_database": True, + "user_added_in_proxysql": True, + "username": site.database_access_user, + "password": site.get_password("database_access_password"), + } + ) + db_user.flags.ignore_after_insert_hooks = True + db_user.insert(ignore_permissions=True) + frappe.db.set_value("Site Database User", db_user.name, "status", "Active") diff --git a/press/press/doctype/agent_job/agent_job.py b/press/press/doctype/agent_job/agent_job.py index 8b3d49c90ed..3674272f0fd 100644 --- a/press/press/doctype/agent_job/agent_job.py +++ b/press/press/doctype/agent_job/agent_job.py @@ -24,6 +24,7 @@ from press.press.doctype.agent_job_type.agent_job_type import ( get_retryable_job_types_and_max_retry_count, ) +from press.press.doctype.site_database_user.site_database_user import SiteDatabaseUser from press.press.doctype.site_migration.site_migration import ( get_ongoing_migration, job_matches_site_migration, @@ -979,9 +980,15 @@ def process_job_updates(job_name: str, response_data: dict | None = None): # no elif job.job_type == "Remove User from Proxy": process_remove_ssh_user_job_update(job) elif job.job_type == "Add User to ProxySQL": - process_add_proxysql_user_job_update(job) + if job.reference_doctype == "Site Database User": + SiteDatabaseUser.process_job_update(job) + else: + process_add_proxysql_user_job_update(job) elif job.job_type == "Remove User from ProxySQL": - process_remove_proxysql_user_job_update(job) + if job.reference_doctype == "Site Database User": + SiteDatabaseUser.process_job_update(job) + else: + process_remove_proxysql_user_job_update(job) elif job.job_type == "Reload NGINX": process_update_nginx_job_update(job) elif job.job_type == "Move Site to Bench": @@ -1015,6 +1022,12 @@ def process_job_updates(job_name: str, response_data: dict | None = None): # no Bench.process_recover_update_inplace(job) elif job.job_type == "Fetch Database Table Schema": SiteDatabaseTableSchema.process_job_update(job) + elif job.job_type in [ + "Create Database User", + "Remove Database User", + "Modify Database User Permissions", + ]: + SiteDatabaseUser.process_job_update(job) # send failure notification if job failed if job.status == "Failure": diff --git a/press/press/doctype/agent_job/agent_job_notifications.py b/press/press/doctype/agent_job/agent_job_notifications.py index 015520adc63..83b3fcc1b0e 100644 --- a/press/press/doctype/agent_job/agent_job_notifications.py +++ b/press/press/doctype/agent_job/agent_job_notifications.py @@ -248,6 +248,9 @@ def send_job_failure_notification(job: AgentJob): notification_type = get_notification_type(job) team = None + if job.reference_doctype == "Site Database User": + return + if job.site: team = frappe.get_value("Site", job.site, "team") else: diff --git a/press/press/doctype/site/site.json b/press/press/doctype/site/site.json index f018f1a5c33..a39feb3908a 100644 --- a/press/press/doctype/site/site.json +++ b/press/press/doctype/site/site.json @@ -695,9 +695,14 @@ "group": "Related Documents", "link_doctype": "Site Access Token", "link_fieldname": "site" + }, + { + "group": "Related Documents", + "link_doctype": "Site Database User", + "link_fieldname": "site" } ], - "modified": "2024-10-15 12:22:11.037182", + "modified": "2024-11-04 09:40:44.252728", "modified_by": "Administrator", "module": "Press", "name": "Site", diff --git a/press/press/doctype/site/site.py b/press/press/doctype/site/site.py index 9bd026d1dee..347e7467b30 100644 --- a/press/press/doctype/site/site.py +++ b/press/press/doctype/site/site.py @@ -2543,6 +2543,12 @@ def get_actions(self): "condition": self.status in ["Inactive", "Broken"], "doc_method": "activate", }, + # { + # "action": "Manage database users", + # "description": "Manage users and permissions for your site database", + # "button_label": "Manage", + # "doc_method": "dummy", + # }, { "action": "Schedule backup", "description": "Schedule a backup for this site", @@ -2589,12 +2595,6 @@ def get_actions(self): "button_label": "Clear", "doc_method": "clear_site_cache", }, - { - "action": "Access site database", - "description": "Enable read/write access to your site database", - "button_label": "Enable", - "doc_method": "enable_database_access", - }, { "action": "Deactivate site", "description": "Deactivated site is not accessible on the internet", @@ -3118,6 +3118,7 @@ def process_rename_site_job_update(job): # noqa: C901 create_site_status_update_webhook_event(job.site) +# TODO def process_add_proxysql_user_job_update(job): if job.status == "Success": frappe.db.set_value("Site", job.site, "is_database_access_enabled", True) diff --git a/press/press/doctype/site_database_table_permission/__init__.py b/press/press/doctype/site_database_table_permission/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/press/press/doctype/site_database_table_permission/site_database_table_permission.json b/press/press/doctype/site_database_table_permission/site_database_table_permission.json new file mode 100644 index 00000000000..d2872364afc --- /dev/null +++ b/press/press/doctype/site_database_table_permission/site_database_table_permission.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-31 17:08:37.280675", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "table", + "column_break_fbqg", + "mode", + "section_break_rswb", + "allow_all_columns", + "selected_columns" + ], + "fields": [ + { + "fieldname": "table", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Table", + "reqd": 1 + }, + { + "fieldname": "mode", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Mode", + "options": "read_only\nread_write", + "reqd": 1 + }, + { + "fieldname": "column_break_fbqg", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_rswb", + "fieldtype": "Section Break" + }, + { + "default": "1", + "fieldname": "allow_all_columns", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Allow All Columns" + }, + { + "depends_on": "eval: !doc.allow_all_columns", + "description": "Comma seperated column names", + "fieldname": "selected_columns", + "fieldtype": "Small Text", + "label": "Selected Columns", + "mandatory_depends_on": "eval: !doc.allow_all_columns", + "not_nullable": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-10-31 17:17:51.606102", + "modified_by": "Administrator", + "module": "Press", + "name": "Site Database Table Permission", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/press/press/doctype/site_database_table_permission/site_database_table_permission.py b/press/press/doctype/site_database_table_permission/site_database_table_permission.py new file mode 100644 index 00000000000..be51665d149 --- /dev/null +++ b/press/press/doctype/site_database_table_permission/site_database_table_permission.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SiteDatabaseTablePermission(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + allow_all_columns: DF.Check + mode: DF.Literal["read_only", "read_write"] + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + selected_columns: DF.SmallText + table: DF.Data + # end: auto-generated types + + pass diff --git a/press/press/doctype/site_database_user/__init__.py b/press/press/doctype/site_database_user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/press/press/doctype/site_database_user/site_database_user.js b/press/press/doctype/site_database_user/site_database_user.js new file mode 100644 index 00000000000..e658f3d2731 --- /dev/null +++ b/press/press/doctype/site_database_user/site_database_user.js @@ -0,0 +1,70 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Site Database User', { + refresh(frm) { + [ + [__('Apply Changes'), 'apply_changes', true], + [ + __('Create User in Database'), + 'create_user', + !frm.doc.user_created_in_database, + ], + [ + __('Remove User from Database'), + 'remove_user', + frm.doc.user_created_in_database, + ], + [ + __('Add User to ProxySQL'), + 'add_user_to_proxysql', + !frm.doc.user_added_in_proxysql, + ], + [ + __('Remove User from ProxySQL'), + 'remove_user_from_proxysql', + frm.doc.user_added_in_proxysql, + ], + [ + __('Modify Permissions'), + 'modify_permissions', + frm.doc.user_created_in_database, + ], + [__('Archive'), 'archive'], + ].forEach(([label, method, condition]) => { + if (typeof condition === 'undefined' || condition) { + frm.add_custom_button( + label, + () => { + frappe.confirm( + `Are you sure you want to ${label.toLowerCase()} this site?`, + () => frm.call(method).then((r) => frm.refresh()), + ); + }, + __('Actions'), + ); + } + }); + + frm.add_custom_button( + __('Show Credentials'), + () => + frm.call('get_credentials').then((r) => { + let message = `Host: ${r.message.host} + +Port: ${r.message.port} + +Database: ${r.message.database} + +Username: ${r.message.username} + +Password: ${r.message.password} + +\`\`\`\nmysql -u ${r.message.username} -p${r.message.password} -h ${r.message.host} -P ${r.message.port} --ssl --ssl-verify-server-cert\n\`\`\``; + + frappe.msgprint(frappe.markdown(message), 'Database Credentials'); + }), + __('Actions'), + ); + }, +}); diff --git a/press/press/doctype/site_database_user/site_database_user.json b/press/press/doctype/site_database_user/site_database_user.json new file mode 100644 index 00000000000..6ef23f3e823 --- /dev/null +++ b/press/press/doctype/site_database_user/site_database_user.json @@ -0,0 +1,184 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-31 16:54:56.752608", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "status", + "mode", + "site", + "team", + "column_break_udtx", + "username", + "password", + "user_created_in_database", + "user_added_in_proxysql", + "section_break_cpbg", + "permissions", + "section_break_ubkn", + "column_break_rczb", + "failed_agent_job", + "failure_reason" + ], + "fields": [ + { + "fieldname": "site", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Site", + "options": "Site", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "mode", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Mode", + "options": "read_only\nread_write\ngranular", + "reqd": 1 + }, + { + "fieldname": "column_break_udtx", + "fieldtype": "Column Break" + }, + { + "fieldname": "username", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Username", + "not_nullable": 1, + "read_only": 1, + "set_only_once": 1, + "unique": 1 + }, + { + "fieldname": "password", + "fieldtype": "Password", + "label": "Password", + "not_nullable": 1, + "read_only": 1, + "set_only_once": 1 + }, + { + "fieldname": "section_break_cpbg", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval: doc.mode == \"granular\"", + "fieldname": "permissions", + "fieldtype": "Table", + "label": "Permissions", + "options": "Site Database Table Permission" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Pending\nActive\nFailed\nArchived", + "read_only": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "user_added_in_proxysql", + "fieldtype": "Check", + "label": "User Added in ProxySQL", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "user_created_in_database", + "fieldtype": "Check", + "label": "User Created in Database", + "read_only": 1 + }, + { + "depends_on": "eval: doc.status === \"Failed\"", + "fieldname": "section_break_ubkn", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval: doc.status === \"Failed\"", + "fieldname": "column_break_rczb", + "fieldtype": "Column Break" + }, + { + "fieldname": "failed_agent_job", + "fieldtype": "Link", + "label": "Failed Agent Job", + "options": "Agent Job" + }, + { + "fieldname": "failure_reason", + "fieldtype": "Small Text", + "label": "Failure Reason", + "not_nullable": 1 + }, + { + "fieldname": "team", + "fieldtype": "Link", + "label": "Team", + "options": "Team", + "reqd": 1, + "search_index": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Related Documents", + "link_doctype": "Agent Job", + "link_fieldname": "reference_name" + } + ], + "modified": "2024-11-07 13:03:27.265288", + "modified_by": "Administrator", + "module": "Press", + "name": "Site Database User", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Press Admin", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Press Member", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/press/press/doctype/site_database_user/site_database_user.py b/press/press/doctype/site_database_user/site_database_user.py new file mode 100644 index 00000000000..ad58e0ca55f --- /dev/null +++ b/press/press/doctype/site_database_user/site_database_user.py @@ -0,0 +1,299 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt +from __future__ import annotations + +import re + +import frappe +from frappe.model.document import Document + +from press.agent import Agent +from press.api.client import dashboard_whitelist +from press.overrides import get_permission_query_conditions_for_doctype + + +class SiteDatabaseUser(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from press.press.doctype.site_database_table_permission.site_database_table_permission import ( + SiteDatabaseTablePermission, + ) + + failed_agent_job: DF.Link | None + failure_reason: DF.SmallText + mode: DF.Literal["read_only", "read_write", "granular"] + password: DF.Password + permissions: DF.Table[SiteDatabaseTablePermission] + site: DF.Link + status: DF.Literal["Pending", "Active", "Failed", "Archived"] + team: DF.Link + user_added_in_proxysql: DF.Check + user_created_in_database: DF.Check + username: DF.Data + # end: auto-generated types + + dashboard_fields = ( + "status", + "site", + "username", + "team", + "mode", + "failed_agent_job", + "failure_reason", + "permissions", + ) + + def validate(self): + if not self.has_value_changed("status"): + self._raise_error_if_archived() + # remove permissions if not granular mode + if self.mode != "granular": + self.permissions.clear() + + def before_insert(self): + site = frappe.get_doc("Site", self.site) + if not site.has_permission(): + frappe.throw("You don't have permission to create database user") + self.status = "Pending" + if not self.username: + self.username = frappe.generate_hash(length=15) + if not self.password: + self.password = frappe.generate_hash(length=20) + + def after_insert(self): + if hasattr(self.flags, "ignore_after_insert_hooks") and self.flags.ignore_after_insert_hooks: + """ + Added for make it easy to migrate records of db access users from site doctype to site database user + """ + return + self.apply_changes() + + def _raise_error_if_archived(self): + if self.status == "Archived": + frappe.throw("user has been deleted and no further changes can be made") + + def _get_database_name(self): + site = frappe.get_doc("Site", self.site) + db_name = site.fetch_info().get("config", {}).get("db_name") + if not db_name: + frappe.throw("Failed to fetch database name of site") + return db_name + + @dashboard_whitelist() + def save_and_apply_changes(self, mode: str, permissions: list): + if self.status == "Pending" or self.status == "Archived": + frappe.throw(f"You can't modify information in {self.status} state. Please try again later") + self.mode = mode + new_permissions = permissions + new_permission_tables = [p["table"] for p in new_permissions] + current_permission_tables = [p.table for p in self.permissions] + # add new permissions + for permission in new_permissions: + if permission["table"] not in current_permission_tables: + self.append("permissions", permission) + # modify permissions + for permission in self.permissions: + for new_permission in new_permissions: + if permission.table == new_permission["table"]: + permission.update(new_permission) + break + # delete permissions which are not in the modified list + self.permissions = [p for p in self.permissions if p.table in new_permission_tables] + self.save() + self.apply_changes() + + @frappe.whitelist() + def apply_changes(self): + if not self.user_created_in_database: + self.create_user() + elif not self.user_added_in_proxysql: + self.add_user_to_proxysql() + else: + self.modify_permissions() + + self.status = "Pending" + self.save(ignore_permissions=True) + + @frappe.whitelist() + def create_user(self): + self._raise_error_if_archived() + agent = Agent(frappe.db.get_value("Site", self.site, "server")) + agent.create_database_user( + frappe.get_doc("Site", self.site), self.username, self.get_password("password"), self.name + ) + + @frappe.whitelist() + def remove_user(self): + self._raise_error_if_archived() + agent = Agent(frappe.db.get_value("Site", self.site, "server")) + agent.remove_database_user( + frappe.get_doc("Site", self.site), + self.username, + self.name, + ) + + @frappe.whitelist() + def add_user_to_proxysql(self): + self._raise_error_if_archived() + database = self._get_database_name() + server = frappe.db.get_value("Site", self.site, "server") + proxy_server = frappe.db.get_value("Server", server, "proxy_server") + database_server_name = frappe.db.get_value( + "Bench", frappe.db.get_value("Site", self.site, "bench"), "database_server" + ) + database_server = frappe.get_doc("Database Server", database_server_name) + agent = Agent(proxy_server, server_type="Proxy Server") + agent.add_proxysql_user( + frappe.get_doc("Site", self.site), + database, + self.username, + self.get_password("password"), + database_server, + reference_doctype="Site Database User", + reference_name=self.name, + ) + + @frappe.whitelist() + def remove_user_from_proxysql(self): + self._raise_error_if_archived() + server = frappe.db.get_value("Site", self.site, "server") + proxy_server = frappe.db.get_value("Server", server, "proxy_server") + agent = Agent(proxy_server, server_type="Proxy Server") + agent.remove_proxysql_user( + frappe.get_doc("Site", self.site), + self.username, + reference_doctype="Site Database User", + reference_name=self.name, + ) + + @frappe.whitelist() + def modify_permissions(self): + self._raise_error_if_archived() + server = frappe.db.get_value("Site", self.site, "server") + agent = Agent(server) + table_permissions = {} + + if self.mode == "granular": + for x in self.permissions: + table_permissions[x.table] = { + "mode": x.mode, + "columns": "*" + if x.allow_all_columns + else [c.strip() for c in x.selected_columns.splitlines() if c.strip()], + } + + agent.modify_database_user_permissions( + frappe.get_doc("Site", self.site), + self.username, + self.mode, + table_permissions, + self.name, + ) + + @dashboard_whitelist() + def get_credential(self): + server = frappe.db.get_value("Site", self.site, "server") + proxy_server = frappe.db.get_value("Server", server, "proxy_server") + database = self._get_database_name() + return { + "host": proxy_server, + "port": 3306, + "database": database, + "username": self.username, + "password": self.get_password("password"), + "mode": self.mode, + } + + @dashboard_whitelist() + def archive(self): + self._raise_error_if_archived() + self.status = "Pending" + self.save() + + if self.user_created_in_database: + self.remove_user() + if self.user_added_in_proxysql: + self.remove_user_from_proxysql() + + if not self.user_created_in_database and not self.user_added_in_proxysql: + self.status = "Archived" + self.save() + + @staticmethod + def process_job_update(job): # noqa: C901 + if job.status not in ("Success", "Failure"): + return + + if not job.reference_name or not frappe.db.exists("Site Database User", job.reference_name): + return + + doc: SiteDatabaseUser = frappe.get_doc("Site Database User", job.reference_name) + + if job.status == "Failure": + doc.status = "Failed" + doc.failed_agent_job = job.name + if job.job_type == "Modify Database User Permissions": + doc.failure_reason = SiteDatabaseUser.user_addressable_error_from_stacktrace(job.traceback) + doc.save(ignore_permissions=True) + return + + if job.job_type == "Create Database User": + doc.user_created_in_database = True + if not doc.user_added_in_proxysql: + doc.add_user_to_proxysql() + if job.job_type == "Remove Database User": + doc.user_created_in_database = False + elif job.job_type == "Add User to ProxySQL": + doc.user_added_in_proxysql = True + doc.modify_permissions() + elif job.job_type == "Remove User from ProxySQL": + doc.user_added_in_proxysql = False + elif job.job_type == "Modify Database User Permissions": + doc.status = "Active" + + doc.save(ignore_permissions=True) + doc.reload() + + if ( + job.job_type in ("Remove Database User", "Remove User from ProxySQL") + and not doc.user_added_in_proxysql + and not doc.user_created_in_database + ): + doc.archive() + + @staticmethod + def user_addressable_error_from_stacktrace(stacktrace: str): + pattern = r"peewee\.\w+Error: (.*)?" + default_error_msg = "Unknown error. Please try again.\nIf the error persists, please contact support." + + matches = re.findall(pattern, stacktrace) + if len(matches) == 0: + return default_error_msg + data = matches[0].strip().replace("(", "").replace(")", "").split(",", 1) + if len(data) != 2: + return default_error_msg + + if data[0] == "1054": + pattern = r"Unknown column '(.*)' in '(.*)'\"*?" + matches = re.findall(pattern, data[1]) + if len(matches) == 1 and len(matches[0]) == 2: + return f"Column '{matches[0][0]}' doesn't exist in '{matches[0][1]}' table.\nPlease remove the column from permissions configuration and apply changes." + + elif data[0] == "1146": + pattern = r"Table '(.*)' doesn't exist" + matches = re.findall(pattern, data[1]) + if len(matches) == 1 and isinstance(matches[0], str): + table_name = matches[0] + table_name = table_name.split(".")[-1] + return f"Table '{table_name}' doesn't exist.\nPlease remove it from permissions table and apply changes." + + return default_error_msg + + +get_permission_query_conditions = get_permission_query_conditions_for_doctype("Site Database User") diff --git a/press/press/doctype/site_database_user/test_site_database_user.py b/press/press/doctype/site_database_user/test_site_database_user.py new file mode 100644 index 00000000000..7ad23275415 --- /dev/null +++ b/press/press/doctype/site_database_user/test_site_database_user.py @@ -0,0 +1,20 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestSiteDatabaseUser(UnitTestCase): + """ + Unit tests for SiteDatabaseUser. + Use this class for testing individual functions and methods. + """ + + pass From 4d3c73b3800291c98788d113255e1b628b831449 Mon Sep 17 00:00:00 2001 From: Balamurali M Date: Mon, 25 Nov 2024 12:14:37 +0530 Subject: [PATCH 64/93] chore(ReleaseGroup): Reword err msg thrown for more direction - Release Group -> Bench Group - Suggestion for what to do --- press/press/doctype/release_group/release_group.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/press/press/doctype/release_group/release_group.py b/press/press/doctype/release_group/release_group.py index ef4bab7f282..451bfa9d12f 100644 --- a/press/press/doctype/release_group/release_group.py +++ b/press/press/doctype/release_group/release_group.py @@ -415,7 +415,10 @@ def validate_title(self): }, limit=1, ): - frappe.throw(f"Release Group {self.title} already exists.", frappe.ValidationError) + frappe.throw( + f"Bench Group of name {self.title} already exists. Please try another name.", + frappe.ValidationError, + ) def validate_frappe_app(self): if self.apps[0].app != "frappe": From 54b79b84f5c4ce1139095f754bbdccf68404e925 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Mon, 25 Nov 2024 12:52:14 +0530 Subject: [PATCH 65/93] chore(site-database-user): typo --- press/press/doctype/site_database_user/site_database_user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/press/press/doctype/site_database_user/site_database_user.js b/press/press/doctype/site_database_user/site_database_user.js index e658f3d2731..6085c60c94d 100644 --- a/press/press/doctype/site_database_user/site_database_user.js +++ b/press/press/doctype/site_database_user/site_database_user.js @@ -47,9 +47,9 @@ frappe.ui.form.on('Site Database User', { }); frm.add_custom_button( - __('Show Credentials'), + __('Show Credential'), () => - frm.call('get_credentials').then((r) => { + frm.call('get_credential').then((r) => { let message = `Host: ${r.message.host} Port: ${r.message.port} From 4c1cd1e116fc97d764fb2e94ebf09f6c486a0aaa Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar <57363826+tanmoysrt@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:25:27 +0530 Subject: [PATCH 66/93] chore(site): on site archive, remove db users as well (#2304) --- .../components/SiteDatabaseAccessDialog.vue | 7 +++-- .../SiteDatabaseAddEditUserDialog.vue | 13 +++++++--- press/press/doctype/site/site.py | 26 ++++++++++++++----- .../site_database_user/site_database_user.py | 17 ++++++++++-- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/dashboard/src2/components/SiteDatabaseAccessDialog.vue b/dashboard/src2/components/SiteDatabaseAccessDialog.vue index 6676cb4a1eb..28683243039 100644 --- a/dashboard/src2/components/SiteDatabaseAccessDialog.vue +++ b/dashboard/src2/components/SiteDatabaseAccessDialog.vue @@ -1,6 +1,9 @@