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/App.vue b/dashboard/src2/App.vue index 09201c3e7a4..0634200d06a 100644 --- a/dashboard/src2/App.vue +++ b/dashboard/src2/App.vue @@ -3,37 +3,17 @@
- +
You are not logged in. @@ -50,7 +30,7 @@ diff --git a/dashboard/src2/components/AppSidebarItemGroup.vue b/dashboard/src2/components/AppSidebarItemGroup.vue index 89a49a1c85b..017d3f18664 100644 --- a/dashboard/src2/components/AppSidebarItemGroup.vue +++ b/dashboard/src2/components/AppSidebarItemGroup.vue @@ -21,7 +21,8 @@
diff --git a/dashboard/src2/components/LinkControl.vue b/dashboard/src2/components/LinkControl.vue index ffe69b655e6..67a46a14390 100644 --- a/dashboard/src2/components/LinkControl.vue +++ b/dashboard/src2/components/LinkControl.vue @@ -47,6 +47,7 @@ export default { query: this.query }, auto: true, + initialData: this.options.initialData || [], transform: data => { return data.map(option => ({ label: option.label || option.value, @@ -65,7 +66,7 @@ export default { computed: { autocompleteOptions() { let options = this.$resources.options.data || []; - let currentValueInOptions = options.find( + const currentValueInOptions = options.find( o => o.value === this.modelValue ); @@ -73,9 +74,14 @@ export default { this.currentValidValueInOptions = currentValueInOptions; } - if (this.modelValue && !currentValueInOptions) { + if ( + this.modelValue && + !currentValueInOptions && + this.currentValidValueInOptions + ) { options = [this.currentValidValueInOptions, ...options]; } + return options; } } diff --git a/dashboard/src2/components/NavigationItems.vue b/dashboard/src2/components/NavigationItems.vue index 75fa33b61cb..03055b9b4a5 100644 --- a/dashboard/src2/components/NavigationItems.vue +++ b/dashboard/src2/components/NavigationItems.vue @@ -14,6 +14,7 @@ import WalletCards from '~icons/lucide/wallet-cards'; import Settings from '~icons/lucide/settings'; import App from '~icons/lucide/layout-grid'; import DatabaseZap from '~icons/lucide/database-zap'; +import Logs from '~icons/lucide/scroll-text'; import Globe from '~icons/lucide/globe'; import Notification from '~icons/lucide/inbox'; import Code from '~icons/lucide/code'; @@ -122,9 +123,15 @@ export default { icon: () => h(DatabaseZap), route: '/sql-playground', isActive: routeName === 'SQL Playground' + }, + { + name: 'Log Browser', + icon: () => h(Logs), + route: '/log-browser', + isActive: routeName === 'Log Browser' } ], - isActive: ['SQL Playground'].includes(routeName), + isActive: ['SQL Playground', 'Log Browser'].includes(routeName), disabled: enforce2FA }, { 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..53b18c6296c 100644 --- a/dashboard/src2/components/SiteDatabaseAccessDialog.vue +++ b/dashboard/src2/components/SiteDatabaseAccessDialog.vue @@ -1,11 +1,17 @@ diff --git a/dashboard/src2/components/SitePlansCards.vue b/dashboard/src2/components/SitePlansCards.vue index 949ef1ce166..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); } @@ -106,7 +107,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' : '' diff --git a/dashboard/src2/components/group/BenchLogsDialog.vue b/dashboard/src2/components/group/BenchLogsDialog.vue index 523e16df09c..1b80848a822 100644 --- a/dashboard/src2/components/group/BenchLogsDialog.vue +++ b/dashboard/src2/components/group/BenchLogsDialog.vue @@ -41,6 +41,7 @@ import { createResource } from 'frappe-ui'; import { defineProps, ref } from 'vue'; import ObjectList from '../ObjectList.vue'; import { date } from '../../utils/format'; +import router from '../../router'; const props = defineProps({ bench: String @@ -100,6 +101,18 @@ const listOptions = ref({ return value ? date(value, 'lll') : ''; } } + ], + actions: () => [ + { + label: '✨ View in Log Browser', + onClick: () => { + show.value = false; + router.push({ + name: 'Log Browser', + params: { mode: 'bench', docName: props.bench } + }); + } + } ] }); diff --git a/dashboard/src2/components/group/UpdateReleaseGroupDialog.vue b/dashboard/src2/components/group/UpdateReleaseGroupDialog.vue index 9409b307f40..535701f6593 100644 --- a/dashboard/src2/components/group/UpdateReleaseGroupDialog.vue +++ b/dashboard/src2/components/group/UpdateReleaseGroupDialog.vue @@ -481,7 +481,8 @@ export default { this.$router.push({ name: 'Deploy Candidate', params: { - id: candidate + id: candidate, + name: this.bench } }); this.restrictMessage = ''; diff --git a/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsForm.vue b/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsForm.vue deleted file mode 100644 index 4d96ab0c710..00000000000 --- a/dashboard/src2/components/in_desk_checkout/BuyPrepaidCreditsForm.vue +++ /dev/null @@ -1,147 +0,0 @@ - - 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/components/server/ServerCharts.vue b/dashboard/src2/components/server/ServerCharts.vue index 2d97d45afaa..bb792100766 100644 --- a/dashboard/src2/components/server/ServerCharts.vue +++ b/dashboard/src2/components/server/ServerCharts.vue @@ -189,7 +189,7 @@ title="Slow logs frequency" > + + + + + + + +
@@ -378,6 +414,20 @@ export default { this.showAdvancedAnalytics && !this.isServerType('Application Server') }; }, + normalizedSlowLogsCount() { + return { + url: 'press.api.server.get_slow_logs_by_site', + params: { + name: this.chosenServer, + query: 'count', + timezone: this.localTimezone, + duration: this.duration, + normalize: true + }, + auto: + this.showAdvancedAnalytics && !this.isServerType('Application Server') + }; + }, slowLogsDuration() { return { url: 'press.api.server.get_slow_logs_by_site', @@ -390,6 +440,20 @@ export default { auto: this.showAdvancedAnalytics && !this.isServerType('Application Server') }; + }, + normalizedSlowLogsDuration() { + return { + url: 'press.api.server.get_slow_logs_by_site', + params: { + name: this.chosenServer, + query: 'duration', + timezone: this.localTimezone, + duration: this.duration, + normalize: true + }, + auto: + this.showAdvancedAnalytics && !this.isServerType('Application Server') + }; } }, computed: { @@ -474,6 +538,18 @@ export default { const slowLogs = this.$resources.slowLogsCount.data; if (!slowLogs) return; + return slowLogs; + }, + normalizedSlowLogsDurationData() { + const slowLogs = this.$resources.normalizedSlowLogsDuration.data; + if (!slowLogs) return; + + return slowLogs; + }, + normalizedSlowLogsCountData() { + const slowLogs = this.$resources.normalizedSlowLogsCount.data; + if (!slowLogs) return; + return slowLogs; } }, diff --git a/dashboard/src2/components/site/NewSiteAppSelector.vue b/dashboard/src2/components/site/NewSiteAppSelector.vue index 0ab5cdcd127..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,6 +73,8 @@ export default { if (!publicApps.length) return; + this.apps = this.availableApps.filter(app => app.preinstalled === true); + return { data: () => publicApps, columns: [ @@ -93,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', @@ -192,7 +202,9 @@ export default { }, methods: { toggleApp(app) { - if (this.apps.map(a => a.app).includes(app.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 { if (app.subscription_type && app.subscription_type !== 'Free') { diff --git a/dashboard/src2/components/site/SiteLogs.vue b/dashboard/src2/components/site/SiteLogs.vue index 7b4f5fea978..2c5ce6abc87 100644 --- a/dashboard/src2/components/site/SiteLogs.vue +++ b/dashboard/src2/components/site/SiteLogs.vue @@ -192,6 +192,17 @@ export default { }; } } + ], + actions: () => [ + { + label: '✨ View in Log Browser', + onClick: () => { + this.$router.push({ + name: 'Log Browser', + params: { mode: 'site', docName: this.name } + }); + } + } ] }; } 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..5a6e4022ef3 --- /dev/null +++ b/dashboard/src2/components/site_database_user/SiteDatabaseAddEditUserDialog.vue @@ -0,0 +1,400 @@ + + 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..3315c9e4ecf --- /dev/null +++ b/dashboard/src2/components/site_database_user/SiteDatabaseUserCredentialDialog.vue @@ -0,0 +1,102 @@ + + 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/data/session.js b/dashboard/src2/data/session.js index a8e3b553e71..09bd2430102 100644 --- a/dashboard/src2/data/session.js +++ b/dashboard/src2/data/session.js @@ -30,6 +30,19 @@ export let session = reactive({ window.location.reload(); } }), + logoutWithoutReload: createResource({ + url: 'logout', + async onSuccess() { + session.user = getSessionUser(); + localStorage.removeItem('current_team'); + // On logout, reset posthog user identity and device id + if (window.posthog?.__loaded) { + posthog.reset(true); + } + + clear(); + } + }), roles: createResource({ url: 'press.api.account.get_permission_roles', cache: ['roles', localStorage.getItem('current_team')], diff --git a/dashboard/src2/main.js b/dashboard/src2/main.js index 2fd7dd7c2bc..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://')) { @@ -94,6 +96,7 @@ getInitialData().then(() => { 'SecurityException', 'AAAARecordExists', 'AuthenticationError', + 'RateLimitExceededError', 'InsufficientSpaceOnServer' ]; const error = hint.originalException; 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); }); }, diff --git a/dashboard/src2/objects/site.js b/dashboard/src2/objects/site.js index d803c64f0b8..e7fc6110f48 100644 --- a/dashboard/src2/objects/site.js +++ b/dashboard/src2/objects/site.js @@ -31,9 +31,6 @@ export default { backup: 'backup', clearSiteCache: 'clear_site_cache', deactivate: 'deactivate', - enableDatabaseAccess: 'enable_database_access', - disableDatabaseAccess: 'disable_database_access', - getDatabaseCredentials: 'get_database_credentials', disableReadWrite: 'disable_read_write', enableReadWrite: 'enable_read_write', installApp: 'install_app', @@ -133,7 +130,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', diff --git a/dashboard/src2/pages/BillingOverview.vue b/dashboard/src2/pages/BillingOverview.vue index 89fcc80ca7a..fc0454e0641 100644 --- a/dashboard/src2/pages/BillingOverview.vue +++ b/dashboard/src2/pages/BillingOverview.vue @@ -38,7 +38,16 @@
Credits Available
{{ $team?.doc?.payment_mode || 'Not set' }}
+
+

+ {{ paymentModeDescription }} +

+
Billing Details
-
+
{{ billingDetailsSummary }} @@ -127,6 +141,9 @@ +
+
+
+ + +
+
+ + + + +
+ +
+
+ +
+
+
+
+
+

+ Select a {{ mode }} to view logs +

+
+
+ +
+
+ + Fetching the log... +
+ + +
+ Select a log to view +
+
+
+
+
+ + + diff --git a/dashboard/src2/pages/devtools/log-browser/LogList.vue b/dashboard/src2/pages/devtools/log-browser/LogList.vue new file mode 100644 index 00000000000..d65b39d0eb4 --- /dev/null +++ b/dashboard/src2/pages/devtools/log-browser/LogList.vue @@ -0,0 +1,129 @@ + + + diff --git a/dashboard/src2/pages/devtools/log-browser/LogViewer.vue b/dashboard/src2/pages/devtools/log-browser/LogViewer.vue new file mode 100644 index 00000000000..aa0d1701a64 --- /dev/null +++ b/dashboard/src2/pages/devtools/log-browser/LogViewer.vue @@ -0,0 +1,322 @@ + + + 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/Login.vue b/dashboard/src2/pages/saas/Login.vue index aa3ba5eb599..2151e6225c2 100644 --- a/dashboard/src2/pages/saas/Login.vue +++ b/dashboard/src2/pages/saas/Login.vue @@ -59,13 +59,20 @@ > Login with email - +
@@ -85,7 +92,7 @@ type="button" @click="$resources.sendVerificationCodeForLogin.submit()" > - Didn't receive email? Resend + Didn't get email? Resend
@@ -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/LoginToSite.vue b/dashboard/src2/pages/saas/LoginToSite.vue index 8fd1e648dfc..bbf2ed77ed5 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: { @@ -130,7 +130,8 @@ export default { }; }, onSuccess: data => { - this.currentStep = data.current_step || this.currentStep; + this.currentBuildStep = + data.current_step || this.currentBuildStep; this.progressCount += 1; if (data.progress == 100) { this.loginToSite(); @@ -140,7 +141,7 @@ export default { this.progressCount <= 10 ) ) { - this.progressCount = data.progress; + this.progressCount = Math.round(data.progress * 10) / 10; setTimeout(() => { this.$resources.siteRequest.getProgress.reload(); }, 2000); @@ -151,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/dashboard/src2/pages/saas/OAuthSetupAccount.vue b/dashboard/src2/pages/saas/OAuthSetupAccount.vue new file mode 100644 index 00000000000..5e3788b3e2e --- /dev/null +++ b/dashboard/src2/pages/saas/OAuthSetupAccount.vue @@ -0,0 +1,153 @@ + + diff --git a/dashboard/src2/pages/saas/SetupSite.vue b/dashboard/src2/pages/saas/SetupSite.vue index af639cf8a3c..caaefd90344 100644 --- a/dashboard/src2/pages/saas/SetupSite.vue +++ b/dashboard/src2/pages/saas/SetupSite.vue @@ -9,8 +9,8 @@
@@ -155,6 +170,9 @@ export default { }, countries() { return this.$resources.signupSettings.data?.countries || []; + }, + isGoogleOAuthEnabled() { + return this.$resources.signupSettings.data?.enable_google_oauth || false; } }, resources: { @@ -203,6 +221,18 @@ export default { } } }; + }, + 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/VerifyEmail.vue b/dashboard/src2/pages/saas/VerifyEmail.vue index 527d4634cf2..2393aa9957e 100644 --- a/dashboard/src2/pages/saas/VerifyEmail.vue +++ b/dashboard/src2/pages/saas/VerifyEmail.vue @@ -51,7 +51,7 @@ :loading="$resources.resendOTP?.loading" @click="$resources.resendOTP.submit()" > - Email didn't arrive? Resend + Didn't get email? Resend 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..3113d083bd6 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', @@ -239,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', @@ -255,6 +232,13 @@ let router = createRouter({ props: true, meta: { isLoginPage: true } }, + { + name: 'SaaSSignupOAuthSetupAccount', + path: ':productId/oauth', + component: () => import('./pages/saas/OAuthSetupAccount.vue'), + props: true, + meta: { isLoginPage: true } + }, { name: 'SaaSSignupSetup', path: ':productId/setup', @@ -307,6 +291,12 @@ let router = createRouter({ component: () => import('./pages/devtools/database/DatabaseSQLPlayground.vue') }, + { + path: '/log-browser/:mode?/:docName?/:logId?', + name: 'Log Browser', + component: () => import('./pages/devtools/log-browser/LogBrowser.vue'), + props: true + }, ...generateRoutes(), { path: '/:pathMatch(.*)*', @@ -322,13 +312,15 @@ 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') { + if ( + [ + 'SaaSLogin', + 'SaaSSignup', + 'SaaSSignupVerifyEmail', + 'SaaSSignupOAuthSetupAccount' + ].includes(to.name) + ) { next(); return; } @@ -351,12 +343,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/dashboard/src2/utils/format.js b/dashboard/src2/utils/format.js index a001f320142..88e2685973e 100644 --- a/dashboard/src2/utils/format.js +++ b/dashboard/src2/utils/format.js @@ -7,7 +7,8 @@ export function bytes(bytes, decimals = 2, current = 0) { const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); + let i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); + if (i < 0) i++; return ( parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i + current] diff --git a/dashboard/src2/vendor/posthog.js b/dashboard/src2/vendor/posthog.js index ab64b8a43f3..81e7aff1c7a 100644 --- a/dashboard/src2/vendor/posthog.js +++ b/dashboard/src2/vendor/posthog.js @@ -12,8 +12,11 @@ }); } ((p = t.createElement('script')).type = 'text/javascript'), + (p.crossOrigin = 'anonymous'), (p.async = !0), - (p.src = s.api_host + '/static/array.js'), + (p.src = + s.api_host.replace('.i.posthog.com', '-assets.i.posthog.com') + + '/static/array.js'), (r = t.getElementsByTagName('script')[0]).parentNode.insertBefore(p, r); var u = e; for ( @@ -27,7 +30,7 @@ return u.toString(1) + '.people (stub)'; }, o = - 'capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId'.split( + 'init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug'.split( ' ' ), n = 0; 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" } 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/account.py b/press/api/account.py index d5b4a3c1c0d..00930992b96 100644 --- a/press/api/account.py +++ b/press/api/account.py @@ -73,7 +73,7 @@ def signup(email, referrer=None): def verify_otp(account_request: str, otp: str): account_request: "AccountRequest" = frappe.get_doc("Account Request", account_request) # ensure no team has been created with this email - if frappe.db.exists("Team", {"user": account_request.email}): + if frappe.db.exists("Team", {"user": account_request.email}) and not account_request.product_trial: frappe.throw("Invalid OTP. Please try again.") if account_request.otp != otp: frappe.throw("Invalid OTP. Please try again.") @@ -85,7 +85,7 @@ def verify_otp(account_request: str, otp: str): def resend_otp(account_request: str): account_request: "AccountRequest" = frappe.get_doc("Account Request", account_request) # ensure no team has been created with this email - if frappe.db.exists("Team", {"user": account_request.email}): + if frappe.db.exists("Team", {"user": account_request.email}) and not account_request.product_trial: frappe.throw("Invalid Email") account_request.reset_otp() account_request.send_verification_email() @@ -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/analytics.py b/press/api/analytics.py index 89f0ddd9434..86ecf227145 100644 --- a/press/api/analytics.py +++ b/press/api/analytics.py @@ -424,7 +424,9 @@ def get_background_job_by_method(site, query_type, timezone, timespan, timegrain return get_stacked_histogram_chart_result(search, query_type, start, end, timegrain) -def get_slow_logs(name, query_type, timezone, timespan, timegrain, filter_by=FILTER_BY_RESOURCE.SITE): +def get_slow_logs( + name, query_type, timezone, timespan, timegrain, filter_by=FILTER_BY_RESOURCE.SITE, normalize=False +): MAX_NO_OF_PATHS = 10 log_server = frappe.db.get_single_value("Press Settings", "log_server") @@ -432,7 +434,7 @@ def get_slow_logs(name, query_type, timezone, timespan, timegrain, filter_by=FIL return {"datasets": [], "labels": []} url = f"https://{log_server}/elasticsearch/" - password = get_decrypted_password("Log Server", log_server, "kibana_password") + password = str(get_decrypted_password("Log Server", log_server, "kibana_password")) start, end = get_rounded_boundaries(timespan, timegrain, timezone) @@ -497,7 +499,7 @@ def get_slow_logs(name, query_type, timezone, timespan, timegrain, filter_by=FIL search.aggs["method_path"].bucket("outside_sum", sum_of_duration) return get_stacked_histogram_chart_result( - search, query_type, start, end, timegrain, to_s_divisor=1e9, normalize_slow_logs=True + search, query_type, start, end, timegrain, to_s_divisor=1e9, normalize_slow_logs=normalize ) diff --git a/press/api/bench.py b/press/api/bench.py index 447ed5b1f2e..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,18 +231,34 @@ def options(): AppSource.frappe, ) .orderby(AppSource.creation) - .run(as_dict=True) ) + if only_frappe: + rows = rows.where(AppSource.frappe == 1) + + 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} 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,6 +268,7 @@ def options(): "repository_owner": source.repository_owner, } app_dict.setdefault("sources", []).append(source_dict) + app_dict["source"] = app_dict["sources"][0] version_dict.setdefault("apps", []).append(app_dict) versions.append(version_dict) 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] 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/api/dboptimize.py b/press/api/dboptimize.py index 02ae9aee3a1..37209e04086 100644 --- a/press/api/dboptimize.py +++ b/press/api/dboptimize.py @@ -1,28 +1,26 @@ import json import frappe -from press.utils import log_error from press.api.site import protected - -from press.press.report.mariadb_slow_queries.mariadb_slow_queries import ( - OptimizeDatabaseQuery, - _fetch_column_stats, - _fetch_table_stats, -) from press.press.report.mariadb_slow_queries.db_optimizer import ( ColumnStat, DBExplain, DBOptimizer, DBTable, ) +from press.press.report.mariadb_slow_queries.mariadb_slow_queries import ( + OptimizeDatabaseQuery, + _fetch_column_stats, + _fetch_table_stats, +) +from press.utils import log_error @frappe.whitelist() @protected("Site") def mariadb_analyze_query(name, row): - suggested_index = analyze_query(row=row, site=name) - return suggested_index + return analyze_query(row=row, site=name) def analyze_query(row, site): @@ -43,11 +41,11 @@ def analyze_query(row, site): if not query.lower().startswith(("select", "update", "delete")): doc.status = "Failure" - doc.save() + doc.save(ignore_permissions=True) frappe.db.commit() - return + return None - doc.save() + doc.save(ignore_permissions=True) frappe.db.commit() analyzer = OptimizeDatabaseQuery(site, query) @@ -63,27 +61,25 @@ def analyze_query(row, site): if not stats: # Old framework version doc.status = "Failure" - doc.save() + doc.save(ignore_permissions=True) frappe.db.commit() - return + return None # This is an agent job. Remaining is processed in the callback. _fetch_column_stats(analyzer.site, table, doc.get_title()) - doc.save() + doc.save(ignore_permissions=True) return doc.status -def check_if_all_fetch_column_stats_was_sucessful(doc): - for item in doc.tables_in_query: - if not item.status == "Success": - return False - return True +def check_if_all_fetch_column_stats_was_successful(doc): + return all(item.status == "Success" for item in doc.tables_in_query) 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"] @@ -94,11 +90,11 @@ def fetch_column_stats_update(job, response_data): item.status = "Success" doc.save() frappe.db.commit() - if check_if_all_fetch_column_stats_was_sucessful(doc): + if check_if_all_fetch_column_stats_was_successful(doc): doc.status = "Success" doc.save() frappe.db.commit() - # Perisists within doctype + # Persists within doctype save_suggested_index(doc) elif job.status == "Failure": doc = frappe.get_doc("MariaDB Analyze Query", doc_name) @@ -143,14 +139,11 @@ def get_status_of_mariadb_analyze_query(name, query): ) if doc: return doc[0] - else: - return None + return None 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 @@ -166,13 +159,12 @@ def mariadb_analyze_query_already_running_for_site(name): @frappe.whitelist() @protected("Site") def get_suggested_index(name, normalized_query): - suggested_index = frappe.get_value( + return frappe.get_value( "MariaDB Analyze Query", {"site": name, "status": "Success", "normalized_query": normalized_query}, ["site", "normalized_query", "suggested_index"], as_dict=True, ) - return suggested_index def delete_all_occurences_of_mariadb_analyze_query(job): @@ -181,4 +173,4 @@ def delete_all_occurences_of_mariadb_analyze_query(job): frappe.db.delete("MariaDB Analyze Query", {"site": job.site}) frappe.db.commit() except Exception as e: - log_error("Deleting all occurences of MariaDB Analyze Query Failed", data=e) + log_error("Deleting all occurrences of MariaDB Analyze Query Failed", data=e) diff --git a/press/api/developer/saas.py b/press/api/developer/saas.py index 4624c54b871..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): @@ -130,6 +141,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")): diff --git a/press/api/email.py b/press/api/email.py index 4c56fd6bf8f..e7789e5ca9e 100644 --- a/press/api/email.py +++ b/press/api/email.py @@ -145,20 +145,23 @@ def validate_plan(secret_key): ) -def check_spam(message: str): - resp = requests.post( - "https://server.frappemail.com/spamd/score", - {"message": message}, - ) - if resp.status_code == 200: +def check_spam(message: bytes): + 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) + 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) @@ -173,8 +176,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", diff --git a/press/api/google.py b/press/api/google.py index 2e0d0ffa2e3..cc08461311d 100644 --- a/press/api/google.py +++ b/press/api/google.py @@ -1,7 +1,7 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -from __future__ import unicode_literals +from __future__ import annotations import json @@ -14,6 +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_of_product_trial from press.utils import log_error @@ -25,38 +26,37 @@ def login(product=None): payload = {"state": state} if product: payload["product"] = product - frappe.cache().set_value( - f"google_oauth_flow:{state}", payload, expires_in_sec=minutes * 60 - ) + frappe.cache().set_value(f"google_oauth_flow:{state}", payload, expires_in_sec=minutes * 60) return authorization_url @frappe.whitelist(allow_guest=True) -def callback(code=None, state=None): +def callback(code=None, state=None): # noqa: C901 cached_key = f"google_oauth_flow:{state}" payload = frappe.cache().get_value(cached_key) if not payload: return invalid_login() product = payload.get("product") - product_trial = ( - frappe.db.get_value("Product Trial", product, ["name"], as_dict=1) - if product - else None - ) + product_trial = frappe.db.get_value("Product Trial", product, ["name"], as_dict=1) if product else None + + def _redirect_to_login_on_failed_authentication(): + frappe.local.response.type = "redirect" + if product_trial: + frappe.local.response.location = f"/dashboard/saas/{product_trial.name}/login" + else: + frappe.local.response.location = "/dashboard/login" try: flow = google_oauth_flow() flow.fetch_token(authorization_response=frappe.request.url) except AccessDeniedError: - frappe.local.response.type = "redirect" - frappe.local.response.location = "/dashboard/login" - return + _redirect_to_login_on_failed_authentication() + return None except Exception as e: log_error("Google Login failed", data=e) - frappe.local.response.type = "redirect" - frappe.local.response.location = "/dashboard/login" - return + _redirect_to_login_on_failed_authentication() + return None # authenticated frappe.cache().delete_value(cached_key) @@ -75,50 +75,58 @@ def callback(code=None, state=None): # phone (this may return nothing if info doesn't exists) phone_number = "" if flow.credentials.refresh_token: # returns only for the first authorization - credentials = Credentials.from_authorized_user_info( - json.loads(flow.credentials.to_json()) - ) + credentials = Credentials.from_authorized_user_info(json.loads(flow.credentials.to_json())) service = build("people", "v1", credentials=credentials) - person = ( - service.people().get(resourceName="people/me", personFields="phoneNumbers").execute() - ) - if person: - phone = person.get("phoneNumbers") - if phone: - phone_number = phone[0].get("value") - - team_name, team_enabled = frappe.db.get_value( - "Team", {"user": email}, ["name", "enabled"] - ) or [0, 0] - - if team_name and team_enabled: + person = service.people().get(resourceName="people/me", personFields="phoneNumbers").execute() + if person and person.get("phoneNumbers"): + phone_number = person.get("phoneNumbers")[0].get("value") + + team_name, team_enabled = frappe.db.get_value("Team", {"user": email}, ["name", "enabled"]) or [0, 0] + + if team_name and not team_enabled: + frappe.throw(_("Account {0} has been deactivated").format(email)) + return None + + # 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) frappe.local.response.type = "redirect" - if product_trial: - frappe.local.response.location = f"/dashboard/app-trial/setup/{product_trial.name}" - else: - frappe.local.response.location = "/dashboard" - elif team_name and not team_enabled: - # cannot move forward because account is disabled - frappe.throw(_("Account {0} has been deactivated").format(email)) - elif not team_name: - account_request = frappe.get_doc( - doctype="Account Request", - email=email, - first_name=id_info.get("given_name"), - last_name=id_info.get("family_name"), - phone_number=phone_number, - role="Press Admin", - ) - if product_trial: - account_request.product_trial = product_trial.name + frappe.local.response.location = "/dashboard" + return None + + # create account request + account_request = frappe.get_doc( + doctype="Account Request", + email=email, + first_name=id_info.get("given_name"), + last_name=id_info.get("family_name"), + phone_number=phone_number, + role="Press Admin", + oauth_signup=True, + product_trial=product_trial.name if product_trial else None, + ) + account_request.insert(ignore_permissions=True) + frappe.db.commit() - account_request.insert(ignore_permissions=True) - frappe.db.commit() + 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" - verification_url = account_request.get_verification_url() - frappe.local.response.location = verification_url + 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 def invalid_login(): @@ -129,11 +137,8 @@ def invalid_login(): def google_oauth_flow(): google_credentials = get_google_credentials() redirect_uri = google_credentials["web"].get("redirect_uris")[0] - redirect_uri = redirect_uri.replace( - "press.api.oauth.callback", "press.api.google.callback" - ) - print(redirect_uri) - flow = Flow.from_client_config( + redirect_uri = redirect_uri.replace("press.api.oauth.callback", "press.api.google.callback") + return Flow.from_client_config( client_config=google_credentials, scopes=[ "https://www.googleapis.com/auth/userinfo.profile", @@ -142,7 +147,6 @@ def google_oauth_flow(): ], redirect_uri=redirect_uri, ) - return flow def get_google_credentials(): diff --git a/press/api/log_browser.py b/press/api/log_browser.py new file mode 100644 index 00000000000..85001b09cd0 --- /dev/null +++ b/press/api/log_browser.py @@ -0,0 +1,359 @@ +import datetime +import re +from enum import Enum + +import frappe + + +class LOG_TYPE(Enum): + SITE = "site" + BENCH = "bench" + + +def bench_log_formatter(log_entries: list) -> list: + """ + Formats bench logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + date, time, level, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + formatted_time = datetime.datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S,%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"level": level, "time": formatted_time, "description": description}) + + return formatted_logs + + +def worker_log_formatter(log_entries: list) -> list: + """ + Formats worker logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + date, time, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + try: + formatted_time = datetime.datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S,%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + except ValueError: + formatted_time = "" + + formatted_logs.append({"time": formatted_time, "description": description}) + + return formatted_logs + + +def frappe_log_formatter(log_entries: list) -> list: + """ + Formats frappe logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + date, time, level, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + formatted_time = datetime.datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S,%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"level": level, "time": formatted_time, "description": description}) + + return formatted_logs + + +def database_log_formatter(log_entries: list) -> list: + """ + Formats database logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + date, time, level, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + formatted_time = datetime.datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S,%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"level": level, "time": formatted_time, "description": description}) + + return formatted_logs + + +def scheduler_log_formatter(log_entries: list) -> list: + """ + Formats scheduler logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + date, time, level, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + # TODO: formatted time goes invalid + formatted_time = datetime.datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S,%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"level": level, "time": formatted_time, "description": description}) + + return formatted_logs + + +def redis_log_formatter(log_entries: list) -> list: + """ + Formats redis logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + _, day, month, year, time, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + formatted_time = datetime.datetime.strptime( + f"{year}-{month}-{day} {time}", "%Y-%b-%d %H:%M:%S.%f" + ).strftime("%Y-%m-%d %H:%M:%S") + + formatted_logs.append({"time": formatted_time, "description": description}) + + return formatted_logs + + +def web_error_log_formatter(log_entries: list) -> list: + """ + Formats web error logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + # Regular expression pattern to match log entries specific to web.error logs + regex = r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})\] \[(\d+)\] \[(\w+)\] (.*)" + + formatted_logs = [] + for entry in log_entries: + match = re.match(regex, entry) + if not match: + formatted_logs.append({"description": entry}) # Unparsable entry + continue + + # Extract groups from the match + date, _, level, description_parts = match.groups() + description = "".join(description_parts) + + # Format date using strftime for cnsistency (no external libraries needed) + formatted_time = datetime.datetime.strptime(date, "%Y-%m-%d %H:%M:%S %z").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"level": level, "time": formatted_time, "description": description}) + + return formatted_logs + + +def monitor_json_log_formatter(log_entries: list) -> list: + """ + Formats monitor.json logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + try: + timestamp_key = '"timestamp":"' + timestamp_start = entry.index(timestamp_key) + len(timestamp_key) + timestamp_end = entry.index('"', timestamp_start) + time = entry[timestamp_start:timestamp_end] + formatted_time = datetime.datetime.strptime(time, "%Y-%m-%d %H:%M:%S.%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"time": formatted_time, "description": entry}) + except ValueError: + formatted_logs.append({"description": entry}) + + return formatted_logs + + +def ipython_log_formatter(log_entries: list) -> list: + """ + Formats ipython logs by extracting timestamp, level, and description. + + Args: + log_entries (list): A list of log entries, where each entry is a string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + if not log_entries: + return [] # Return empty list if no log entries + + formatted_logs = [] + for entry in log_entries: + date, time, level, *description_parts = entry.split(" ") + description = " ".join(description_parts) + + formatted_time = datetime.datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S,%f").strftime( + "%Y-%m-%d %H:%M:%S" + ) + + formatted_logs.append({"level": level, "time": formatted_time, "description": description}) + + return formatted_logs + + +def fallback_log_formatter(log_entries: list) -> list: + """ + Fallback formatter for logs that don't have a specific formatter. + + Args: + log_entries (list): A list of log entries, where each entry is string. + + Returns: + list: A list of dictionaries, where each dictionary represents a formatted log entry. + """ + + formatted_logs = [] + for entry in log_entries: + formatted_logs.append({"description": entry}) + + return formatted_logs + + +FORMATTER_MAP = { + "bench": bench_log_formatter, + "worker": worker_log_formatter, + "frappe": frappe_log_formatter, + "ipython": ipython_log_formatter, + "database": database_log_formatter, + "redis-cache": redis_log_formatter, + "redis-queue": redis_log_formatter, + "scheduler": scheduler_log_formatter, + "web.error": web_error_log_formatter, + "worker.error": worker_log_formatter, + "monitor.json": monitor_json_log_formatter, +} + + +@frappe.whitelist() +def get_log(log_type: LOG_TYPE, doc_name: str, log_name: str) -> list: + MULTILINE_LOGS = ("database.log", "scheduler.log", "worker", "ipython", "frappe.log") + + log = get_raw_log(log_type, doc_name, log_name) + + log_entries = [] + for k, v in log.items(): + if k == log_name: + if v == "": + return [] + if log_name.startswith(MULTILINE_LOGS): + # split line if nextline starts with timestamp + log_entries = re.split(r"\n(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", v) + break + + log_entries = v.strip().splitlines() + break + + return format_log(log_name, log_entries) + + +def get_raw_log(log_type: LOG_TYPE, doc_name: str, log_name: str) -> list: + if log_type == LOG_TYPE.BENCH: + return frappe.get_doc("Bench", doc_name).get_server_log(log_name) + if log_type == LOG_TYPE.SITE: + return frappe.get_doc("Site", doc_name).get_server_log(log_name) + return frappe.throw("Invalid log type") + + +def format_log(log_name: str, log_entries: list) -> list: + log_key = get_log_key(log_name) + if log_key in FORMATTER_MAP: + return FORMATTER_MAP[log_key](log_entries) + return fallback_log_formatter(log_entries) + + +def get_log_key(log_name: str) -> str: + # if the log file has a number at the end, it's a rotated log + # and we don't need to consider the number for formatter mapping + if log_name[-1].isdigit(): + log_name = log_name.rsplit(".", 1)[0] + + return log_name.rsplit(".", 1)[0] diff --git a/press/api/product_trial.py b/press/api/product_trial.py index 1dba7ad94d0..60bd6e08db3 100644 --- a/press/api/product_trial.py +++ b/press/api/product_trial.py @@ -148,12 +148,20 @@ def signup( @frappe.whitelist(allow_guest=True, methods=["POST"]) -def setup_account(key: str): +def setup_account(key: str, country: str | None = None): ar = get_account_request_from_key(key) if not ar: frappe.throw("Invalid or Expired Key") if not ar.product_trial: frappe.throw("Invalid Product Trial") + + if country: + ar.country = country + ar.save(ignore_permissions=True) + + if not ar.country: + frappe.throw("Please provide a valid country name") + frappe.set_user("Administrator") # check if team already exists if frappe.db.exists("Team", {"user": ar.email}): @@ -181,31 +189,34 @@ def setup_account(key: str): 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}/process", + "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 { diff --git a/press/api/server.py b/press/api/server.py index 2e1313f28f5..ac55a717f4c 100644 --- a/press/api/server.py +++ b/press/api/server.py @@ -317,12 +317,12 @@ def get_request_by_site(name, query, timezone, duration): @frappe.whitelist() @protected(["Server", "Database Server"]) -def get_slow_logs_by_site(name, query, timezone, duration): +def get_slow_logs_by_site(name, query, timezone, duration, normalize=False): from press.api.analytics import FILTER_BY_RESOURCE, get_slow_logs timespan, timegrain = get_timespan_timegrain(duration) - return get_slow_logs(name, query, timezone, timespan, timegrain, FILTER_BY_RESOURCE.SERVER) + return get_slow_logs(name, query, timezone, timespan, timegrain, FILTER_BY_RESOURCE.SERVER, normalize) def prometheus_query(query, function, timezone, timespan, timegrain): diff --git a/press/api/site.py b/press/api/site.py index bc47e40a36e..820fb027ba8 100644 --- a/press/api/site.py +++ b/press/api/site.py @@ -522,99 +522,7 @@ def app_details_for_new_public_site(): @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: @@ -654,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 @@ -673,6 +584,121 @@ def options_for_new(for_bench: str | None = None): # noqa: C901 } +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["preinstalled"] = True + + +def get_available_versions(for_bench: str = None): # noqa + 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"]) @@ -785,6 +811,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 @@ -1269,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( @@ -1295,7 +1324,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, @@ -1303,11 +1332,12 @@ 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 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 @@ -1917,27 +1947,6 @@ def update_auto_update_info(name, info=None): site_doc.save() -@frappe.whitelist() -@protected("Site") -def get_database_access_info(name): - return frappe.get_doc("Site", name).get_database_access_info() - - -@frappe.whitelist() -@protected("Site") -def enable_database_access(name, mode="read_only"): - site_doc = frappe.get_doc("Site", name) - return site_doc.enable_database_access(mode) - - -@frappe.whitelist() -@protected("Site") -def disable_database_access(name): - site_doc = frappe.get_doc("Site", name) - disable_access_job = site_doc.disable_database_access() - return disable_access_job.name - - @frappe.whitelist() def get_job_status(job_name): return {"status": frappe.db.get_value("Agent Job", job_name, "status")} diff --git a/press/api/tests/test_server.py b/press/api/tests/test_server.py index 59f1033bb40..879eb56fb32 100644 --- a/press/api/tests/test_server.py +++ b/press/api/tests/test_server.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe and Contributors # See license.txt +from __future__ import annotations from unittest.mock import MagicMock, Mock, patch import frappe -from frappe.core.utils import find from frappe.model.naming import make_autoname from frappe.tests.utils import FrappeTestCase @@ -33,8 +32,8 @@ def create_test_server_plan( document_type: str, price_usd: float = 10.0, price_inr: float = 750.0, - title: str = None, - plan_name: str = None, + title: str | None = None, + plan_name: str | None = None, ): """Create test Plan doc.""" plan_name = plan_name or f"Test {document_type} plan {make_autoname('.#')}" @@ -71,9 +70,7 @@ def successful_ping_ansible(self: BaseServer): def successful_upgrade_mariadb(self: DatabaseServer): - create_test_ansible_play( - "Upgrade MariaDB", "upgrade_mariadb.yml", self.doctype, self.name - ) + create_test_ansible_play("Upgrade MariaDB", "upgrade_mariadb.yml", self.doctype, self.name) def successful_upgrade_mariadb_patched(self: DatabaseServer): @@ -101,9 +98,7 @@ def successful_wait_for_cloud_init(self: BaseServer): @patch.object(Ansible, "run", new=Mock()) @patch.object(BaseServer, "ping_ansible", new=successful_ping_ansible) @patch.object(DatabaseServer, "upgrade_mariadb", new=successful_upgrade_mariadb) -@patch.object( - DatabaseServer, "upgrade_mariadb_patched", new=successful_upgrade_mariadb_patched -) +@patch.object(DatabaseServer, "upgrade_mariadb_patched", new=successful_upgrade_mariadb_patched) @patch.object(BaseServer, "wait_for_cloud_init", new=successful_wait_for_cloud_init) @patch.object(BaseServer, "update_tls_certificate", new=successful_tls_certificate) @patch.object(BaseServer, "update_agent_ansible", new=successful_update_agent_ansible) @@ -232,9 +227,7 @@ def test_change_plan_changes_plan_of_server_and_updates_subscription_doc(self): app_plan_2.db_set("memory", 2048) db_plan_2 = create_test_server_plan(document_type="Database Server") - self.team.allocate_credit_amount( - 100000, source="Prepaid Credits", remark="Test Credits" - ) + self.team.allocate_credit_amount(100000, source="Prepaid Credits", remark="Test Credits") frappe.set_user(self.team.user) new( @@ -282,37 +275,6 @@ def test_change_plan_changes_plan_of_server_and_updates_subscription_doc(self): self.assertTrue(db_subscription.enabled) self.assertEqual(db_server.plan, db_plan_2.name) - @patch( - "press.press.doctype.press_job.press_job.frappe.enqueue_doc", - new=foreground_enqueue_doc, - ) - @patch.object(VirtualMachine, "provision", new=successful_provision) - @patch.object(VirtualMachine, "sync", new=successful_sync) - def test_creation_of_db_server_adds_default_mariadb_variables(self): - create_test_virtual_machine_image(cluster=self.cluster, series="m") - create_test_virtual_machine_image( - cluster=self.cluster, series="f" - ) # call from here and not setup, so mocks work - frappe.set_user(self.team.user) - - new( - { - "cluster": self.cluster.name, - "db_plan": self.db_plan.name, - "app_plan": self.app_plan.name, - "title": "Test Server", - } - ) - - db_server = frappe.get_last_doc("Database Server") - self.assertEqual( - find( - db_server.mariadb_system_variables, - lambda x: x.mariadb_variable == "tmp_disk_table_size", - ).value_int, - 5120, - ) - class TestAPIServerList(FrappeTestCase): def setUp(self): @@ -367,9 +329,7 @@ def test_list_all_servers(self): self.assertEqual(all(), [self.app_server_dict, self.db_server_dict]) def test_list_app_servers(self): - self.assertEqual( - all(server_filter={"server_type": "App Servers", "tag": ""}), [self.app_server_dict] - ) + self.assertEqual(all(server_filter={"server_type": "App Servers", "tag": ""}), [self.app_server_dict]) def test_list_db_servers(self): self.assertEqual( diff --git a/press/fixtures/agent_job_type.json b/press/fixtures/agent_job_type.json index 64d7776cae5..b6b044d9e44 100644 --- a/press/fixtures/agent_job_type.json +++ b/press/fixtures/agent_job_type.json @@ -1827,6 +1827,12 @@ "parenttype": "Agent Job Type", "step_name": "Restore Site" }, + { + "parent": "Restore Site", + "parentfield": "steps", + "parenttype": "Agent Job Type", + "step_name": "Checksum of Downloaded Backup Files" + }, { "parent": "Restore Site", "parentfield": "steps", @@ -2200,5 +2206,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/fixtures/mariadb_variable.json b/press/fixtures/mariadb_variable.json index 10822cc71bc..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 }, { @@ -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 diff --git a/press/hooks.py b/press/hooks.py index b803afe6991..09aa1adc401 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 @@ -287,7 +289,7 @@ "0 8 * * *": [ "press.press.doctype.aws_savings_plan_recommendation.aws_savings_plan_recommendation.create", ], - "0 9 * * *": [ + "0 18 * * *": [ "press.press.audit.billing_audit", "press.press.audit.partner_billing_audit", ], 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 diff --git a/press/patches.txt b/press/patches.txt index 5bc2dbd9ca2..d6e837399b4 100644 --- a/press/patches.txt +++ b/press/patches.txt @@ -132,3 +132,5 @@ 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 +press.press.doctype.drip_email.patches.set_correct_field_for_html 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/playbooks/roles/convert/templates/mariadb.cnf b/press/playbooks/roles/convert/templates/mariadb.cnf index 307d3e9d472..e8ce58ee685 100644 --- a/press/playbooks/roles/convert/templates/mariadb.cnf +++ b/press/playbooks/roles/convert/templates/mariadb.cnf @@ -5,10 +5,9 @@ default-storage-engine = InnoDB # MyISAM # key-buffer-size = 32M -myisam-recover = FORCE,BACKUP +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 67cf36ee740..9086a4228eb 100644 --- a/press/playbooks/roles/mariadb/templates/mariadb.cnf +++ b/press/playbooks/roles/mariadb/templates/mariadb.cnf @@ -5,10 +5,9 @@ default-storage-engine = InnoDB # MyISAM # key-buffer-size = 32M -myisam-recover = FORCE,BACKUP +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 diff --git a/press/playbooks/roles/secondary/tasks/main.yml b/press/playbooks/roles/secondary/tasks/main.yml index 0ff60902d76..316b5b75973 100644 --- a/press/playbooks/roles/secondary/tasks/main.yml +++ b/press/playbooks/roles/secondary/tasks/main.yml @@ -18,7 +18,7 @@ - name: RSync Backup Directory From Primary command: rsync -avpPR -e ssh\ -p22 root@{{ primary_private_ip }}:/tmp/replica / - async: 3600 + async: 7200 poll: 5 - name: Stop MariaDB Service diff --git a/press/press/doctype/account_request/account_request.py b/press/press/doctype/account_request/account_request.py index 25b5d5465c4..3cf92871218 100644 --- a/press/press/doctype/account_request/account_request.py +++ b/press/press/doctype/account_request/account_request.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe and contributors # For license information, please see license.txt +from __future__ import annotations import json import random @@ -22,6 +22,7 @@ class AccountRequest(Document): if TYPE_CHECKING: from frappe.types import DF + from press.press.doctype.account_request_press_role.account_request_press_role import ( AccountRequestPressRole, ) @@ -80,10 +81,9 @@ def before_insert(self): if ( geo_location.get("country") == "United States" or geo_location.get("continent") == "Europe" + or self.country == "United States" ): self.is_us_eu = True - elif self.country == "United States": - self.is_us_eu = True else: self.is_us_eu = False @@ -131,7 +131,7 @@ def reset_otp(self): self.save(ignore_permissions=True) @frappe.whitelist() - def send_verification_email(self): + def send_verification_email(self): # noqa: C901 url = self.get_verification_url() if frappe.conf.developer_mode: @@ -207,12 +207,10 @@ def send_verification_email(self): def get_verification_url(self): if self.saas: - return get_url( - f"/api/method/press.api.saas.validate_account_request?key={self.request_key}" - ) + return get_url(f"/api/method/press.api.saas.validate_account_request?key={self.request_key}") if self.product_trial: return get_url( - f"/api/method/press.api.saas.setup_account_product_trial?key={self.request_key}" + f"/dashboard/saas/{self.product_trial}/oauth?key={self.request_key}&email={self.email}" ) return get_url(f"/dashboard/setup-account/{self.request_key}") @@ -221,9 +219,7 @@ def full_name(self): return " ".join(filter(None, [self.first_name, self.last_name])) def get_site_name(self): - return ( - self.subdomain + "." + frappe.db.get_value("Saas Settings", self.saas_app, "domain") - ) + return self.subdomain + "." + frappe.db.get_value("Saas Settings", self.saas_app, "domain") def is_using_new_saas_flow(self): return bool(self.product_trial) 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/ansible_play/test_ansible_play.py b/press/press/doctype/ansible_play/test_ansible_play.py index b81890aee06..cbee9b5de41 100644 --- a/press/press/doctype/ansible_play/test_ansible_play.py +++ b/press/press/doctype/ansible_play/test_ansible_play.py @@ -1,21 +1,27 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe and Contributors # See license.txt +from __future__ import annotations import unittest +from typing import TYPE_CHECKING import frappe +if TYPE_CHECKING: + from frappe.types.DF import Data + def create_test_ansible_play( play: str = "", playbook: str = "", server_type: str = "Server", - server: str = "", - vars: dict = {}, + server: Data | None = "", + vars: dict | None = None, status: str = "Success", ): + if vars is None: + vars = {} play = frappe.get_doc( { "doctype": "Ansible Play", 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..f0b0ad7e463 --- /dev/null +++ b/press/press/doctype/app_group/app_group.py @@ -0,0 +1,26 @@ +# 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 + + +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/bench/bench.py b/press/press/doctype/bench/bench.py index a8c33a97ad5..29bd5b4bf24 100644 --- a/press/press/doctype/bench/bench.py +++ b/press/press/doctype/bench/bench.py @@ -518,6 +518,9 @@ def server_logs(self): def get_server_log(self, log): return Agent(self.server).get(f"benches/{self.name}/logs/{log}") + def get_server_log_for_log_browser(self, log): + return Agent(self.server).get(f"benches/{self.name}/logs_v2/{log}") + @frappe.whitelist() def move_sites(self, server: str): try: @@ -1103,8 +1106,10 @@ def archive_obsolete_benches_for_server(benches: Iterable[dict]): # Bench is Broken but a reset to a working state is being attempted if ( bench.resetting_bench - or bench.last_archive_failure - and bench.last_archive_failure > frappe.utils.add_to_date(None, hours=-24) + or ( + bench.last_archive_failure + and bench.last_archive_failure > frappe.utils.add_to_date(None, hours=-24) + ) or get_archive_jobs(bench.name) # already being archived or get_ongoing_jobs(bench.name) or get_active_site_updates(bench.name) diff --git a/press/press/doctype/bench_update/bench_update.py b/press/press/doctype/bench_update/bench_update.py index 54668c7ae3e..5fd41e6e114 100644 --- a/press/press/doctype/bench_update/bench_update.py +++ b/press/press/doctype/bench_update/bench_update.py @@ -1,5 +1,6 @@ # Copyright (c) 2023, Frappe and contributors # For license information, please see license.txt + from __future__ import annotations from typing import TYPE_CHECKING @@ -135,12 +136,9 @@ def update_sites_on_server(self, bench, server): limit=1, ): continue - current_user = frappe.session.user - frappe.set_user(self.owner) site_update = frappe.get_doc("Site", row.site).schedule_update( skip_failing_patches=row.skip_failing_patches, skip_backups=row.skip_backups ) - frappe.set_user(current_user) frappe.db.set_value("Bench Site Update", row.name, "site_update", site_update) frappe.db.commit() except Exception: @@ -164,10 +162,10 @@ def get_bench_update( if sites is None: sites = [] - team = get_current_team(True) + current_team = get_current_team() rg_team = frappe.db.get_value("Release Group", name, "team") - if rg_team != team.name: + if rg_team != current_team: frappe.throw("Bench can only be deployed by the bench owner", exc=frappe.PermissionError) bench_update: "BenchUpdate" = frappe.get_doc( diff --git a/press/press/doctype/drip_email/drip_email.json b/press/press/doctype/drip_email/drip_email.json index cdd5216981e..90bed47f6bc 100644 --- a/press/press/doctype/drip_email/drip_email.json +++ b/press/press/doctype/drip_email/drip_email.json @@ -18,22 +18,20 @@ "sender", "reply_to", "pre_header", + "section_break_ehlw", + "condition", + "column_break_jext", + "html_pzsv", "section_break_9", - "message", + "content_type", + "message_html", + "message_markdown", + "message_rich_text", "section_break_4", + "skip_sites_with_paid_plan", "send_after", "send_after_payment", - "minimum_activation_level", - "maximum_activation_level", "column_break_2", - "distribution", - "manufacturing", - "retail", - "services", - "education", - "healthcare", - "non_profit", - "other", "section_break_25", "module_setup_guide" ], @@ -90,14 +88,8 @@ }, { "fieldname": "section_break_9", - "fieldtype": "Section Break" - }, - { - "fieldname": "message", - "fieldtype": "Text Editor", - "in_list_view": 1, - "label": "Message", - "reqd": 1 + "fieldtype": "Section Break", + "label": "Content" }, { "fieldname": "section_break_4", @@ -116,96 +108,87 @@ "fieldtype": "Check", "label": "Send After Payment" }, - { - "description": "1-7", - "fieldname": "minimum_activation_level", - "fieldtype": "Int", - "label": "Minimum Activation Level" - }, - { - "description": "1-7", - "fieldname": "maximum_activation_level", - "fieldtype": "Int", - "label": "Maximum Activation Level" - }, { "default": "(1-7)", "fieldname": "column_break_2", "fieldtype": "Column Break" }, { - "default": "0", - "fieldname": "distribution", - "fieldtype": "Check", - "label": "Distribution" + "fieldname": "pre_header", + "fieldtype": "Data", + "label": "Pre Header" }, { - "default": "0", - "fieldname": "manufacturing", - "fieldtype": "Check", - "label": "Manufacturing" + "fieldname": "section_break_25", + "fieldtype": "Section Break" }, { - "default": "0", - "fieldname": "retail", - "fieldtype": "Check", - "label": "Retail" + "fieldname": "module_setup_guide", + "fieldtype": "Table", + "label": "Module Setup Guide", + "options": "Module Setup Guide" }, { - "default": "0", - "fieldname": "services", - "fieldtype": "Check", - "label": "Services" + "fieldname": "saas_app", + "fieldtype": "Link", + "label": "Saas App", + "options": "Marketplace App" }, { "default": "0", - "fieldname": "education", + "fieldname": "skip_sites_with_paid_plan", "fieldtype": "Check", - "label": "Education" + "label": "Skip Sites With Paid Plan" }, { - "default": "0", - "fieldname": "healthcare", - "fieldtype": "Check", - "label": "Healthcare" + "fieldname": "condition", + "fieldtype": "Code", + "label": "Condition" }, { - "default": "0", - "fieldname": "non_profit", - "fieldtype": "Check", - "label": "Non Profit" + "fieldname": "html_pzsv", + "fieldtype": "HTML", + "options": "

Condition Examples:

\n
doc.status==\"Open\"
account_request.country==\"Spain\"
doc.total > 40000\n
\n\n

App doc is available as app, Account Request as account_request and the current doc as just doc" }, { - "default": "0", - "fieldname": "other", - "fieldtype": "Check", - "label": "Other" + "fieldname": "section_break_ehlw", + "fieldtype": "Section Break" }, { - "fieldname": "pre_header", - "fieldtype": "Data", - "label": "Pre Header" + "fieldname": "column_break_jext", + "fieldtype": "Column Break" }, { - "fieldname": "section_break_25", - "fieldtype": "Section Break" + "fieldname": "content_type", + "fieldtype": "Select", + "label": "Content Type", + "options": "Rich Text\nMarkdown\nHTML" }, { - "fieldname": "module_setup_guide", - "fieldtype": "Table", - "label": "Module Setup Guide", - "options": "Module Setup Guide" + "depends_on": "eval: doc.content_type === 'Markdown'", + "fieldname": "message_markdown", + "fieldtype": "Markdown Editor", + "in_list_view": 1, + "label": "Message (Markdown)" }, { - "fieldname": "saas_app", - "fieldtype": "Link", - "label": "Saas App", - "options": "Marketplace App" + "depends_on": "eval: doc.content_type === 'Rich Text'", + "fieldname": "message_rich_text", + "fieldtype": "Text Editor", + "in_list_view": 1, + "label": "Message (Rich Text)" + }, + { + "depends_on": "eval: doc.content_type === 'HTML'", + "fieldname": "message_html", + "fieldtype": "HTML Editor", + "in_list_view": 1, + "label": "Message (HTML)" } ], "icon": "icon-envelope", "links": [], - "modified": "2022-08-24 17:58:28.497406", + "modified": "2024-11-25 09:50:47.777689", "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..19c050708b3 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 @@ -26,35 +26,30 @@ class DripEmail(Document): from press.press.doctype.module_setup_guide.module_setup_guide import ModuleSetupGuide - distribution: DF.Check - education: DF.Check + condition: DF.Code | None + content_type: DF.Literal["Rich Text", "Markdown", "HTML"] email_type: DF.Literal[ "Drip", "Sign Up", "Subscription Activation", "Whitepaper Feedback", "Onboarding" ] enabled: DF.Check - healthcare: DF.Check - manufacturing: DF.Check - maximum_activation_level: DF.Int - message: DF.TextEditor - minimum_activation_level: DF.Int + message_html: DF.HTMLEditor | None + message_markdown: DF.MarkdownEditor | None + message_rich_text: DF.TextEditor | None module_setup_guide: DF.Table[ModuleSetupGuide] - non_profit: DF.Check - other: DF.Check pre_header: DF.Data | None reply_to: DF.Data | None - retail: DF.Check saas_app: DF.Link | None send_after: DF.Int send_after_payment: DF.Check send_by_consultant: DF.Check sender: DF.Data sender_name: DF.Data - services: DF.Check + skip_sites_with_paid_plan: DF.Check subject: DF.SmallText # end: auto-generated types - def send(self, site_name=None, lead=None): - if self.email_type in ["Drip", "Sign Up"] and site_name: + def send(self, site_name=None): + if self.evaluate_condition(site_name) and self.email_type in ["Drip", "Sign Up"] and site_name: self.send_drip_email(site_name) def send_drip_email(self, site_name): @@ -103,6 +98,33 @@ def send_mail(self, context, recipient): args={"message": message, "title": title}, ) + @property + def message(self): + if self.content_type == "Markdown": + return frappe.utils.md_to_html(self.message_markdown) + if self.content_type == "Rich Text": + return self.message_rich_text + return self.message_html + + def evaluate_condition(self, site_name: str) -> bool: + """ + Evaluate the condition to check if the email should be sent. + """ + if not self.condition: + return True + + saas_app = frappe.get_doc("Marketplace App", self.saas_app) + site_account_request = frappe.db.get_value("Site", site_name, "account_request") + account_request = frappe.get_doc("Account Request", site_account_request) + + eval_locals = dict( + app=saas_app, + doc=self, + account_request=account_request, + ) + + return frappe.safe_eval(self.condition, None, eval_locals) + def select_consultant(self, site) -> str: """ Select random ERPNext Consultant to send email. @@ -119,7 +141,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 +149,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 +163,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 +188,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 +229,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/patches/set_correct_field_for_html.py b/press/press/doctype/drip_email/patches/set_correct_field_for_html.py new file mode 100644 index 00000000000..886140965c7 --- /dev/null +++ b/press/press/doctype/drip_email/patches/set_correct_field_for_html.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# For license information, please see license.txt + +import frappe + + +def execute(): + frappe.reload_doctype("Drip Email") + frappe.db.sql("UPDATE `tabDrip Email` SET message_html = message, content_type = 'HTML'") 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/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..cedb08f91c5 100644 --- a/press/press/doctype/press_settings/press_settings.py +++ b/press/press/doctype/press_settings/press_settings.py @@ -23,6 +23,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 +52,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 +63,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 +248,9 @@ 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_default_apps(self): + 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 [] diff --git a/press/press/doctype/release_group/release_group.py b/press/press/doctype/release_group/release_group.py index 233f2d8158e..451bfa9d12f 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({}) @@ -412,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": @@ -782,7 +788,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( { diff --git a/press/press/doctype/server/server.js b/press/press/doctype/server/server.js index fd1687d5549..fd2a352dfc2 100644 --- a/press/press/doctype/server/server.js +++ b/press/press/doctype/server/server.js @@ -172,7 +172,7 @@ frappe.ui.form.on('Server', { __('Reboot with serial console'), 'reboot_with_serial_console', true, - frm.doc.virtual_machine, + frm.doc.provider === 'AWS EC2', ], [ __('Enable Public Bench and Site Creation'), diff --git a/press/press/doctype/server/server.py b/press/press/doctype/server/server.py index 69d14aa4d22..3705152dbad 100644 --- a/press/press/doctype/server/server.py +++ b/press/press/doctype/server/server.py @@ -919,15 +919,16 @@ def real_ram(self): @frappe.whitelist() def reboot_with_serial_console(self): - if self.provider in ("AWS EC2",): - console = frappe.new_doc("Serial Console Log") - console.server_type = self.doctype - console.server = self.name - console.virtual_machine = self.virtual_machine - console.action = "reboot" - console.save() - console.reload() - console.run_sysrq() + if self.provider != "AWS EC2": + raise NotImplementedError + console = frappe.new_doc("Serial Console Log") + console.server_type = self.doctype + console.server = self.name + console.virtual_machine = self.virtual_machine + console.action = "reboot" + console.save() + console.reload() + console.run_sysrq() @dashboard_whitelist() def reboot(self): diff --git a/press/press/doctype/site/site.js b/press/press/doctype/site/site.js index 59285fdd8a9..7ad3c1827e7 100644 --- a/press/press/doctype/site/site.js +++ b/press/press/doctype/site/site.js @@ -109,27 +109,7 @@ frappe.ui.form.on('Site', { [__('Clear Cache'), 'clear_site_cache'], [__('Optimize Tables'), 'optimize_tables'], [__('Update Site Config'), 'update_site_config'], - [ - __('Enable Database Access'), - 'enable_database_access', - !frm.doc.is_database_access_enabled, - ], - [ - __('Disable Database Access'), - 'disable_database_access', - frm.doc.is_database_access_enabled, - ], [__('Create DNS Record'), 'create_dns_record'], - [ - __('Enable Database Write Access'), - 'enable_read_write', - frm.doc.database_access_mode == 'read_only', - ], - [ - __('Disable Database Write Access'), - 'disable_read_write', - frm.doc.database_access_mode == 'read_write', - ], [__('Run After Migrate Steps'), 'run_after_migrate_steps'], [__('Retry Rename'), 'retry_rename'], [ 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 d5c7011a089..ebdf3fde25a 100644 --- a/press/press/doctype/site/site.py +++ b/press/press/doctype/site/site.py @@ -3,6 +3,7 @@ from __future__ import annotations +import contextlib import json import re from collections import defaultdict @@ -509,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: @@ -520,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") @@ -800,10 +801,17 @@ def create_agent_request(self): if self.remote_database_file: agent.new_site_from_backup(self, skip_failing_patches=self.skip_failing_patches) else: + """ + If the site is creating for saas / product trial purpose, + Create a system user with password at the time of site creation. + + If `ignore_additional_system_user_creation` is set, don't create additional system user + """ if (self.standby_for_product or self.standby_for) and not self.is_standby: - self.flags.new_site_agent_job_name = agent.new_site( - self, create_user=self.get_user_details() - ).name + user_details = self.get_user_details() + if self.flags.get("ignore_additional_system_user_creation", False): + user_details = None + self.flags.new_site_agent_job_name = agent.new_site(self, create_user=user_details).name else: self.flags.new_site_agent_job_name = agent.new_site(self).name @@ -1260,6 +1268,8 @@ def archive(self, site_name=None, reason=None, force=False, skip_reload=False): self.disable_subscription() self.disable_marketplace_subscriptions() + self.archive_site_database_users() + @frappe.whitelist() def cleanup_after_archive(self): site_cleanup_after_archive(self.name) @@ -1902,14 +1912,27 @@ def change_plan(self, plan, ignore_card_setup=False): ) return ret + def archive_site_database_users(self): + db_users = frappe.get_all( + "Site Database User", + filters={ + "site": self.name, + "status": ("!=", "Archived"), + }, + pluck="name", + ) + + for db_user in db_users: + frappe.get_doc("Site Database User", db_user).archive( + raise_error=False, skip_remove_db_user_step=True + ) + def revoke_database_access_on_plan_change(self): # If the new plan doesn't have database access, disable it - if not self.is_database_access_enabled: - return if frappe.db.get_value("Site Plan", self.plan, "database_access"): return - self.disable_database_access() + self.archive_site_database_users() def unsuspend_if_applicable(self): try: @@ -2014,11 +2037,35 @@ def get_user_details(self): ) user_first_name = user.first_name if (user and user.first_name) else "" user_last_name = user.last_name if (user and user.last_name) else "" - return { + payload = { "email": user_email, "first_name": user_first_name or "", "last_name": user_last_name or "", } + """ + If the site is created for product trial, + we might have collected the password from end-user for his site + """ + if self.account_request and self.standby_for_product and not self.is_standby: + with contextlib.suppress(frappe.DoesNotExistError): + # fetch the product trial request + product_trial_request = frappe.get_doc( + "Product Trial Request", + { + "account_request": self.account_request, + "product_trial": self.standby_for_product, + "site": self.name, + }, + ) + setup_wizard_completion_mode = frappe.get_value( + "Product Trial", product_trial_request.product_trial, "setup_wizard_completion_mode" + ) + if setup_wizard_completion_mode == "manual": + password = product_trial_request.get_user_login_password_from_signup_details() + if password: + payload["password"] = password + + return payload def setup_erpnext(self): account_request = frappe.get_doc("Account Request", self.account_request) @@ -2165,96 +2212,6 @@ def check_db_access_enabling(self): ): frappe.throw("Database Access is already being enabled on this site. Please check after a while.") - def check_db_access_enabled_already(self): - if frappe.db.get_value(self.doctype, self.name, "is_database_access_enabled", for_update=True): - frappe.throw("Database Access already enabled. Reload the page and try.") - - @dashboard_whitelist() - @site_action(["Active"]) - def enable_database_access(self, mode="read_only"): - if not frappe.db.get_value("Site Plan", self.plan, "database_access"): - frappe.throw(f"Database Access is not available on {self.plan} plan") - self.check_db_access_enabling() - self.check_db_access_enabled_already() - - server_agent = Agent(self.server) - credentials = server_agent.create_database_access_credentials(self, mode) - self.database_access_mode = mode - self.database_access_user = credentials["user"] - self.database_access_password = credentials["password"] - self.save() - - proxy_server = frappe.db.get_value("Server", self.server, "proxy_server") - agent = Agent(proxy_server, server_type="Proxy Server") - - database_server_name = frappe.db.get_value("Server", self.server, "database_server") - database_server = frappe.get_doc("Database Server", database_server_name) - - job = agent.add_proxysql_user( - self, - credentials["database"], - credentials["user"], - credentials["password"], - database_server, - ) - log_site_activity(self.name, "Enable Database Access", job=job.name) - - # BREAKING CHANGE: This may cause problems for - # serverscripts that rely on the return value of this function - return job.name - - @dashboard_whitelist() - @site_action(["Active"]) - def disable_database_access(self): - server_agent = Agent(self.server) - server_agent.revoke_database_access_credentials(self) - - proxy_server = frappe.db.get_value("Server", self.server, "proxy_server") - agent = Agent(proxy_server, server_type="Proxy Server") - - user = self.database_access_user - - self.database_access_mode = None - self.database_access_user = None - self.database_access_password = None - self.save() - job = agent.remove_proxysql_user(self, user) - - log_site_activity(self.name, "Disable Database Access", job=job.name) - return job - - @dashboard_whitelist() - def get_database_credentials(self): - proxy_server = frappe.db.get_value("Server", self.server, "proxy_server") - config = self.fetch_info()["config"] - - return { - "host": proxy_server, - "port": 3306, - "database": config["db_name"], - "username": self.database_access_user, - "password": self.get_password("database_access_password"), - "mode": self.database_access_mode, - } - - def get_database_access_info(self): - db_access_info = frappe._dict({}) - - is_available_on_current_plan = ( - frappe.db.get_value("Site Plan", self.plan, "database_access") if self.plan else None - ) - - db_access_info.is_available_on_current_plan = is_available_on_current_plan - db_access_info.is_database_access_enabled = self.is_database_access_enabled - - if not self.is_database_access_enabled: - # Nothing more we can return here - return db_access_info - - db_access_info.credentials = self.get_database_credentials() - - return db_access_info - def get_auto_update_info(self): fields = [ "auto_updates_scheduled", @@ -2313,6 +2270,9 @@ def server_logs(self): def get_server_log(self, log): return Agent(self.server).get(f"benches/{self.bench}/sites/{self.name}/logs/{log}") + def get_server_log_for_log_browser(self, log): + return Agent(self.server).get(f"benches/{self.bench}/sites/{self.name}/logs_v2/{log}") + @property def has_paid(self) -> bool: """Has the site been paid for by customer.""" @@ -2491,14 +2451,6 @@ def run_after_migrate_steps(self): agent = Agent(self.server) agent.run_after_migrate_steps(self) - @frappe.whitelist() - def enable_read_write(self): - self.enable_database_access("read_write") - - @frappe.whitelist() - def disable_read_write(self): - self.enable_database_access("read_only") - @frappe.whitelist() def get_actions(self): is_group_public = frappe.get_cached_value("Release Group", self.group, "public") @@ -2511,6 +2463,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", @@ -2557,12 +2515,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", @@ -2830,6 +2782,7 @@ def process_complete_setup_wizard_job_update(job): return product_trial_request = frappe.get_doc("Product Trial Request", records[0].name, for_update=True) if job.status == "Success": + frappe.db.set_value("Site", job.site, "additional_system_user_created", True) product_trial_request.status = "Site Created" product_trial_request.site_creation_completed_on = now_datetime() product_trial_request.save(ignore_permissions=True) @@ -3085,6 +3038,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_activity/site_activity.json b/press/press/doctype/site_activity/site_activity.json index 646a580ade6..b25c051cf0b 100644 --- a/press/press/doctype/site_activity/site_activity.json +++ b/press/press/doctype/site_activity/site_activity.json @@ -29,7 +29,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Action", - "options": "Activate Site\nAdd Domain\nArchive\nBackup\nCreate\nClear Cache\nDeactivate Site\nInstall App\nLogin as Administrator\nMigrate\nReinstall\nRestore\nSuspend Site\nUninstall App\nUnsuspend Site\nUpdate\nUpdate Configuration\nDrop Offsite Backups\nEnable Database Access\nDisable Database Access", + "options": "Activate Site\nAdd Domain\nArchive\nBackup\nCreate\nClear Cache\nDeactivate Site\nInstall App\nLogin as Administrator\nMigrate\nReinstall\nRestore\nSuspend Site\nUninstall App\nUnsuspend Site\nUpdate\nUpdate Configuration\nDrop Offsite Backups\nEnable Database Access\nDisable Database Access\nCreate Database User\nRemove Database User\nModify Database User Permissions", "read_only": 1, "reqd": 1, "search_index": 1 @@ -56,7 +56,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-10-20 18:48:39.472990", + "modified": "2024-11-26 11:53:47.035359", "modified_by": "Administrator", "module": "Press", "name": "Site Activity", diff --git a/press/press/doctype/site_activity/site_activity.py b/press/press/doctype/site_activity/site_activity.py index cec765d1a0b..9c492f2a904 100644 --- a/press/press/doctype/site_activity/site_activity.py +++ b/press/press/doctype/site_activity/site_activity.py @@ -36,6 +36,9 @@ class SiteActivity(Document): "Drop Offsite Backups", "Enable Database Access", "Disable Database Access", + "Create Database User", + "Remove Database User", + "Modify Database User Permissions", ] job: DF.Link | None reason: DF.SmallText | None 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..50b260ed0ed --- /dev/null +++ b/press/press/doctype/site_database_user/site_database_user.js @@ -0,0 +1,65 @@ +// 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, + ], + [__('Archive User'), 'archive', frm.doc.status !== 'Archived'], + ].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 Credential'), + () => + frm.call('get_credential').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..30d5f0a85cf --- /dev/null +++ b/press/press/doctype/site_database_user/site_database_user.py @@ -0,0 +1,331 @@ +# 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 +from press.press.doctype.site_activity.site_activity import log_site_activity + + +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") + if not frappe.db.get_value("Site Plan", site.plan, "database_access"): + frappe.throw(f"Database Access is not available on {site.plan} plan") + 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): + log_site_activity( + self.site, + "Create Database User", + reason=f"Created user {self.username} with {self.mode} permission", + ) + 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 on_update(self): + if self.has_value_changed("status") and self.status == "Archived": + log_site_activity( + self.site, + "Remove Database User", + reason=f"Removed user {self.username} with {self.mode} permission", + ) + + 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() + log_site_activity( + self.site, + "Modify Database User Permissions", + reason=f"Modified user {self.username} with {self.mode} permission", + ) + 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, raise_error: bool = True, skip_remove_db_user_step: bool = False): + if not raise_error and self.status == "Archived": + return + self._raise_error_if_archived() + self.status = "Pending" + self.save() + + if self.user_created_in_database and not skip_remove_db_user_step: + """ + If we are dropping the database, there is no need to drop + db users separately. + In those cases, use `skip_remove_db_user_step` param to skip it + """ + self.remove_user() + else: + self.user_created_in_database = False + self.save() + + 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 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..8415331fb70 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 @@ -606,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 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: diff --git a/press/press/doctype/site_update/site_update.py b/press/press/doctype/site_update/site_update.py index 680e6ce637f..057bee1293b 100644 --- a/press/press/doctype/site_update/site_update.py +++ b/press/press/doctype/site_update/site_update.py @@ -265,7 +265,7 @@ def is_workload_diff_high(self) -> bool: THRESHOLD = 8 # USD 100 site equivalent. (Since workload is based off of CPU) - workload_diff_high = cpu > THRESHOLD + workload_diff_high = cpu >= THRESHOLD if not workload_diff_high: source_bench = frappe.get_doc("Bench", self.source_bench) @@ -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): diff --git a/press/press/doctype/stripe_webhook_log/stripe_webhook_log.json b/press/press/doctype/stripe_webhook_log/stripe_webhook_log.json index 745ba7cc502..bf601e5d834 100644 --- a/press/press/doctype/stripe_webhook_log/stripe_webhook_log.json +++ b/press/press/doctype/stripe_webhook_log/stripe_webhook_log.json @@ -13,6 +13,7 @@ "customer_id", "invoice_id", "stripe_payment_method", + "stripe_payment_intent_id", "section_break_ecbt", "payload" ], @@ -39,7 +40,8 @@ "fieldname": "invoice", "fieldtype": "Link", "label": "Invoice", - "options": "Invoice" + "options": "Invoice", + "search_index": 1 }, { "fieldname": "invoice_id", @@ -50,7 +52,8 @@ "fieldname": "team", "fieldtype": "Link", "label": "Team", - "options": "Team" + "options": "Team", + "search_index": 1 }, { "fieldname": "column_break_hywj", @@ -67,10 +70,15 @@ { "fieldname": "section_break_ecbt", "fieldtype": "Section Break" + }, + { + "fieldname": "stripe_payment_intent_id", + "fieldtype": "Data", + "label": "Stripe Payment Intent ID" } ], "links": [], - "modified": "2024-06-12 14:53:14.051698", + "modified": "2024-11-29 10:44:55.011202", "modified_by": "Administrator", "module": "Press", "name": "Stripe Webhook Log", 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..b6e78de7c50 100644 --- a/press/press/doctype/stripe_webhook_log/stripe_webhook_log.py +++ b/press/press/doctype/stripe_webhook_log/stripe_webhook_log.py @@ -30,6 +30,7 @@ class StripeWebhookLog(Document): invoice: DF.Link | None invoice_id: DF.Data | None payload: DF.Code | None + stripe_payment_intent_id: DF.Data | None stripe_payment_method: DF.Link | None team: DF.Link | None # end: auto-generated types @@ -40,6 +41,7 @@ def before_insert(self): self.event_type = payload.get("type") customer_id = get_customer_id(payload) invoice_id = get_invoice_id(payload) + self.stripe_payment_intent_id = get_intent_id(payload) if customer_id: self.customer_id = customer_id self.team = frappe.db.get_value("Team", {"stripe_customer_id": customer_id}, "name") @@ -61,7 +63,11 @@ 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") @@ -95,6 +101,17 @@ def stripe_webhook_handler(): raise +def get_intent_id(form_dict): + try: + form_dict_str = frappe.as_json(form_dict) + intent_id = re.findall(r"pi_\w+", form_dict_str) + if intent_id: + return intent_id[1] + return None + except Exception: + frappe.log_error(title="Failed to capture intent id from stripe webhook log") + + def get_customer_id(form_dict): try: form_dict_str = frappe.as_json(form_dict) 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) 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 diff --git a/press/press/doctype/virtual_machine/virtual_machine.py b/press/press/doctype/virtual_machine/virtual_machine.py index 58d3c0a1c5b..1ace6b9d2dc 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, - }, - ) - frappe.enqueue_doc( - machine.doctype, - machine.name, - method="bulk_sync_oci_cluster", - queue="sync", - job_id=f"bulk_sync_oci:{machine.cluster}", - deduplicate=True, - ) + 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( + "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): + 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 diff --git a/press/press/report/mariadb_slow_queries/mariadb_slow_queries.py b/press/press/report/mariadb_slow_queries/mariadb_slow_queries.py index fdb3fdbef0c..902c4cd70c0 100644 --- a/press/press/report/mariadb_slow_queries/mariadb_slow_queries.py +++ b/press/press/report/mariadb_slow_queries/mariadb_slow_queries.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe and contributors # For license information, please see license.txt +from __future__ import annotations + import json import re from collections import defaultdict @@ -25,7 +27,7 @@ def execute(filters=None): - frappe.only_for(["System Manager", "Site Manager"]) + frappe.only_for(["System Manager", "Site Manager", "Press Admin", "Press Member"]) filters.database = frappe.db.get_value("Site", filters.site, "database_name") make_access_log( @@ -150,9 +152,7 @@ def get_slow_query_logs(database, start_datetime, end_datetime, search_pattern, } if search_pattern and search_pattern != ".*": - query["query"]["bool"]["filter"].append( - {"regexp": {"mysql.slowlog.query": search_pattern}} - ) + query["query"]["bool"]["filter"].append({"regexp": {"mysql.slowlog.query": search_pattern}}) response = requests.post(url, json=query, auth=("frappe", password)).json() @@ -176,9 +176,7 @@ def normalize_query(query: str) -> str: q = format_query(q, strip_comments=True) # Transform IN parts like this: IN (?, ?, ?) -> IN (?) - q = re.sub(r" IN \(\?[\s\n\?\,]*\)", " IN (?)", q, flags=re.IGNORECASE) - - return q + return re.sub(r" IN \(\?[\s\n\?\,]*\)", " IN (?)", q, flags=re.IGNORECASE) def format_query(q, strip_comments=False): @@ -241,12 +239,12 @@ def analyze(self) -> DBIndex | None: stats = _fetch_table_stats(self.site, table) if not stats: # Old framework version - return + return None db_table = DBTable.from_frappe_ouput(stats) column_stats = _fetch_column_stats(self.site, table) if not column_stats: # Failing due to large size, TODO: move this to a job - return + return None db_table.update_cardinality(column_stats) optimizer.update_table_data(db_table) @@ -254,9 +252,7 @@ def analyze(self) -> DBIndex | None: def fetch_explain(self) -> list[dict]: site = frappe.get_cached_doc("Site", self.site) - db_server_name = frappe.db.get_value( - "Server", site.server, "database_server", cache=True - ) + db_server_name = frappe.db.get_value("Server", site.server, "database_server", cache=True) database_server = frappe.get_cached_doc("Database Server", db_server_name) agent = Agent(database_server.name, "Database Server") @@ -284,9 +280,7 @@ def _fetch_table_stats(site: str, table: str): @redis_cache(ttl=60 * 5) def _fetch_column_stats(site, table, doc_name): site = frappe.get_cached_doc("Site", site) - db_server_name = frappe.db.get_value( - "Server", site.server, "database_server", cache=True - ) + db_server_name = frappe.db.get_value("Server", site.server, "database_server", cache=True) database_server = frappe.get_cached_doc("Database Server", db_server_name) agent = Agent(database_server.name, "Database Server") @@ -324,6 +318,4 @@ def _add_suggested_index(site_name, indexes): site = frappe.get_cached_doc("Site", site_name) agent = Agent(site.server) agent.add_database_index(site, doctype=doctype, columns=[column]) - frappe.msgprint( - f"Index {index} added on site {site_name} successfully", realtime=True - ) + frappe.msgprint(f"Index {index} added on site {site_name} successfully", realtime=True) diff --git a/press/saas/README.md b/press/saas/README.md index e69de29bb2d..3764f5624a7 100644 --- a/press/saas/README.md +++ b/press/saas/README.md @@ -0,0 +1,68 @@ +### 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. + +#### 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** + - **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. + - **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 +- 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 + 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..99ee337cfb4 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,76 @@ 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() + 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) + + """ + 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 + + +@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 diff --git a/press/saas/doctype/product_trial/product_trial.json b/press/saas/doctype/product_trial/product_trial.json index 6eec795c8f3..4139b4f9cb6 100644 --- a/press/saas/doctype/product_trial/product_trial.json +++ b/press/saas/doctype/product_trial/product_trial.json @@ -36,7 +36,9 @@ "email_header_content", "setup_wizard_tab", "setup_wizard_completion_mode", - "setup_wizard_payload_generator_script" + "setup_wizard_payload_generator_script", + "create_additional_system_user", + "redirect_to_after_login" ], "fields": [ { @@ -128,7 +130,7 @@ "label": "Signup Details" }, { - "description": "For timezone fields, append _tz at the end of fieldname and choose Select as fieldtype and you can leave the Options field empty. ", + "description": "For timezone fields, append _tz at the end of fieldname and choose Select as fieldtype and you can leave the Options field empty.
\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 +162,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 +210,21 @@ { "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" + }, + { + "default": "/desk", + "fieldname": "redirect_to_after_login", + "fieldtype": "Data", + "label": "Redirect To After Login", + "reqd": 1 } ], "image_field": "logo", @@ -221,7 +239,7 @@ "link_fieldname": "product_trial" } ], - "modified": "2024-08-22 11:20:54.136031", + "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 0f809e02e57..2bb72292e89 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 @@ -28,6 +30,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 @@ -36,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 @@ -53,8 +57,11 @@ class ProductTrial(Document): "domain", "trial_days", "trial_plan", + "redirect_to_after_login", ) + USER_LOGIN_PASSWORD_FIELD = "user_login_password" + def get_doc(self, doc): if not self.published: frappe.throw("Not permitted") @@ -88,13 +95,26 @@ 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") + + 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 + 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 @@ -105,9 +125,14 @@ 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._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] @@ -120,6 +145,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,15 +153,15 @@ def setup_trial_site(self, team, plan, cluster=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 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: - 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): @@ -269,6 +295,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") @@ -290,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: @@ -310,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, ) 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) 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

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