diff --git a/argocd_capacity/views/application_tag_view.xml b/argocd_capacity/views/application_tag_view.xml index 2e76633..2d94ec2 100644 --- a/argocd_capacity/views/application_tag_view.xml +++ b/argocd_capacity/views/application_tag_view.xml @@ -4,7 +4,7 @@ argocd.application.tag - + diff --git a/argocd_capacity/views/application_template_view.xml b/argocd_capacity/views/application_template_view.xml index f6fd982..5ecbfce 100644 --- a/argocd_capacity/views/application_template_view.xml +++ b/argocd_capacity/views/application_template_view.xml @@ -4,7 +4,7 @@ argocd.application.template - + diff --git a/argocd_deployer/__manifest__.py b/argocd_deployer/__manifest__.py index f733b84..20328f1 100644 --- a/argocd_deployer/__manifest__.py +++ b/argocd_deployer/__manifest__.py @@ -10,6 +10,7 @@ "data/application_namespace_prefix.xml", "data/application_set_template.xml", "data/application_set.xml", + "views/application_domain_view.xml", "views/application_template_view.xml", "views/application_set_template_view.xml", "views/application_tag_view.xml", diff --git a/argocd_deployer/data/application_set.xml b/argocd_deployer/data/application_set.xml index b36018c..efd5725 100644 --- a/argocd_deployer/data/application_set.xml +++ b/argocd_deployer/data/application_set.xml @@ -17,8 +17,6 @@ main /home/tarteo/repo instances - %(application_name)s.curq.k8s.onestein.eu - %(subdomain)s.%(application_name)s.curq.k8s.onestein.eu diff --git a/argocd_deployer/demo/application_tag_demo.xml b/argocd_deployer/demo/application_tag_demo.xml index b76c4f6..8de3dc6 100644 --- a/argocd_deployer/demo/application_tag_demo.xml +++ b/argocd_deployer/demo/application_tag_demo.xml @@ -9,6 +9,5 @@ Matomo Server matomo_server - matomo.domain diff --git a/argocd_deployer/models/__init__.py b/argocd_deployer/models/__init__.py index 29e08db..36c5909 100644 --- a/argocd_deployer/models/__init__.py +++ b/argocd_deployer/models/__init__.py @@ -3,6 +3,6 @@ from . import application_template from . import application from . import application_tag -from . import application_tag_domain_override from . import application_value from . import application_namespace_prefix +from . import application_domain diff --git a/argocd_deployer/models/application.py b/argocd_deployer/models/application.py index b7d8531..8d56cbf 100644 --- a/argocd_deployer/models/application.py +++ b/argocd_deployer/models/application.py @@ -2,9 +2,7 @@ import re import jinja2 -import yaml from git import Repo -from yaml import Loader from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -42,6 +40,11 @@ class Application(models.Model): inverse_name="application_id", string="Values", ) + domain_ids = fields.One2many( + comodel_name="argocd.application.domain", + inverse_name="application_id", + string="Domains", + ) application_set_id = fields.Many2one( "argocd.application.set", ) @@ -61,23 +64,12 @@ def has_tag(self, key): self.ensure_one() return bool(self.tag_ids.filtered(lambda t: t.key == key)) - def format_domain(self, subdomain=None): - """ - Helper method for generating the yaml / helm values. If no domain is specified in e.g. value_ids this can be used - to make a default domain. - Uses config parameters `argocd.application_subdomain_format` and `argocd.application_domain_format` for the format. - - @param subdomain: tag key (e.g. matomo) - @return: formatted domain - """ + def create_domain(self, preferred, *alternatives, scope="global"): + """Shortcut""" self.ensure_one() - values = {"application_name": self.name} - if subdomain: - domain_format = self.application_set_id.subdomain_format or "" - values["subdomain"] = subdomain - else: - domain_format = self.application_set_id.domain_format or "" - return domain_format % values + return self.env["argocd.application.domain"].create_domain( + self, preferred, *alternatives, scope=scope + ) @api.depends("config") def _compute_description(self): @@ -132,28 +124,16 @@ def _render_description(self): raise_if_not_found=False, ) - @staticmethod - def _get_domain(helm): - return helm.get("domain") or helm.get("global", {}).get("domain") - def get_urls(self): self.ensure_one() urls = [] - if not self.config: - return urls - - config = yaml.load(self.config, Loader=Loader) - helm = yaml.load(config["helm"], Loader=Loader) - urls.append(("https://%s" % self._get_domain(helm), "Odoo")) - for tag in self.tag_ids.filtered(lambda t: bool(t.domain_yaml_path)): - yaml_path = tag.get_domain_yaml_path(self.application_set_id).split(".") - domain = helm - for p in yaml_path: - domain = domain.get(p) - if not domain: - break - else: - urls.append(("https://%s" % domain, tag.name)) + for scope in self.domain_ids.mapped("scope"): + prioritized_domain = self.domain_ids.filtered( + lambda d: d.scope == scope + ).sorted("sequence")[0] + urls.append( + ("https://%s" % prioritized_domain.name, prioritized_domain.scope) + ) return urls @api.depends("tag_ids", "tag_ids.is_odoo_module") @@ -163,20 +143,32 @@ def _compute_modules(self): application.tag_ids.filtered(lambda t: t.is_odoo_module).mapped("key") ) - _sql_constraints = [("application_name_unique", "unique(name)", "Already exists")] + _sql_constraints = [ + ( + "application_name_unique", + "unique(application_set_id, name)", + "Already exists in this application set", + ) + ] @api.model - def find_next_available_name(self, name): + def find_next_available_name(self, app_set, name): """ Find a name which is available based on name (e.g. greg2) + @param app_set: application set @param name: a name @return: first available name """ - if not self.search([("name", "=", name)], count=True): + if not self.search( + [("application_set_id", "=", app_set.id), ("name", "=", name)], count=True + ): return name i = 0 - while self.search([("name", "=", name + str(i))], count=True): + while self.search( + [("application_set_id", "=", app_set.id), ("name", "=", name + str(i))], + count=True, + ): i += 1 return name + str(i) @@ -197,7 +189,7 @@ def _get_config_render_values(self): "application": self, "has_tag": self.has_tag, "get_value": self.get_value, - "format_domain": self.format_domain, + "create_domain": self.create_domain, } def render_config(self, context=None): @@ -263,11 +255,11 @@ def immediate_destroy(self): self.ensure_one() self._apply_repository_changes(self._get_destroy_content) - def destroy(self): + def destroy(self, eta=0): self.ensure_one() delay = safe_eval( self.env["ir.config_parameter"].get_param( "argocd.application_destruction_delay", "0" ) ) - self.with_delay(eta=delay).immediate_destroy() + self.with_delay(eta=eta or delay).immediate_destroy() diff --git a/argocd_deployer/models/application_domain.py b/argocd_deployer/models/application_domain.py new file mode 100644 index 0000000..ff3c164 --- /dev/null +++ b/argocd_deployer/models/application_domain.py @@ -0,0 +1,46 @@ +from odoo import api, fields, models + + +class ApplicationDomain(models.Model): + _name = "argocd.application.domain" + _description = "ArgoCD Application Domain" + _order = "sequence" + + application_id = fields.Many2one(comodel_name="argocd.application", required=True) + scope = fields.Char(default="Application") + sequence = fields.Integer(default=10) + name = fields.Char(required=True) + + _sql_constraints = [ + ( + "application_domain_name_unique", + "unique(name)", + "Domain is already in use", + ) + ] + + @api.model + def create_domain(self, application, preferred, *alternatives, scope="Application"): + existing = application.domain_ids.filtered(lambda d: d.scope == scope).sorted( + "sequence" + ) + if existing: + return existing.name + domains = (preferred,) + alternatives + i = 0 + best_available = False + while not best_available: + i_as_str = str(i) + for domain in domains: + domain_name = domain + if i: + domain_name += i_as_str + already_exists = self.search([("name", "=", domain_name)], count=True) + if not already_exists: + best_available = domain_name + break + i += 1 + self.create( + {"application_id": application.id, "name": best_available, "scope": scope} + ) + return best_available diff --git a/argocd_deployer/models/application_set.py b/argocd_deployer/models/application_set.py index bf122a4..ea03020 100644 --- a/argocd_deployer/models/application_set.py +++ b/argocd_deployer/models/application_set.py @@ -38,16 +38,6 @@ class ApplicationSet(models.Model): deployment_directory = fields.Char( help="Folder inside the repository in which to store the application YAML files.", ) - domain_format = fields.Char( - required=True, - help="The domain format used to build the domain for the deployment.", - default="-", - ) - subdomain_format = fields.Char( - required=True, - help="The domain format used to build the domain for the deployment.", - default="-", - ) namespace_prefix_id = fields.Many2one("argocd.application.namespace.prefix") _sql_constraints = [ diff --git a/argocd_deployer/models/application_tag.py b/argocd_deployer/models/application_tag.py index b817855..e28d8f6 100644 --- a/argocd_deployer/models/application_tag.py +++ b/argocd_deployer/models/application_tag.py @@ -8,25 +8,5 @@ class ApplicationTag(models.Model): name = fields.Char(required=True) key = fields.Char(required=True, copy=False) is_odoo_module = fields.Boolean(string="Is additional Odoo Module") - domain_yaml_path = fields.Char( - help="Path where to find the domain in the yaml (e.g. nextcloud.domain)" - ) - domain_override_ids = fields.One2many( - "argocd.application.tag.domain.override", inverse_name="tag_id" - ) _sql_constraints = [("application_tag_key_unique", "unique(key)", "Already exists")] - - def get_domain_yaml_path(self, application_set=False): - self.ensure_one() - if ( - not application_set - or application_set.id not in self.domain_override_ids.application_set_id.ids - ): - return self.domain_yaml_path or "" - return ( - self.domain_override_ids.filtered( - lambda do: do.application_set_id == application_set - ).domain_yaml_path - or "" - ) diff --git a/argocd_deployer/models/application_tag_domain_override.py b/argocd_deployer/models/application_tag_domain_override.py deleted file mode 100644 index b17d967..0000000 --- a/argocd_deployer/models/application_tag_domain_override.py +++ /dev/null @@ -1,20 +0,0 @@ -from odoo import fields, models - - -class ApplicationTagDomainOverride(models.Model): - _name = "argocd.application.tag.domain.override" - _description = "Application Tag Domain Override" - - tag_id = fields.Many2one("argocd.application.tag") - application_set_id = fields.Many2one("argocd.application.set") - domain_yaml_path = fields.Char( - help="Path where to find the domain in the yaml (e.g. nextcloud.domain)" - ) - - _sql_constraints = [ - ( - "application_tag_key_unique", - "unique(tag_id, application_set_id)", - "Override already exists.", - ) - ] diff --git a/argocd_deployer/security/ir.model.access.csv b/argocd_deployer/security/ir.model.access.csv index af621aa..47f4b2c 100644 --- a/argocd_deployer/security/ir.model.access.csv +++ b/argocd_deployer/security/ir.model.access.csv @@ -5,5 +5,5 @@ application_tag_access,application_tag_access,model_argocd_application_tag,base. application_value_access,application_value_access,model_argocd_application_value,base.group_system,1,1,1,1 application_set_access,application_set_access,model_argocd_application_set,base.group_system,1,1,1,1 application_set_template_access,application_set_template_access,model_argocd_application_set_template,base.group_system,1,1,1,1 -application_tag_domain_override_access,application_tag_domain_override_access,model_argocd_application_tag_domain_override,base.group_system,1,1,1,1 application_namespace_prefix_access,application_namespace_prefix_access,model_argocd_application_namespace_prefix,base.group_system,1,1,1,1 +application_domain_access,application_domain_access,model_argocd_application_domain,base.group_system,1,1,1,1 diff --git a/argocd_deployer/tests/test_application_tag.py b/argocd_deployer/tests/test_application_tag.py deleted file mode 100644 index a6f1b86..0000000 --- a/argocd_deployer/tests/test_application_tag.py +++ /dev/null @@ -1,58 +0,0 @@ -from odoo import Command -from odoo.tests.common import TransactionCase - - -class TestApplicationTag(TransactionCase): - def test_get_domain_yaml_path(self): - """get_domain_yaml_path returns a reasonable value in all circumstances.""" - - template = self.env["argocd.application.set.template"].create( - { - "name": "test-set-template", - "yaml": """test: - me: domain""", - } - ) - application_set_1 = self.env["argocd.application.set"].create( - { - "name": "test-set", - "template_id": template.id, - "repository_url": "http://hello.com", - "branch": "hello", - "repository_directory": "hello", - "deployment_directory": "bye", - } - ) - application_set_2 = self.env["argocd.application.set"].create( - { - "name": "test-set-also", - "template_id": template.id, - "repository_url": "http://hello.com", - "branch": "hello", - "repository_directory": "hello", - "deployment_directory": "byeagain", - } - ) - tag = self.env["argocd.application.tag"].create( - {"name": "test-tag", "domain_yaml_path": False, "key": "thegoldentowerkey"} - ) - - self.assertFalse(tag.get_domain_yaml_path()) - self.assertFalse(tag.get_domain_yaml_path(application_set_1)) - - tag.domain_yaml_path = "test.me" - self.assertEqual("test.me", tag.get_domain_yaml_path()) - self.assertEqual("test.me", tag.get_domain_yaml_path(application_set_1)) - - tag.domain_override_ids = [ - Command.create( - { - "application_set_id": application_set_1.id, - "domain_yaml_path": "test.me.now", - } - ) - ] - - self.assertEqual("test.me", tag.get_domain_yaml_path()) - self.assertEqual("test.me.now", tag.get_domain_yaml_path(application_set_1)) - self.assertEqual("test.me", tag.get_domain_yaml_path(application_set_2)) diff --git a/argocd_deployer/views/application_domain_view.xml b/argocd_deployer/views/application_domain_view.xml new file mode 100644 index 0000000..ac421fc --- /dev/null +++ b/argocd_deployer/views/application_domain_view.xml @@ -0,0 +1,13 @@ + + + + argocd.application.domain + + + + + + + + + diff --git a/argocd_deployer/views/application_set_view.xml b/argocd_deployer/views/application_set_view.xml index e7fbb6a..f4c66d4 100644 --- a/argocd_deployer/views/application_set_view.xml +++ b/argocd_deployer/views/application_set_view.xml @@ -78,8 +78,6 @@ - - diff --git a/argocd_deployer/views/application_tag_view.xml b/argocd_deployer/views/application_tag_view.xml index 414bd43..894226d 100644 --- a/argocd_deployer/views/application_tag_view.xml +++ b/argocd_deployer/views/application_tag_view.xml @@ -9,15 +9,6 @@ - - - - - - - - - diff --git a/argocd_deployer/views/application_view.xml b/argocd_deployer/views/application_view.xml index 9f9384b..88c1aa7 100644 --- a/argocd_deployer/views/application_view.xml +++ b/argocd_deployer/views/application_view.xml @@ -80,6 +80,7 @@ + diff --git a/argocd_sale/__manifest__.py b/argocd_sale/__manifest__.py index 2aa2351..f192a2d 100644 --- a/argocd_sale/__manifest__.py +++ b/argocd_sale/__manifest__.py @@ -20,7 +20,8 @@ "data/ir_config_parameter_data.xml", "views/product_template.xml", "views/application_view.xml", - "views/application_template_view.xml", "views/res_config_settings_view.xml", + "views/sale_subscription_view.xml", + "views/product_attribute_view.xml", ], } diff --git a/argocd_sale/data/ir_config_parameter_data.xml b/argocd_sale/data/ir_config_parameter_data.xml index c310dda..6ccb806 100644 --- a/argocd_sale/data/ir_config_parameter_data.xml +++ b/argocd_sale/data/ir_config_parameter_data.xml @@ -12,12 +12,4 @@ argocd_sale.grace_period_tag_id - - argocd_sale.subscription_free_period - 3 - - - argocd_sale.subscription_free_period_type - months - diff --git a/argocd_sale/models/__init__.py b/argocd_sale/models/__init__.py index 7c5d432..feed92e 100644 --- a/argocd_sale/models/__init__.py +++ b/argocd_sale/models/__init__.py @@ -1,7 +1,9 @@ from . import product_template from . import account_move from . import application -from . import application_template from . import res_partner from . import subscription +from . import sale_subscription_line from . import res_config_settings +from . import product_attribute +from . import product_attribute_value diff --git a/argocd_sale/models/application.py b/argocd_sale/models/application.py index 57927f2..d1d21c6 100644 --- a/argocd_sale/models/application.py +++ b/argocd_sale/models/application.py @@ -1,5 +1,4 @@ -from odoo import _, api, fields, models -from odoo.exceptions import UserError +from odoo import api, fields, models class Application(models.Model): @@ -11,6 +10,21 @@ class Application(models.Model): store=True, readonly=False, ) + subscription_id = fields.Many2one( + comodel_name="sale.subscription", + compute="_compute_subscription_id", + store=True, + readonly=False, + ) + subscription_line_id = fields.Many2one(comodel_name="sale.subscription.line") + + _sql_constraints = [ + ( + "application_subscription_line_id_unique", + "unique(subscription_line_id)", + "Only one application can be linked to a subscription line", + ) + ] def is_created_by_reseller(self): self.ensure_one() @@ -18,27 +32,25 @@ def is_created_by_reseller(self): self.partner_id.parent_id and self.partner_id.parent_id.is_reseller ) - subscription_id = fields.Many2one(comodel_name="sale.subscription") - - @api.depends("subscription_id", "subscription_id.partner_id") + def get_attribute(self, argocd_identifier): + self.ensure_one() + variant_value = self.subscription_line_id.product_id.product_template_variant_value_ids.filtered( + lambda kv: kv.attribute_id.argocd_identifier == argocd_identifier + ).product_attribute_value_id + return variant_value.argocd_name or variant_value.name + + @api.depends("subscription_line_id", "subscription_line_id.sale_subscription_id") + def _compute_subscription_id(self): + for app in self.filtered(lambda a: a.subscription_line_id): + app.subscription_id = app.subscription_line_id.sale_subscription_id + + @api.depends( + "subscription_id", + "subscription_id.partner_id", + "subscription_id.end_partner_id", + ) def _compute_partner_id(self): for app in self.filtered(lambda a: a.subscription_id): - app.partner_id = app.subscription_id.partner_id - - def _get_deployment_notification_mail_template(self): - self.ensure_one() - return "argocd_sale.deployment_notification_mail_template" - - def send_deployment_notification(self): - self.ensure_one() - if not self.partner_id: - raise UserError(_("Please provide a partner")) - mail_template_id = self._get_deployment_notification_mail_template() - template = self.env.ref(mail_template_id) - template.sudo().send_mail(self.id, force_send=True) - - def deploy(self): - res = super().deploy() - if self.template_id.auto_send_deployment_notification and self.partner_id: - self.send_deployment_notification() - return res + app.partner_id = ( + app.subscription_id.end_partner_id or app.subscription_id.partner_id + ) diff --git a/argocd_sale/models/application_template.py b/argocd_sale/models/application_template.py deleted file mode 100644 index 03f0d50..0000000 --- a/argocd_sale/models/application_template.py +++ /dev/null @@ -1,11 +0,0 @@ -from odoo import fields, models - - -class ApplicationTemplate(models.Model): - _inherit = "argocd.application.template" - - auto_send_deployment_notification = fields.Boolean( - default=True, - string="Automatically send deployment notification", - help="Determines if an email is automatically send to the partner if deployed.", - ) diff --git a/argocd_sale/models/product_attribute.py b/argocd_sale/models/product_attribute.py new file mode 100644 index 0000000..8fb9b44 --- /dev/null +++ b/argocd_sale/models/product_attribute.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class ProductAttribute(models.Model): + _inherit = "product.attribute" + + argocd_identifier = fields.Char( + string="ArgoCD Identifier", + help="Makes it easier to look it up when rendering the YAML config for applications", + ) diff --git a/argocd_sale/models/product_attribute_value.py b/argocd_sale/models/product_attribute_value.py new file mode 100644 index 0000000..26c6e3e --- /dev/null +++ b/argocd_sale/models/product_attribute_value.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ProductAttributeValue(models.Model): + _inherit = "product.attribute.value" + + argocd_name = fields.Char(string="Value in ArgoCD") diff --git a/argocd_sale/models/product_template.py b/argocd_sale/models/product_template.py index 9e1f91b..3c16194 100644 --- a/argocd_sale/models/product_template.py +++ b/argocd_sale/models/product_template.py @@ -4,6 +4,11 @@ class ProductTemplate(models.Model): _inherit = "product.template" + application_set_id = fields.Many2one( + comodel_name="argocd.application.set", + help="The application set in which this product will be deployed.", + ) + application_template_id = fields.Many2one( string="Application Template", comodel_name="argocd.application.template" ) @@ -13,11 +18,6 @@ class ProductTemplate(models.Model): string="Application Tags", ) - application_filter_ids = fields.Many2many( - comodel_name="argocd.application.template", - string="Only applies to (leave empty for all)", - ) - reseller_partner_ids = fields.Many2many( comodel_name="res.partner", relation="product_reseller_rel", @@ -26,7 +26,9 @@ class ProductTemplate(models.Model): column2="partner_id", ) - application_set_id = fields.Many2one( - comodel_name="argocd.application.set", - help="The application set in which this product will be deployed.", + allowed_reseller_partner_ids = fields.Many2many( + comodel_name="res.partner", + relation="product_reseller_all_rel", + column1="product_template_id", + column2="partner_id", ) diff --git a/argocd_sale/models/res_partner.py b/argocd_sale/models/res_partner.py index 99b2230..b808a45 100644 --- a/argocd_sale/models/res_partner.py +++ b/argocd_sale/models/res_partner.py @@ -7,13 +7,42 @@ class ResPartner(models.Model): reselling_product_ids = fields.Many2many( comodel_name="product.template", relation="product_reseller_rel", - string="Resellers", + string="Reselling Products", + column1="partner_id", + column2="product_template_id", + ) + allowed_reselling_products_ids = fields.Many2many( + comodel_name="product.template", + compute="_compute_allowed_reselling_products_ids", + store=True, + relation="product_reseller_all_rel", column1="partner_id", column2="product_template_id", ) is_reseller = fields.Boolean(compute="_compute_is_reseller") - @api.depends("reselling_product_ids") + @api.depends( + "reselling_product_ids", "parent_id", "parent_id.reselling_product_ids" + ) + def _compute_allowed_reselling_products_ids(self): + for partner in self: + allowed_reselling_products_ids = partner.reselling_product_ids + if partner.parent_id: + allowed_reselling_products_ids += ( + partner.parent_id.reselling_product_ids + ) # Notice no recursion + partner.allowed_reselling_products_ids = allowed_reselling_products_ids + + @api.depends("allowed_reselling_products_ids") def _compute_is_reseller(self): for partner in self: - partner.is_reseller = bool(partner.reselling_product_ids) + partner.is_reseller = bool(partner.allowed_reselling_products_ids) + + def to_valid_subdomain(self): + self.ensure_one() + replacements = {" ": "-", ".": "", "&": "-", "_": "-"} + name = self.display_name + name = name.strip().lower() + for replace in replacements: + name = name.replace(replace, replacements[replace]) + return "".join(c for c in name if c.isalnum() or c == "-") diff --git a/argocd_sale/models/sale_subscription_line.py b/argocd_sale/models/sale_subscription_line.py new file mode 100644 index 0000000..0a754c3 --- /dev/null +++ b/argocd_sale/models/sale_subscription_line.py @@ -0,0 +1,88 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class SubscriptionLine(models.Model): + _inherit = "sale.subscription.line" + + application_ids = fields.One2many( + comodel_name="argocd.application", + inverse_name="subscription_line_id", + help="This is essentially a one2one as subscription_line_id" + " is unique in argocd.application", + ) + + def _to_application_name(self): + self.ensure_one() + replacements = {" ": "-", ".": "", "&": "-"} + # It's not possible to have more than one application linked to a + # subscription line because of the sql constraint. + # Let's assume that here. + product = self.product_id + partner = self.sale_subscription_id.partner_id.commercial_partner_id + name = "-".join([partner.display_name, product.default_code or product.name]) + name = name.strip().lower() + for replace in replacements: + name = name.replace(replace, replacements[replace]) + return "".join(c for c in name if c.isalnum() or c == "-") + + def write(self, vals): + to_redeploy = self.env["argocd.application"] + if "product_id" in vals: + product = self.env["product.product"].browse(vals["product_id"]) + # TODO: Enforce only up here e.g. 50GB to 20GB in some cases should not be allowed, but 5 users to 4 should + changed_lines = self.filtered( + lambda l: l.application_ids and product != l.product_id + ) + invalid_changes = changed_lines.filtered( + lambda l: product.product_tmpl_id != l.product_id.product_tmpl_id + ) + # TODO: Enforce only up here e.g. 50GB to 20GB in some cases should not be allowed, but 5 users to 4 should + if invalid_changes: + raise UserError( + _( + "This variant has a different product template, please create a new line and delete this one instead." + ) + ) + to_redeploy += changed_lines.mapped("application_ids") + + if "product_uom_qty" in vals: + qty = int( + vals["product_uom_qty"] + ) # Cast to int just to make sure. I'm not sure if it's required + to_redeploy += self.filtered( + lambda l: l.application_ids and l.product_uom_qty != qty + ).mapped("application_ids") + res = super().write(vals) + for app in to_redeploy: + app.render_config() + app.deploy() + return res + + @api.ondelete(at_uninstall=False) + def _unlink_and_destroy_app(self): + for line in self.filtered(lambda l: l.application_ids): + delta = line.sale_subscription_id.recurring_next_date - fields.Date.today() + line.application_ids.destroy(eta=int(delta.total_seconds())) + + def _invoice_paid_hook(self): + self.ensure_one() + application_sudo = self.env["argocd.application"].sudo() + + if self.application_ids or not self.product_id.application_template_id: + return + + name = application_sudo.find_next_available_name( + self.product_id.application_set_id, self._to_application_name() + ) + application = application_sudo.create( + { + "name": name, + "subscription_line_id": self.id, + "tag_ids": self.product_id.application_tag_ids.ids, + "template_id": self.product_id.application_template_id.id, + "application_set_id": self.product_id.application_set_id.id, + } + ) + application.render_config() + application.deploy() diff --git a/argocd_sale/models/subscription.py b/argocd_sale/models/subscription.py index edc169a..0215118 100644 --- a/argocd_sale/models/subscription.py +++ b/argocd_sale/models/subscription.py @@ -1,9 +1,6 @@ from datetime import timedelta -from dateutil.relativedelta import relativedelta - -from odoo import Command, _, api, fields, models -from odoo.exceptions import ValidationError +from odoo import Command, _, fields, models class Subscription(models.Model): @@ -12,24 +9,12 @@ class Subscription(models.Model): application_ids = fields.One2many( comodel_name="argocd.application", inverse_name="subscription_id" ) + end_partner_id = fields.Many2one(comodel_name="res.partner") + application_count = fields.Integer(compute="_compute_application_count") - free_trial_end_date = fields.Date() - - def create_invoice(self): - res = super().create_invoice() - if ( - self.sale_subscription_line_ids.filtered( - lambda l: l.product_id.application_template_id - ) - and len(self.invoice_ids) == 1 - ): - # Set price of the first invoice to 1.00 - free_period = self._get_free_period() - if free_period: - self.invoice_ids.invoice_line_ids.filtered( - lambda l: l.product_id.application_template_id - ).price_unit = 1.0 - return res + def _compute_application_count(self): + for sub in self: + sub.application_count = len(sub.application_ids) def _get_grace_period(self): return int( @@ -38,66 +23,22 @@ def _get_grace_period(self): .get_param("argocd_sale.grace_period", "0") ) - @api.constrains("sale_subscription_line_ids") - def _check_multiple_application_products(self): - app_lines = self.sale_subscription_line_ids.filtered( - lambda l: l.product_id.application_template_id - ) - if len(app_lines) > 1: - raise ValidationError( - _("Subscription can only have one application, please remove one") - ) - - def _customer_name_to_application_name(self): - self.ensure_one() - replacements = {" ": "-", ".": "", "&": "-"} - partner = self.partner_id.commercial_partner_id - name = partner.display_name - name = name.strip().lower() - for replace in replacements: - name = name.replace(replace, replacements[replace]) - return "".join(c for c in name if c.isalnum() or c == "-") - def _invoice_paid_hook(self): - application_sudo = self.env["argocd.application"].sudo() - - for subscription in self.filtered( - lambda i: len(i.invoice_ids) == 1 - and i.sale_subscription_line_ids.filtered( + for subscription in self: + # Start the subscription, which is not done by subscription_oca, so we do it here for our purposes + # this probably should be moved to subscription_oca. + if len( + subscription.invoice_ids + ) == 1 and subscription.sale_subscription_line_ids.filtered( lambda l: l.product_id.application_template_id - ) - ): # Create the application after the first invoice has been paid - free_period = subscription._get_free_period() - if free_period: - today = fields.Datetime.today() - subscription.recurring_next_date = today + free_period - subscription.free_trial_end_date = today + free_period - subscription.action_start_subscription() + ): + subscription.action_start_subscription() + lines = subscription.sale_subscription_line_ids.filtered( lambda l: l.product_id.application_template_id ) for line in lines: - name = application_sudo.find_next_available_name( - self._customer_name_to_application_name() - ) - tags = subscription.sale_subscription_line_ids.filtered( - lambda l: l.product_id.application_tag_ids - and not l.product_id.application_filter_ids # All lines with modules linked to them - or line.product_id.application_template_id # If there's no filter - in l.product_id.application_filter_ids # If there's a filter - ).mapped("product_id.application_tag_ids") - - application = application_sudo.create( - { - "name": name, - "subscription_id": subscription.id, - "tag_ids": tags.ids, - "template_id": line.product_id.application_template_id.id, - "application_set_id": line.product_id.application_set_id.id, - } - ) - application.render_config() - application.deploy() + line._invoice_paid_hook() def _do_grace_period_action(self): """ @@ -109,7 +50,8 @@ def _do_grace_period_action(self): "argocd_sale.grace_period_action" ) if not grace_period_action: - return False # Do nothing + return False + linked_apps = self.mapped("sale_subscription_line_ids.application_ids") if grace_period_action == "add_tag": grace_period_tag_id = int( self.env["ir.config_parameter"].get_param( @@ -121,70 +63,46 @@ def _do_grace_period_action(self): tag = self.env["argocd.application.tag"].browse(grace_period_tag_id) if not tag: return False - self.mapped("application_ids").write({"tag_ids": [Command.link(tag.id)]}) + linked_apps.write({"tag_ids": [Command.link(tag.id)]}) elif grace_period_action == "destroy_app": - self.mapped("application_ids").destroy() + linked_apps.destroy() return True - def cron_update_payment_provider_subscriptions(self): + def cron_update_payment_provider_payments(self): # Process last payments first because in here paid_for_date can be updated - res = super().cron_update_payment_provider_subscriptions() + res = super().cron_update_payment_provider_payments() period = self._get_grace_period() if not period: return res today = fields.Date.today() late_date = today - timedelta(days=period) late_subs = self.search( - [ - ("paid_for_date", "<", late_date), - ("in_progress", "=", True), - "|", - ("free_trial_end_date", "<", today), - ("free_trial_end_date", "=", False), - ] + [("paid_for_date", "<", late_date), ("in_progress", "=", True)] ) - if late_subs.filtered( - lambda s: not s.free_trial_end_date - or s.free_trial_end_date + timedelta(days=period) < today - ): - late_subs.close_subscription() - late_subs._do_grace_period_action() + for late_sub in late_subs: + late_sub.with_context( + no_destroy_app=True + ).close_subscription() # no_destroy_app since we're doing the grace period action after this. + late_subs._do_grace_period_action() return res - def _get_free_period(self): - self.ensure_one() - if ( - self.partner_id.is_reseller - or self.partner_id.parent_id - and self.partner_id.parent_id.is_reseller - ): - return None - existing_subs = self.partner_id.subscription_ids - if self.partner_id.parent_id: - existing_subs += self.partner_id.parent_id.subscription_ids - existing_subs -= self - if existing_subs: - return None - sudo_config = self.env["ir.config_parameter"].sudo() - free_period = int( - sudo_config.get_param("argocd_sale.subscription_free_period", "0") - ) - if not free_period: - return None - free_period_type = sudo_config.get_param( - "argocd_sale.subscription_free_period_type" - ) - valid_period_types = ( - "years", - "months", - "days", - "leapdays", - "weeks", - "hours", - "minutes", - "seconds", - "microseconds", - ) - if free_period_type not in valid_period_types: - return None - return relativedelta(**{free_period_type: free_period}) + def close_subscription(self, close_reason_id=False): + if not self.env.context.get( + "no_destroy_app", False + ): # This is fine since portal users don't have write access on sale.subscription and the super writes the record + # Destroy app + self.ensure_one() + delta = self.recurring_next_date - fields.Date.today() + for line in self.filtered(lambda l: l.application_ids): + line.application_ids.destroy(eta=int(delta.total_seconds())) + return super().close_subscription(close_reason_id) + + def view_applications(self): + return { + "name": _("Applications"), + "view_type": "form", + "view_mode": "tree,form", + "res_model": "argocd.application", + "type": "ir.actions.act_window", + "domain": [("id", "in", self.application_ids.ids)], + } diff --git a/argocd_sale/tests/__init__.py b/argocd_sale/tests/__init__.py index 1228ca3..aa69326 100644 --- a/argocd_sale/tests/__init__.py +++ b/argocd_sale/tests/__init__.py @@ -1,5 +1,3 @@ from . import test_reseller from . import test_grace_period -from . import test_free_period -from . import test_both_periods_in_conjuction from . import test_invoice_to_application diff --git a/argocd_sale/tests/test_both_periods_in_conjuction.py b/argocd_sale/tests/test_both_periods_in_conjuction.py deleted file mode 100644 index 7d3a99e..0000000 --- a/argocd_sale/tests/test_both_periods_in_conjuction.py +++ /dev/null @@ -1,112 +0,0 @@ -from dateutil.relativedelta import relativedelta - -from odoo import Command, fields -from odoo.tests.common import TransactionCase - - -class TestFreePeriod(TransactionCase): - @classmethod - def setUpClass(cls): - super(TestFreePeriod, cls).setUpClass() - cls.sub_product_tmpl = cls.env.ref( - "argocd_sale.demo_curq_basis_product_template" - ) - cls.sub_product = cls.sub_product_tmpl.product_variant_ids[0] - cls.sub_tmpl = cls.env.ref("argocd_sale.demo_subscription_template") - cls.partner_id = cls.env.ref("base.partner_admin") - cls.disable_odoo_tag = cls.env["argocd.application.tag"].create( - {"name": "Disable Odoo", "key": "disable_odoo", "is_odoo_module": True} - ) - cls.grace_period = 7 - cls.free_period = 14 - cls.env["ir.config_parameter"].set_param( - "argocd_sale.grace_period", cls.grace_period - ) - cls.env["ir.config_parameter"].set_param( - "argocd_sale.grace_period_tag_id", cls.disable_odoo_tag.id - ) - cls.env["ir.config_parameter"].set_param( - "argocd_sale.grace_period_action", "add_tag" - ) - cls.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period", cls.free_period - ) - cls.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period_type", "days" - ) - - def test_in_trial(self): - """Subs in trial shouldn't be affected by grace period""" - sub = self.env["sale.subscription"].create( - { - "template_id": self.sub_tmpl.id, - "sale_subscription_line_ids": [ - Command.create({"product_id": self.sub_product.id}) - ], - "partner_id": self.partner_id.id, - "pricelist_id": self.partner_id.property_product_pricelist.id, - } - ) - sub.generate_invoice() - sub._invoice_paid_hook() # Trial end date should be today + 7 days - sub.paid_for_date = fields.Date.today() - relativedelta( - days=self.grace_period + 1 - ) # Make sure it's out of the grace period - - self.env["sale.subscription"].cron_update_payment_provider_subscriptions() - self.assertNotIn( - self.disable_odoo_tag, - sub.mapped("application_ids.tag_ids"), - "This sub is in the trial period and shouldn't be affected by grace period", - ) - - def test_just_out_of_trial(self): - """Subs out of trial but not passed the grace period shouldn't be affected by grace period""" - sub = self.env["sale.subscription"].create( - { - "template_id": self.sub_tmpl.id, - "sale_subscription_line_ids": [ - Command.create({"product_id": self.sub_product.id}) - ], - "partner_id": self.partner_id.id, - "pricelist_id": self.partner_id.property_product_pricelist.id, - } - ) - sub.generate_invoice() - sub._invoice_paid_hook() - sub.free_trial_end_date = fields.Date.today() - relativedelta( - days=2 - ) # Two days ago the free trial ended - sub.paid_for_date = sub.free_trial_end_date - relativedelta( - days=self.free_period - ) # But no payment has been made yet (< grace period) - # A payment must be made before trial period end + grace_period - self.env["sale.subscription"].cron_update_payment_provider_subscriptions() - self.assertNotIn( - self.disable_odoo_tag, - sub.mapped("application_ids.tag_ids"), - "This sub is out of the trial period but grace period has not passed so shouldn't be affected by grace period", - ) - - def test_out_of_trial(self): - """Subs out of trial and pass the grace period should be affected""" - sub = self.env["sale.subscription"].create( - { - "template_id": self.sub_tmpl.id, - "sale_subscription_line_ids": [ - Command.create({"product_id": self.sub_product.id}) - ], - "partner_id": self.partner_id.id, - "pricelist_id": self.partner_id.property_product_pricelist.id, - } - ) - sub.generate_invoice() - sub._invoice_paid_hook() - sub.free_trial_end_date = fields.Date.today() - relativedelta( - days=self.grace_period + 1 - ) # Free trial ended - sub.paid_for_date = sub.free_trial_end_date - relativedelta( - days=self.free_period - ) # To make it realistic the date the trial started - self.env["sale.subscription"].cron_update_payment_provider_subscriptions() - self.assertIn(self.disable_odoo_tag, sub.mapped("application_ids.tag_ids")) diff --git a/argocd_sale/tests/test_free_period.py b/argocd_sale/tests/test_free_period.py deleted file mode 100644 index 849ef35..0000000 --- a/argocd_sale/tests/test_free_period.py +++ /dev/null @@ -1,95 +0,0 @@ -from dateutil.relativedelta import relativedelta - -from odoo import Command, fields -from odoo.tests.common import TransactionCase - - -class TestFreePeriod(TransactionCase): - @classmethod - def setUpClass(cls): - super(TestFreePeriod, cls).setUpClass() - cls.sub_product_tmpl = cls.env.ref( - "argocd_sale.demo_curq_basis_product_template" - ) - cls.sub_product = cls.sub_product_tmpl.product_variant_ids[0] - cls.sub_tmpl = cls.env.ref("argocd_sale.demo_subscription_template") - cls.partner_id = cls.env.ref("base.partner_admin") - cls.sub_product.list_price = 30.0 # Make sure it's not 1.0 - - def test_disabled(self): - self.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period", 3 - ) - self.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period_type", "" # This should disable it. - ) - sub = self.env["sale.subscription"].create( - { - "template_id": self.sub_tmpl.id, - "sale_subscription_line_ids": [ - Command.create({"product_id": self.sub_product.id}) - ], - "partner_id": self.partner_id.id, - "pricelist_id": self.partner_id.property_product_pricelist.id, - } - ) - sub.generate_invoice() - sub._invoice_paid_hook() - self.assertEqual( - sub.invoice_ids.amount_untaxed, - 30.0, - "Free period should be disabled", - ) - - def test_next_payment_date_and_price(self): - self.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period", 3 - ) - self.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period_type", "months" - ) - expected_next_payment_date = fields.Date.today() + relativedelta(months=3) - sub = self.env["sale.subscription"].create( - { - "template_id": self.sub_tmpl.id, - "sale_subscription_line_ids": [ - Command.create({"product_id": self.sub_product.id}) - ], - "partner_id": self.partner_id.id, - "pricelist_id": self.partner_id.property_product_pricelist.id, - } - ) - sub.generate_invoice() - sub._invoice_paid_hook() - self.assertEqual(sub.recurring_next_date, expected_next_payment_date) - self.assertEqual( - sub.invoice_ids.amount_untaxed, - 1.0, - "Invoice amount of first invoice must be 1.0", - ) - - sub.generate_invoice() - sub._invoice_paid_hook() - self.assertEqual( - sub.invoice_ids[0].amount_untaxed, # It's order newest to oldest - 30.0, - "Next invoice should be the normal price", - ) - - sub2 = self.env["sale.subscription"].create( - { - "template_id": self.sub_tmpl.id, - "sale_subscription_line_ids": [ - Command.create({"product_id": self.sub_product.id}) - ], - "partner_id": self.partner_id.id, - "pricelist_id": self.partner_id.property_product_pricelist.id, - } - ) - sub2.generate_invoice() - sub2._invoice_paid_hook() - self.assertEqual( - sub2.invoice_ids.amount_untaxed, - 30.0, - "Invoice of new second subscription must be the normal price", - ) diff --git a/argocd_sale/tests/test_grace_period.py b/argocd_sale/tests/test_grace_period.py index 69f6f87..fd946b7 100644 --- a/argocd_sale/tests/test_grace_period.py +++ b/argocd_sale/tests/test_grace_period.py @@ -28,9 +28,6 @@ def setUpClass(cls): cls.env["ir.config_parameter"].set_param( "argocd_sale.grace_period_tag_id", cls.disable_odoo_tag.id ) - cls.env["ir.config_parameter"].set_param( - "argocd_sale.subscription_free_period_type", "" # Disable free period - ) def _create_and_prepare_sub(self, paid_for_date=False, create_invoice=True): sub = self.env["sale.subscription"].create( diff --git a/argocd_sale/tests/test_invoice_to_application.py b/argocd_sale/tests/test_invoice_to_application.py index 95f02e8..57e7152 100644 --- a/argocd_sale/tests/test_invoice_to_application.py +++ b/argocd_sale/tests/test_invoice_to_application.py @@ -20,8 +20,6 @@ def _create_subscription(self): "template_id": self.env.ref( "argocd_deployer.application_set_template_default" ).id, - "domain_format": "1", - "subdomain_format": "2", } ) product.product_tmpl_id.application_set_id = application_set diff --git a/argocd_sale/views/application_template_view.xml b/argocd_sale/views/application_template_view.xml deleted file mode 100644 index 57af979..0000000 --- a/argocd_sale/views/application_template_view.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - argocd.application.template - - - - - - - - diff --git a/argocd_sale/views/application_view.xml b/argocd_sale/views/application_view.xml index 89b348d..501cdf1 100644 --- a/argocd_sale/views/application_view.xml +++ b/argocd_sale/views/application_view.xml @@ -6,16 +6,8 @@ - - - - + + + + diff --git a/argocd_website/__manifest__.py b/argocd_website/__manifest__.py index f7671e5..3deee1e 100644 --- a/argocd_website/__manifest__.py +++ b/argocd_website/__manifest__.py @@ -8,6 +8,7 @@ "depends": [ "website", "payment", + "account_payment", "argocd_sale", "portal", "auth_signup", @@ -16,8 +17,8 @@ "subscription_portal", ], "data": [ - "data/mail_template_data.xml", "data/ir_config_parameter_data.xml", + "data/ir_cron.xml", "security/ir_model_access.xml", "security/ir_rule.xml", "templates/website.xml", @@ -27,7 +28,10 @@ "assets": { "web.assets_frontend": [ "argocd_website/static/src/js/portal.js", + "argocd_website/static/src/js/website.js", "argocd_website/static/src/scss/portal.scss", + "argocd_website/static/src/scss/website.scss", + "argocd_website/static/src/xml/website.xml", ] }, "external_dependencies": {"python": ["yaml", "requests", "dnspython==2.6.1"]}, diff --git a/argocd_website/controllers/__init__.py b/argocd_website/controllers/__init__.py index 984e838..10bb7ff 100644 --- a/argocd_website/controllers/__init__.py +++ b/argocd_website/controllers/__init__.py @@ -1,2 +1,4 @@ from . import main from . import portal +from . import payment +from . import subscription diff --git a/argocd_website/controllers/main.py b/argocd_website/controllers/main.py index 3072958..7ea0e49 100644 --- a/argocd_website/controllers/main.py +++ b/argocd_website/controllers/main.py @@ -1,12 +1,12 @@ import re -from odoo import Command, _ +from odoo import _, api from odoo.exceptions import ValidationError from odoo.http import Controller, request, route class MainController(Controller): - def _validate(self, post, captcha_enabled): + def _validate(self, post, captcha_enabled, is_public, is_reseller): if captcha_enabled: try: request.env["librecaptcha"].answer( @@ -15,39 +15,115 @@ def _validate(self, post, captcha_enabled): except ValidationError as e: return {"subject": "captcha", "message": str(e)} + if is_public or is_reseller: + if not post["email"]: + return {"subject": "email", "message": _("Email Address is required.")} + if not post["name"]: + return {"subject": "name", "message": _("Company Name is required.")} + if not post["street_name"]: + return {"subject": "street_name", "message": _("Street is required.")} + if not post["street_number"]: + return { + "subject": "street_number", + "message": _("Street Number is required."), + } + if not post["zip"]: + return {"subject": "zip", "message": _("Zip is required.")} + if not post["city"]: + return {"subject": "city", "message": _("City is required.")} + if not post["company_registry"]: + return { + "subject": "company_registry", + "message": _("CoC number is required."), + } + if not re.match( + "^.+\\@(\\[?)[a-zA-Z0-9\\-\\.]+\\.([a-zA-Z]{2,3}|[0-9]{1,3})(\\]?)$", + post["email"], + ): + return {"subject": "email", "message": _("Invalid email address.")} + if "terms_of_use" not in post: return { "subject": "terms_of_use", "message": _("Please accept the terms of use."), } - if not post["email"]: - return {"subject": "email", "message": _("Email Address is required.")} - if not post["name"]: - return {"subject": "name", "message": _("Company Name is required.")} - if not post["street_name"]: - return {"subject": "street_name", "message": _("Street is required.")} - if not post["street_number"]: - return { - "subject": "street_number", - "message": _("Street Number is required."), - } - if not post["zip"]: - return {"subject": "zip", "message": _("Zip is required.")} - if not post["city"]: - return {"subject": "city", "message": _("City is required.")} - if not post["coc"]: - return {"subject": "coc", "message": _("CoC number is required.")} - if not re.match( - "^.+\\@(\\[?)[a-zA-Z0-9\\-\\.]+\\.([a-zA-Z]{2,3}|[0-9]{1,3})(\\]?)$", - post["email"], - ): - return {"subject": "email", "message": _("Invalid email address.")} + if is_public: + existing_users = ( + request.env["res.users"] + .sudo() + .search([("login", "=", post["email"])], count=True) + ) + if existing_users: + return { + "subject": "email", + "message": _("Email address already in use."), + } return False + @route( + "/application/update_subscription_product", + type="json", + auth="public", + website=True, + ) + def update_subscription_product(self, product_template_id, combination): + website = request.website.sudo() + website.update_subscription_product(product_template_id, combination) + + @route( + "/application/remove_subscription_product", + type="json", + auth="public", + website=True, + ) + def remove_subscription_product(self, product_template_id): + website = request.website.sudo() + website.remove_subscription_product(product_template_id) + + @route( + "/application/get_subscription_details", + type="json", + auth="public", + website=True, + ) + def get_subscription_details(self): + website = request.website.sudo() + sub = website.ensure_subscription() + currency = sub.currency_id + return { + "currency_id": sub.currency_id.id, + "amount_tax": sub.amount_tax, + "amount_total": sub.amount_total, + "amount_tax_formatted": currency.format(sub.amount_tax), + "amount_total_formatted": currency.format(sub.amount_total), + "lines": [ + { + "name": line.product_id.product_tmpl_id.name, + "price_base": line.product_id.product_tmpl_id.list_price, + "price_base_formatted": currency.format( + line.product_id.product_tmpl_id.list_price + ), + "variant_values": [ + { + "name": variant.attribute_id.name, + "value": variant.name, + "price_extra": variant.price_extra, + "price_extra_formatted": currency.format( + variant.price_extra + ), + } + for variant in line.product_id.product_template_variant_value_ids + ], + } + for line in sub.sale_subscription_line_ids + ], + } + @route( [ - """/application/additional//request-delete"], - type="http", - auth="user", - website=True, - ) - def portal_my_application_request_delete(self, app_id, **kw): - try: - app_sudo = self._document_check_access("argocd.application", app_id) - except (AccessError, MissingError): - return request.redirect("/my") - app_sudo.request_destroy() - return request.redirect("/my/applications/%s?message=request_deletion" % app_id) - - @http.route( - ["/my/applications//confirm-delete"], - type="http", - auth="user", - website=True, - ) - def portal_my_application_confirm_delete(self, app_id, **kw): - try: - app_sudo = self._document_check_access("argocd.application", app_id) - except (AccessError, MissingError): - return request.redirect("/my/applications") - if not app_sudo.deletion_token: - return request.redirect("/my/applications") - if app_sudo.deletion_token != kw.get("token"): - return request.render( - "argocd_website.error_page", {"message": _("Invalid token")} - ) - if fields.Datetime.now() > app_sudo.deletion_token_expiration: - return request.render( - "argocd_website.error_page", {"message": _("Token expired")} - ) - - if request.httprequest.method == "POST": - try: - app_sudo.confirm_destroy(kw.get("token")) - except ValidationError as e: - return request.render("argocd_website.error_page", {"message": str(e)}) - return request.redirect( - "/my/applications/%s?message=pending_deletion" % app_id - ) - - values = { - "page_name": "Applications", - "app": app_sudo, - "message": kw.get("message"), - } - - return request.render( - "argocd_website.portal_application_confirm_deletion_page", values - ) - @http.route( ["/my/applications//domain-names"], type="http", diff --git a/argocd_website/controllers/subscription.py b/argocd_website/controllers/subscription.py new file mode 100644 index 0000000..abe54b6 --- /dev/null +++ b/argocd_website/controllers/subscription.py @@ -0,0 +1,21 @@ +from odoo.osv import expression + +from odoo.addons.subscription_portal.controllers.main import PortalSubscription + + +class ArgoCDPortalSubscription(PortalSubscription): + def _get_filter_domain(self, kw): + res = super()._get_filter_domain(kw) + res = expression.AND( + [ + res, + [ + "|", + ("website_id", "=", False), + "&", + ("stage_id.type", "!=", "draft"), + ("stage_id", "!=", False), + ], + ] + ) + return res diff --git a/argocd_website/data/ir_config_parameter_data.xml b/argocd_website/data/ir_config_parameter_data.xml index 56093ab..6d0b9ef 100644 --- a/argocd_website/data/ir_config_parameter_data.xml +++ b/argocd_website/data/ir_config_parameter_data.xml @@ -4,4 +4,8 @@ argocd_website.subscription_template_id 0 + + argocd_website.subscription_abandoned_period + 14 + diff --git a/argocd_website/data/ir_cron.xml b/argocd_website/data/ir_cron.xml new file mode 100644 index 0000000..9cf420f --- /dev/null +++ b/argocd_website/data/ir_cron.xml @@ -0,0 +1,13 @@ + + + + ArgoCD Website: Cleanup abandoned subscriptions + + code + model._cron_cleanup_abandoned() + 1 + days + -1 + + + diff --git a/argocd_website/data/mail_template_data.xml b/argocd_website/data/mail_template_data.xml deleted file mode 100644 index baaaa92..0000000 --- a/argocd_website/data/mail_template_data.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - Request Deletion - - {{ object.partner_id.id }} - {{ object.partner_id.email }} - {{ object.partner_id.lang }} - Deletion Request for '{{ object.name }}' - -

Dear Customer,

-
-

Are you sure you want to delete application ""?

-
- Continue -
-
-
diff --git a/argocd_website/models/__init__.py b/argocd_website/models/__init__.py index 63ba269..a885555 100644 --- a/argocd_website/models/__init__.py +++ b/argocd_website/models/__init__.py @@ -1,2 +1,3 @@ from . import application from . import subscription +from . import website diff --git a/argocd_website/models/application.py b/argocd_website/models/application.py index 0f327b9..7b04a34 100644 --- a/argocd_website/models/application.py +++ b/argocd_website/models/application.py @@ -1,15 +1,10 @@ import logging -from datetime import timedelta import requests -import yaml -from yaml import Loader from odoo import _, fields, models from odoo.exceptions import MissingError, ValidationError -from odoo.addons.auth_signup.models.res_partner import random_token - _logger = logging.getLogger(__name__) try: @@ -31,35 +26,15 @@ def _compute_access_url(self): def check_health(self): self.ensure_one() - try: - config = yaml.load(self.config, Loader=Loader) - helm = yaml.load(config["helm"], Loader=Loader) - res = requests.get("https://%s" % self._get_domain(helm)) - except Exception: - return False - return res.ok - - def request_destroy(self): - # We don't want user to easily delete their applications - self.ensure_one() - self.deletion_token = random_token() - self.deletion_token_expiration = fields.Datetime.now() + timedelta(days=1) - - template = self.env.ref("argocd_website.delete_request_mail_template") - template.send_mail(self.id) - - def confirm_destroy(self, token): - self.ensure_one() - if self.deletion_token != token: - raise ValidationError(_("Invalid token")) - if fields.Datetime.now() > self.deletion_token_expiration: - raise ValidationError(_("Token expired")) - - self.deletion_token = False - self.deletion_token_expiration = False - - # Destroy and delete app record - self.destroy() + statuses = [] + urls = self.get_urls() + for url in urls: + try: + response = requests.get(url[0]) + statuses.append(response.ok) + except Exception: + statuses.append(False) + return statuses def dns_cname_check(self, domain, tag_id=None): """ diff --git a/argocd_website/models/subscription.py b/argocd_website/models/subscription.py index f7d0be4..5710367 100644 --- a/argocd_website/models/subscription.py +++ b/argocd_website/models/subscription.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from odoo import fields, models @@ -7,6 +9,7 @@ class Subscription(models.Model): template_id = fields.Many2one( default=lambda self: self._default_subscription_template_id() ) + website_id = fields.Many2one(comodel_name="website") def _default_subscription_template_id(self): website = self.env["website"].get_current_website() @@ -20,9 +23,31 @@ def _default_subscription_template_id(self): template = self.env["sale.subscription.template"].browse(template_id) return template - def _stop_service_hook(self): - res = super()._stop_service_hook() - apps_to_destroy = self.application_ids - for app in apps_to_destroy: - app.destroy() - return res + def _cron_cleanup_abandoned(self): + period = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("argocd_website.subscription_abandoned_period" "0") + ) + if not period: + return + abandoned_date = fields.Datetime.now() - timedelta(days=period) + self.search( + [ + ( + "website_id", + "!=", + False, + "create_date", + "<=", + fields.Datetime.to_string(abandoned_date), + "|", + "stage_id.type", + "=", + "draft", + "stage_id", + "=", + False, + ) + ] + ).unlink() diff --git a/argocd_website/models/website.py b/argocd_website/models/website.py new file mode 100644 index 0000000..fc7a3d9 --- /dev/null +++ b/argocd_website/models/website.py @@ -0,0 +1,92 @@ +from odoo import Command, _, api, models +from odoo.exceptions import UserError +from odoo.http import request + + +class Website(models.Model): + _inherit = "website" + + def reset_subscription(self): + subscription = self.ensure_subscription() + subscription.sale_subscription_line_ids = [Command.clear()] + + def update_subscription_product(self, product_template_id, combination): + product_template = self.env["product.template"].browse(product_template_id) + + if not product_template.application_template_id or not product_template.sale_ok: + raise UserError(_("Product not available")) + + combination = self.env["product.template.attribute.value"].browse(combination) + combination_possible = product_template._is_combination_possible(combination) + if not combination_possible: + raise UserError(_("Combination is not possible")) + product = product_template._get_variant_for_combination(combination) + + subscription = self.ensure_subscription() + existing_line = subscription.sale_subscription_line_ids.filtered( + lambda l: l.product_id.product_tmpl_id == product_template + ) + + if not existing_line: + subscription.sale_subscription_line_ids = [ + Command.create({"product_id": product.id}) + ] + else: + existing_line.product_id = product + subscription._compute_total() # Depends is not correctly set on this method + subscription.invoice_ids.unlink() + + def remove_subscription_product(self, product_template_id): + subscription = self.ensure_subscription() + to_remove = subscription.sale_subscription_line_ids.filtered( + lambda l: l.product_id.product_tmpl_id.id == product_template_id + ) + subscription.sale_subscription_line_ids = [ + Command.unlink(t.id) for t in to_remove + ] + subscription.invoice_ids.unlink() + + @api.returns("sale.subscription") + def ensure_subscription(self): + """ + Get current draft subscription. The draft subscription is like a shopping cart but for a subscription + """ + self.ensure_one() + subscription = self.env["sale.subscription"] + subscription_id = request.session.get( + "subscription_id" + ) # Session is retrieved by cookie, so we don't need to filter on website + if subscription_id: + subscription = subscription.browse(subscription_id) + if ( + not subscription.exists() + or (subscription.stage_id and subscription.stage_id.type != "draft") + or not subscription.website_id + or subscription.invoice_ids + ): + subscription = self.env["sale.subscription"] + + user = request.env.user or self.user_id + partner = user.partner_id.commercial_partner_id + + if not subscription: + draft_stage = self.env["sale.subscription.stage"].search( + [("type", "=", "draft")], order="sequence desc", limit=1 + ) + subscription = subscription.create( + { + "partner_id": partner.id, + "website_id": self.id, + "pricelist_id": partner.property_product_pricelist.id, + "stage_id": draft_stage and draft_stage.id, + "user_id": user.id, + } + ) + request.session["subscription_id"] = subscription.id + else: + subscription.partner_id = partner + subscription.user_id = user + subscription.onchange_partner_id() + subscription.onchange_partner_id_fpos() + + return subscription diff --git a/argocd_website/security/ir_model_access.xml b/argocd_website/security/ir_model_access.xml index ce1e590..3aaa479 100644 --- a/argocd_website/security/ir_model_access.xml +++ b/argocd_website/security/ir_model_access.xml @@ -50,37 +50,13 @@ - - argocd_application_template_portal_access - + + argocd_application_domain_portal_access + - - - argocd_application_template_public_access - - - - - - - - - - - application_tag_portal_access - - - - - - - diff --git a/argocd_website/security/ir_rule.xml b/argocd_website/security/ir_rule.xml index 4fadd69..e894b74 100644 --- a/argocd_website/security/ir_rule.xml +++ b/argocd_website/security/ir_rule.xml @@ -5,13 +5,11 @@ [ - '|', - ('application_tag_ids', '!=', False), + ('sale_ok', '=', True), ('application_template_id', '!=', False), '|', - ('reseller_partner_ids', '=', False), - ('reseller_partner_ids', 'in', [user.partner_id.id]), - ('sale_ok', '=', True), + ('allowed_reseller_partner_ids', '=', False), + ('allowed_reseller_partner_ids', 'in', [user.partner_id.id]), ] @@ -22,13 +20,11 @@ [ - '|', - ('application_tag_ids', '!=', False), + ('sale_ok', '=', True), ('application_template_id', '!=', False), '|', - ('reseller_partner_ids', '=', False), - ('reseller_partner_ids', 'in', [user.partner_id.id]), - ('sale_ok', '=', True), + ('allowed_reseller_partner_ids', '=', False), + ('allowed_reseller_partner_ids', 'in', [user.partner_id.id]), ] @@ -38,7 +34,16 @@ argocd_application_portal_access - ['|', '|', '&', ('partner_id.parent_id', '!=', False), ('partner_id.parent_id', '=', user.partner_id.parent_id.id), ('partner_id', '=', user.partner_id.id), ('partner_id', 'child_of', user.partner_id.id)] + ['|', '|', '&', ('partner_id.parent_id', '!=', False), ('partner_id.parent_id', '=', user.partner_id.parent_id.id), ('partner_id', '=', user.partner_id.id), ('partner_id', 'child_of', user.partner_id.id)] + + + + + + argocd_application_domain_portal_rule + + + ['|', '|', '&', ('application_id.partner_id.parent_id', '!=', False), ('application_id.partner_id.parent_id', '=', user.partner_id.parent_id.id), ('application_id.partner_id', '=', user.partner_id.id), ('application_id.partner_id', 'child_of', user.partner_id.id)] diff --git a/argocd_website/static/src/js/portal.js b/argocd_website/static/src/js/portal.js index 5bf4539..6b66fe7 100644 --- a/argocd_website/static/src/js/portal.js +++ b/argocd_website/static/src/js/portal.js @@ -21,12 +21,16 @@ odoo.define("argocd_website.portal", function (require) { model: "argocd.application", method: "check_health", args: [parseInt(appId, 10)] - }).then(function (healthy) { - $el.removeClass(["fa-spin", "fa-circle-o-notch"]); - if (healthy) { - $el.addClass(["fa-heart", "text-success"]); - } else { - $el.addClass(["fa-times", "text-danger"]); + }).then(function (healthStatuses) { + $el.html(""); + for (var i in healthStatuses) { + var $statusEl = $(""); + if (healthStatuses[i]) { + $statusEl.addClass(["fa-heart", "text-success"]); + } else { + $statusEl.addClass(["fa-times", "text-danger"]); + } + $el.append($statusEl); } }); } diff --git a/argocd_website/static/src/js/website.js b/argocd_website/static/src/js/website.js new file mode 100644 index 0000000..24058fa --- /dev/null +++ b/argocd_website/static/src/js/website.js @@ -0,0 +1,151 @@ +odoo.define("argocd_website.website", function (require) { + "use strict"; + + var publicWidget = require("web.public.widget"); + var { SIZES, uiService } = require('@web/core/ui/ui_service'); + var { qweb } = require("web.core"); + require("website.content.menu"); + + publicWidget.registry.OrderAppProductConfigurator = publicWidget.Widget.extend({ + selector: ".js_order_app_product", + events: { + "change select.js_order_app_attribute": "_onAttributeChange", + "change .js_order_app_attribute input": "_onAttributeChange", + "change .js_order_app_toggle": "_onToggleChange", + "click .js_order_app_product_header": "_onClickHeader" + }, + + init: function (websiteRoot) { + this._super.apply(this, arguments); + this._websiteRoot = websiteRoot; + }, + + start: function () { + this._super.apply(this, arguments); + this.productTemplateId = parseInt(this.$el.attr("data-id"), 10); + this.$attributes = this.$el.find(".js_order_app_attribute"); + this.$toggle = this.$el.find(".js_order_app_toggle"); + this.$header = this.$el.find(".js_order_app_product_header"); + }, + + _isActive: function () { + if (!this.$toggle.length) return true; + return this.$toggle.is(":checked"); + }, + + _onClickHeader: function (ev) { + if (ev.target.nodeName === "INPUT") return; + this.$toggle.click(); + }, + + _onAttributeChange: function () { + // TODO: Look for available combinations, and change the other attribute selectors accordingly + if (!this._isActive()) return; + this._updateSubscriptionProduct(); + }, + + _onToggleChange: function () { + if (this._isActive()) { + this._updateSubscriptionProduct(); + this.$header.addClass("bg-light"); + } else { + this._removeSubscriptionProduct(); + this.$header.removeClass("bg-light"); + } + }, + + _removeSubscriptionProduct: function () { + return this._rpc({ + route: "/application/remove_subscription_product", + params: { + product_template_id: this.productTemplateId + } + }).then(function () { + this._websiteRoot.trigger("refreshSubscription"); + }.bind(this)); + }, + + _updateSubscriptionProduct: function() { + var combination = this.$attributes.map(function () { + var $el = $(this); + var valueId = null; + if ($el.is("select")) { + valueId = $el.val(); + } else { + valueId = $el.find(":checked").val(); + } + return parseInt(valueId, 10); + }).get(); + + return this._rpc({ + route: "/application/update_subscription_product", + params: { + product_template_id: this.productTemplateId, + combination: combination + } + }).then(function () { + this._websiteRoot.trigger("refreshSubscription"); + }.bind(this)); + } + }); + + publicWidget.registry.OrderAppDetails = publicWidget.Widget.extend({ + selector: ".js_order_app_details", + + init: function (websiteRoot) { + this._super.apply(this, arguments); + this._websiteRoot = websiteRoot; + }, + + start: function () { + this._super(); + this.$proceedBtn = this.$el.find(".js_order_app_proceed"); + this.$list = this.$el.find(".js_order_app_list"); + this.$loader = this.$el.find(".spinner-border"); + + this.refreshSubscription(); + this._websiteRoot.on("refreshSubscription", this, this.refreshSubscription); + }, + + refreshSubscription: function () { + this.$loader.removeClass("d-none"); + this.$proceedBtn.addClass("d-none"); + this.$list.addClass("d-none"); + + this._rpc({ + route: "/application/get_subscription_details" + }).then(function (data) { + var details = qweb.render( + "argo_website.List", { + sub: data + } + ); + this.$list.html(details); + + this.$loader.addClass("d-none"); + this.$proceedBtn.removeClass("d-none"); + this.$list.removeClass("d-none"); + this.$proceedBtn.toggleClass("disabled", !data.lines.length); + }.bind(this)); + } + }); + + var UpdateOrderDetailsPaddingMixin = { + start: function () { + var res = this._super(...arguments); + this.$orderDetails = this.$main.find(".js_order_app_details"); + return res; + }, + + _updateMainPaddingTop: function () { + var isLarge = uiService.getSize() >= SIZES.LG; + this.$orderDetails.css("padding-top", this.fixedHeader && this._isShown() && isLarge ? this.headerHeight : ""); + return this._super(...arguments); + } + }; + + publicWidget.registry.StandardAffixedHeader.include(UpdateOrderDetailsPaddingMixin) + + publicWidget.registry.FixedHeader.include(UpdateOrderDetailsPaddingMixin); + +}); diff --git a/argocd_website/static/src/scss/website.scss b/argocd_website/static/src/scss/website.scss new file mode 100644 index 0000000..b2e860e --- /dev/null +++ b/argocd_website/static/src/scss/website.scss @@ -0,0 +1,32 @@ +.js_order_app_product { + > .row { + border: $border-width solid $border-color; + &:not(:last-child) { + border-bottom: 0; + } + + &:last-child { + border-bottom: $border-width solid $border-color; + } + } + + .js_order_app_product_header { + cursor: pointer; + input { + cursor: pointer; + } + } +} + +.js_order_app_details { + top: 1em; +} + + +.argocd_website_order_progress_bar.s_process_steps { + @include media-breakpoint-down(lg) { + .s_process_step .s_process_step_connector { + display: block; + } + } +} diff --git a/argocd_website/static/src/xml/website.xml b/argocd_website/static/src/xml/website.xml new file mode 100644 index 0000000..6e2086c --- /dev/null +++ b/argocd_website/static/src/xml/website.xml @@ -0,0 +1,42 @@ + + diff --git a/argocd_website/templates/portal.xml b/argocd_website/templates/portal.xml index d4d6b2b..f14d9f6 100644 --- a/argocd_website/templates/portal.xml +++ b/argocd_website/templates/portal.xml @@ -35,14 +35,21 @@ @@ -145,25 +140,10 @@ Application - -
- - - - - - +
@@ -186,83 +166,35 @@ Chamber of Commerce #: -
+
Health: - - + +
-
- - - - -
-
- - Your subscription - - - - - excl. VAT - -
-
- - - - - - - - - -
Product / servicePrice
- - / month -
+
+
    +
  • +
    +
    +
    +
    +
    + : + +
    +
    +
  • +
- - - - - - - - - -