diff --git a/saas_apps/README.rst b/saas_apps/README.rst new file mode 100644 index 000000000..95c401776 --- /dev/null +++ b/saas_apps/README.rst @@ -0,0 +1,49 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +=========== + Saas Apps +=========== + +Base module for manage modules in saas. + +Module allows to choose modules that users gona use in their db. + +Credits +======= + +Contributors +------------ +* `Vildan Safin `__ + +Sponsors +-------- +* `IT-Projects LLC `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + + To get a guaranteed support + you are kindly requested to purchase the module + at `odoo apps store `__. + + Thank you for understanding! + + `IT-Projects Team `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/odoo-saas-tools/12.0 + +HTML Description: https://apps.odoo.com/apps/modules/12.0/saas_apps/ + +Usage instructions: ``_ + +Changelog: ``_ + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on Odoo 12.0 feb7c99f47cae55fff77035fe53975ea4a14d624 diff --git a/saas_apps/__init__.py b/saas_apps/__init__.py new file mode 100644 index 000000000..e465a1fcd --- /dev/null +++ b/saas_apps/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import controllers, models diff --git a/saas_apps/__manifest__.py b/saas_apps/__manifest__.py new file mode 100644 index 000000000..aa5bd5a86 --- /dev/null +++ b/saas_apps/__manifest__.py @@ -0,0 +1,54 @@ +# Copyright 2020 Vildan Safin +# License AGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": """SaaS Apps""", + "summary": """Choose your apps""", + "category": "Marketing", + # "live_test_url": "http://apps.it-projects.info/shop/product/DEMO-URL?version=12.0", + "images": ['/images/attention.jpg'], + "version": "12.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Vildan Safin", + "support": "apps@it-projects.info", + "website": "https://apps.odoo.com/apps/modules/12.0/saas_apps/", + "license": "AGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": ['website', 'saas_public', 'website_sale'], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + 'security/ir.model.access.csv', + 'views/calculator.xml', + 'views/manage.xml', + "data/saas_base_template.xml", + "data/saas_apps_product_user.xml", + 'views/assets.xml' + ], + "demo": [ + ], + "qweb": [ + 'views/refresh.xml' + ], + + "post_load": None, + "pre_init_hook": None, + "post_init_hook": None, + "uninstall_hook": None, + + "auto_install": False, + "installable": True, + + # "demo_title": "SaaS Apps", + # "demo_addons": [ + # ], + # "demo_addons_hidden": [ + # ], + # "demo_url": "DEMO-URL", + # "demo_summary": "short", + # "demo_images": [ + # "images/MAIN_IMAGE", + # ] +} diff --git a/saas_apps/controllers/__init__.py b/saas_apps/controllers/__init__.py new file mode 100644 index 000000000..ce9b7f1e6 --- /dev/null +++ b/saas_apps/controllers/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import main diff --git a/saas_apps/controllers/main.py b/saas_apps/controllers/main.py new file mode 100644 index 000000000..184c59010 --- /dev/null +++ b/saas_apps/controllers/main.py @@ -0,0 +1,163 @@ +# Copyright 2020 Vildan Safin +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.http import route, request, Controller +from odoo.addons.saas_public.controllers.saas_public import SaaSPublicController +from odoo.addons.website_sale.controllers.main import WebsiteSale +import urllib.parse + +DB_TEMPLATE = 'template_database_' + +class SaaSAppsController(Controller): + + + @route('/price', type='http', auth='public', website=True) + def user_page(self, **kw): + res = request.env['res.config.settings'].sudo().get_values() + apps = request.env['saas.line'].sudo() + packages = request.env['saas.template'].sudo() + if not apps.search_count([]): + apps.refresh_lines() + return request.render('saas_apps.Price', { + 'apps': apps.search([('allow_to_sell', '=', True)]), + 'packages': packages.search([('set_as_package', '=', True)]), + 'show_apps': bool(res['show_apps']), + 'show_packages': bool(res['show_packages']), + 'show_buy_now_button':bool(res['show_buy_now_button']) + }) + + @route(['/refresh'], type='json', auth='public') + def catch_app_click(self, **kw): + apps = request.env['saas.line'] + apps.refresh_lines() + return {} + + @route(['/what_dependencies'], type='json', auth='public') + def search_incoming_app_dependencies(self, **kw): + app_tech_name = kw.get('root') + app = request.env['saas.line'].sudo().search([('name', '=', app_tech_name)]) + return { + 'dependencies': app.dependencies_info('root') + } + + @route(['/check_currency'], type='json', auth='public') + def what_company_curency_to_use(self, **kw): + apps = request.env['saas.line'].sudo() + return { + 'currency': apps.search([])[0].currency_id.display_name, + 'symbol': apps.search([])[0].currency_id.symbol + } + + @route(['/take_template_id'], type='json', auth='public') + def is_build_created(self, **kw): + package = kw.get('package') + templates = request.env['saas.template'].sudo() + # If package exist, use package saas_template + template = templates.search([('set_as_package', '=', True), ('name', '=', package)]) + if not template: + # If package wasn't selected, use base saas_template + template = templates.search([('set_as_base', '=', True)]) + if not template: + template, saas_template_operator = self.create_new_template() + return { + 'id': template.id, + 'state': 'creating' + } + if not template.operator_ids.random_ready_operator_check(): + return { + 'id': template.id, + 'state': 'creating' + } + return { + 'id': template.id, + 'state': 'ready' + } + + def create_new_template(self): + saas_template = request.env['saas.template'].sudo().create({ + 'name': 'Base', + 'template_demo': True, + 'public_access': True, + 'set_as_base': True, + 'template_module_ids': request.env['saas.module'].sudo().search([('name', '=', 'mail')]), + 'build_post_init': "env['ir.module.module'].search([('name', 'in', {installing_modules})]).button_immediate_install()" + }) + saas_operator = request.env.ref("saas.local_operator") + saas_template_operator = request.env['saas.template.operator'].sudo().create({ + 'template_id': saas_template.id, + 'operator_id': saas_operator.id, + 'operator_db_name': DB_TEMPLATE + str(saas_template.operator_ids.search_count([]) + 1), + }) + saas_template_operator.sudo().preparing_template_next() + return saas_template, saas_template_operator + + @route(['/price/take_product_ids'], type='json', auth='public') + def take_product_ids(self, **kw): + module_names = kw.get('module_names', []) + modules = request.env['saas.line'].sudo() + apps_product_ids = [] + apps = modules.search([('name', 'in', module_names), ('application', '=', True)]) + templates = request.env['saas.template'].sudo().search([('name', 'in', module_names)]) + for app in apps.product_id + templates.product_id: + apps_product_ids.append(app.product_variant_id.id) + + return { + 'ids': apps_product_ids + } + + +class SaasAppsCart(WebsiteSale): + + + @route('/price/cart_update', type='json', auth='public', website=True) + def cart_update_price_page(self, **kw): + period = kw.get('period') + sale_order = request.website.sale_get_order(force_create=True) + product_ids = kw.get('old_apps_ids', []) + # Adding user as product in cart + user_product_tmp = request.env.ref("saas_apps.product_user").sudo() + user_product = user_product_tmp.product_variant_id + user_product.price = kw.get('user_price') + if not period == 'm': + user_product.price *= 12 + old_user_cnt = 0 + if kw.get('old_user_cnt'): + old_user_cnt = float(kw.get('old_user_cnt')) + user_cnt = float(kw.get('user_cnt')) + if not old_user_cnt: + old_user_cnt = 0 + sale_order._cart_update( + product_id=int(user_product.id), + add_qty=(user_cnt - old_user_cnt) + ) + + # Delete old products from cart + for id in product_ids: + sale_order._cart_update( + product_id=int(id), + add_qty=-1 + ) + + # Changing prices + product_ids = kw.get('product_ids', []) + pr_tmp = request.env['product.template'].sudo() + for id in product_ids: + product = pr_tmp.browse(id).product_variant_id + app = request.env['saas.line'].sudo().search([('module_name', '=', product.name)]) + packages = request.env['saas.template'].sudo().search([('name', '=', product.name)]) + if period == 'm': + app.change_product_price(app, app.month_price) + packages.change_product_price(packages, packages.month_price) + else: + app.change_product_price(app, app.year_price) + packages.change_product_price(packages, packages.year_price) + + # Add new ones + for id in product_ids: + sale_order._cart_update( + product_id=int(id), + add_qty=1 + ) + return { + "link": "/shop/cart" + } diff --git a/saas_apps/data/saas_apps_product_user.xml b/saas_apps/data/saas_apps_product_user.xml new file mode 100644 index 000000000..6acba6556 --- /dev/null +++ b/saas_apps/data/saas_apps_product_user.xml @@ -0,0 +1,10 @@ + + + + User + 10 + True + + + diff --git a/saas_apps/data/saas_base_template.xml b/saas_apps/data/saas_base_template.xml new file mode 100644 index 000000000..b5e002663 --- /dev/null +++ b/saas_apps/data/saas_base_template.xml @@ -0,0 +1,27 @@ + + + + Base + True + True + True + env['ir.module.module'].search([('name', 'in', {installing_modules})]).button_immediate_install() + + + demo_template_database + + + + + + Application base icon + base.png + True + + + diff --git a/saas_apps/doc/changelog.rst b/saas_apps/doc/changelog.rst new file mode 100644 index 000000000..5583eb326 --- /dev/null +++ b/saas_apps/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- **Init version** diff --git a/saas_apps/doc/index.rst b/saas_apps/doc/index.rst new file mode 100644 index 000000000..47a5ea1d3 --- /dev/null +++ b/saas_apps/doc/index.rst @@ -0,0 +1,42 @@ +=========== + Saas Apps +=========== + +Installation +============ + +* `Activate longpolling `__ +* +* `Install `__ this module in a usual way + +Configuration +============= + +* `Log in as SUPERUSER `__ +* `Activate Developer Mode `__ +* Open menu ``[[ Website ]] >> Configuration >> Manage Apps`` +* Click ``[ Refresh ]`` +* Choose modules that you want to make saleable by clicking to ``[ Saleable ]`` + +{Instruction for make apps and packages visible on ``Price`` page.} + +* Open menu ``[[ Website ]] >> Configuration >> Settings >> SaaS pricing page`` +* Click ``[[ Show packages ]]`` to show packages +* Click ``[[ Show apps ]]`` to show packages + +Usage +===== + +{Instruction for daily usage. It should describe how to check that module works. What shall user do and what would user get.} + +* Open ``[http://odoo-saas.sh/]`` +* Click ``[{Make new Odoo instance}]`` +* Choose modules that you want to buy by on them +* Choose the using period year/month +* Click ``[Buy now]`` or ``[Try trial]`` +* RESULT: you will be redirected and logged in to the created build with choosen modules + +Uninstallation +============== + +* Open menu ``[[ Apps ]] >> [[SaaS Apps]] >> Uninstall`` diff --git a/saas_apps/models/__init__.py b/saas_apps/models/__init__.py new file mode 100644 index 000000000..b9a95232a --- /dev/null +++ b/saas_apps/models/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import saas_apps diff --git a/saas_apps/models/saas_apps.py b/saas_apps/models/saas_apps.py new file mode 100644 index 000000000..7fbfd0fc5 --- /dev/null +++ b/saas_apps/models/saas_apps.py @@ -0,0 +1,308 @@ +# Copyright 2020 Vildan Safin +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +import logging +from slugify import slugify +import base64 + +_logger = logging.getLogger(__name__) + + +class SAASModule(models.Model): + _inherit = 'saas.module' + + month_price = fields.Float('Month price', default=0.0) + year_price = fields.Float('Year price', default=0.0) + saas_modules = fields.Many2many('saas.line') + + @api.constrains('month_price') + def _validate_month_price(self): + if self.month_price < 0: + raise ValidationError("Month price can't be negative.") + + @api.constrains('year_price') + def _validate_year_price(self): + if self.year_price < 0: + raise ValidationError("Year price can't be negative.") + + @api.multi + def write(self, vals): + res = super(SAASModule, self).write(vals) + if 'month_price' in vals or 'year_price' in vals: + for line in self.saas_modules: + line.compute_price() + for template in self.template_ids: + template.compute_price() + return res + + def refresh_modules(self): + for app in map(self.browse, self._search([])): + app.unlink() + irmodules = self.env["ir.module.module"].search([]) + for irmodule in irmodules: + if self.search_count([('name', '=', irmodule.name)]) == 0: + self.create({'name': irmodule.name}) + + +class SAASDependence(models.Model): + _name = 'saas.line' + _description = 'Module dependencies' + + # First dependence is root module + name = fields.Char(default="default", string="Module technical name") + module_name = fields.Char(default="default", string="Module name") + icon_path = fields.Char(string="Icon path") + allow_to_sell = fields.Boolean(default=True, string="Sellable") + dependencies = fields.Many2many('saas.module') + year_price = fields.Float(default=0.0, string="Price per year") + month_price = fields.Float(default=0.0, string="Price per month") + application = fields.Boolean(default=False, string="Application") + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda s: s.env.user.company_id, + ) + currency_id = fields.Many2one("res.currency", compute="_compute_currency_id") + product_id = fields.Many2many('product.template') + + def _compute_currency_id(self): + self.currency_id = self.company_id.currency_id + + app_image = fields.Binary( + string='App image' + ) + + def refresh_lines(self): + apps = self.env["saas.module"] + for line in map(self.browse, self._search([])): + line.unlink() + apps.refresh_modules() + base_icon = self.env["ir.module.module"].search([('name', '=', 'base')]).icon_image + for app in apps.search([]): + if self.search_count([('name', '=', app.name)]) == 0: + ir_module_obj = self.env["ir.module.module"].get_module_info(app.name) + if len(ir_module_obj): + new = self.create({ + 'name': app.name, + 'module_name': ir_module_obj['name'], + 'app_image': self.env["ir.module.module"].search([('name', '=', app.name)]).icon_image, + 'application': ir_module_obj['application'] + }) + new.dependencies = app + apps.search([('name', 'in', ir_module_obj['depends'])]) + else: + new = self.create({ + 'name': app.name, + 'module_name': app.name, + 'app_image': base_icon + }) + + @api.multi + def make_product(self, app): + prod_templ = self.env["product.template"] + ready_product = prod_templ.search([('name', '=', app.module_name)]) + if ready_product: + if not len(app.product_id): + app.product_id += ready_product + elif not app.application: + return + else: + app.product_id += prod_templ.create({ + 'name': app.module_name, + 'price': app.year_price, + 'image_1920': app.app_image, + 'website_published': True + }) + + def change_product_price(self, app, price): + if len(app) == 1: + app.product_id.price = price + + def compute_price(self): + sum = 0 + for module in self.dependencies: + sum += module.year_price + self.year_price = sum + self.change_product_price(self, sum) + sum = 0 + for module in self.dependencies: + sum += module.month_price + self.month_price = sum + + def dependencies_info(self, root): + apps = [] + childs = [] + saas_module = self.dependencies.search([('name', '=', self.name)]) + for child in self.dependencies - saas_module: + if self.search_count([('name', '=', child.name)]): + childs.append(child.name) + apps.append({ + 'parent': root, + 'name': self.name, + 'childs': childs, + 'application': self.application + }) + # Looking to the period + for app in self.dependencies - saas_module: + set = self.search([('name', '=', app.name)]) + if len(set) == 0: + continue + leafs = set.dependencies_info(self.name) + for leaf in leafs: + if not(leaf in apps): + apps.append(leaf) + return apps + + def change_allow_to_sell(self, vals, used_apps): + if not vals['allow_to_sell']: + vals['used'] = [self] + used_apps + apps = self.dependencies.filtered(lambda r: r.name == self.name).saas_modules + for app in apps - self: + if not app in vals['used']: + app.write(vals) + else: + this_app = self.dependencies.search([('name', '=', self.name)]) + for app in self.dependencies - this_app: + temp_app = self.search([('name', '=', app.name)]) + if len(temp_app) > 0: + temp_app.allow_to_sell = True + + @api.multi + def write(self, vals): + used_apps = [] + if 'used' in vals: + used_apps = vals['used'] + del vals['used'] + res = super(SAASDependence, self).write(vals) + # If value of allow_to_sell changed, other sets allow_to_sell vars should be changed too + if "allow_to_sell" in vals: + self.change_allow_to_sell(vals, used_apps) + if len(self.product_id) == 1: + self.product_id.website_published = vals['allow_to_sell'] + if "year_price" in vals: + self.change_product_price(self, vals["year_price"]) + if "month_price" in vals: + self.year_price = self.month_price*12 + return res + + @api.model + def create(self, vals): + res = super(SAASDependence, self).create(vals) + if "module_name" in vals: + self.make_product(res) + return res + + +class SAASTemplateLine(models.Model): + _inherit = 'saas.template.operator' + + @api.multi + def random_ready_operator_check(self): + ready_operators = self.filtered(lambda r: r.state == 'done') + if len(ready_operators) > 0: + return True + return False + + +class SAASAppsTemplate(models.Model): + _inherit = 'saas.template' + + set_as_base = fields.Boolean("Base template") + set_as_package = fields.Boolean("Package") + month_price = fields.Float() + year_price = fields.Float() + product_id = fields.Many2many('product.template', ondelete='cascade') + + def _compute_default_image(self): + return self.env.ref("saas_apps.saas_apps_base_image").datas + + package_image = fields.Binary( + string='Package image' + ) + + @api.onchange('set_as_base') + def change_base_template(self): + old_base_template = self.search([('set_as_base', '=', True)]) + if old_base_template: + old_base_template.write({'set_as_base': False}) + + def compute_price(self): + sum = 0 + for module in self.template_module_ids: + sum += module.year_price + self.year_price = sum + self.change_product_price(self, sum) + sum = 0 + for module in self.template_module_ids: + sum += module.month_price + self.month_price = sum + + def change_product_price(self, package, price): + if len(package) == 1: + package.product_id.price = price + + def compute_year_price(self, vals): + if "month_price" in vals: + self.year_price = self.month_price*12 + + @api.model + def create(self, vals): + res = super(SAASAppsTemplate, self).create(vals) + if res.set_as_package: + res.package_image = self._compute_default_image() + if not (res.year_price + res.month_price): + res.compute_price() + prod = self.env['product.template'] + ready_product = prod.search([('name', '=', res.name)]) + if ready_product: + if not len(res.product_id): + res.product_id += ready_product + else: + res.product_id += prod.create({ + 'name': res.name, + 'price': res.year_price, + 'image_1920': res.package_image, + 'website_published': True + }) + res.compute_year_price(vals) + return res + + def write(self, vals): + res = super(SAASAppsTemplate, self).write(vals) + if "year_price" in vals: + self.change_product_price(self, self.year_price) + self.compute_year_price(vals) + return res + + +class SAASProduct(models.Model): + _inherit = 'product.template' + + application = fields.Many2many('saas.line') + package = fields.Many2many('saas.template') + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + show_packages = fields.Boolean('Show packages', + config_parameter='saas_apps.show_packages') + show_apps = fields.Boolean('Show apps', + config_parameter='saas_apps.show_apps') + show_buy_now_button = fields.Boolean("Show 'Buy now' button", + config_parameter='saas_apps.show_buy_now_button') + + @api.model + def get_values(self): + res = super(ResConfigSettings, self).get_values() + select_type = self.env['ir.config_parameter'].sudo() + packages = select_type.get_param('saas_apps.show_packages') + apps = select_type.get_param('saas_apps.show_apps') + buy_now_button = select_type.get_param('saas_apps.show_buy_now_button') + res.update({ + 'show_packages' : packages, + 'show_apps' : apps, + 'show_buy_now_button' : buy_now_button + }) + return res diff --git a/saas_apps/security/ir.model.access.csv b/saas_apps/security/ir.model.access.csv new file mode 100644 index 000000000..a0ebbea3f --- /dev/null +++ b/saas_apps/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_saas_line,access_saas_line,model_saas_line,base.group_user,1,1,1,1 +access_saas_module,access_saas_module,model_saas_module,base.group_user,1,1,1,1 diff --git a/saas_apps/static/description/icon.png b/saas_apps/static/description/icon.png new file mode 100644 index 000000000..6c7d8a3ca Binary files /dev/null and b/saas_apps/static/description/icon.png differ diff --git a/saas_apps/static/src/css/calculator.css b/saas_apps/static/src/css/calculator.css new file mode 100644 index 000000000..27bdbf7be --- /dev/null +++ b/saas_apps/static/src/css/calculator.css @@ -0,0 +1,169 @@ +.col-lg-12{ + flex: 0 0 100%; + max-width: 100%; +} + +#price{ + display: inline; +} + +.pricing-card-title{ + font-size: 32px; +} + +.users-qty-change-buttons{ + max-width: 30px; + max-height: 30px; + opacity: 0.5; +} + +.loader{ + position: fixed; + z-index: 99; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: white; + display: flex; + justify-content: center; + align-items: center; + opacity: 0.5; +} + +.status{ + font-size: 26px; + color: black; + position: fixed; + margin-top: -70px; +} + +.loader > img { + width: 100px; + opacity: 0.6; + transform: rotate(-30deg); +} + +.transition { + transition: 0.3s; +} + +.app{ + max-height: 70px; + display: flex; + margin-bottom: 10px; + min-width: 174px; +} + +.app-data{ + margin-left: 5%; +} + +.price-value{ + display: inline; +} + +.period{ + display: inline; +} + +.green-border{ + border: 2px solid green; +} + +.normal-border{ + border: 2px solid #FFFFFF; +} + +.hid{ + display: none; +} + +.fnt-24{ + font-size: 24px; +} + +.leftstr, .rightstr { + float: left; + width: 50%; +} + +.rightstr { + text-align: right; +} + +.fnt-larger{ + font-size: larger; +} + +@media (min-width: 1400px) { + #price-window + { + position: fixed; + right: 30%; + top: 20%; + width: 15%; + min-width: 20%; + z-index: 1; + } + + .page-alignment{ + min-width: 640px; + } + + .main-column{ + margin-right: auto; + margin-left: 10%; + max-width: 50%; + } +} + +@media (min-width: 999px) { + #price-window + { + position: fixed; + right: 15%; + top: 15%; + width: 20%; + z-index: 1; + } + + .main-column{ + margin-right: auto; + margin-left: 10%; + max-width: 45%; + } + + .page-alignment{ + min-width: 640px; + } + + .app{ + margin-left: 7px; + max-width: 31%; + } +} + +@media (max-width: 799px) { + #price-window + { + position: fixed; + right: 0; + bottom: -6%; + width: 100%; + z-index: 1; + } + .container{ + margin-left: auto !important; + } + + .main-column{ + margin-right: auto; + margin-left: 10%; + max-width: 80%; + } +} + +.col-lg-9{ + padding-left: 0px; +} \ No newline at end of file diff --git a/saas_apps/static/src/img/add-users.png b/saas_apps/static/src/img/add-users.png new file mode 100644 index 000000000..517a363a3 Binary files /dev/null and b/saas_apps/static/src/img/add-users.png differ diff --git a/saas_apps/static/src/img/base.png b/saas_apps/static/src/img/base.png new file mode 100644 index 000000000..995e7461e Binary files /dev/null and b/saas_apps/static/src/img/base.png differ diff --git a/saas_apps/static/src/img/default.png b/saas_apps/static/src/img/default.png new file mode 100644 index 000000000..abf1e5d8d Binary files /dev/null and b/saas_apps/static/src/img/default.png differ diff --git a/saas_apps/static/src/img/loader.gif b/saas_apps/static/src/img/loader.gif new file mode 100644 index 000000000..ee9b1db7a Binary files /dev/null and b/saas_apps/static/src/img/loader.gif differ diff --git a/saas_apps/static/src/img/starter_pack.png b/saas_apps/static/src/img/starter_pack.png new file mode 100644 index 000000000..4e1a2f2ec Binary files /dev/null and b/saas_apps/static/src/img/starter_pack.png differ diff --git a/saas_apps/static/src/img/substr-users.png b/saas_apps/static/src/img/substr-users.png new file mode 100644 index 000000000..b64e7dd9e Binary files /dev/null and b/saas_apps/static/src/img/substr-users.png differ diff --git a/saas_apps/static/src/img/user.png b/saas_apps/static/src/img/user.png new file mode 100644 index 000000000..46fb6f009 Binary files /dev/null and b/saas_apps/static/src/img/user.png differ diff --git a/saas_apps/static/src/js/apps.js b/saas_apps/static/src/js/apps.js new file mode 100644 index 000000000..2d02dd73e --- /dev/null +++ b/saas_apps/static/src/js/apps.js @@ -0,0 +1,494 @@ +/* Copyright 2020 Vildan Safin + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).*/ +odoo.define('saas_apps.model', function (require) { + 'use_strict'; + + var session = require('web.session'); + var base = require('web_editor.base'); + + var price = 0, + per_month = false, + choosen = new Map(), + parent_tree = new Map(), + child_tree = new Map(), + apps_in_basket = 0, + currency = "", + currency_symbol = ""; + + function calc_apps_price() { + price = 0; + for (var value of choosen.values()) { + price += value; + } + return price; + } + + function user_price() { + return per_month ? 12.5 : 10.0; + } + + function Calc_Price() { + // Calculate general price + if($('#users')[0] !== undefined) + return calc_apps_price() + parseInt($('#users')[0].value, 10) * user_price(); + return 0; + } + + function get_choosen_package() { + var packages = []; + $.each($('.package'), function (index, value) { + packages.push(value.children[2].innerText); + }); + for (var key of choosen.keys()) { + if (packages.includes(key)) { + return key; + } + } + } + + function redirect_to_build(modules_to_install) { + // Checking for choosen packages + var package = get_choosen_package(); + // If the package selected, we'll use packages saas_template to create build + if (!package) { + // If package wasn't selected, then collect choosen modules + if (!modules_to_install) { + modules_to_install = '?installing_modules=[' + // Collecting choosen modules in string + for (var key of choosen.keys()) { + modules_to_install += ',' + modules_to_install += '"' + String(key) + '"'; + } + modules_to_install += ']'; + // Deleting extra coma + modules_to_install = modules_to_install.replace(',', ''); + } + if (!choosen.size) { + alert("You haven't chosen any application!") + return; + // modules_to_install = '?installing_modules=["mail"]'; + } + } + go_to_build(modules_to_install, package); + } + + function go_to_build(modules_to_install, package) { + // Checking for choosen packages + if (package) modules_to_install = ''; + session.rpc('/take_template_id', { + package: package + }).then(function (template) { + if (template.state === 'ready') { + console.log("Redirect to: " + "/saas_public/" + template.id + "/create-fast-build" + modules_to_install); + // When there's ready saas_template_operator obj, then start creating new build + window.location.href = "/saas_public/" + template.id + "/create-fast-build" + modules_to_install; + } else { + // If there's no ready saas_template_operator, + // recalling this func till the saas_template_operator obj isn't ready + setTimeout(redirect_to_build, 5000, modules_to_install, package); + } + }); + } + + function redirect_to_cart() { + var modules = []; + for (var key of choosen.keys()) { + modules.push(key); + } + if (!modules) { + alert("You haven't chosen any application!") + return; + } + // Getting product ids + session.rpc('/price/take_product_ids', { + module_names: modules + }).then(function (product_ids) { + // If ids are know, redirect to cart + session.rpc('/price/cart_update', { + product_ids: product_ids.ids, + old_apps_ids: get_old_products(), + period: per_month ? 'm' : 'y', + user_price: user_price(), + old_user_cnt: get_old_user_cnt(), + user_cnt: $('#users').val() + }).then(function (response) { + window.location.href = response.link; + }); + // This method is necessary, to delete old products from cart + save_old_products(product_ids.ids); + }); + } + + // Finding all the links up to the parent_tree, + // and push them to delete_list + delete_list = []; + function leaf_to_root(name) { + if (delete_list.includes(name)) + return; + delete_list.push(name); + roots = parent_tree.get(name); + if (roots === undefined) + return; + if (roots.length > 0) { + roots.forEach(function (root) { + leaf_to_root(root); + }); + } + } + + leafs = []; + function root_to_leafs(name) { + if (leafs.includes(name)) + return; + leafs.push(name); + deps = child_tree.get(name); + if (deps === undefined) + return; + if (deps.length > 0) { + deps.forEach(function (leaf) { + root_to_leafs(leaf); + }); + } + } + + // Downloading apps dependencies + + + base.ready().then(function() { + // Check needs to avoid js code loading on another pages + if (!window.location.pathname.includes('/price')) + return; + // Catching click to the app + $(".app").click(function () { + // Get app technical name + var app = this.children[2].innerText; + // If app isn't in basket right now, put it in + if (choosen.get(app) === undefined) { + // Get app dependencies and add them to the basket + root_to_leafs(app); + leafs.forEach(function (leaf) { + add_to_basket(leaf); + }); + leafs = []; + } else { + // Get app dependencies and take them off from the basket + leaf_to_root(app); + delete_list.forEach(function (module) { + delete_from_basket(module); + }); + delete_list = []; + } + calc_price_window_vals(); + }); + // Catching click to the 'Annually' button + $(".nav-link:contains('Annually')").click(function () { + per_month = false; + change_period(); + }); + // Catching click to the 'Monthly' button + $(".nav-link:contains('Monthly')").click(function () { + per_month = true; + change_period(); + }); + // Catching click to the 'Try trial' button + $("#get-started").click(function () { + // Showing the loader + $('.loader')[0].classList.remove('hid'); + redirect_to_build(null, null); + }); + // Catching click to the 'Buy now' button + $("#buy-now").click(function () { + // Showing the loader + redirect_to_cart(); + }); + + $('#users').click(function () { + calc_price_window_vals(); + }); + $('#users').keyup(function (event) { + if (event.keyCode === 13) { + calc_price_window_vals(); + } + }); + // Changing users qty buttons + $('#substr-users').click(function () { + check_users_input(); + $('#users').val(parseInt($('#users').val(), 10) - 1); + calc_price_window_vals(); + }); + $('#add-users').click(function () { + check_users_input(); + $('#users').val(parseInt($('#users').val(), 10) + 1); + calc_price_window_vals(); + }); + + session.rpc('/check_currency', { + }).then(function (result) { + currency = result.currency; + currency_symbol = result.symbol; + }); + + // Check session storage + old_modules = get_modules_from_session_storage(); + if (old_modules.length) { + old_modules.forEach(elem => { + add_to_basket(elem); + }); + calc_price_window_vals(); + } + // Activate loader + $('.loader')[0].classList.remove('hid'); + + // Counting reqests and answers + var requests_stack = 0; + $.each($('.app_tech_name'), function (key, app) { + ++requests_stack; + session.rpc('/what_dependencies', { + root: app.innerText + }).then(function (result) { + --requests_stack; + if (requests_stack < 5) { + $('.loader')[0].classList.add('hid'); + } + /* Be carefull with dependecies when changing programm logic, + cause first dependence - is module himself. + Now result contains app's themself and their dependencies, + now parse incoming data in child and parent tree, to save dependencies.*/ + var first_dependence = true; + result.dependencies.forEach(dependence => { + // Add new element to the dependencies parent_tree, cause we'll restablish a path from leaf to the root + // when we'll have to delete one of leafs + if (!first_dependence) { + var modules_parents = parent_tree.get(dependence.name), + root_module_name = dependence.parent, + leaf_name = dependence.name; + if (modules_parents === undefined) { + parent_tree.set(leaf_name, [root_module_name]); + console.log("INFO:Added new leaf '" + leaf_name + "' with root module '" + root_module_name + "'."); + } else if (!modules_parents.includes(root_module_name)) { + modules_parents.push(root_module_name); + console.log("INFO:Added new root module '" + root_module_name + "' to leaf '" + leaf_name + "'."); + } else { + console.log("WARNING:Root module '" + root_module_name + "' already in parent_tree!"); + } + } + if (dependence.childs) { + var root = dependence.name, + in_tree_childs = child_tree.get(root); + // Here we get new elements from dependence.childs, difference btw + // dependence.childs and in_tree_childs. + if (in_tree_childs === undefined) { + child_tree.set(root, dependence.childs); + console.log("INFO:Added new root '" + root + "' with childs '" + dependence.childs[0] + "...'"); + } else { + var new_childs = dependence.childs.filter(x => !in_tree_childs.includes(x)); + new_childs.forEach(function (child) { + in_tree_childs.push(child); + console.log("INFO:Added new child module '" + child + "' to root '" + root + "'."); + }); + } + } + + first_dependence = false; + }); + }); + }); + if (requests_stack === 0) { + $('.loader')[0].classList.add('hid'); + } + }); + + function change_border_color(elem) { + if (elem.classList.contains('green-border')) { + elem.classList.add('normal-border'); + elem.classList.remove('green-border'); + } else { + elem.classList.add('green-border'); + elem.classList.remove('normal-border'); + } + } + + function change_period() { + var i = 0, + monthly = $('.monthly-price'), + yearly = $('.yearly-price'), + n = yearly.length; + if (per_month) { + for (; i < n; ++i) { + monthly[i].classList.remove('hid'); + yearly[i].classList.add('hid'); + } + } + else { + for (; i < n; ++i) { + monthly[i].classList.add('hid'); + yearly[i].classList.remove('hid'); + } + } + var size = choosen.size, i = 0; + for (var key of choosen.keys()) { + if (i >= size) break; + delete_from_basket(key); + add_to_basket(key); + ++i; + } + calc_price_window_vals(); + } + + function check_for_packages(module_name) { + if (choosen.size == 0) { + return; + } + // Collect all packages names in array + var packages = []; + $.each($('.package'), function (index, value) { + packages.push(value.children[2].innerText); + }); + // if package choosen, then delete other products from cart + if (packages.includes(module_name)) { + for (var key of choosen.keys()) { + delete_from_basket(key); + } + choosen.clear(); + } + else { + // If app choosen, then delete package from cart + for (var key of choosen.keys()) { + if (packages.includes(key)) { + delete_from_basket(key); + return; + } + } + } + } + + function add_to_basket(module_name) { + check_for_packages(module_name); + if (choosen.get(module_name) === undefined) { + // Finding choosen element + elem = $(".app_tech_name:contains('" + module_name + "')").filter(function (_, el) { + return $(el).html() == module_name + }) + price = 0; + // Get choosen app price + if (elem.length > 0) { + price_i = per_month ? 1 : 0; + price = parseInt(elem[0].previousElementSibling.children[1].children[price_i].children[0].innerText, 10); + // Changing border color + ++apps_in_basket; + change_border_color(elem[0].parentElement); + } + // Insert new app in to the basket + choosen.set(module_name, price); + save_modules_to_session_storage(); + } + } + + function delete_from_basket(module_name) { + if (choosen.get(module_name) !== undefined) { + // Delete app from the basket + choosen.delete(module_name); + // Finding choosen element + elem = $(".app_tech_name:contains('" + module_name + "')").filter(function (_, el) { + return $(el).html() == module_name + }) + // Changing border color + if (elem.length > 0) { + --apps_in_basket; + change_border_color(elem[0].parentElement); + } + } + save_modules_to_session_storage(); + } + + function blink_anim(elems) { + elems.forEach((elem) => { + elem.animate({ opacity: "0" }, 250); + elem.animate({ opacity: "1" }, 250); + }); + } + + function calc_price_window_vals() { + check_users_input(); + // This method refreshes data in price window + price = Calc_Price(); + // Adding blink animation + blink_anim([$('#apps-cost'), $('#users-cnt-cost'), + $('#apps-qty'), $('#price-users'), $('#users-qty'), $('#price')]); + var period = per_month ? "month" : "year"; + $('#price').text(String(price)); + $('#box-period').text(String(period)); + $('#users-qty').text($('#users').val()) + users_price_period = per_month ? 12.5 : 10.0; + $('#price-users').text(String(users_price_period)); + $('#apps-qty').text(String(apps_in_basket)); + $('#users-cnt-cost').text(String(users_price_period * $('#users').val())); + $('#apps-cost').text(String(calc_apps_price())); + if(!apps_in_basket){ + $('#get-started')[0].classList.add('hid'); + if($('#buy-now').length){ + $('#buy-now')[0].classList.add('hid'); + } + }else{ + $('#get-started')[0].classList.remove('hid'); + if($('#buy-now').length){ + $('#buy-now')[0].classList.remove('hid'); + } + } + } + + function check_users_input() { + if (parseInt($('#users').val(), 10) <= 0 || $('#users').val() === '') + $('#users').val(1); + } + + function get_modules_to_install() { + var modules = []; + for (var key of choosen.keys()) { + modules.push(key); + } + return modules; + } + + function save_old_products(ids) { + localStorage.removeItem('old_user_cnt'); + localStorage.removeItem('old_products'); + localStorage.setItem('old_products', ids); + localStorage.setItem('old_user_cnt', $('#users').val()); + } + + function get_old_user_cnt() { return localStorage.getItem('old_user_cnt'); } + + function get_old_products() { + return parse_string_to_arr(localStorage.getItem('old_products')); + } + + function save_modules_to_session_storage() { + localStorage.removeItem('modules'); + localStorage.setItem('modules', get_modules_to_install()); + } + + function parse_string_to_arr(str) { + if (str) { + var i = 0, j = 1, arr = []; + for (; j < str.length; ++j) { + if (str[j] === ',') { + arr.push(str.slice(i, j)); + i = j + 1; + j += 2; + } + } + arr.push(str.slice(i, j)); + return arr; + } + return []; + } + + function get_modules_from_session_storage() { + return parse_string_to_arr(localStorage.getItem('modules')); + } + + return { + "get_modules_to_install": get_modules_to_install, + } +}); diff --git a/saas_apps/static/src/js/refresh_button.js b/saas_apps/static/src/js/refresh_button.js new file mode 100644 index 000000000..7edc029a8 --- /dev/null +++ b/saas_apps/static/src/js/refresh_button.js @@ -0,0 +1,26 @@ +/* Copyright 2020 Vildan Safin + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).*/ + odoo.define('saas_apps.filter_button', function (require) { + "use strict"; + + var core = require('web.core'); + var session = require('web.session'); + var ListController = require('web.ListController'); + + ListController.include({ + renderButtons: function($node) { + this._super.apply(this, arguments); + if (this.$buttons) { + var filter_button = this.$buttons.find('.oe_filter_button'); + filter_button && filter_button.click(this.proxy('filter_button')) ; + } + }, + filter_button: function () { + // Loading all modules in saas.line from ir.module.module + session.rpc('/refresh', { + }).then(function (result) { + window.location.reload() + }); + } + }); +}); diff --git a/saas_apps/views/assets.xml b/saas_apps/views/assets.xml new file mode 100644 index 000000000..2a8270f30 --- /dev/null +++ b/saas_apps/views/assets.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/saas_apps/views/calculator.xml b/saas_apps/views/calculator.xml new file mode 100644 index 000000000..dfb1f6145 --- /dev/null +++ b/saas_apps/views/calculator.xml @@ -0,0 +1,139 @@ + + + + + diff --git a/saas_apps/views/manage.xml b/saas_apps/views/manage.xml new file mode 100644 index 000000000..1921319bf --- /dev/null +++ b/saas_apps/views/manage.xml @@ -0,0 +1,137 @@ + + + + + + saas.line.search + saas.line + + + + + + + + + + saas.line.list + saas.line + + + + + + + + + + Website Apps + saas.line + form + tree,form + + current + + + + + + + saas.module.form.view + saas.module + + + + + + + + + + + saas.template.form.view + saas.template + + + + + + + + + + + + + + saas.line.form.view + saas.line + +
+ + + + + + + + + + + + +
+
+
+ + + res.config.settings.view.form.inherit.website.apps + res.config.settings + + + +

SaaS pricing page

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/saas_apps/views/refresh.xml b/saas_apps/views/refresh.xml new file mode 100644 index 000000000..33824c39f --- /dev/null +++ b/saas_apps/views/refresh.xml @@ -0,0 +1,13 @@ + + + diff --git a/saas_public/controllers/saas_public.py b/saas_public/controllers/saas_public.py index 0efe9cea0..eb9bcbd40 100644 --- a/saas_public/controllers/saas_public.py +++ b/saas_public/controllers/saas_public.py @@ -7,6 +7,7 @@ class SaaSPublicController(Controller): @route('/saas_public//create-fast-build', type='http', auth='public') def create_fast_build(self, template_id, **kwargs): + import wdb;wdb.set_trace() if not kwargs: kwargs = {} template = request.env['saas.template'].browse(template_id).sudo()