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 @@
- If you select Razorpay, you can pay using Credit Card, Debit Card, Net - Banking, UPI, Wallets, etc. If you are using Net Banking, it may take upto - 5 days for balance to reflect. -
-- {{ paymentModeDescription }} -
-Description | -Rate | -- Quantity - | -Amount | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
- {{ type }} - | -|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- {{ row.document_name }} - - ({{ row.plan }}/mo) - - | -- {{ formatCurrency(row.rate) }} - | -- {{ row.quantity }} - {{ - ['Site', 'Release Group', 'Server'].includes( - row.document_type - ) - ? $format.plural(row.quantity, 'day', 'days') - : '' - }} - | -- {{ formatCurrency(row.amount) }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | - Total Without Discount - | -- {{ formatCurrency(doc.total_before_discount) }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | - Total Discount Amount - | -- {{ - doc.partner_email && doc.partner_email != team?.data?.user - ? formatCurrency(0) - : formatCurrency(doc.total_discount_amount) - }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | - Total (Without Tax) - | -- {{ formatCurrency(doc.total) }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | - IGST @ {{ Number(gstPercentage * 100) }}% - | -- {{ doc.gst }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | Grand Total | -- {{ - doc.partner_email && doc.partner_email != team?.data?.user - ? formatCurrency(doc.total_before_discount) - : formatCurrency(doc.total + doc.gst) - }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | Applied Balance | -- - {{ formatCurrency(doc.applied_credits) }} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
- | - | Amount Due | -- {{ formatCurrency(doc.amount_due) }} - | -
- * Support includes only issues and bug fixes related - to Frappe apps, functional queries will not be entertained. -
-- ** If you face any issue while using Frappe Cloud, you can raise - support ticket regardless of site plan. -
-- We are attempting to charge your card with - {{ formattedMicroChargeAmount }} to make sure the - card works. This amount will be refunded back to your - account. -
- - -This can take upto 5 minutes
++ Configure Permissions +
++ Using an Analytics or Business Intelligence Tool +
++ Use following credentials with your analytics or business + intelligence tool +
++ Host: {{ databaseCredential?.host }} +
++ Port: {{ databaseCredential?.port }} +
++ Database Name: {{ databaseCredential?.database }} +
++ Username: {{ databaseCredential?.username }} +
++ Password: {{ databaseCredential?.password }} +
+Use SSL: Yes
++ Using MariaDB Client +
++ Run this command in your terminal to access MariaDB console +
++ Note: You should have a + mariadb client installed on your + computer. +
++ {{ paymentModeDescription }} +
++ Select a {{ mode }} to view logs +
+{{ log.name }}
++ {{ $format.date(log.modified, 'YYYY-MM-DD HH:mm') }} +
++ {{ $format.bytes(log.size) }} +
+
+
+
+ |
+
---|
+
+
+
+
+ |
+
+ {{ row.original.description }} + | +
+ No log entries found +
++ {{ pageStart }} - {{ pageEnd }} of {{ totalRows }} rows +
+Loading
-Loading
+or
+or
- {{ - currentPlanPricing - }} - / month -
-Condition Examples:
\ndoc.status==\"Open\"\n\n
account_request.country==\"Spain\"
doc.total > 40000\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.{\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
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
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
I agree to Frappe Terms of Service, @@ -69,8 +70,9 @@