diff --git a/.gitignore b/.gitignore index ea844d14..c3fadaa5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,10 @@ __pycache__/ # hg stuff *.orig status -# odoo filestore -odoo/filestore +# flectra filestore +flectra/filestore # maintenance migration scripts -odoo/addons/base/maintenance +flectra/addons/base/maintenance # generated for windows installer? install/win32/*.bat diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d5fe1dd5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at flectra@flectrahq.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e2dc5df..6ac9f095 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ -Contributing to Odoo +Contributing to Flectra ==================== +#TODO + [Full contribution guidelines](https://github.com/odoo/odoo/wiki/Contributing) TL;DR diff --git a/README.md b/README.md index 0d2c7f48..f7eb1fcc 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,17 @@ -[![Build Status](http://runbot.odoo.com/runbot/badge/flat/1/11.0.svg)](http://runbot.odoo.com/runbot) -[![Tech Doc](http://img.shields.io/badge/11.0-docs-875A7B.svg?style=flat&colorA=8F8F8F)](http://www.odoo.com/documentation/11.0) -[![Help](http://img.shields.io/badge/11.0-help-875A7B.svg?style=flat&colorA=8F8F8F)](https://www.odoo.com/forum/help-1) -[![Nightly Builds](http://img.shields.io/badge/11.0-nightly-875A7B.svg?style=flat&colorA=8F8F8F)](http://nightly.odoo.com/) -Odoo ----- +Flectra +======= -Odoo is a suite of web based open source business apps. +Flectra is a suite of web based open source business apps forked from Odoo. -The main Odoo Apps include an Open Source CRM, -Website Builder, -eCommerce, -Warehouse Management, -Project Management, -Billing & Accounting, -Point of Sale, -Human Resources, -Marketing, -Manufacturing, -Purchase Management, -... +The main Flectra Apps include an Open Source CRM,Website Builder,eCommerce,Warehouse Management, +Project Management,Billing & Accounting,Point of Sale,Human Resources,Marketing,Manufacturing, +Purchase Management and many more. -Odoo Apps can be used as stand-alone applications, but they also integrate seamlessly so you get -a full-featured Open Source ERP when you install several Apps. +Flectra Apps can be used as stand-alone applications, but they also integrate seamlessly so you get +a full-featured Open Source ERP when you install several Apps. -Getting started with Odoo -------------------------- -For a standard installation please follow the Setup instructions -from the documentation. - -Then follow the developer tutorials +Getting started with Flectra +---------------------------- +For a standard installation please follow this gist. diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 2fbc2944..b9c65fc0 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -183,6 +183,12 @@ + +

Fiscal Periods

+
+
+
+

Customer Payments

diff --git a/addons/account/views/web_planner_data.xml b/addons/account/views/web_planner_data.xml index bdc1fc36..b8c2561c 100644 --- a/addons/account/views/web_planner_data.xml +++ b/addons/account/views/web_planner_data.xml @@ -30,10 +30,8 @@

Enjoy your Flectra experience,

-
For the Flectra Team,
- Fabien Pinckaers, Founder
diff --git a/addons/account_invoicing/static/description/icon.png b/addons/account_invoicing/static/description/icon.png index 9f0c5c6d..1d33bb2a 100644 Binary files a/addons/account_invoicing/static/description/icon.png and b/addons/account_invoicing/static/description/icon.png differ diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 3d8f52e7..f68eaf1a 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -2,6 +2,7 @@ # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from flectra import api, fields, models, _ +import json class ResConfigSettings(models.TransientModel): @@ -35,17 +36,26 @@ class ResConfigSettings(models.TransientModel): help="Allows to work in a multi currency environment") paperformat_id = fields.Many2one(related="company_id.paperformat_id", string='Paper format') external_report_layout = fields.Selection(related="company_id.external_report_layout") + send_statistics = fields.Boolean( + "Send Statistics") @api.model def get_values(self): res = super(ResConfigSettings, self).get_values() params = self.env['ir.config_parameter'].sudo() default_external_email_server = params.get_param('base_setup.default_external_email_server', default=False) + send_statistics = params._get_param( + 'base_setup.send_statistics') + if send_statistics is None: + send_statistics = 'true' + if send_statistics in ['true', 'false']: + send_statistics = json.loads(send_statistics) default_user_rights = params.get_param('base_setup.default_user_rights', default=False) default_custom_report_footer = params.get_param('base_setup.default_custom_report_footer', default=False) res.update( default_external_email_server=default_external_email_server, default_user_rights=default_user_rights, + send_statistics=send_statistics, default_custom_report_footer=default_custom_report_footer, company_share_partner=not self.env.ref('base.res_partner_rule').active, ) @@ -57,6 +67,11 @@ class ResConfigSettings(models.TransientModel): self.env['ir.config_parameter'].sudo().set_param("base_setup.default_external_email_server", self.default_external_email_server) self.env['ir.config_parameter'].sudo().set_param("base_setup.default_user_rights", self.default_user_rights) self.env['ir.config_parameter'].sudo().set_param("base_setup.default_custom_report_footer", self.default_custom_report_footer) + send_statistics = 'true' + if not self.send_statistics: + send_statistics = 'false' + self.env['ir.config_parameter'].sudo().set_param( + "base_setup.send_statistics", send_statistics) self.env.ref('base.res_partner_rule').write({'active': not self.company_share_partner}) @api.multi diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index e9d1894e..e8527b64 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -218,6 +218,20 @@
+

System Parameter

+
+
+
+ +
+
+
+
+
diff --git a/addons/board/static/description/icon.png b/addons/board/static/description/icon.png index aa60ebeb..e0da0c0e 100644 Binary files a/addons/board/static/description/icon.png and b/addons/board/static/description/icon.png differ diff --git a/addons/bus/controllers/main.py b/addons/bus/controllers/main.py index 5f8a99b6..a6178677 100644 --- a/addons/bus/controllers/main.py +++ b/addons/bus/controllers/main.py @@ -9,9 +9,9 @@ from flectra.tools import pycompat class BusController(Controller): """ Examples: - openerp.jsonRpc('/longpolling/poll','call',{"channels":["c1"],last:0}).then(function(r){console.log(r)}); - openerp.jsonRpc('/longpolling/send','call',{"channel":"c1","message":"m1"}); - openerp.jsonRpc('/longpolling/send','call',{"channel":"c2","message":"m2"}); + flectra.jsonRpc('/longpolling/poll','call',{"channels":["c1"],last:0}).then(function(r){console.log(r)}); + flectra.jsonRpc('/longpolling/send','call',{"channel":"c1","message":"m1"}); + flectra.jsonRpc('/longpolling/send','call',{"channel":"c2","message":"m2"}); """ @route('/longpolling/send', type="json", auth="public") diff --git a/addons/bus/models/bus.py b/addons/bus/models/bus.py index bb301b41..a0ac1b1f 100644 --- a/addons/bus/models/bus.py +++ b/addons/bus/models/bus.py @@ -121,7 +121,7 @@ class ImDispatch(object): current._Thread__daemonic = True # PY2 current._daemonic = True # PY3 # rename the thread to avoid tests waiting for a longpolling - current.setName("openerp.longpolling.request.%s" % current.ident) + current.setName("flectra.longpolling.request.%s" % current.ident) registry = flectra.registry(dbname) diff --git a/addons/calendar/static/description/icon.png b/addons/calendar/static/description/icon.png index a59c253f..021868ee 100644 Binary files a/addons/calendar/static/description/icon.png and b/addons/calendar/static/description/icon.png differ diff --git a/addons/contacts/static/description/icon.png b/addons/contacts/static/description/icon.png index 7a358c2e..7489c571 100644 Binary files a/addons/contacts/static/description/icon.png and b/addons/contacts/static/description/icon.png differ diff --git a/addons/crm/data/web_planner_data.xml b/addons/crm/data/web_planner_data.xml index 954eba5b..9123b319 100644 --- a/addons/crm/data/web_planner_data.xml +++ b/addons/crm/data/web_planner_data.xml @@ -28,10 +28,8 @@ Have fun deploying your sales strategy,

-
For the Flectra Team,
- Fabien Pinckaers, Founder
diff --git a/addons/crm/static/description/icon.png b/addons/crm/static/description/icon.png index 8eb7f8b4..ed568ba3 100644 Binary files a/addons/crm/static/description/icon.png and b/addons/crm/static/description/icon.png differ diff --git a/addons/event/static/description/icon.png b/addons/event/static/description/icon.png index 0c137735..99081d61 100644 Binary files a/addons/event/static/description/icon.png and b/addons/event/static/description/icon.png differ diff --git a/addons/fleet/static/description/icon.png b/addons/fleet/static/description/icon.png index 4b4eaf42..e5c8e47d 100644 Binary files a/addons/fleet/static/description/icon.png and b/addons/fleet/static/description/icon.png differ diff --git a/addons/google_spreadsheet/__manifest__.py b/addons/google_spreadsheet/__manifest__.py index 14964fe9..6b0ac6e8 100644 --- a/addons/google_spreadsheet/__manifest__.py +++ b/addons/google_spreadsheet/__manifest__.py @@ -7,7 +7,7 @@ 'version': '1.0', 'category': 'Extra Tools', 'description': """ -The module adds the possibility to display data from Odoo in Google Spreadsheets in real time. +The module adds the possibility to display data from Flectra in Google Spreadsheets in real time. ================================================================================================= """, 'depends': ['google_drive'], diff --git a/addons/hr/static/description/icon.png b/addons/hr/static/description/icon.png index b8b25609..92390d78 100644 Binary files a/addons/hr/static/description/icon.png and b/addons/hr/static/description/icon.png differ diff --git a/addons/hr_attendance/static/description/icon.png b/addons/hr_attendance/static/description/icon.png index 2687d5a3..48dac411 100644 Binary files a/addons/hr_attendance/static/description/icon.png and b/addons/hr_attendance/static/description/icon.png differ diff --git a/addons/hr_expense/data/web_planner_data.xml b/addons/hr_expense/data/web_planner_data.xml index 0d0bd892..4f25498a 100644 --- a/addons/hr_expense/data/web_planner_data.xml +++ b/addons/hr_expense/data/web_planner_data.xml @@ -29,10 +29,8 @@

Enjoy your Flectra experience,

-
For the Flectra Team,
- Fabien Pinckaers, Founder
diff --git a/addons/hr_expense/static/description/icon.png b/addons/hr_expense/static/description/icon.png index 6f2833fc..7d16de63 100644 Binary files a/addons/hr_expense/static/description/icon.png and b/addons/hr_expense/static/description/icon.png differ diff --git a/addons/hr_holidays/static/description/icon.png b/addons/hr_holidays/static/description/icon.png index 918d3a3a..6a6e89ef 100644 Binary files a/addons/hr_holidays/static/description/icon.png and b/addons/hr_holidays/static/description/icon.png differ diff --git a/addons/hr_payroll/static/description/icon.png b/addons/hr_payroll/static/description/icon.png index bdf43ee8..f0b292ee 100644 Binary files a/addons/hr_payroll/static/description/icon.png and b/addons/hr_payroll/static/description/icon.png differ diff --git a/addons/hr_recruitment/static/description/icon.png b/addons/hr_recruitment/static/description/icon.png index 3b2a1ac5..5a65ba5a 100644 Binary files a/addons/hr_recruitment/static/description/icon.png and b/addons/hr_recruitment/static/description/icon.png differ diff --git a/addons/hr_timesheet/static/description/icon.png b/addons/hr_timesheet/static/description/icon.png index fa79f032..9f893c33 100644 Binary files a/addons/hr_timesheet/static/description/icon.png and b/addons/hr_timesheet/static/description/icon.png differ diff --git a/addons/hr_timesheet/static/description/icon_timesheet.png b/addons/hr_timesheet/static/description/icon_timesheet.png index fa79f032..9f893c33 100644 Binary files a/addons/hr_timesheet/static/description/icon_timesheet.png and b/addons/hr_timesheet/static/description/icon_timesheet.png differ diff --git a/addons/hw_screen/controllers/main.py b/addons/hw_screen/controllers/main.py index 85c002fe..ffe82799 100644 --- a/addons/hw_screen/controllers/main.py +++ b/addons/hw_screen/controllers/main.py @@ -4,7 +4,7 @@ from flectra import http from flectra.tools import config from flectra.addons.web.controllers import main as web -from openerp.addons.hw_posbox_homepage.controllers import main as homepage +from flectra.addons.hw_posbox_homepage.controllers import main as homepage import logging import netifaces as ni diff --git a/addons/im_livechat/static/description/icon.png b/addons/im_livechat/static/description/icon.png index 07d31ae9..ff573c15 100644 Binary files a/addons/im_livechat/static/description/icon.png and b/addons/im_livechat/static/description/icon.png differ diff --git a/addons/l10n_fr/migrations/2.0/post-migrate_tags_on_taxes.py b/addons/l10n_fr/migrations/2.0/post-migrate_tags_on_taxes.py index a37770d2..9062980b 100644 --- a/addons/l10n_fr/migrations/2.0/post-migrate_tags_on_taxes.py +++ b/addons/l10n_fr/migrations/2.0/post-migrate_tags_on_taxes.py @@ -1,7 +1,7 @@ -from openerp.modules.registry import RegistryManager +from flectra.modules.registry import RegistryManager def migrate(cr, version): registry = RegistryManager.get(cr.dbname) - from openerp.addons.account.models.chart_template import migrate_tags_on_taxes + from flectra.addons.account.models.chart_template import migrate_tags_on_taxes migrate_tags_on_taxes(cr, registry) diff --git a/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py b/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py index 6f933404..36f15799 100644 --- a/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py +++ b/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py @@ -1,7 +1,7 @@ -from openerp.modules.registry import RegistryManager +from flectra.modules.registry import RegistryManager def migrate(cr, version): registry = RegistryManager.get(cr.dbname) - from openerp.addons.account.models.chart_template import migrate_tags_on_taxes + from flectra.addons.account.models.chart_template import migrate_tags_on_taxes migrate_tags_on_taxes(cr, registry) diff --git a/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py b/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py index 586ae984..f735e4a2 100644 --- a/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py +++ b/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py @@ -1,7 +1,7 @@ -from openerp.modules.registry import RegistryManager +from flectra.modules.registry import RegistryManager def migrate(cr, version): registry = RegistryManager.get(cr.dbname) - from openerp.addons.account.models.chart_template import migrate_set_tags_and_taxes_updatable + from flectra.addons.account.models.chart_template import migrate_set_tags_and_taxes_updatable migrate_set_tags_and_taxes_updatable(cr, registry, 'l10n_fr') diff --git a/addons/l10n_lu/migrations/9.0.2.0/post-migrate_tags_on_taxes.py b/addons/l10n_lu/migrations/9.0.2.0/post-migrate_tags_on_taxes.py index fa56fd00..1f2ccd49 100644 --- a/addons/l10n_lu/migrations/9.0.2.0/post-migrate_tags_on_taxes.py +++ b/addons/l10n_lu/migrations/9.0.2.0/post-migrate_tags_on_taxes.py @@ -1,6 +1,6 @@ -from openerp.modules.registry import RegistryManager +from flectra.modules.registry import RegistryManager def migrate(cr, version): registry = RegistryManager.get(cr.dbname) - from openerp.addons.account.models.chart_template import migrate_tags_on_taxes + from flectra.addons.account.models.chart_template import migrate_tags_on_taxes migrate_tags_on_taxes(cr, registry) diff --git a/addons/lunch/static/description/icon.png b/addons/lunch/static/description/icon.png index 117a5821..1608a388 100644 Binary files a/addons/lunch/static/description/icon.png and b/addons/lunch/static/description/icon.png differ diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index e9054213..72c663b6 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -36,6 +36,7 @@ 'views/ir_actions_views.xml', 'views/ir_model_views.xml', 'views/res_partner_views.xml', + 'wizard/mass_mail.xml', ], 'demo': [ 'data/mail_demo.xml', diff --git a/addons/mail/models/update.py b/addons/mail/models/update.py index f41c028b..9f9e6d21 100644 --- a/addons/mail/models/update.py +++ b/addons/mail/models/update.py @@ -30,35 +30,41 @@ class PublisherWarrantyContract(AbstractModel): db_create_date = IrParamSudo.get_param('database.create_date') limit_date = datetime.datetime.now() limit_date = limit_date - datetime.timedelta(15) - limit_date_str = limit_date.strftime(misc.DEFAULT_SERVER_DATETIME_FORMAT) + limit_date_str = limit_date.strftime( + misc.DEFAULT_SERVER_DATETIME_FORMAT) nbr_users = Users.search_count([('active', '=', True)]) - nbr_active_users = Users.search_count([("login_date", ">=", limit_date_str), ('active', '=', True)]) + nbr_active_users = Users.search_count( + [("login_date", ">=", limit_date_str), ('active', '=', True)]) nbr_share_users = 0 nbr_active_share_users = 0 if "share" in Users._fields: - nbr_share_users = Users.search_count([("share", "=", True), ('active', '=', True)]) - nbr_active_share_users = Users.search_count([("share", "=", True), ("login_date", ">=", limit_date_str), ('active', '=', True)]) + nbr_share_users = Users.search_count( + [("share", "=", True), ('active', '=', True)]) + nbr_active_share_users = Users.search_count( + [("share", "=", True), ("login_date", ">=", limit_date_str), + ('active', '=', True)]) user = self.env.user - domain = [('application', '=', True), ('state', 'in', ['installed', 'to upgrade', 'to remove'])] + domain = [('application', '=', True), + ('state', 'in', ['installed', 'to upgrade', 'to remove'])] apps = self.env['ir.module.module'].sudo().search_read(domain, ['name']) - - enterprise_code = IrParamSudo.get_param('database.enterprise_code') + demo_domain = [('name', 'ilike', 'base'), ('demo', '=', True)] + demo_data_ids = self.env['ir.module.module'].sudo().search(demo_domain) + demo_data = True + if not demo_data_ids: + demo_data = False + support_code = IrParamSudo.get_param('database.support_code') web_base_url = IrParamSudo.get_param('web.base.url') - msg = { - "dbuuid": dbuuid, - "nbr_users": nbr_users, - "nbr_active_users": nbr_active_users, - "nbr_share_users": nbr_share_users, - "nbr_active_share_users": nbr_active_share_users, - "dbname": self._cr.dbname, - "db_create_date": db_create_date, - "version": release.version, - "language": user.lang, - "web_base_url": web_base_url, - "apps": [app['name'] for app in apps], - "enterprise_code": enterprise_code, - } + msg = {"dbuuid": dbuuid, "nbr_users": nbr_users, + "nbr_active_users": nbr_active_users, + "nbr_share_users": nbr_share_users, + "nbr_active_share_users": nbr_active_share_users, + "dbname": self._cr.dbname, "db_create_date": db_create_date, + "version": release.version, "language": user.lang, + "web_base_url": web_base_url, + "apps": [app['name'] for app in apps], + "support_code": support_code, + "demo_data": demo_data} if user.partner_id.company_id: company_id = user.partner_id.company_id msg.update(company_id.read(["name", "email", "phone"])[0]) @@ -88,13 +94,20 @@ class PublisherWarrantyContract(AbstractModel): @type cron_mode: boolean """ try: + # Code will be execute only if parameter value 'True' + parameter_id = self.env['ir.config_parameter'].sudo().get_param( + 'base_setup.send_statistics') + if parameter_id != 'true': + return True try: result = self._get_sys_logs() except Exception: - if cron_mode: # we don't want to see any stack trace in cron + if cron_mode: # we don't want to see any stack trace in cron return False - _logger.debug("Exception while sending a get logs messages", exc_info=1) - raise UserError(_("Error during communication with the publisher warranty server.")) + _logger.debug("Exception while sending a get logs messages", + exc_info=1) + raise UserError(_( + "Error during communication with the publisher warranty server.")) # old behavior based on res.log; now on mail.message, that is not necessarily installed user = self.env['res.users'].sudo().browse(SUPERUSER_ID) poster = self.sudo().env.ref('mail.channel_all_employees') @@ -104,19 +117,24 @@ class PublisherWarrantyContract(AbstractModel): poster = user for message in result["messages"]: try: - poster.message_post(body=message, subtype='mt_comment', partner_ids=[user.partner_id.id]) + poster.message_post(body=message, subtype='mt_comment', + partner_ids=[user.partner_id.id]) except Exception: pass - if result.get('enterprise_info'): + if result.get('support_info'): # Update expiration date set_param = self.env['ir.config_parameter'].sudo().set_param - set_param('database.expiration_date', result['enterprise_info'].get('expiration_date')) - set_param('database.expiration_reason', result['enterprise_info'].get('expiration_reason', 'trial')) - set_param('database.enterprise_code', result['enterprise_info'].get('enterprise_code')) + set_param('database.expiration_date', + result['support_info'].get('expiration_date')) + set_param('database.expiration_reason', + result['support_info'].get('expiration_reason', + 'trial')) + set_param('database.support_code', + result['support_info'].get('support_code')) except Exception: if cron_mode: - return False # we don't want to see any stack trace in cron + return False # we don't want to see any stack trace in cron else: raise return True diff --git a/addons/mail/static/description/icon.png b/addons/mail/static/description/icon.png index 97a5fffc..f89dc6ba 100644 Binary files a/addons/mail/static/description/icon.png and b/addons/mail/static/description/icon.png differ diff --git a/addons/mail/static/src/xml/announcement.xml b/addons/mail/static/src/xml/announcement.xml deleted file mode 100644 index 46397861..00000000 --- a/addons/mail/static/src/xml/announcement.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - -
- - - - -
- - - - - -
-
-
-
diff --git a/addons/mail/wizard/__init__.py b/addons/mail/wizard/__init__.py index 6ec53a7f..2625f6bc 100644 --- a/addons/mail/wizard/__init__.py +++ b/addons/mail/wizard/__init__.py @@ -5,3 +5,5 @@ from . import invite from . import mail_compose_message from . import email_template_preview from . import base_module_uninstall +from . import mass_mail + diff --git a/addons/mail/wizard/mass_mail.py b/addons/mail/wizard/mass_mail.py new file mode 100644 index 00000000..af2345fe --- /dev/null +++ b/addons/mail/wizard/mass_mail.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import api, fields, models + + +class MassMail(models.TransientModel): + _name = 'mass.mail' + _description = 'Mass Mail Options' + + message = fields.Char('Message') + + @api.multi + def mass_emails(self): + send_method = self.env.context['mail'] + active_ids = self.env.context['active_ids'] + lines = self.env['mail.mail'].search([( 'id', 'in', active_ids)]) + if send_method == 'mark_outgoing': + lines.mark_outgoing() + elif send_method == 'send': + lines.send() + elif send_method == 'cancel': + lines.cancel() diff --git a/addons/mail/wizard/mass_mail.xml b/addons/mail/wizard/mass_mail.xml new file mode 100644 index 00000000..0b1e1dbc --- /dev/null +++ b/addons/mail/wizard/mass_mail.xml @@ -0,0 +1,71 @@ + + + + + mass.retry.mail.form + mass.mail + +
+

Are you sure?

+
+ +
+
+
+
+ + + + mass.resend.mail.form + mass.mail + +
+

Are you sure?

+
+ +
+
+
+
+ + + + mass.cancel.mail.form + mass.mail + +
+

Are you sure?

+
+ +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/addons/maintenance/static/description/icon.png b/addons/maintenance/static/description/icon.png index fbc98a85..e03a1ceb 100644 Binary files a/addons/maintenance/static/description/icon.png and b/addons/maintenance/static/description/icon.png differ diff --git a/addons/mass_mailing/static/description/icon.png b/addons/mass_mailing/static/description/icon.png index a4d28747..b2f0f066 100644 Binary files a/addons/mass_mailing/static/description/icon.png and b/addons/mass_mailing/static/description/icon.png differ diff --git a/addons/membership/static/description/icon.png b/addons/membership/static/description/icon.png index 6667d623..c6be40d9 100644 Binary files a/addons/membership/static/description/icon.png and b/addons/membership/static/description/icon.png differ diff --git a/addons/mrp/static/description/icon.png b/addons/mrp/static/description/icon.png index c8ecda7c..d12ac94f 100644 Binary files a/addons/mrp/static/description/icon.png and b/addons/mrp/static/description/icon.png differ diff --git a/addons/mrp_repair/static/description/icon.png b/addons/mrp_repair/static/description/icon.png index 9c67371d..ade8c38c 100644 Binary files a/addons/mrp_repair/static/description/icon.png and b/addons/mrp_repair/static/description/icon.png differ diff --git a/addons/note/static/description/icon.png b/addons/note/static/description/icon.png index 97fed60c..b7a68d5f 100644 Binary files a/addons/note/static/description/icon.png and b/addons/note/static/description/icon.png differ diff --git a/addons/password_security/README.rst b/addons/password_security/README.rst new file mode 100644 index 00000000..fab9bf13 --- /dev/null +++ b/addons/password_security/README.rst @@ -0,0 +1,63 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 + +================= +Password Security +================= + +This module allows admin to set company-level password security requirements +and enforces them on the user. + +It contains features such as + +* Password expiration days +* Password length requirement +* Password minimum number of lowercase letters +* Password minimum number of uppercase letters +* Password minimum number of numbers +* Password minimum number of special characters + +Configuration +============= + +# Navigate to company you would like to set requirements on +# Click the ``Password Policy`` page +# Set the policies to your liking. + +Password complexity requirements will be enforced upon next password change for +any user in that company. + + +Settings & Defaults +------------------- + +These are defined at the company level: + +===================== ======= =================================================== + Name Default Description +===================== ======= =================================================== + password_expiration 60 Days until passwords expire + password_length 12 Minimum number of characters in password + password_lower 0 Minimum number of lowercase letter in password + password_upper 0 Minimum number of uppercase letters in password + password_numeric 0 Minimum number of number in password + password_special 0 Minimum number of unique special character in password + password_history 30 Disallow reuse of this many previous passwords + password_minimum 24 Amount of hours that must pass until another reset +===================== ======= =================================================== + + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* James Foster +* Dave Lasley diff --git a/addons/password_security/__init__.py b/addons/password_security/__init__.py new file mode 100644 index 00000000..1269dc63 --- /dev/null +++ b/addons/password_security/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import controllers +from . import models diff --git a/addons/password_security/__manifest__.py b/addons/password_security/__manifest__.py new file mode 100644 index 00000000..24e8a84d --- /dev/null +++ b/addons/password_security/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + + 'name': 'Password Security', + "summary": "Allow admin to set password security requirements.", + 'version': '11.0.1.0.0', + 'author': "LasLabs, Odoo Community Association (OCA), FlectraHQ", + 'category': 'Base', + 'depends': [ + 'auth_crypt', + 'auth_signup', + ], + "website": "https://laslabs.com", + "license": "LGPL-3", + "data": [ + 'views/res_company_view.xml', + 'security/ir.model.access.csv', + 'security/res_users_pass_history.xml', + ], + "demo": [ + 'demo/res_users.xml', + ], + 'installable': True, +} diff --git a/addons/password_security/controllers/__init__.py b/addons/password_security/controllers/__init__.py new file mode 100644 index 00000000..ff5aacdc --- /dev/null +++ b/addons/password_security/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import main diff --git a/addons/password_security/controllers/main.py b/addons/password_security/controllers/main.py new file mode 100644 index 00000000..47dc8399 --- /dev/null +++ b/addons/password_security/controllers/main.py @@ -0,0 +1,93 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import operator + +from flectra import http +from flectra.http import request +from flectra.addons.auth_signup.controllers.main import AuthSignupHome +from flectra.addons.web.controllers.main import ensure_db, Session + +from ..exceptions import PassError + + +class PasswordSecuritySession(Session): + + @http.route() + def change_password(self, fields): + new_password = operator.itemgetter('new_password')( + dict(map(operator.itemgetter('name', 'value'), fields)) + ) + user_id = request.env.user + user_id._check_password(new_password) + return super(PasswordSecuritySession, self).change_password(fields) + + +class PasswordSecurityHome(AuthSignupHome): + + def do_signup(self, qcontext): + password = qcontext.get('password') + user_id = request.env.user + user_id._check_password(password) + return super(PasswordSecurityHome, self).do_signup(qcontext) + + @http.route() + def web_login(self, *args, **kw): + ensure_db() + response = super(PasswordSecurityHome, self).web_login(*args, **kw) + if not request.httprequest.method == 'POST': + return response + uid = request.session.authenticate( + request.session.db, + request.params['login'], + request.params['password'] + ) + if not uid: + return response + users_obj = request.env['res.users'].sudo() + user_id = users_obj.browse(request.uid) + if not user_id._password_has_expired(): + return response + user_id.action_expire_password() + request.session.logout(keep_db=True) + redirect = user_id.partner_id.signup_url + return http.redirect_with_hash(redirect) + + @http.route() + def web_auth_signup(self, *args, **kw): + try: + return super(PasswordSecurityHome, self).web_auth_signup( + *args, **kw + ) + except PassError as e: + qcontext = self.get_auth_signup_qcontext() + qcontext['error'] = e.message + return request.render('auth_signup.signup', qcontext) + + @http.route() + def web_auth_reset_password(self, *args, **kw): + """ It provides hook to disallow front-facing resets inside of min + Unfortuantely had to reimplement some core logic here because of + nested logic in parent + """ + qcontext = self.get_auth_signup_qcontext() + if ( + request.httprequest.method == 'POST' and + qcontext.get('login') and + 'error' not in qcontext and + 'token' not in qcontext + ): + login = qcontext.get('login') + user_ids = request.env.sudo().search( + [('login', '=', login)], + limit=1, + ) + if not user_ids: + user_ids = request.env.sudo().search( + [('email', '=', login)], + limit=1, + ) + user_ids._validate_pass_reset() + return super(PasswordSecurityHome, self).web_auth_reset_password( + *args, **kw + ) diff --git a/addons/password_security/demo/res_users.xml b/addons/password_security/demo/res_users.xml new file mode 100644 index 00000000..e4d70177 --- /dev/null +++ b/addons/password_security/demo/res_users.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/addons/password_security/exceptions.py b/addons/password_security/exceptions.py new file mode 100644 index 00000000..a85fc853 --- /dev/null +++ b/addons/password_security/exceptions.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from flectra.exceptions import Warning as UserError + + +class PassError(UserError): + """ Example: When you try to create an insecure password.""" + def __init__(self, msg): + self.message = msg + super(PassError, self).__init__(msg) diff --git a/addons/password_security/models/__init__.py b/addons/password_security/models/__init__.py new file mode 100644 index 00000000..4633ec91 --- /dev/null +++ b/addons/password_security/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import res_users +from . import res_company +from . import res_users_pass_history diff --git a/addons/password_security/models/res_company.py b/addons/password_security/models/res_company.py new file mode 100644 index 00000000..45233163 --- /dev/null +++ b/addons/password_security/models/res_company.py @@ -0,0 +1,47 @@ +# Copyright 2015 LasLabs Inc. +# Copyright 2004-TODAY FlectraHQ. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from flectra import models, fields + + +class ResCompany(models.Model): + _inherit = 'res.company' + + password_expiration = fields.Integer( + 'Days', + default=60, + help='How many days until passwords expire', + ) + password_length = fields.Integer( + 'Characters', + default=12, + help='Minimum number of characters', + ) + password_lower = fields.Integer( + 'Lowercase', + help='Require lowercase letters', + ) + password_upper = fields.Integer( + 'Uppercase', + help='Require uppercase letters', + ) + password_numeric = fields.Integer( + 'Numeric', + help='Require numeric digits', + ) + password_special = fields.Integer( + 'Special', + help='Require unique special characters', + ) + password_history = fields.Integer( + 'History', + default=30, + help='Disallow reuse of this many previous passwords - use negative ' + 'number for infinite, or 0 to disable', + ) + password_minimum = fields.Integer( + 'Minimum Hours', + default=24, + help='Amount of hours until a user may change password again', + ) diff --git a/addons/password_security/models/res_users.py b/addons/password_security/models/res_users.py new file mode 100644 index 00000000..6d4d5ed2 --- /dev/null +++ b/addons/password_security/models/res_users.py @@ -0,0 +1,162 @@ +# Copyright 2015 LasLabs Inc. +# Copyright 2004-TODAY FlectraHQ. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import re + +from datetime import datetime, timedelta + +from flectra import api, fields, models, _ + +from ..exceptions import PassError + + +def delta_now(**kwargs): + dt = datetime.now() + timedelta(**kwargs) + return fields.Datetime.to_string(dt) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + password_write_date = fields.Datetime( + 'Last password update', + readonly=True, + ) + password_history_ids = fields.One2many( + string='Password History', + comodel_name='res.users.pass.history', + inverse_name='user_id', + readonly=True, + ) + + @api.model + def create(self, vals): + vals['password_write_date'] = fields.Datetime.now() + return super(ResUsers, self).create(vals) + + @api.multi + def write(self, vals): + if vals.get('password'): + self._check_password(vals['password']) + vals['password_write_date'] = fields.Datetime.now() + return super(ResUsers, self).write(vals) + + @api.multi + def _check_password(self, password): + self._check_password_rules(password) + self._check_password_history(password) + return True + + @api.multi + def _check_password_rules(self, password): + self.ensure_one() + if not password: + return True + company_id = self.company_id + message = [] + if company_id.password_lower and sum(map(str.islower, password)) < \ + company_id.password_lower: + message.append('\n ' + _('Lowercase letter (At least ' + + str(company_id.password_lower) + + ' character)') + ) + if company_id.password_upper and sum(map(str.isupper, password)) < \ + company_id.password_upper: + message.append('\n ' + _('Uppercase letter (At least ' + + str(company_id.password_upper) + + ' character)') + ) + if company_id.password_numeric and sum(map(str.isdigit, password)) < \ + company_id.password_numeric: + message.append('\n ' + _('Numeric digit (At least ' + + str(company_id.password_numeric) + + ' numeric)') + ) + if company_id.password_special and len(set('[~!@#$%^&*()_+{}":;\']+$' + ).intersection( + password)) < company_id.password_numeric: + message.append('\n ' + _('Special character (At least ' + + str(company_id.password_special) + + ' character of [ ~ ! @ # $ % ^ & * ( )_+ {' + ' } " : ; \' ])') + ) + if company_id.password_length and len(password) < \ + company_id.password_length: + message = [_('Password must be %d characters or more.') % + company_id.password_length] + message + if len(message) > 0: + raise PassError('\r'.join(message)) + else: + return True + + @api.multi + def _password_has_expired(self): + self.ensure_one() + if not self.password_write_date: + return True + write_date = fields.Datetime.from_string(self.password_write_date) + today = fields.Datetime.from_string(fields.Datetime.now()) + days = (today - write_date).days + return days > self.company_id.password_expiration + + @api.multi + def action_expire_password(self): + expiration = delta_now(days=+1) + for rec_id in self: + rec_id.mapped('partner_id').signup_prepare( + signup_type="reset", expiration=expiration + ) + + @api.multi + def _validate_pass_reset(self): + """ It provides validations before initiating a pass reset email + :raises: PassError on invalidated pass reset attempt + :return: True on allowed reset + """ + for rec_id in self: + pass_min = rec_id.company_id.password_minimum + if pass_min <= 0: + pass + write_date = fields.Datetime.from_string( + rec_id.password_write_date + ) + delta = timedelta(hours=pass_min) + if write_date + delta > datetime.now(): + raise PassError( + _('Passwords can only be reset every %d hour(s). ' + 'Please contact an administrator for assistance.') % + pass_min, + ) + return True + + @api.multi + def _check_password_history(self, password): + """ It validates proposed password against existing history + :raises: PassError on reused password + """ + crypt = self._crypt_context() + for rec_id in self: + recent_passes = rec_id.company_id.password_history + if recent_passes < 0: + recent_passes = rec_id.password_history_ids + else: + recent_passes = rec_id.password_history_ids[ + 0:recent_passes-1 + ] + if recent_passes.filtered( + lambda r: crypt.verify(password, r.password_crypt)): + raise PassError( + _('Cannot use the most recent %d passwords') % + rec_id.company_id.password_history + ) + + @api.multi + def _set_encrypted_password(self, encrypted): + """ It saves password crypt history for history rules """ + super(ResUsers, self)._set_encrypted_password(encrypted) + self.write({ + 'password_history_ids': [(0, 0, { + 'password_crypt': encrypted, + })], + }) diff --git a/addons/password_security/models/res_users_pass_history.py b/addons/password_security/models/res_users_pass_history.py new file mode 100644 index 00000000..f6f2f521 --- /dev/null +++ b/addons/password_security/models/res_users_pass_history.py @@ -0,0 +1,25 @@ +# Copyright 2016 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from flectra import fields, models + + +class ResUsersPassHistory(models.Model): + _name = 'res.users.pass.history' + _description = 'Res Users Password History' + + _order = 'user_id, date desc' + + user_id = fields.Many2one( + string='User', + comodel_name='res.users', + ondelete='cascade', + index=True, + ) + password_crypt = fields.Char( + string='Encrypted Password', + ) + date = fields.Datetime( + default=lambda s: fields.Datetime.now(), + index=True, + ) diff --git a/addons/password_security/security/ir.model.access.csv b/addons/password_security/security/ir.model.access.csv new file mode 100644 index 00000000..0936e187 --- /dev/null +++ b/addons/password_security/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_users_pass_history,access_res_users_pass_history,model_res_users_pass_history,base.group_user,1,0,1,0 diff --git a/addons/password_security/security/res_users_pass_history.xml b/addons/password_security/security/res_users_pass_history.xml new file mode 100644 index 00000000..d2a6f7e7 --- /dev/null +++ b/addons/password_security/security/res_users_pass_history.xml @@ -0,0 +1,20 @@ + + + + + + + + Res Users Pass History Access + + + [ + ('user_id', '=', user.id) + ] + + + diff --git a/addons/password_security/static/description/icon.png b/addons/password_security/static/description/icon.png new file mode 100644 index 00000000..ae3f1ca6 Binary files /dev/null and b/addons/password_security/static/description/icon.png differ diff --git a/addons/password_security/tests/__init__.py b/addons/password_security/tests/__init__.py new file mode 100644 index 00000000..2263c21e --- /dev/null +++ b/addons/password_security/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import test_res_users +from . import test_password_security_home +from . import test_password_security_session diff --git a/addons/password_security/tests/test_password_security_home.py b/addons/password_security/tests/test_password_security_home.py new file mode 100644 index 00000000..aabc387c --- /dev/null +++ b/addons/password_security/tests/test_password_security_home.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import mock + +from contextlib import contextmanager + +from flectra.tests.common import TransactionCase +from flectra.http import Response + +from ..controllers import main + + +IMPORT = 'flectra.addons.password_security.controllers.main' + + +class EndTestException(Exception): + """ It allows for isolation of resources by raise """ + + +class MockResponse(object): + def __new__(cls): + return mock.Mock(spec=Response) + + +class MockPassError(main.PassError): + def __init__(self): + super(MockPassError, self).__init__('Message') + + +class TestPasswordSecurityHome(TransactionCase): + + def setUp(self): + super(TestPasswordSecurityHome, self).setUp() + self.PasswordSecurityHome = main.PasswordSecurityHome + self.password_security_home = self.PasswordSecurityHome() + self.passwd = 'I am a password!' + self.qcontext = { + 'password': self.passwd, + } + + @contextmanager + def mock_assets(self): + """ It mocks and returns assets used by this controller """ + methods = ['do_signup', 'web_login', 'web_auth_signup', + 'web_auth_reset_password', + ] + with mock.patch.multiple( + main.AuthSignupHome, **{m: mock.DEFAULT for m in methods} + ) as _super: + mocks = {} + for method in methods: + mocks[method] = _super[method] + mocks[method].return_value = MockResponse() + with mock.patch('%s.request' % IMPORT) as request: + with mock.patch('%s.ensure_db' % IMPORT) as ensure: + with mock.patch('%s.http' % IMPORT) as http: + http.redirect_with_hash.return_value = \ + MockResponse() + mocks.update({ + 'request': request, + 'ensure_db': ensure, + 'http': http, + }) + yield mocks + + def test_do_signup_check(self): + """ It should check password on user """ + with self.mock_assets() as assets: + check_password = assets['request'].env.user._check_password + check_password.side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_home.do_signup(self.qcontext) + check_password.assert_called_once_with( + self.passwd, + ) + + def test_do_signup_return(self): + """ It should return result of super """ + with self.mock_assets() as assets: + res = self.password_security_home.do_signup(self.qcontext) + self.assertEqual(assets['do_signup'](), res) + + def test_web_login_ensure_db(self): + """ It should verify available db """ + with self.mock_assets() as assets: + assets['ensure_db'].side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_home.web_login() + + def test_web_login_super(self): + """ It should call superclass w/ proper args """ + expect_list = [1, 2, 3] + expect_dict = {'test1': 'good1', 'test2': 'good2'} + with self.mock_assets() as assets: + assets['web_login'].side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_home.web_login( + *expect_list, **expect_dict + ) + assets['web_login'].assert_called_once_with( + *expect_list, **expect_dict + ) + + def test_web_login_no_post(self): + """ It should return immediate result of super when not POST """ + with self.mock_assets() as assets: + assets['request'].httprequest.method = 'GET' + assets['request'].session.authenticate.side_effect = \ + EndTestException + res = self.password_security_home.web_login() + self.assertEqual( + assets['web_login'](), res, + ) + + def test_web_login_authenticate(self): + """ It should attempt authentication to obtain uid """ + with self.mock_assets() as assets: + assets['request'].httprequest.method = 'POST' + authenticate = assets['request'].session.authenticate + request = assets['request'] + authenticate.side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_home.web_login() + authenticate.assert_called_once_with( + request.session.db, + request.params['login'], + request.params['password'], + ) + + def test_web_login_authenticate_fail(self): + """ It should return super result if failed auth """ + with self.mock_assets() as assets: + authenticate = assets['request'].session.authenticate + request = assets['request'] + request.httprequest.method = 'POST' + request.env['res.users'].sudo.side_effect = EndTestException + authenticate.return_value = False + res = self.password_security_home.web_login() + self.assertEqual( + assets['web_login'](), res, + ) + + def test_web_login_get_user(self): + """ It should get the proper user as sudo """ + with self.mock_assets() as assets: + request = assets['request'] + request.httprequest.method = 'POST' + sudo = request.env['res.users'].sudo() + sudo.browse.side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_home.web_login() + sudo.browse.assert_called_once_with( + request.uid + ) + + def test_web_login_valid_pass(self): + """ It should return parent result if pass isn't expired """ + with self.mock_assets() as assets: + request = assets['request'] + request.httprequest.method = 'POST' + user = request.env['res.users'].sudo().browse() + user.action_expire_password.side_effect = EndTestException + user._password_has_expired.return_value = False + res = self.password_security_home.web_login() + self.assertEqual( + assets['web_login'](), res, + ) + + def test_web_login_expire_pass(self): + """ It should expire password if necessary """ + with self.mock_assets() as assets: + request = assets['request'] + request.httprequest.method = 'POST' + user = request.env['res.users'].sudo().browse() + user.action_expire_password.side_effect = EndTestException + user._password_has_expired.return_value = True + with self.assertRaises(EndTestException): + self.password_security_home.web_login() + + def test_web_login_log_out_if_expired(self): + """It should log out user if password expired""" + with self.mock_assets() as assets: + request = assets['request'] + request.httprequest.method = 'POST' + user = request.env['res.users'].sudo().browse() + user._password_has_expired.return_value = True + self.password_security_home.web_login() + + logout_mock = request.session.logout + logout_mock.assert_called_once_with(keep_db=True) + + def test_web_login_redirect(self): + """ It should redirect w/ hash to reset after expiration """ + with self.mock_assets() as assets: + request = assets['request'] + request.httprequest.method = 'POST' + user = request.env['res.users'].sudo().browse() + user._password_has_expired.return_value = True + res = self.password_security_home.web_login() + self.assertEqual( + assets['http'].redirect_with_hash(), res, + ) + + def test_web_auth_signup_valid(self): + """ It should return super if no errors """ + with self.mock_assets() as assets: + res = self.password_security_home.web_auth_signup() + self.assertEqual( + assets['web_auth_signup'](), res, + ) + + def test_web_auth_signup_invalid_qcontext(self): + """ It should catch PassError and get signup qcontext """ + with self.mock_assets() as assets: + with mock.patch.object( + main.AuthSignupHome, 'get_auth_signup_qcontext', + ) as qcontext: + assets['web_auth_signup'].side_effect = MockPassError + qcontext.side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_home.web_auth_signup() + + def test_web_auth_signup_invalid_render(self): + """ It should render & return signup form on invalid """ + with self.mock_assets() as assets: + with mock.patch.object( + main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict + ) as qcontext: + assets['web_auth_signup'].side_effect = MockPassError + res = self.password_security_home.web_auth_signup() + assets['request'].render.assert_called_once_with( + 'auth_signup.signup', qcontext(), + ) + self.assertEqual( + assets['request'].render(), res, + ) + + def test_web_auth_reset_password_fail_login(self): + """ It should raise from failed _validate_pass_reset by login """ + with self.mock_assets() as assets: + with mock.patch.object( + main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict + ) as qcontext: + qcontext['login'] = 'login' + search = assets['request'].env.sudo().search + assets['request'].httprequest.method = 'POST' + user = mock.MagicMock() + user._validate_pass_reset.side_effect = MockPassError + search.return_value = user + with self.assertRaises(MockPassError): + self.password_security_home.web_auth_reset_password() + + def test_web_auth_reset_password_fail_email(self): + """ It should raise from failed _validate_pass_reset by email """ + with self.mock_assets() as assets: + with mock.patch.object( + main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict + ) as qcontext: + qcontext['login'] = 'login' + search = assets['request'].env.sudo().search + assets['request'].httprequest.method = 'POST' + user = mock.MagicMock() + user._validate_pass_reset.side_effect = MockPassError + search.side_effect = [[], user] + with self.assertRaises(MockPassError): + self.password_security_home.web_auth_reset_password() + + def test_web_auth_reset_password_success(self): + """ It should return parent response on no validate errors """ + with self.mock_assets() as assets: + with mock.patch.object( + main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict + ) as qcontext: + qcontext['login'] = 'login' + assets['request'].httprequest.method = 'POST' + res = self.password_security_home.web_auth_reset_password() + self.assertEqual( + assets['web_auth_reset_password'](), res, + ) diff --git a/addons/password_security/tests/test_password_security_session.py b/addons/password_security/tests/test_password_security_session.py new file mode 100644 index 00000000..269f78e6 --- /dev/null +++ b/addons/password_security/tests/test_password_security_session.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import mock + +from contextlib import contextmanager + +from flectra.tests.common import TransactionCase + +from ..controllers import main + + +IMPORT = 'flectra.addons.password_security.controllers.main' + + +class EndTestException(Exception): + """ It allows for isolation of resources by raise """ + + +class TestPasswordSecuritySession(TransactionCase): + + def setUp(self): + super(TestPasswordSecuritySession, self).setUp() + self.PasswordSecuritySession = main.PasswordSecuritySession + self.password_security_session = self.PasswordSecuritySession() + self.passwd = 'I am a password!' + self.fields = [ + {'name': 'new_password', 'value': self.passwd}, + ] + + @contextmanager + def mock_assets(self): + """ It mocks and returns assets used by this controller """ + with mock.patch('%s.request' % IMPORT) as request: + yield { + 'request': request, + } + + def test_change_password_check(self): + """ It should check password on request user """ + with self.mock_assets() as assets: + check_password = assets['request'].env.user._check_password + check_password.side_effect = EndTestException + with self.assertRaises(EndTestException): + self.password_security_session.change_password(self.fields) + check_password.assert_called_once_with( + self.passwd, + ) + + def test_change_password_return(self): + """ It should return result of super """ + with self.mock_assets(): + with mock.patch.object(main.Session, 'change_password') as chg: + res = self.password_security_session.change_password( + self.fields + ) + self.assertEqual(chg(), res) diff --git a/addons/password_security/tests/test_res_users.py b/addons/password_security/tests/test_res_users.py new file mode 100644 index 00000000..44f54d75 --- /dev/null +++ b/addons/password_security/tests/test_res_users.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import time + +from flectra.tests.common import TransactionCase + +from ..exceptions import PassError + + +class TestResUsers(TransactionCase): + + def setUp(self): + super(TestResUsers, self).setUp() + self.login = 'foslabs@example.com' + self.partner_vals = { + 'name': 'Partner', + 'is_company': False, + 'email': self.login, + } + self.password = 'asdQWE123$%^' + self.main_comp = self.env.ref('base.main_company') + self.vals = { + 'name': 'User', + 'login': self.login, + 'password': self.password, + 'company_id': self.main_comp.id + } + self.model_obj = self.env['res.users'] + + def _new_record(self): + partner_id = self.env['res.partner'].create(self.partner_vals) + self.vals['partner_id'] = partner_id.id + return self.model_obj.create(self.vals) + + def test_password_write_date_is_saved_on_create(self): + rec_id = self._new_record() + self.assertTrue( + rec_id.password_write_date, + 'Password write date was not saved to db.', + ) + + def test_password_write_date_is_updated_on_write(self): + rec_id = self._new_record() + old_write_date = rec_id.password_write_date + time.sleep(2) + rec_id.write({'password': 'asdQWE123$%^2'}) + rec_id.refresh() + new_write_date = rec_id.password_write_date + self.assertNotEqual( + old_write_date, new_write_date, + 'Password write date was not updated on write.', + ) + + def test_does_not_update_write_date_if_password_unchanged(self): + rec_id = self._new_record() + old_write_date = rec_id.password_write_date + time.sleep(2) + rec_id.write({'name': 'Luser'}) + rec_id.refresh() + new_write_date = rec_id.password_write_date + self.assertEqual( + old_write_date, new_write_date, + 'Password not changed but write date updated anyway.', + ) + + def test_check_password_returns_true_for_valid_password(self): + rec_id = self._new_record() + self.assertTrue( + rec_id._check_password('asdQWE123$%^3'), + 'Password is valid but check failed.', + ) + + def test_check_password_raises_error_for_invalid_password(self): + rec_id = self._new_record() + with self.assertRaises(PassError): + rec_id._check_password('password') + + def test_save_password_crypt(self): + rec_id = self._new_record() + self.assertEqual( + 1, len(rec_id.password_history_ids), + ) + + def test_check_password_crypt(self): + """ It should raise PassError if previously used """ + rec_id = self._new_record() + with self.assertRaises(PassError): + rec_id.write({'password': self.password}) + + def test_password_is_expired_if_record_has_no_write_date(self): + rec_id = self._new_record() + rec_id.write({'password_write_date': None}) + rec_id.refresh() + self.assertTrue( + rec_id._password_has_expired(), + 'Record has no password write date but check failed.', + ) + + def test_an_old_password_is_expired(self): + rec_id = self._new_record() + old_write_date = '1970-01-01 00:00:00' + rec_id.write({'password_write_date': old_write_date}) + rec_id.refresh() + self.assertTrue( + rec_id._password_has_expired(), + 'Password is out of date but check failed.', + ) + + def test_a_new_password_is_not_expired(self): + rec_id = self._new_record() + self.assertFalse( + rec_id._password_has_expired(), + 'Password was just created but has already expired.', + ) + + def test_expire_password_generates_token(self): + rec_id = self._new_record() + rec_id.sudo().action_expire_password() + rec_id.refresh() + token = rec_id.partner_id.signup_token + self.assertTrue( + token, + 'A token was not generated.', + ) + + def test_validate_pass_reset_error(self): + """ It should throw PassError on reset inside min threshold """ + rec_id = self._new_record() + with self.assertRaises(PassError): + rec_id._validate_pass_reset() + + def test_validate_pass_reset_allow(self): + """ It should allow reset pass when outside threshold """ + rec_id = self._new_record() + rec_id.password_write_date = '2016-01-01' + self.assertEqual( + True, rec_id._validate_pass_reset(), + ) + + def test_validate_pass_reset_zero(self): + """ It should allow reset pass when <= 0 """ + rec_id = self._new_record() + rec_id.company_id.password_minimum = 0 + self.assertEqual( + True, rec_id._validate_pass_reset(), + ) diff --git a/addons/password_security/views/res_company_view.xml b/addons/password_security/views/res_company_view.xml new file mode 100644 index 00000000..bcbbd059 --- /dev/null +++ b/addons/password_security/views/res_company_view.xml @@ -0,0 +1,42 @@ + + + + + + + + res.company.form + res.company + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/point_of_sale/static/description/icon.png b/addons/point_of_sale/static/description/icon.png index 6121aad7..17814c3b 100644 Binary files a/addons/point_of_sale/static/description/icon.png and b/addons/point_of_sale/static/description/icon.png differ diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index 6b24a649..3e23d19c 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -278,7 +278,7 @@ User Interface design - 2011-02-06 + 2016-08-25 diff --git a/addons/project/data/web_planner_data.xml b/addons/project/data/web_planner_data.xml index 5cba81c3..c8fb8ae9 100644 --- a/addons/project/data/web_planner_data.xml +++ b/addons/project/data/web_planner_data.xml @@ -25,10 +25,8 @@ Good luck!

-
For the Flectra Team,
- Fabien Pinckaers, Founder
diff --git a/addons/project/static/description/icon.png b/addons/project/static/description/icon.png index 9c66dcaf..2f77078b 100644 Binary files a/addons/project/static/description/icon.png and b/addons/project/static/description/icon.png differ diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 40d4a941..f6741061 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -627,6 +627,14 @@
+ + project.task.gantt + project.task + + + + + account.analytic.account.form.inherit account.analytic.account @@ -645,7 +653,7 @@ Tasks project.task - kanban,tree,form,calendar,pivot,graph + kanban,tree,form,calendar,pivot,graph,gantt {'search_default_my_tasks': 1} diff --git a/addons/purchase/static/description/icon.png b/addons/purchase/static/description/icon.png index e70b08f3..147f67fe 100644 Binary files a/addons/purchase/static/description/icon.png and b/addons/purchase/static/description/icon.png differ diff --git a/addons/sale/static/description/icon.png b/addons/sale/static/description/icon.png index bd83ea1c..0e2d7aa4 100644 Binary files a/addons/sale/static/description/icon.png and b/addons/sale/static/description/icon.png differ diff --git a/addons/sale_management/static/description/icon.png b/addons/sale_management/static/description/icon.png index 0a2a697b..0e2d7aa4 100644 Binary files a/addons/sale_management/static/description/icon.png and b/addons/sale_management/static/description/icon.png differ diff --git a/addons/sale_margin/__init__.py b/addons/sale_margin/__init__.py index 49a98edb..e153912d 100644 --- a/addons/sale_margin/__init__.py +++ b/addons/sale_margin/__init__.py @@ -2,8 +2,8 @@ # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from functools import partial -import openerp -from openerp import api, SUPERUSER_ID +import flectra +from flectra import api, SUPERUSER_ID from . import models # noqa from . import report # noqa diff --git a/addons/stock/data/web_planner_data.xml b/addons/stock/data/web_planner_data.xml index 55f85cbb..8bde952a 100644 --- a/addons/stock/data/web_planner_data.xml +++ b/addons/stock/data/web_planner_data.xml @@ -22,10 +22,8 @@ to inventory reduction and better efficiencies in your daily operations.

-
For the Flectra Team,
- Fabien Pinckaers, Founder
diff --git a/addons/stock/static/description/icon.png b/addons/stock/static/description/icon.png index fad5df90..870b90a7 100644 Binary files a/addons/stock/static/description/icon.png and b/addons/stock/static/description/icon.png differ diff --git a/addons/survey/static/description/icon.png b/addons/survey/static/description/icon.png index 6749a040..425c1219 100644 Binary files a/addons/survey/static/description/icon.png and b/addons/survey/static/description/icon.png differ diff --git a/addons/web/static/lib/jquery.ganttView/README.markdown b/addons/web/static/lib/jquery.ganttView/README.markdown new file mode 100644 index 00000000..aa5bfc57 --- /dev/null +++ b/addons/web/static/lib/jquery.ganttView/README.markdown @@ -0,0 +1,73 @@ +jQuery.ganttView +================ + +The jQuery.ganttView plugin is a very lightweight plugin for creating a Gantt chart in plain HTML...no vector graphics or images required. The plugin supports dragging and resizing the Gantt blocks and callbacks to trap the updated data. + +[![Sample Gantt](https://raw.githubusercontent.com/thegrubbsian/jquery.ganttView/master/example/jquery-ganttview.png) A sample chart](http://thegrubbsian.github.io/jquery.ganttView/example/index.html) + + +Browser Compatibility +--------------------- +Currently the plugin has been tested, and is working in: FF 3.5+, Chrome 5+, Safari 4+, IE8+. There are minor issues in IE7 and I haven't even attempted to use it in IE6. If you encounter any issues with any version of Internet Explorer and would like to contribute CSS fixes please do so, several people have asked for IE6 support. + + +Dependencies +------------ +The plugin depends on the following libraries: + +- jQuery 1.7 or higher +- jQuery-UI 1.8 or higher +- date.js + + +Documentation +------------- +Forthcoming... + + +Contribution Guidelines +------------ +The internal roadmap for the plugin is detailed in the project wiki. If you're interested in features outside of what's described there, we'd be interested to discuss pull requests that would add these features. If you like the plugin, feel free to fork it and submit your patches back. + +**Guidelines:** If you'd like to offer a new feature please help us out by submitting the pull request with only the fewest changes necessary. + +Ideal: Fork the project, apply just the individual changes to the individual files effected, submit pull request. + +Those pull requests can usually be automatically merged and closed through the site here. + +If your pull request includes things like: + +- changes to dependencies or where they're hosted +- stylistic modifications +- moving project files to different directories +- more than one new feature / functional change + +one of us will have to do the work of carving out just the feature being pulled. Your request is likely to sit unmerged for a while if that's the case. + + +License +------- +The jQuery.ganttView plugin may be used free of charge under the conditions +of the following license: + +The MIT License + +Copyright (c) 2010 JC Grubbs - jc.grubbs@devmynd.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/web/static/lib/jquery.ganttView/date.js b/addons/web/static/lib/jquery.ganttView/date.js new file mode 100644 index 00000000..2ef3a1c5 --- /dev/null +++ b/addons/web/static/lib/jquery.ganttView/date.js @@ -0,0 +1,145 @@ +/** + * @version: 1.0 Alpha-1 + * @author: Coolite Inc. http://www.coolite.com/ + * @date: 2008-05-13 + * @copyright: Copyright (c) 2006-2008, Coolite Inc. (http://www.coolite.com/). All rights reserved. + * @license: Licensed under The MIT License. See license.txt and http://www.datejs.com/license/. + * @website: http://www.datejs.com/ + */ +Date.CultureInfo={name:"en-US",englishName:"English (United States)",nativeName:"English (United States)",dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],abbreviatedDayNames:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],shortestDayNames:["Su","Mo","Tu","We","Th","Fr","Sa"],firstLetterDayNames:["S","M","T","W","T","F","S"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],abbreviatedMonthNames:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],amDesignator:"AM",pmDesignator:"PM",firstDayOfWeek:0,twoDigitYearMax:2029,dateElementOrder:"mdy",formatPatterns:{shortDate:"M/d/yyyy",longDate:"dddd, MMMM dd, yyyy",shortTime:"h:mm tt",longTime:"h:mm:ss tt",fullDateTime:"dddd, MMMM dd, yyyy h:mm:ss tt",sortableDateTime:"yyyy-MM-ddTHH:mm:ss",universalSortableDateTime:"yyyy-MM-dd HH:mm:ssZ",rfc1123:"ddd, dd MMM yyyy HH:mm:ss GMT",monthDay:"MMMM dd",yearMonth:"MMMM, yyyy"},regexPatterns:{jan:/^jan(uary)?/i,feb:/^feb(ruary)?/i,mar:/^mar(ch)?/i,apr:/^apr(il)?/i,may:/^may/i,jun:/^jun(e)?/i,jul:/^jul(y)?/i,aug:/^aug(ust)?/i,sep:/^sep(t(ember)?)?/i,oct:/^oct(ober)?/i,nov:/^nov(ember)?/i,dec:/^dec(ember)?/i,sun:/^su(n(day)?)?/i,mon:/^mo(n(day)?)?/i,tue:/^tu(e(s(day)?)?)?/i,wed:/^we(d(nesday)?)?/i,thu:/^th(u(r(s(day)?)?)?)?/i,fri:/^fr(i(day)?)?/i,sat:/^sa(t(urday)?)?/i,future:/^next/i,past:/^last|past|prev(ious)?/i,add:/^(\+|aft(er)?|from|hence)/i,subtract:/^(\-|bef(ore)?|ago)/i,yesterday:/^yes(terday)?/i,today:/^t(od(ay)?)?/i,tomorrow:/^tom(orrow)?/i,now:/^n(ow)?/i,millisecond:/^ms|milli(second)?s?/i,second:/^sec(ond)?s?/i,minute:/^mn|min(ute)?s?/i,hour:/^h(our)?s?/i,week:/^w(eek)?s?/i,month:/^m(onth)?s?/i,day:/^d(ay)?s?/i,year:/^y(ear)?s?/i,shortMeridian:/^(a|p)/i,longMeridian:/^(a\.?m?\.?|p\.?m?\.?)/i,timezone:/^((e(s|d)t|c(s|d)t|m(s|d)t|p(s|d)t)|((gmt)?\s*(\+|\-)\s*\d\d\d\d?)|gmt|utc)/i,ordinalSuffix:/^\s*(st|nd|rd|th)/i,timeContext:/^\s*(\:|a(?!u|p)|p)/i},timezones:[{name:"UTC",offset:"-000"},{name:"GMT",offset:"-000"},{name:"EST",offset:"-0500"},{name:"EDT",offset:"-0400"},{name:"CST",offset:"-0600"},{name:"CDT",offset:"-0500"},{name:"MST",offset:"-0700"},{name:"MDT",offset:"-0600"},{name:"PST",offset:"-0800"},{name:"PDT",offset:"-0700"}]}; +(function(){var $D=Date,$P=$D.prototype,$C=$D.CultureInfo,p=function(s,l){if(!l){l=2;} +return("000"+s).slice(l*-1);};$P.clearTime=function(){this.setHours(0);this.setMinutes(0);this.setSeconds(0);this.setMilliseconds(0);return this;};$P.setTimeToNow=function(){var n=new Date();this.setHours(n.getHours());this.setMinutes(n.getMinutes());this.setSeconds(n.getSeconds());this.setMilliseconds(n.getMilliseconds());return this;};$D.today=function(){return new Date().clearTime();};$D.compare=function(date1,date2){if(isNaN(date1)||isNaN(date2)){throw new Error(date1+" - "+date2);}else if(date1 instanceof Date&&date2 instanceof Date){return(date1date2)?1:0;}else{throw new TypeError(date1+" - "+date2);}};$D.equals=function(date1,date2){return(date1.compareTo(date2)===0);};$D.getDayNumberFromName=function(name){var n=$C.dayNames,m=$C.abbreviatedDayNames,o=$C.shortestDayNames,s=name.toLowerCase();for(var i=0;i=start.getTime()&&this.getTime()<=end.getTime();};$P.isAfter=function(date){return this.compareTo(date||new Date())===1;};$P.isBefore=function(date){return(this.compareTo(date||new Date())===-1);};$P.isToday=function(){return this.isSameDay(new Date());};$P.isSameDay=function(date){return this.clone().clearTime().equals(date.clone().clearTime());};$P.addMilliseconds=function(value){this.setMilliseconds(this.getMilliseconds()+value);return this;};$P.addSeconds=function(value){return this.addMilliseconds(value*1000);};$P.addMinutes=function(value){return this.addMilliseconds(value*60000);};$P.addHours=function(value){return this.addMilliseconds(value*3600000);};$P.addDays=function(value){this.setDate(this.getDate()+value);return this;};$P.addWeeks=function(value){return this.addDays(value*7);};$P.addMonths=function(value){var n=this.getDate();this.setDate(1);this.setMonth(this.getMonth()+value);this.setDate(Math.min(n,$D.getDaysInMonth(this.getFullYear(),this.getMonth())));return this;};$P.addYears=function(value){return this.addMonths(value*12);};$P.add=function(config){if(typeof config=="number"){this._orient=config;return this;} +var x=config;if(x.milliseconds){this.addMilliseconds(x.milliseconds);} +if(x.seconds){this.addSeconds(x.seconds);} +if(x.minutes){this.addMinutes(x.minutes);} +if(x.hours){this.addHours(x.hours);} +if(x.weeks){this.addWeeks(x.weeks);} +if(x.months){this.addMonths(x.months);} +if(x.years){this.addYears(x.years);} +if(x.days){this.addDays(x.days);} +return this;};var $y,$m,$d;$P.getWeek=function(){var a,b,c,d,e,f,g,n,s,w;$y=(!$y)?this.getFullYear():$y;$m=(!$m)?this.getMonth()+1:$m;$d=(!$d)?this.getDate():$d;if($m<=2){a=$y-1;b=(a/4|0)-(a/100|0)+(a/400|0);c=((a-1)/4|0)-((a-1)/100|0)+((a-1)/400|0);s=b-c;e=0;f=$d-1+(31*($m-1));}else{a=$y;b=(a/4|0)-(a/100|0)+(a/400|0);c=((a-1)/4|0)-((a-1)/100|0)+((a-1)/400|0);s=b-c;e=s+1;f=$d+((153*($m-3)+2)/5)+58+s;} +g=(a+b)%7;d=(f+g-e)%7;n=(f+3-d)|0;if(n<0){w=53-((g-s)/5|0);}else if(n>364+s){w=1;}else{w=(n/7|0)+1;} +$y=$m=$d=null;return w;};$P.getISOWeek=function(){$y=this.getUTCFullYear();$m=this.getUTCMonth()+1;$d=this.getUTCDate();return p(this.getWeek());};$P.setWeek=function(n){return this.moveToDayOfWeek(1).addWeeks(n-this.getWeek());};$D._validate=function(n,min,max,name){if(typeof n=="undefined"){return false;}else if(typeof n!="number"){throw new TypeError(n+" is not a Number.");}else if(nmax){throw new RangeError(n+" is not a valid value for "+name+".");} +return true;};$D.validateMillisecond=function(value){return $D._validate(value,0,999,"millisecond");};$D.validateSecond=function(value){return $D._validate(value,0,59,"second");};$D.validateMinute=function(value){return $D._validate(value,0,59,"minute");};$D.validateHour=function(value){return $D._validate(value,0,23,"hour");};$D.validateDay=function(value,year,month){return $D._validate(value,1,$D.getDaysInMonth(year,month),"day");};$D.validateMonth=function(value){return $D._validate(value,0,11,"month");};$D.validateYear=function(value){return $D._validate(value,0,9999,"year");};$P.set=function(config){if($D.validateMillisecond(config.millisecond)){this.addMilliseconds(config.millisecond-this.getMilliseconds());} +if($D.validateSecond(config.second)){this.addSeconds(config.second-this.getSeconds());} +if($D.validateMinute(config.minute)){this.addMinutes(config.minute-this.getMinutes());} +if($D.validateHour(config.hour)){this.addHours(config.hour-this.getHours());} +if($D.validateMonth(config.month)){this.addMonths(config.month-this.getMonth());} +if($D.validateYear(config.year)){this.addYears(config.year-this.getFullYear());} +if($D.validateDay(config.day,this.getFullYear(),this.getMonth())){this.addDays(config.day-this.getDate());} +if(config.timezone){this.setTimezone(config.timezone);} +if(config.timezoneOffset){this.setTimezoneOffset(config.timezoneOffset);} +if(config.week&&$D._validate(config.week,0,53,"week")){this.setWeek(config.week);} +return this;};$P.moveToFirstDayOfMonth=function(){return this.set({day:1});};$P.moveToLastDayOfMonth=function(){return this.set({day:$D.getDaysInMonth(this.getFullYear(),this.getMonth())});};$P.moveToNthOccurrence=function(dayOfWeek,occurrence){var shift=0;if(occurrence>0){shift=occurrence-1;} +else if(occurrence===-1){this.moveToLastDayOfMonth();if(this.getDay()!==dayOfWeek){this.moveToDayOfWeek(dayOfWeek,-1);} +return this;} +return this.moveToFirstDayOfMonth().addDays(-1).moveToDayOfWeek(dayOfWeek,+1).addWeeks(shift);};$P.moveToDayOfWeek=function(dayOfWeek,orient){var diff=(dayOfWeek-this.getDay()+7*(orient||+1))%7;return this.addDays((diff===0)?diff+=7*(orient||+1):diff);};$P.moveToMonth=function(month,orient){var diff=(month-this.getMonth()+12*(orient||+1))%12;return this.addMonths((diff===0)?diff+=12*(orient||+1):diff);};$P.getOrdinalNumber=function(){return Math.ceil((this.clone().clearTime()-new Date(this.getFullYear(),0,1))/86400000)+1;};$P.getTimezone=function(){return $D.getTimezoneAbbreviation(this.getUTCOffset());};$P.setTimezoneOffset=function(offset){var here=this.getTimezoneOffset(),there=Number(offset)*-6/10;return this.addMinutes(there-here);};$P.setTimezone=function(offset){return this.setTimezoneOffset($D.getTimezoneOffset(offset));};$P.hasDaylightSavingTime=function(){return(Date.today().set({month:0,day:1}).getTimezoneOffset()!==Date.today().set({month:6,day:1}).getTimezoneOffset());};$P.isDaylightSavingTime=function(){return(this.hasDaylightSavingTime()&&new Date().getTimezoneOffset()===Date.today().set({month:6,day:1}).getTimezoneOffset());};$P.getUTCOffset=function(){var n=this.getTimezoneOffset()*-10/6,r;if(n<0){r=(n-10000).toString();return r.charAt(0)+r.substr(2);}else{r=(n+10000).toString();return"+"+r.substr(1);}};$P.getElapsed=function(date){return(date||new Date())-this;};if(!$P.toISOString){$P.toISOString=function(){function f(n){return n<10?'0'+n:n;} +return'"'+this.getUTCFullYear()+'-'+ +f(this.getUTCMonth()+1)+'-'+ +f(this.getUTCDate())+'T'+ +f(this.getUTCHours())+':'+ +f(this.getUTCMinutes())+':'+ +f(this.getUTCSeconds())+'Z"';};} +$P._toString=$P.toString;$P.toString=function(format){var x=this;if(format&&format.length==1){var c=$C.formatPatterns;x.t=x.toString;switch(format){case"d":return x.t(c.shortDate);case"D":return x.t(c.longDate);case"F":return x.t(c.fullDateTime);case"m":return x.t(c.monthDay);case"r":return x.t(c.rfc1123);case"s":return x.t(c.sortableDateTime);case"t":return x.t(c.shortTime);case"T":return x.t(c.longTime);case"u":return x.t(c.universalSortableDateTime);case"y":return x.t(c.yearMonth);}} +var ord=function(n){switch(n*1){case 1:case 21:case 31:return"st";case 2:case 22:return"nd";case 3:case 23:return"rd";default:return"th";}};return format?format.replace(/(\\)?(dd?d?d?|MM?M?M?|yy?y?y?|hh?|HH?|mm?|ss?|tt?|S)/g,function(m){if(m.charAt(0)==="\\"){return m.replace("\\","");} +x.h=x.getHours;switch(m){case"hh":return p(x.h()<13?(x.h()===0?12:x.h()):(x.h()-12));case"h":return x.h()<13?(x.h()===0?12:x.h()):(x.h()-12);case"HH":return p(x.h());case"H":return x.h();case"mm":return p(x.getMinutes());case"m":return x.getMinutes();case"ss":return p(x.getSeconds());case"s":return x.getSeconds();case"yyyy":return p(x.getFullYear(),4);case"yy":return p(x.getFullYear());case"dddd":return $C.dayNames[x.getDay()];case"ddd":return $C.abbreviatedDayNames[x.getDay()];case"dd":return p(x.getDate());case"d":return x.getDate();case"MMMM":return $C.monthNames[x.getMonth()];case"MMM":return $C.abbreviatedMonthNames[x.getMonth()];case"MM":return p((x.getMonth()+1));case"M":return x.getMonth()+1;case"t":return x.h()<12?$C.amDesignator.substring(0,1):$C.pmDesignator.substring(0,1);case"tt":return x.h()<12?$C.amDesignator:$C.pmDesignator;case"S":return ord(x.getDate());default:return m;}}):this._toString();};}()); +(function(){var $D=Date,$P=$D.prototype,$C=$D.CultureInfo,$N=Number.prototype;$P._orient=+1;$P._nth=null;$P._is=false;$P._same=false;$P._isSecond=false;$N._dateElement="day";$P.next=function(){this._orient=+1;return this;};$D.next=function(){return $D.today().next();};$P.last=$P.prev=$P.previous=function(){this._orient=-1;return this;};$D.last=$D.prev=$D.previous=function(){return $D.today().last();};$P.is=function(){this._is=true;return this;};$P.same=function(){this._same=true;this._isSecond=false;return this;};$P.today=function(){return this.same().day();};$P.weekday=function(){if(this._is){this._is=false;return(!this.is().sat()&&!this.is().sun());} +return false;};$P.at=function(time){return(typeof time==="string")?$D.parse(this.toString("d")+" "+time):this.set(time);};$N.fromNow=$N.after=function(date){var c={};c[this._dateElement]=this;return((!date)?new Date():date.clone()).add(c);};$N.ago=$N.before=function(date){var c={};c[this._dateElement]=this*-1;return((!date)?new Date():date.clone()).add(c);};var dx=("sunday monday tuesday wednesday thursday friday saturday").split(/\s/),mx=("january february march april may june july august september october november december").split(/\s/),px=("Millisecond Second Minute Hour Day Week Month Year").split(/\s/),pxf=("Milliseconds Seconds Minutes Hours Date Week Month FullYear").split(/\s/),nth=("final first second third fourth fifth").split(/\s/),de;$P.toObject=function(){var o={};for(var i=0;itemp){throw new RangeError($D.getDayName(n)+" does not occur "+ntemp+" times in the month of "+$D.getMonthName(temp.getMonth())+" "+temp.getFullYear()+".");} +return this;} +return this.moveToDayOfWeek(n,this._orient);};};var sdf=function(n){return function(){var t=$D.today(),shift=n-t.getDay();if(n===0&&$C.firstDayOfWeek===1&&t.getDay()!==0){shift=shift+7;} +return t.addDays(shift);};};for(var i=0;i-1;m--){v=px[m].toLowerCase();if(o1[v]!=o2[v]){return false;} +if(k==v){break;}} +return true;} +if(j.substring(j.length-1)!="s"){j+="s";} +return this["add"+j](this._orient);};};var nf=function(n){return function(){this._dateElement=n;return this;};};for(var k=0;k0&&!last){try{q=d.call(this,r[1]);}catch(ex){last=true;}}else{last=true;} +if(!last&&q[1].length===0){last=true;} +if(!last){var qx=[];for(var j=0;j0){rx[0]=rx[0].concat(p[0]);rx[1]=p[1];}} +if(rx[1].length1){args=Array.prototype.slice.call(arguments);}else if(arguments[0]instanceof Array){args=arguments[0];} +if(args){for(var i=0,px=args.shift();i2)?n:(n+(((n+2000)<$C.twoDigitYearMax)?2000:1900)));};},rday:function(s){return function(){switch(s){case"yesterday":this.days=-1;break;case"tomorrow":this.days=1;break;case"today":this.days=0;break;case"now":this.days=0;this.now=true;break;}};},finishExact:function(x){x=(x instanceof Array)?x:[x];for(var i=0;i$D.getDaysInMonth(this.year,this.month)){throw new RangeError(this.day+" is not a valid value for days.");} +var r=new Date(this.year,this.month,this.day,this.hour,this.minute,this.second);if(this.timezone){r.set({timezone:this.timezone});}else if(this.timezoneOffset){r.set({timezoneOffset:this.timezoneOffset});} +return r;},finish:function(x){x=(x instanceof Array)?flattenAndCompact(x):[x];if(x.length===0){return null;} +for(var i=0;i", { "class": "ganttview" }); + new Chart(div, opts).render(); + container.append(div); + + var w = jQuery("div.ganttview-vtheader", container).outerWidth() + + jQuery("div.ganttview-slide-container", container).outerWidth(); + container.css("width", (w + 2) + "px"); + + new Behavior(container, opts).apply(); + }); + } + } + + function handleMethod(method, value) { + + if (method == "setSlideWidth") { + var div = $("div.ganttview", this); + div.each(function () { + var vtWidth = $("div.ganttview-vtheader", div).outerWidth(); + $(div).width(vtWidth + value + 1); + $("div.ganttview-slide-container", this).width(value); + }); + } + } + + var Chart = function(div, opts) { + + function render() { + addVtHeader(div, opts.data, opts.cellHeight); + + var slideDiv = jQuery("
", { + "class": "ganttview-slide-container", + "css": { "width": opts.slideWidth + "px" } + }); + + dates = getDates(opts.start, opts.end); + addHzHeader(slideDiv, dates, opts.cellWidth); + addGrid(slideDiv, opts.data, dates, opts.cellWidth, opts.showWeekends, opts.showToday); + addBlockContainers(slideDiv, opts.data); + addBlocks(slideDiv, opts.data, opts.cellWidth, opts.start); + div.append(slideDiv); + applyLastClass(div.parent()); + } + + var monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + // Creates a 3 dimensional array [year][month][day] of every day + // between the given start and end dates + function getDates(start, end) { + var end = end.clone().addDays(6); + var dates = []; + dates[start.getFullYear()] = []; + dates[start.getFullYear()][start.getMonth()] = [start] + var last = start; + while (last.compareTo(end) == -1) { + var next = last.clone().addDays(1); + if (!dates[next.getFullYear()]) { dates[next.getFullYear()] = []; } + if (!dates[next.getFullYear()][next.getMonth()]) { + dates[next.getFullYear()][next.getMonth()] = []; + } + dates[next.getFullYear()][next.getMonth()].push(next); + last = next; + } + return dates; + } + + function addVtHeader(div, data, cellHeight) { + var headerDiv = jQuery("
", { "class": "ganttview-vtheader" }); + for (var i = 0; i < data.length; i++) { + var itemDiv = jQuery("
", { "class": "ganttview-vtheader-item" }); + if ($.trim(data[i].name).length > 0) + itemDiv.append(jQuery("
", { + "class": "ganttview-vtheader-item-name", + "css": { "height": (data[i].series.length * cellHeight) + "px" } + }).append(data[i].name)); + var seriesDiv = jQuery("
", { "class": "ganttview-vtheader-series" }); + for (var j = 0; j < data[i].series.length; j++) { + seriesDiv.append(jQuery("
", { "class": "ganttview-vtheader-series-name" }) + .append(data[i].series[j].name)); + } + itemDiv.append(seriesDiv); + headerDiv.append(itemDiv); + } + div.append(headerDiv); + } + + function addHzHeader(div, dates, cellWidth) { + var headerDiv = jQuery("
", { "class": "ganttview-hzheader" }); + var monthsDiv = jQuery("
", { "class": "ganttview-hzheader-months" }); + var daysDiv = jQuery("
", { "class": "ganttview-hzheader-days" }); + var totalW = 0; + for (var y in dates) { + for (var m in dates[y]) { + var w = dates[y][m].length * cellWidth; + totalW = totalW + w; + monthsDiv.append(jQuery("
", { + "class": "ganttview-hzheader-month", + "css": { "width": w + "px" } + }).append(monthNames[m] + "/" + y)); + for (var d in dates[y][m]) { + daysDiv.append(jQuery("
", { "class": "ganttview-hzheader-day" }) + .append(dates[y][m][d].getDate())); + } + } + } + monthsDiv.css("width", totalW + "px"); + daysDiv.css("width", totalW + "px"); + headerDiv.append(monthsDiv).append(daysDiv); + div.append(headerDiv); + } + + function addGrid(div, data, dates, cellWidth, showWeekends, showToday) { + var gridDiv = jQuery("
", { "class": "ganttview-grid" }); + var rowDiv = jQuery("
", { "class": "ganttview-grid-row" }); + for (var y in dates) { + for (var m in dates[y]) { + for (var d in dates[y][m]) { + var cellDiv = jQuery("
", { "class": "ganttview-grid-row-cell" }); + if (DateUtils.isWeekend(dates[y][m][d]) && showWeekends) { + cellDiv.addClass("ganttview-weekend"); + } + if (DateUtils.isToday(dates[y][m][d]) && showToday) { + cellDiv.addClass("ganttview-today"); + } + rowDiv.append(cellDiv); + } + } + } + var w = jQuery("div.ganttview-grid-row-cell", rowDiv).length * cellWidth; + rowDiv.css("width", w + "px"); + gridDiv.css("width", w + "px"); + for (var i = 0; i < data.length; i++) { + for (var j = 0; j < data[i].series.length; j++) { + gridDiv.append(rowDiv.clone()); + } + } + div.append(gridDiv); + } + + function addBlockContainers(div, data) { + var blocksDiv = jQuery("
", { "class": "ganttview-blocks" }); + for (var i = 0; i < data.length; i++) { + for (var j = 0; j < data[i].series.length; j++) { + blocksDiv.append(jQuery("
", { "class": "ganttview-block-container" })); + } + } + div.append(blocksDiv); + } + + function addBlocks(div, data, cellWidth, start) { + var rows = jQuery("div.ganttview-blocks div.ganttview-block-container", div); + var rowIdx = 0; + for (var i = 0; i < data.length; i++) { + for (var j = 0; j < data[i].series.length; j++) { + var series = data[i].series[j]; + var size = DateUtils.daysBetween(series.start, series.end) + 1; + var offset = DateUtils.daysBetween(start, series.start); + var block = jQuery("
", { + "class": "ganttview-block", + "title": series.name + ", " + size + " days \n"+ series.start +" to "+ series.end, + "css": { + "width": ((size * cellWidth) - 7) + "px", + "margin-left": ((offset * cellWidth) + 3) + "px" + } + }); + addBlockData(block, data[i], series); + if (data[i].series[j].color) { + block.css("background-color", data[i].series[j].color); + } + block.append(jQuery("
", { "class": "ganttview-block-text" }).text(size)); + jQuery(rows[rowIdx]).append(block); + rowIdx = rowIdx + 1; + } + } + } + + function addBlockData(block, data, series) { + // This allows custom attributes to be added to the series data objects + // and makes them available to the 'data' argument of click, resize, and drag handlers + var blockData = { id: data.id, name: data.name }; + jQuery.extend(blockData, series); + block.data("block-data", blockData); + } + + function applyLastClass(div) { + jQuery("div.ganttview-grid-row div.ganttview-grid-row-cell:last-child", div).addClass("last"); + jQuery("div.ganttview-hzheader-days div.ganttview-hzheader-day:last-child", div).addClass("last"); + jQuery("div.ganttview-hzheader-months div.ganttview-hzheader-month:last-child", div).addClass("last"); + } + + return { + render: render + }; + } + + var Behavior = function (div, opts) { + + function apply() { + + if (opts.behavior.clickable) { + bindBlockClick(div, opts.behavior.onDblClick); + } + + if (opts.behavior.resizable) { + bindBlockResize(div, opts.cellWidth, opts.start, opts.behavior.onResize); + } + + if (opts.behavior.draggable) { + bindBlockDrag(div, opts.cellWidth, opts.start, opts.behavior.onDrag); + } + } + + function bindBlockClick(div, callback) { + jQuery("div.ganttview-block", div).on("dblclick", function () { + if (callback) { callback(jQuery(this).data("block-data")); } + }); + } + + function bindBlockResize(div, cellWidth, startDate, callback) { + jQuery("div.ganttview-block", div).resizable({ + grid: cellWidth, + handles: "e,w", + stop: function () { + var block = jQuery(this); + updateDataAndPosition(div, block, cellWidth, startDate); + if (callback) { callback(block.data("block-data")); } + } + }); + } + + function bindBlockDrag(div, cellWidth, startDate, callback) { + jQuery("div.ganttview-block", div).draggable({ + axis: "x", + grid: [cellWidth, cellWidth], + stop: function () { + var block = jQuery(this); + updateDataAndPosition(div, block, cellWidth, startDate); + if (callback) { callback(block.data("block-data")); } + } + }); + } + + function updateDataAndPosition(div, block, cellWidth, startDate) { + var container = jQuery("div.ganttview-slide-container", div); + var scroll = container.scrollLeft(); + var offset = block.offset().left - container.offset().left - 1 + scroll; + + // Set new start date + var daysFromStart = Math.round(offset / cellWidth); + var newStart = startDate.clone().addDays(daysFromStart); + block.data("block-data").start = newStart; + + // Set new end date + var width = block.outerWidth(); + var numberOfDays = Math.round(width / cellWidth) - 1; + block.data("block-data").end = newStart.clone().addDays(numberOfDays); + jQuery("div.ganttview-block-text", block).text(numberOfDays + 1); + + // Remove top and left properties to avoid incorrect block positioning, + // set position to relative to keep blocks relative to scrollbar when scrolling + block.css("top", "").css("left", "") + .css("position", "relative").css("margin-left", offset + "px"); + } + + return { + apply: apply + }; + } + + var ArrayUtils = { + + contains: function (arr, obj) { + var has = false; + for (var i = 0; i < arr.length; i++) { if (arr[i] == obj) { has = true; } } + return has; + } + }; + + var DateUtils = { + + daysBetween: function (start, end) { + if (!start || !end) { return 0; } + start = Date.parse(start); end = Date.parse(end); + if (start.getYear() == 1901 || end.getYear() == 8099) { return 0; } + var count = 0, date = start.clone(); + while (date.compareTo(end) == -1) { count = count + 1; date.addDays(1); } + return count; + }, + + isWeekend: function (date) { + return date.getDay() % 6 == 0; + }, + + isToday: function (date) { + return date.isToday(); + }, + + getBoundaryDatesFromData: function (data, minDays) { + var minStart = new Date(); maxEnd = new Date(); + for (var i = 0; i < data.length; i++) { + for (var j = 0; j < data[i].series.length; j++) { + var start = Date.parse(data[i].series[j].start); + var end = Date.parse(data[i].series[j].end) + if (i == 0 && j == 0) { minStart = start; maxEnd = end; } + if (minStart.compareTo(start) == 1) { minStart = start; } + if (maxEnd.compareTo(end) == -1) { maxEnd = end; } + } + } + + // Insure that the width of the chart is at least the slide width to avoid empty + // whitespace to the right of the grid + if (DateUtils.daysBetween(minStart, maxEnd) < minDays) { + maxEnd = minStart.clone().addDays(minDays); + } + + return [minStart, maxEnd]; + } + }; + +})(jQuery); diff --git a/addons/web/static/src/img/favicon.ico b/addons/web/static/src/img/favicon.ico index 4abb4092..ec39d4dd 100644 Binary files a/addons/web/static/src/img/favicon.ico and b/addons/web/static/src/img/favicon.ico differ diff --git a/addons/web/static/src/img/logo.png b/addons/web/static/src/img/logo.png index eeb685b9..3472b13d 100644 Binary files a/addons/web/static/src/img/logo.png and b/addons/web/static/src/img/logo.png differ diff --git a/addons/web/static/src/img/logo2.png b/addons/web/static/src/img/logo2.png index dc9ab9a3..63cf6d4d 100644 Binary files a/addons/web/static/src/img/logo2.png and b/addons/web/static/src/img/logo2.png differ diff --git a/addons/web/static/src/img/logo_inverse_white_206px.png b/addons/web/static/src/img/logo_inverse_white_206px.png index 528d485f..418cc4c0 100644 Binary files a/addons/web/static/src/img/logo_inverse_white_206px.png and b/addons/web/static/src/img/logo_inverse_white_206px.png differ diff --git a/addons/web/static/src/img/nologo.png b/addons/web/static/src/img/nologo.png index 9f8cf01d..fbbe25fd 100644 Binary files a/addons/web/static/src/img/nologo.png and b/addons/web/static/src/img/nologo.png differ diff --git a/addons/web/static/src/img/view_empty_arrow.png b/addons/web/static/src/img/view_empty_arrow.png index dc2b88a3..e7a33a44 100644 Binary files a/addons/web/static/src/img/view_empty_arrow.png and b/addons/web/static/src/img/view_empty_arrow.png differ diff --git a/addons/web/static/src/js/views/gantt/gantt_controller.js b/addons/web/static/src/js/views/gantt/gantt_controller.js new file mode 100644 index 00000000..2c23fae1 --- /dev/null +++ b/addons/web/static/src/js/views/gantt/gantt_controller.js @@ -0,0 +1,25 @@ +flectra.define('web.GanttController', function (require) { +"use strict"; +/*--------------------------------------------------------- + * Flectra Gantt view + *---------------------------------------------------------*/ + +var AbstractController = require('web.AbstractController'); + +return AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + updateRecord: '_onUpdateRecord', + }), + _onUpdateRecord: function (record) { + this._rpc({ + model: this.model.modelName, + method: 'write', + args: [record.data.id, { + [this.model.data.arch['date_start']]: record.data.start.toString('yyyy-M-d'), + [this.model.data.arch['date_stop']]: record.data.end.toString('yyyy-M-d'), + }], + }).then(this.reload.bind(this)); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/gantt/gantt_model.js b/addons/web/static/src/js/views/gantt/gantt_model.js new file mode 100644 index 00000000..659ab401 --- /dev/null +++ b/addons/web/static/src/js/views/gantt/gantt_model.js @@ -0,0 +1,144 @@ +flectra.define('web.GanttModel', function (require) { +"use strict"; + +/** + * The gantt model is responsible for fetching and processing data from the + * server. It basically just do a search_read and format/normalize data. + */ + +var AbstractModel = require('web.AbstractModel'); + +return AbstractModel.extend({ + /** + * @override + * @param {Object} params + */ + init: function () { + this._super.apply(this, arguments); + this.data = null; + }, + /** + * @override + * @param {Object} params + * @param {string[]} params.groupedBy a list of valid field names + * @param {Object} params.context + * @param {string[]} params.domain + * @returns {Deferred} + */ + load: function (params) { + this.modelName = params.modelName; + this.data = { + records: [], + domain: params.domain, + context: params.context, + groupedBy: params.groupedBy || [], + arch: params.arch.attrs, + }; + return this._loadData(); + }, + /** + * @override + * @param {Object} params + * @param {string[]} params.groupedBy a list of valid field names + * @param {Object} params.context + * @param {string[]} params.domain + * @returns {Deferred} + */ + reload: function (handle, params) { + if (params.domain) { + this.data.domain = params.domain; + } + if (params.context) { + this.data.context = params.context; + } + if (params.groupBy) { + this.data.groupedBy = params.groupBy; + } + return this._loadData(); + }, + /** + * @returns {Deferred} + */ + _loadData: function () { + var self = this; + return this._rpc({ + model: this.modelName, + method: 'search_read', + context: this.data.context, + domain: this.data.domain, + }) + .then(function (records) { + self.data.records = self._processData(records); + }); + }, + _processData: function (raw_datas) { + /** + * GroupBy is only supported till 1st level ! + * @todo Flectra: Support Multi level GroupBy + */ + var self = this; + var ganttData = []; + if (self.data.groupedBy.length) { + _.each(raw_datas, function (raw_data) { + var grpByStr = raw_data[self.data.groupedBy[0]] ? raw_data[self.data.groupedBy[0]] : 'Undefined'; + if (grpByStr && grpByStr instanceof Array) { + grpByStr = raw_data[self.data.groupedBy[0]] ? raw_data[self.data.groupedBy[0]][1] : 'Undefined'; + } + var keyCheck = _.findKey(ganttData, {name: grpByStr}); + if (!keyCheck) { + ganttData.push({ + name: grpByStr, + series: [], + }); + } + keyCheck = _.findKey(ganttData, {name: grpByStr}); + if (ganttData[keyCheck]) { + if (raw_data[self.data.arch['date_stop']]) { + ganttData[keyCheck].series.push({ + id: raw_data['id'], + name: raw_data['display_name'], + start: raw_data[self.data.arch['date_start']].split(' ')[0], + end: raw_data[self.data.arch['date_stop']].split(' ')[0] + }); + } else { + ganttData[keyCheck].series.push({ + id: raw_data['id'], + name: raw_data['display_name'], + start: raw_data[self.data.arch['date_start']].split(' ')[0], + end: raw_data[self.data.arch['date_start']].split(' ')[0] + }); + } + } + }); + } else { + _.each(raw_datas, function (raw_data) { + if (raw_data[self.data.arch['date_stop']]) { + ganttData.push({ + series: [ + { + id: raw_data['id'], + name: raw_data['display_name'], + start: raw_data[self.data.arch['date_start']].split(' ')[0], + end: raw_data[self.data.arch['date_stop']].split(' ')[0] + }, + ], + }); + } else { + ganttData.push({ + series: [ + { + id: raw_data['id'], + name: raw_data['display_name'], + start: raw_data[self.data.arch['date_start']].split(' ')[0], + end: raw_data[self.data.arch['date_start']].split(' ')[0] + }, + ], + }); + } + }); + } + return ganttData; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/gantt/gantt_renderer.js b/addons/web/static/src/js/views/gantt/gantt_renderer.js new file mode 100644 index 00000000..f84f5b3f --- /dev/null +++ b/addons/web/static/src/js/views/gantt/gantt_renderer.js @@ -0,0 +1,122 @@ +flectra.define('web.GanttRenderer', function (require) { +"use strict"; + +/** + * The graph renderer turns the data from the graph model into a nice looking + * svg chart. This code uses the nvd3 library. + * + * Note that we use a custom build for the nvd3, with only the model we actually + * use. + */ + +var core = require('web.core'); +var AbstractRenderer = require('web.AbstractRenderer'); +var Dialog = require('web.Dialog'); + +var _t = core._t; +var QWeb = core.qweb; + +return AbstractRenderer.extend({ + template: "GanttView", + /** + * @override + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + * @param {boolean} params.stacked + */ + init: function (parent, state, params) { + this.parent = parent; + this._super.apply(this, arguments); + }, + + /** + * Render the chart. + * + * Note that This method is synchronous, but the actual rendering is done + * asynchronously (in a setTimeout). The reason for that is that nvd3/d3 + * needs to be in the DOM to correctly render itself. So, we trick Flectra by + * returning immediately, then wait a tiny interval before actually + * displaying the data. + * + * @returns {Deferred} The _super deferred is actually resolved immediately + */ + _render: function () { + this.data = this.parent.active_view.controller.model.data; + this._loadGanttView(); + return $.when(); + }, + _loadGanttView: function () { + var self = this; + this.$el.empty().ganttView({ + data: self.data.records, + slideWidth: 'auto', + cellWidth: 20, + behavior: { + onDblClick: function (data) { + var dialog = new Dialog(self, { + title: _t(data.name), + $content: $(QWeb.render('GanttViewWizard')), + size: 'small', + buttons: [ + {text: _t("Save"), classes: 'btn-success', click: _.bind(_callSave, self)}, + {text: _t("Cancel"), classes: 'btn-danger', close: true} + ] + }).open(); + + dialog.opened().then(function () { + var datepickers_options = { + keepOpen: true, + minDate: moment({y: 1900}), + maxDate: moment().add(200, "y"), + calendarWeeks: true, + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + }, + locale: moment.locale(), + format: "YYYY-MM-DD", + ignoreReadonly: true + }; + dialog.$el.find('input#start_date').val(data.start); + dialog.$el.find('input#end_date').val(data.end); + dialog.$el.find('input#start_date, input#end_date').datetimepicker(datepickers_options); + }); + + function _callSave(event) { + var newData = { + start: dialog.$el.find('input#start_date').val().toString(), + end: dialog.$el.find('input#end_date').val().toString(), + id: data.id + }; + if (data.start !== newData.start || data.end !== newData.end) { + var start_data = new Date(dialog.$el.find('input#start_date').val().toString()).getTime(); + var end_data = new Date(dialog.$el.find('input#end_date').val().toString()).getTime(); + if(start_data <= end_data){ + self.trigger_up('updateRecord', newData); + dialog.close(); + }else { + self.do_warn(_t("Warning"), _t("Start date should be less than or equal to End date")); + } + } + } + }, + + onResize: function (data) { + self.trigger_up('updateRecord', data); + }, + + onDrag: function (data) { + self.trigger_up('updateRecord', data); + }, + } + }); + this.$el.removeAttr('style'); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/gantt/gantt_view.js b/addons/web/static/src/js/views/gantt/gantt_view.js new file mode 100644 index 00000000..3d9fe207 --- /dev/null +++ b/addons/web/static/src/js/views/gantt/gantt_view.js @@ -0,0 +1,32 @@ +flectra.define('web.GanttView', function (require) { +"use strict"; + +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var GanttModel = require('web.GanttModel'); +var GanttRenderer = require('web.GanttRenderer'); +var Controller = require('web.GanttController'); + +var _lt = core._lt; + +var GanttView = AbstractView.extend({ + display_name: _lt('Gantt'), + icon: 'fa-tasks', + config: { + Model: GanttModel, + Controller: Controller, + Renderer: GanttRenderer, + }, + /** + * @override + */ + init: function(viewInfo) { + this._super.apply(this, arguments); + var arch = viewInfo.arch; + this.loadParams.arch = arch; + }, +}); + +return GanttView; + +}); diff --git a/addons/web/static/src/js/views/view_registry.js b/addons/web/static/src/js/views/view_registry.js index bacc448d..6f6c33eb 100644 --- a/addons/web/static/src/js/views/view_registry.js +++ b/addons/web/static/src/js/views/view_registry.js @@ -31,6 +31,7 @@ var KanbanView = require('web.KanbanView'); var ListView = require('web.ListView'); var PivotView = require('web.PivotView'); var CalendarView = require('web.CalendarView'); +var GanttView = require('web.GanttView'); var view_registry = require('web.view_registry'); view_registry @@ -39,6 +40,7 @@ view_registry .add('kanban', KanbanView) .add('graph', GraphView) .add('pivot', PivotView) - .add('calendar', CalendarView); + .add('calendar', CalendarView) + .add('gantt', GanttView); }); diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index e463ed5f..2dce0ab9 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -528,6 +528,39 @@

+ +
+ + + + + + + + + + + + + + +
+ + +
+ +
+
+ + +
+ +
+
+
+
@@ -1332,7 +1365,6 @@
  • Support
  • Preferences
  • -
  • My Flectra.com account
  • Log out
  • diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index b1aaab00..93f8e62a 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -236,6 +236,18 @@