+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2587,31 +2644,24 @@ action = pool.get('res.config').next(cr, uid, [], context)
-
-
-
-
+
+
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
@@ -2619,18 +2669,15 @@ action = pool.get('res.config').next(cr, uid, [], context)
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
diff --git a/addons/account/company_view.xml b/addons/account/company_view.xml
index 5aa1c76cc5e..4ade4b90876 100644
--- a/addons/account/company_view.xml
+++ b/addons/account/company_view.xml
@@ -22,11 +22,11 @@
res.companyform
-
-
-
-
-
+
+
+
+
+
diff --git a/addons/account/i18n/account.pot b/addons/account/i18n/account.pot
index b092b3a2f8c..174002a8d8d 100644
--- a/addons/account/i18n/account.pot
+++ b/addons/account/i18n/account.pot
@@ -203,7 +203,7 @@ msgstr ""
#. module: account
#: help:account.tax.code,notprintable:0
#: help:account.tax.code.template,notprintable:0
-msgid "Check this box if you don't want any VAT related to this Tax Code to appear on invoices"
+msgid "Check this box if you don't want any tax related to this tax code to appear on invoices"
msgstr ""
#. module: account
@@ -1853,7 +1853,7 @@ msgstr ""
#. module: account
#: report:account.journal.period.print.sale.purchase:0
-msgid "VAT Declaration"
+msgid "Tax Declaration"
msgstr ""
#. module: account
@@ -2249,7 +2249,7 @@ msgstr ""
#. module: account
#: view:account.vat.declaration:0
-msgid "This menu prints a VAT declaration based on invoices or payments. Select one or several periods of the fiscal year. The information required for a tax declaration is automatically generated by OpenERP from invoices (or payments, in some countries). This data is updated in real time. That’s very useful because it enables you to preview at any time the tax that you owe at the start and end of the month or quarter."
+msgid "This menu prints a tax declaration based on invoices or payments. Select one or several periods of the fiscal year. The information required for a tax declaration is automatically generated by OpenERP from invoices (or payments, in some countries). This data is updated in real time. That’s very useful because it enables you to preview at any time the tax that you owe at the start and end of the month or quarter."
msgstr ""
#. module: account
@@ -4480,7 +4480,7 @@ msgstr ""
#. module: account
#: model:ir.actions.act_window,name:account.action_account_vat_declaration
#: model:ir.model,name:account.model_account_vat_declaration
-msgid "Account Vat Declaration"
+msgid "Account Tax Declaration"
msgstr ""
#. module: account
@@ -4821,7 +4821,7 @@ msgstr ""
#: help:account.tax.template,ref_base_code_id:0
#: help:account.tax.template,ref_tax_code_id:0
#: help:account.tax.template,tax_code_id:0
-msgid "Use this code for the VAT declaration."
+msgid "Use this code for the tax declaration."
msgstr ""
#. module: account
@@ -5247,7 +5247,7 @@ msgstr ""
#. module: account
#: report:account.journal.period.print.sale.purchase:0
-msgid "VAT"
+msgid "Tax"
msgstr ""
#. module: account
@@ -7561,7 +7561,7 @@ msgstr ""
#. module: account
#: model:ir.actions.act_window,help:account.action_account_vat_declaration
-msgid "This menu print a VAT declaration based on invoices or payments. You can select one or several periods of the fiscal year. Information required for a tax declaration is automatically generated by OpenERP from invoices (or payments, in some countries). This data is updated in real time. That’s very useful because it enables you to preview at any time the tax that you owe at the start and end of the month or quarter."
+msgid "This menu print a tax declaration based on invoices or payments. You can select one or several periods of the fiscal year. Information required for a tax declaration is automatically generated by OpenERP from invoices (or payments, in some countries). This data is updated in real time. That’s very useful because it enables you to preview at any time the tax that you owe at the start and end of the month or quarter."
msgstr ""
#. module: account
diff --git a/addons/account/partner_view.xml b/addons/account/partner_view.xml
index ec3dcba3b84..1e360f6c742 100644
--- a/addons/account/partner_view.xml
+++ b/addons/account/partner_view.xml
@@ -76,56 +76,57 @@
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -142,16 +143,5 @@
view_type="form"
view_mode="tree,form,graph,calendar"/>
-
- res.partner.form.reconcile
- res.partner
-
-
-
-
-
-
-
-
diff --git a/addons/account/project/project_view.xml b/addons/account/project/project_view.xml
index 492ab249a39..76aaaf7df6a 100644
--- a/addons/account/project/project_view.xml
+++ b/addons/account/project/project_view.xml
@@ -118,6 +118,7 @@
account.analytic.line.formaccount.analytic.lineform
+ 1
diff --git a/addons/account/res_currency.py b/addons/account/res_currency.py
index 8c9c6a8b103..8e02836274a 100644
--- a/addons/account/res_currency.py
+++ b/addons/account/res_currency.py
@@ -44,4 +44,5 @@ class res_currency_account(osv.osv):
res_currency_account()
-# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
\ No newline at end of file
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
+
diff --git a/addons/account/test/account_cash_statement.yml b/addons/account/test/account_cash_statement.yml
index 2f1ae4b0076..6bff530bb54 100644
--- a/addons/account/test/account_cash_statement.yml
+++ b/addons/account/test/account_cash_statement.yml
@@ -1,5 +1,5 @@
-
- In order to test Cash statement I create a Cash statement and confirm it and check it's move created
+ In order to test Cash statement I create a Cash statement and confirm it and check its move created
-
!record {model: account.bank.statement, id: account_bank_statement_1}:
date: !eval time.strftime('%Y-%m-%d')
@@ -57,7 +57,6 @@
- pieces: 500.0
number: 2
subtotal: 1000.0
- balance_end_cash: 1120.0
-
I clicked on Close CashBox button to close the cashbox
diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py
index a78cf12050b..87a053d558c 100644
--- a/addons/account/wizard/__init__.py
+++ b/addons/account/wizard/__init__.py
@@ -64,6 +64,8 @@ import account_report_account_balance
import account_change_currency
+import pos_box;
+
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/account/wizard/account_chart_view.xml b/addons/account/wizard/account_chart_view.xml
index d7820caa647..553d83fa01c 100644
--- a/addons/account/wizard/account_chart_view.xml
+++ b/addons/account/wizard/account_chart_view.xml
@@ -7,13 +7,18 @@
form
diff --git a/addons/account/wizard/account_move_journal.py b/addons/account/wizard/account_move_journal.py
index c484da72304..66f634bd59f 100644
--- a/addons/account/wizard/account_move_journal.py
+++ b/addons/account/wizard/account_move_journal.py
@@ -102,22 +102,20 @@ class account_move_journal(osv.osv_memory):
period = period_pool.browse(cr, uid, [period_id], ['name'])[0]['name']
period_string = _("Period: %s") % tools.ustr(period)
- separator_string = _("Open Journal Items !")
open_string = _("Open")
view = """
""" % (open_string, separator_string, journal_string, period_string)
+
+ """ % (_('Journal'), journal_string, _('Period'), period_string, open_string)
view = etree.fromstring(view.encode('utf8'))
xarch, xfields = self._view_look_dom_arch(cr, uid, view, view_id, context=context)
diff --git a/addons/account/wizard/account_report_account_balance_view.xml b/addons/account/wizard/account_report_account_balance_view.xml
index b700ab8a736..c42e0bc38ad 100644
--- a/addons/account/wizard/account_report_account_balance_view.xml
+++ b/addons/account/wizard/account_report_account_balance_view.xml
@@ -13,10 +13,6 @@
-
-
-
-
diff --git a/addons/account/wizard/account_report_common_view.xml b/addons/account/wizard/account_report_common_view.xml
index 2711154a35e..18953270e09 100644
--- a/addons/account/wizard/account_report_common_view.xml
+++ b/addons/account/wizard/account_report_common_view.xml
@@ -17,16 +17,16 @@
-
+
-
-
-
+
+
+
-
-
-
+
+
+
diff --git a/addons/account/wizard/account_report_general_journal_view.xml b/addons/account/wizard/account_report_general_journal_view.xml
index 96f45f9d028..93c82cd5810 100644
--- a/addons/account/wizard/account_report_general_journal_view.xml
+++ b/addons/account/wizard/account_report_general_journal_view.xml
@@ -9,10 +9,6 @@
-
-
-
-
diff --git a/addons/account/wizard/account_report_general_ledger_view.xml b/addons/account/wizard/account_report_general_ledger_view.xml
index 20b6ab0f042..5078eeac88b 100644
--- a/addons/account/wizard/account_report_general_ledger_view.xml
+++ b/addons/account/wizard/account_report_general_ledger_view.xml
@@ -9,10 +9,6 @@
-
-
-
-
diff --git a/addons/account/wizard/account_report_partner_balance_view.xml b/addons/account/wizard/account_report_partner_balance_view.xml
index 6dbe6d7b77b..e8abd1c68a2 100644
--- a/addons/account/wizard/account_report_partner_balance_view.xml
+++ b/addons/account/wizard/account_report_partner_balance_view.xml
@@ -9,10 +9,6 @@
-
-
-
-
diff --git a/addons/account/wizard/account_report_partner_ledger_view.xml b/addons/account/wizard/account_report_partner_ledger_view.xml
index c3c731c1954..1bc223a956d 100644
--- a/addons/account/wizard/account_report_partner_ledger_view.xml
+++ b/addons/account/wizard/account_report_partner_ledger_view.xml
@@ -9,10 +9,6 @@
-
-
-
-
diff --git a/addons/account/wizard/account_report_print_journal_view.xml b/addons/account/wizard/account_report_print_journal_view.xml
index cf4eb0a0c9f..9a2ef25a1fe 100644
--- a/addons/account/wizard/account_report_print_journal_view.xml
+++ b/addons/account/wizard/account_report_print_journal_view.xml
@@ -9,10 +9,6 @@
-
-
-
-
diff --git a/addons/account/wizard/account_subscription_generate.py b/addons/account/wizard/account_subscription_generate.py
index 8efcd25b789..dc5209ebea0 100644
--- a/addons/account/wizard/account_subscription_generate.py
+++ b/addons/account/wizard/account_subscription_generate.py
@@ -28,7 +28,7 @@ class account_subscription_generate(osv.osv_memory):
_name = "account.subscription.generate"
_description = "Subscription Compute"
_columns = {
- 'date': fields.date('Date', required=True),
+ 'date': fields.date('Generate Entries Before', required=True),
}
_defaults = {
'date': lambda *a: time.strftime('%Y-%m-%d'),
diff --git a/addons/account/wizard/account_subscription_generate_view.xml b/addons/account/wizard/account_subscription_generate_view.xml
index 64ca3beeb11..e32439537d7 100644
--- a/addons/account/wizard/account_subscription_generate_view.xml
+++ b/addons/account/wizard/account_subscription_generate_view.xml
@@ -8,8 +8,6 @@
form
-
+
-
-
-
+
+
+
diff --git a/addons/account_asset/wizard/wizard_asset_compute_view.xml b/addons/account_asset/wizard/wizard_asset_compute_view.xml
index 45d3aa22625..e9b64a33a25 100644
--- a/addons/account_asset/wizard/wizard_asset_compute_view.xml
+++ b/addons/account_asset/wizard/wizard_asset_compute_view.xml
@@ -7,8 +7,6 @@
form
- Send follow-ups
+ Send Follow-Upsir.actions.act_windowaccount.followup.print.allform
diff --git a/addons/account_payment/account_payment.py b/addons/account_payment/account_payment.py
index 80d7a5419e7..22b7fc5f6f2 100644
--- a/addons/account_payment/account_payment.py
+++ b/addons/account_payment/account_payment.py
@@ -87,9 +87,9 @@ class payment_order(osv.osv):
return res
_columns = {
- 'date_scheduled': fields.date('Scheduled date if fixed', states={'done':[('readonly', True)]}, help='Select a date if you have chosen Preferred Date to be fixed.'),
+ 'date_scheduled': fields.date('Scheduled Date', states={'done':[('readonly', True)]}, help='Select a date if you have chosen Preferred Date to be fixed.'),
'reference': fields.char('Reference', size=128, required=1, states={'done': [('readonly', True)]}),
- 'mode': fields.many2one('payment.mode', 'Payment mode', select=True, required=1, states={'done': [('readonly', True)]}, help='Select the Payment Mode to be applied.'),
+ 'mode': fields.many2one('payment.mode', 'Payment Mode', select=True, required=1, states={'done': [('readonly', True)]}, help='Select the Payment Mode to be applied.'),
'state': fields.selection([
('draft', 'Draft'),
('cancel', 'Cancelled'),
@@ -98,14 +98,14 @@ class payment_order(osv.osv):
help='When an order is placed the state is \'Draft\'.\n Once the bank is confirmed the state is set to \'Confirmed\'.\n Then the order is paid the state is \'Done\'.'),
'line_ids': fields.one2many('payment.line', 'order_id', 'Payment lines', states={'done': [('readonly', True)]}),
'total': fields.function(_total, string="Total", type='float'),
- 'user_id': fields.many2one('res.users', 'User', required=True, states={'done': [('readonly', True)]}),
+ 'user_id': fields.many2one('res.users', 'Responsible', required=True, states={'done': [('readonly', True)]}),
'date_prefered': fields.selection([
('now', 'Directly'),
('due', 'Due date'),
('fixed', 'Fixed date')
- ], "Preferred date", change_default=True, required=True, states={'done': [('readonly', True)]}, help="Choose an option for the Payment Order:'Fixed' stands for a date specified by you.'Directly' stands for the direct execution.'Due date' stands for the scheduled date of execution."),
- 'date_created': fields.date('Creation date', readonly=True),
- 'date_done': fields.date('Execution date', readonly=True),
+ ], "Preferred Date", change_default=True, required=True, states={'done': [('readonly', True)]}, help="Choose an option for the Payment Order:'Fixed' stands for a date specified by you.'Directly' stands for the direct execution.'Due date' stands for the scheduled date of execution."),
+ 'date_created': fields.date('Creation Date', readonly=True),
+ 'date_done': fields.date('Execution Date', readonly=True),
'company_id': fields.related('mode', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True),
}
diff --git a/addons/account_payment/account_payment_view.xml b/addons/account_payment/account_payment_view.xml
index cf91dcc80a2..1577d1b86bd 100644
--- a/addons/account_payment/account_payment_view.xml
+++ b/addons/account_payment/account_payment_view.xml
@@ -112,16 +112,25 @@
-
-
-
-
-
-
-
-
-
+
+
diff --git a/addons/hr_recruitment/security/ir.model.access.csv b/addons/hr_recruitment/security/ir.model.access.csv
index 7d082070be5..8b029c9324a 100644
--- a/addons/hr_recruitment/security/ir.model.access.csv
+++ b/addons/hr_recruitment/security/ir.model.access.csv
@@ -3,7 +3,6 @@ access_hr_applicant_user,hr.applicant.user,model_hr_applicant,base.group_hr_user
access_hr_recruitment_report,hr.recruitment.report,model_hr_recruitment_report,base.group_hr_manager,1,1,1,1
access_hr_recruitment_stage_user,hr.recruitment.stage.user,model_hr_recruitment_stage,base.group_hr_user,1,1,1,1
access_hr_recruitment_degree,hr.recruitment.degree,model_hr_recruitment_degree,base.group_hr_user,1,1,1,1
-access_mail_message_user,mail.message.user,mail.model_mail_message,base.group_hr_user,1,1,1,1
access_res_partner_hr_user,res.partner.user,base.model_res_partner,base.group_hr_user,1,1,1,1
access_survey_hr_user,survey.hr.user,survey.model_survey,base.group_hr_user,1,1,1,0
access_crm_meeting_hruser,crm.meeting.hruser,base_calendar.model_crm_meeting,base.group_hr_user,1,1,1,1
diff --git a/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml b/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml
index 9888972105e..81dc1e3c9cf 100644
--- a/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml
+++ b/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml
@@ -114,7 +114,7 @@
form
-
+
diff --git a/addons/hr_timesheet_sheet/__openerp__.py b/addons/hr_timesheet_sheet/__openerp__.py
index defa3ee5873..04be6045f4d 100644
--- a/addons/hr_timesheet_sheet/__openerp__.py
+++ b/addons/hr_timesheet_sheet/__openerp__.py
@@ -25,6 +25,7 @@
'version': '1.0',
'category': 'Human Resources',
"sequence": 16,
+ "summary": "Attendances, Activities, Timing",
'description': """
This module helps you to easily encode and validate timesheet and attendances within the same view.
===================================================================================================
diff --git a/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml b/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml
index 6635c38b909..dbc9e1d6dac 100644
--- a/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml
+++ b/addons/hr_timesheet_sheet/hr_timesheet_sheet_view.xml
@@ -200,12 +200,12 @@
form
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/addons/hr_timesheet_sheet/i18n/sv.po b/addons/hr_timesheet_sheet/i18n/sv.po
index 341abbb0858..ba584de0934 100644
--- a/addons/hr_timesheet_sheet/i18n/sv.po
+++ b/addons/hr_timesheet_sheet/i18n/sv.po
@@ -7,14 +7,14 @@ msgstr ""
"Project-Id-Version: OpenERP Server 5.0.14\n"
"Report-Msgid-Bugs-To: support@openerp.com\n"
"POT-Creation-Date: 2012-02-08 01:37+0100\n"
-"PO-Revision-Date: 2010-11-22 21:07+0000\n"
-"Last-Translator: Olivier Dony (OpenERP) \n"
+"PO-Revision-Date: 2012-07-16 15:49+0000\n"
+"Last-Translator: Anders Wallenquist \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"X-Launchpad-Export-Date: 2012-07-14 06:05+0000\n"
-"X-Generator: Launchpad (build 15614)\n"
+"X-Launchpad-Export-Date: 2012-07-17 04:49+0000\n"
+"X-Generator: Launchpad (build 15627)\n"
#. module: hr_timesheet_sheet
#: field:hr.analytic.timesheet,sheet_id:0 field:hr.attendance,sheet_id:0
@@ -66,7 +66,7 @@ msgstr ""
#. module: hr_timesheet_sheet
#: view:hr_timesheet_sheet.sheet:0
msgid "Today"
-msgstr "Today"
+msgstr "Idag"
#. module: hr_timesheet_sheet
#: code:addons/hr_timesheet_sheet/hr_timesheet_sheet.py:274
@@ -74,7 +74,8 @@ msgstr "Today"
msgid ""
"Please verify that the total difference of the sheet is lower than %.2f !"
msgstr ""
-"Please verify that the total difference of the sheet is lower than %.2f !"
+"Vänligen kontrollera att den totala skillnaden mellan rapporterna är mindre "
+"än %.2f!"
#. module: hr_timesheet_sheet
#: selection:hr.timesheet.report,month:0 selection:timesheet.report,month:0
@@ -89,7 +90,7 @@ msgstr "# Kostnad"
#. module: hr_timesheet_sheet
#: view:hr.timesheet.report:0 view:timesheet.report:0
msgid "Timesheet of last month"
-msgstr ""
+msgstr "Tidrapport från förra månaden"
#. module: hr_timesheet_sheet
#: view:hr.timesheet.report:0 field:hr.timesheet.report,company_id:0
diff --git a/addons/import_google/i18n/es.po b/addons/import_google/i18n/es.po
new file mode 100644
index 00000000000..325df15688c
--- /dev/null
+++ b/addons/import_google/i18n/es.po
@@ -0,0 +1,217 @@
+# Spanish translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 00:36+0000\n"
+"PO-Revision-Date: 2012-07-21 21:08+0000\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: Spanish \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-22 04:45+0000\n"
+"X-Generator: Launchpad (build 15654)\n"
+
+#. module: import_google
+#: help:synchronize.google.import,group_name:0
+msgid "Choose which group to import, By default it takes all."
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Import Google Calendar Events"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "_Import Events"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:71
+#, python-format
+msgid ""
+"No Google Username or password Defined for user.\n"
+"Please define in user view"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:127
+#, python-format
+msgid ""
+"Invalid login detail !\n"
+" Specify Username/Password."
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,supplier:0
+msgid "Supplier"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Import Options"
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,group_name:0
+msgid "Group Name"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_crm_case_categ
+msgid "Category of Case"
+msgstr ""
+
+#. module: import_google
+#: model:ir.actions.act_window,name:import_google.act_google_login_contact_form
+#: model:ir.ui.menu,name:import_google.menu_sync_contact
+msgid "Import Google Contacts"
+msgstr ""
+
+#. module: import_google
+#: view:google.import.message:0
+msgid "Import Google Data"
+msgstr ""
+
+#. module: import_google
+#: view:crm.meeting:0
+msgid "Meeting Type"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google.py:38
+#: code:addons/import_google/wizard/import_google_data.py:28
+#, python-format
+msgid ""
+"Please install gdata-python-client from http://code.google.com/p/gdata-"
+"python-client/downloads/list"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_google_login
+msgid "Google Contact"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Import contacts from a google account"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:133
+#, python-format
+msgid "Please specify correct user and password !"
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,customer:0
+msgid "Customer"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "_Cancel"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_synchronize_google_import
+msgid "synchronize.google.import"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "_Import Contacts"
+msgstr ""
+
+#. module: import_google
+#: model:ir.actions.act_window,name:import_google.act_google_login_form
+#: model:ir.ui.menu,name:import_google.menu_sync_calendar
+msgid "Import Google Calendar"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:50
+#, python-format
+msgid "Import google"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:127
+#: code:addons/import_google/wizard/import_google_data.py:133
+#, python-format
+msgid "Error"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:71
+#, python-format
+msgid "Warning !"
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,create_partner:0
+msgid "Options"
+msgstr ""
+
+#. module: import_google
+#: view:google.import.message:0
+msgid "_Ok"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google.py:38
+#: code:addons/import_google/wizard/import_google_data.py:28
+#, python-format
+msgid "Google Contacts Import Error!"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_google_import_message
+msgid "Import Message"
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,calendar_name:0
+msgid "Calendar Name"
+msgstr ""
+
+#. module: import_google
+#: help:synchronize.google.import,supplier:0
+msgid "Check this box to set newly created partner as Supplier."
+msgstr ""
+
+#. module: import_google
+#: selection:synchronize.google.import,create_partner:0
+msgid "Import only address"
+msgstr ""
+
+#. module: import_google
+#: field:crm.case.categ,user_id:0
+msgid "User"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Partner Status for this Group:"
+msgstr ""
+
+#. module: import_google
+#: field:google.import.message,name:0
+msgid "Message"
+msgstr ""
+
+#. module: import_google
+#: selection:synchronize.google.import,create_partner:0
+msgid "Create partner for each contact"
+msgstr ""
+
+#. module: import_google
+#: help:synchronize.google.import,customer:0
+msgid "Check this box to set newly created partner as Customer."
+msgstr ""
diff --git a/addons/import_google/i18n/pt_BR.po b/addons/import_google/i18n/pt_BR.po
new file mode 100644
index 00000000000..56654d0f9e7
--- /dev/null
+++ b/addons/import_google/i18n/pt_BR.po
@@ -0,0 +1,217 @@
+# Brazilian Portuguese translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 00:36+0000\n"
+"PO-Revision-Date: 2012-07-16 20:10+0000\n"
+"Last-Translator: Rafael \n"
+"Language-Team: Brazilian Portuguese \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-17 04:49+0000\n"
+"X-Generator: Launchpad (build 15627)\n"
+
+#. module: import_google
+#: help:synchronize.google.import,group_name:0
+msgid "Choose which group to import, By default it takes all."
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Import Google Calendar Events"
+msgstr "Importar o Calendário de Eventos da Google"
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "_Import Events"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:71
+#, python-format
+msgid ""
+"No Google Username or password Defined for user.\n"
+"Please define in user view"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:127
+#, python-format
+msgid ""
+"Invalid login detail !\n"
+" Specify Username/Password."
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,supplier:0
+msgid "Supplier"
+msgstr "Fornecedor"
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Import Options"
+msgstr "Opções de importação"
+
+#. module: import_google
+#: field:synchronize.google.import,group_name:0
+msgid "Group Name"
+msgstr "Nome do Grupo"
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_crm_case_categ
+msgid "Category of Case"
+msgstr ""
+
+#. module: import_google
+#: model:ir.actions.act_window,name:import_google.act_google_login_contact_form
+#: model:ir.ui.menu,name:import_google.menu_sync_contact
+msgid "Import Google Contacts"
+msgstr "Importar Contatos do Google"
+
+#. module: import_google
+#: view:google.import.message:0
+msgid "Import Google Data"
+msgstr ""
+
+#. module: import_google
+#: view:crm.meeting:0
+msgid "Meeting Type"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google.py:38
+#: code:addons/import_google/wizard/import_google_data.py:28
+#, python-format
+msgid ""
+"Please install gdata-python-client from http://code.google.com/p/gdata-"
+"python-client/downloads/list"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_google_login
+msgid "Google Contact"
+msgstr ""
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Import contacts from a google account"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:133
+#, python-format
+msgid "Please specify correct user and password !"
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,customer:0
+msgid "Customer"
+msgstr "Cliente"
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "_Cancel"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_synchronize_google_import
+msgid "synchronize.google.import"
+msgstr "sincronizar.importação.google"
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "_Import Contacts"
+msgstr ""
+
+#. module: import_google
+#: model:ir.actions.act_window,name:import_google.act_google_login_form
+#: model:ir.ui.menu,name:import_google.menu_sync_calendar
+msgid "Import Google Calendar"
+msgstr "Importar Calendário do Google"
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:50
+#, python-format
+msgid "Import google"
+msgstr ""
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:127
+#: code:addons/import_google/wizard/import_google_data.py:133
+#, python-format
+msgid "Error"
+msgstr "Erro"
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google_data.py:71
+#, python-format
+msgid "Warning !"
+msgstr "Aviso !"
+
+#. module: import_google
+#: field:synchronize.google.import,create_partner:0
+msgid "Options"
+msgstr "Opções"
+
+#. module: import_google
+#: view:google.import.message:0
+msgid "_Ok"
+msgstr "_Ok"
+
+#. module: import_google
+#: code:addons/import_google/wizard/import_google.py:38
+#: code:addons/import_google/wizard/import_google_data.py:28
+#, python-format
+msgid "Google Contacts Import Error!"
+msgstr ""
+
+#. module: import_google
+#: model:ir.model,name:import_google.model_google_import_message
+msgid "Import Message"
+msgstr ""
+
+#. module: import_google
+#: field:synchronize.google.import,calendar_name:0
+msgid "Calendar Name"
+msgstr "Nome do Calendário"
+
+#. module: import_google
+#: help:synchronize.google.import,supplier:0
+msgid "Check this box to set newly created partner as Supplier."
+msgstr ""
+
+#. module: import_google
+#: selection:synchronize.google.import,create_partner:0
+msgid "Import only address"
+msgstr ""
+
+#. module: import_google
+#: field:crm.case.categ,user_id:0
+msgid "User"
+msgstr "Usuário"
+
+#. module: import_google
+#: view:synchronize.google.import:0
+msgid "Partner Status for this Group:"
+msgstr ""
+
+#. module: import_google
+#: field:google.import.message,name:0
+msgid "Message"
+msgstr "Mensagem"
+
+#. module: import_google
+#: selection:synchronize.google.import,create_partner:0
+msgid "Create partner for each contact"
+msgstr ""
+
+#. module: import_google
+#: help:synchronize.google.import,customer:0
+msgid "Check this box to set newly created partner as Customer."
+msgstr ""
diff --git a/addons/import_sugarcrm/import_sugarcrm_view.xml b/addons/import_sugarcrm/import_sugarcrm_view.xml
index 7188108ba6c..6ef9be1a5bd 100644
--- a/addons/import_sugarcrm/import_sugarcrm_view.xml
+++ b/addons/import_sugarcrm/import_sugarcrm_view.xml
@@ -13,7 +13,7 @@
+ type="object"/> or
diff --git a/addons/knowledge/i18n/nb.po b/addons/knowledge/i18n/nb.po
new file mode 100644
index 00000000000..6f721f133bb
--- /dev/null
+++ b/addons/knowledge/i18n/nb.po
@@ -0,0 +1,33 @@
+# Norwegian Bokmal translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 01:37+0100\n"
+"PO-Revision-Date: 2012-07-23 10:30+0000\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: Norwegian Bokmal \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-24 04:52+0000\n"
+"X-Generator: Launchpad (build 15668)\n"
+
+#. module: knowledge
+#: model:ir.ui.menu,name:knowledge.menu_document2
+msgid "Collaborative Content"
+msgstr ""
+
+#. module: knowledge
+#: model:ir.ui.menu,name:knowledge.menu_document_configuration
+msgid "Configuration"
+msgstr "Konfigurasjon"
+
+#. module: knowledge
+#: model:ir.ui.menu,name:knowledge.menu_document
+msgid "Knowledge"
+msgstr "Kunnskap"
diff --git a/addons/knowledge/knowledge_view.xml b/addons/knowledge/knowledge_view.xml
index cdc176b347f..9acba0f4b43 100644
--- a/addons/knowledge/knowledge_view.xml
+++ b/addons/knowledge/knowledge_view.xml
@@ -1,13 +1,18 @@
+
-
-
-
+
-
+
+
-
+
+
+
+
+
diff --git a/addons/knowledge/res_config_view.xml b/addons/knowledge/res_config_view.xml
index 0cec5899c41..ec4d91683f1 100644
--- a/addons/knowledge/res_config_view.xml
+++ b/addons/knowledge/res_config_view.xml
@@ -11,7 +11,6 @@
or
-
diff --git a/addons/l10n_be_invoice_bba/account_invoice_view.xml b/addons/l10n_be_invoice_bba/account_invoice_view.xml
index 51c39a4b3be..bf1cf96f537 100644
--- a/addons/l10n_be_invoice_bba/account_invoice_view.xml
+++ b/addons/l10n_be_invoice_bba/account_invoice_view.xml
@@ -12,7 +12,7 @@
+ on_change="generate_bbacomm(type,reference_type, partner_id,reference, context)" colspan="1"/>
diff --git a/addons/l10n_be_invoice_bba/invoice.py b/addons/l10n_be_invoice_bba/invoice.py
index 4cdbca84c0a..bb03e4d6d02 100644
--- a/addons/l10n_be_invoice_bba/invoice.py
+++ b/addons/l10n_be_invoice_bba/invoice.py
@@ -44,7 +44,7 @@ class account_invoice(osv.osv):
#l_logger.warning('reference_type = %s' %res )
return res
- def check_bbacomm(self, val):
+ def check_bbacomm(self, val):
supported_chars = '0-9+*/ '
pattern = re.compile('[^' + supported_chars + ']')
if pattern.findall(val or ''):
@@ -75,10 +75,7 @@ class account_invoice(osv.osv):
if (type == 'out_invoice'):
reference_type = self.pool.get('res.partner').browse(cr, uid, partner_id).out_inv_comm_type
if reference_type:
- algorithm = self.pool.get('res.partner').browse(cr, uid, partner_id).out_inv_comm_algorithm
- if not algorithm:
- algorithm = 'random'
- reference = self.generate_bbacomm(cr, uid, ids, type, reference_type, algorithm, partner_id, '')['value']['reference']
+ reference = self.generate_bbacomm(cr, uid, ids, type, reference_type, partner_id, '', context={})['value']['reference']
res_update = {
'reference_type': reference_type or 'none',
'reference': reference,
@@ -86,17 +83,15 @@ class account_invoice(osv.osv):
result['value'].update(res_update)
return result
- def generate_bbacomm(self, cr, uid, ids, type, reference_type, algorithm, partner_id, reference):
+ def generate_bbacomm(self, cr, uid, ids, type, reference_type, partner_id, reference, context=None):
partner_obj = self.pool.get('res.partner')
- reference = reference or ''
+ reference = reference or ''
+ algorithm = False
+ if partner_id:
+ algorithm = partner_obj.browse(cr, uid, partner_id, context=context).out_inv_comm_algorithm
+ algorithm = algorithm or 'random'
if (type == 'out_invoice'):
if reference_type == 'bba':
- if not algorithm:
- if partner_id:
- algorithm = partner_obj.browse(cr, uid, partner_id).out_inv_comm_algorithm
- if not algorithm:
- if not algorithm:
- algorithm = 'random'
if algorithm == 'date':
if not self.check_bbacomm(reference):
doy = time.strftime('%j')
diff --git a/addons/l10n_be_invoice_bba/partner_view.xml b/addons/l10n_be_invoice_bba/partner_view.xml
index 8be0f31d612..d78df2d7a42 100644
--- a/addons/l10n_be_invoice_bba/partner_view.xml
+++ b/addons/l10n_be_invoice_bba/partner_view.xml
@@ -9,7 +9,7 @@
form
-
+
diff --git a/addons/l10n_ch/company_view.xml b/addons/l10n_ch/company_view.xml
index 0d107f63723..108982526b6 100644
--- a/addons/l10n_ch/company_view.xml
+++ b/addons/l10n_ch/company_view.xml
@@ -7,22 +7,22 @@
form
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/addons/l10n_ch/wizard/bvr_import_view.xml b/addons/l10n_ch/wizard/bvr_import_view.xml
index 222aad3b6b9..18ee44efcf9 100644
--- a/addons/l10n_ch/wizard/bvr_import_view.xml
+++ b/addons/l10n_ch/wizard/bvr_import_view.xml
@@ -30,15 +30,15 @@
new
-
+ account.bank.statement.form.inheritaccount.bank.statementform
-
-
-
+
+
+
diff --git a/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_view.xml b/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_view.xml
index 4bf8cdf018b..eb407917d81 100755
--- a/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_view.xml
+++ b/addons/l10n_fr_hr_payroll/l10n_fr_hr_payroll_view.xml
@@ -11,15 +11,15 @@
-
-
+
+
-
+
diff --git a/addons/l10n_fr_rib/bank_view.xml b/addons/l10n_fr_rib/bank_view.xml
index b3062f4c706..627c2eacf04 100644
--- a/addons/l10n_fr_rib/bank_view.xml
+++ b/addons/l10n_fr_rib/bank_view.xml
@@ -9,7 +9,7 @@
form
-
+
@@ -17,7 +17,7 @@
-
+
diff --git a/addons/mail/__openerp__.py b/addons/mail/__openerp__.py
index e911f24e809..abb290332a7 100644
--- a/addons/mail/__openerp__.py
+++ b/addons/mail/__openerp__.py
@@ -24,6 +24,7 @@
'version': '1.0',
'category':'Social Network',
"sequence": 2,
+ "summary": "Discussions, Feeds, Alerts",
'description': """
A bussiness oriented Social Networking with a fully-integrated email
and message management.
@@ -66,11 +67,11 @@ The main features of the module are :
'mail_thread_view.xml',
'mail_group_view.xml',
'res_partner_view.xml',
+ 'res_users_view.xml',
+ 'data/mail_data.xml',
+ 'data/mail_group_data.xml',
'security/mail_security.xml',
'security/ir.model.access.csv',
- 'mail_data.xml',
- 'mail_group_data.xml',
- 'res_users_view.xml',
],
'installable': True,
'auto_install': False,
@@ -83,13 +84,15 @@ The main features of the module are :
'static/src/img/email_icong.png',
'static/src/img/_al.png',
'static/src/img/_pincky.png',
- 'static/src/img/feeds.png',
- 'static/src/img/feeds-hover.png',
'static/src/img/groupdefault.png',
+ 'static/src/img/attachment.png',
+ 'static/src/img/checklist.png',
+ 'static/src/img/formatting.png',
],
'css': [
'static/src/css/mail.css',
'static/src/css/mail_group.css',
+ 'static/src/css/mail_compose_message.css',
],
'js': [
'static/lib/jquery.expander/jquery.expander.js',
@@ -99,7 +102,7 @@ The main features of the module are :
'static/src/xml/mail.xml',
],
'demo': [
- 'mail_demo.xml',
+ 'data/mail_demo.xml',
],
}
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/mail/mail_data.xml b/addons/mail/data/mail_data.xml
similarity index 100%
rename from addons/mail/mail_data.xml
rename to addons/mail/data/mail_data.xml
diff --git a/addons/mail/mail_demo.xml b/addons/mail/data/mail_demo.xml
similarity index 93%
rename from addons/mail/mail_demo.xml
rename to addons/mail/data/mail_demo.xml
index 1adec42a910..274259e544e 100644
--- a/addons/mail/mail_demo.xml
+++ b/addons/mail/data/mail_demo.xml
@@ -1,6 +1,6 @@
-
+
A cool attachment
@@ -28,7 +28,7 @@
Internal company announcemail.group
- html
+ htmlsit amet, consectetur adipiscing elit. Pellentesque et quam sapien, in sagittis tellus.
Praesent vel massa sed massa consequat egestas in tristique orci. Praesent iaculis libero et neque vehicula iaculis. Vivamus placerat tincidunt orci ac ornare. Proin ut dolor fringilla velit ultricies consequat. Maecenas sit amet ipsum non leo interdum imperdiet. Donec sapien mi, varius a consequat id, consectetur sit amet nulla.
@@ -41,10 +41,9 @@ Nulla turpis leo, rhoncus ut egestas sit amet, consectetur vitae urna. Mauris in
- Replymail.group
- html
+ htmltremendous blogpost ! (first comment)]]>comment
@@ -52,10 +51,9 @@ Nulla turpis leo, rhoncus ut egestas sit amet, consectetur vitae urna. Mauris in
- Replymail.group
- html
+ html
@@ -75,10 +73,9 @@ Would it be possible to learn more about the author ? (second comment)]]>
- Replymail.group
- html
+ htmlAll Company
+ All company users can come here and discuss.Sales
+
+ All Employees
+
+
diff --git a/addons/mail/doc/mail_message.rst b/addons/mail/doc/mail_message.rst
index e41a772cac8..56244e251bf 100644
--- a/addons/mail/doc/mail_message.rst
+++ b/addons/mail/doc/mail_message.rst
@@ -3,22 +3,26 @@
mail.message
============
-TODO
+Models
++++++++
-mail.group
-++++++++++
+``mail.message.common`` is an abstract class for holding the main attributes of a
+message object. It could be reused as parent model for any database model
+or wizard screen that needs to hold a kind of message.
-A mail_group is a collection of users sharing messages in a discussion group. Group users are users that follow the mail group, using the subscription/follow mechanism of OpenSocial. A mail group has nothing in common wih res.users.group.
-Additional information on fields:
+All internal logic should be in a database-based model while this model
+holds the basics of a message. For example, a wizard for writing emails
+should inherit from this class and not from mail.message.
- - ``member_ids``: user member of the groups are calculated with ``message_get_subscribers`` method from mail.thread
- - ``member_count``: calculated with member_ids
- - ``is_subscriber``: calculated with member_ids
-res.users
-+++++++++
+.. versionchanged:: 7.0
-OpenChatter updates the res.users class:
- - it adds a preference about sending emails when receiving a notification
- - make a new user follow itself automatically
- - create a welcome message when creating a new user, to make his arrival in OpenERP more friendly
+ - ``subtype`` is renamed to ``content_subtype``: usually 'html' or 'plain'.
+ This field is used to select plain-text or rich-text contents accordingly.
+ - ``subtype`` is moved to mail.message model. The purpose is to be able to
+ distinguish message of the same type, such as notifications about creating
+ or cancelling a record. For example, it is used to add the possibility
+ to hide notifications in the wall.
+
+Those changes aim at being able to distinguish the message content to the
+message itself.
diff --git a/addons/mail/doc/mail_thread.rst b/addons/mail/doc/mail_thread.rst
index f15f78156d3..41ea014b06c 100644
--- a/addons/mail/doc/mail_thread.rst
+++ b/addons/mail/doc/mail_thread.rst
@@ -3,7 +3,25 @@
mail.thread and OpenChatter
===========================
-TODO
+API
++++
+
+Writing messages and notifications
+----------------------------------
+
+``message_append``
+
+Creates a new mail.message through message_create. The new message is attached
+to the current mail.thread, containing all the details passed as parameters.
+All attachments will be attached to the thread record as well as to the
+actual message.
+
+This method calls message_create that will handle management of subscription
+and notifications, and effectively create the message.
+
+If ``email_from`` is not set or ``type`` not set as 'email', a note message
+is created (comment or system notification), without the usual envelope
+attributes (sender, recipients, etc.).
mail.group
++++++++++
diff --git a/addons/mail/mail_group.py b/addons/mail/mail_group.py
index f691d75e0bc..36001ee6074 100644
--- a/addons/mail/mail_group.py
+++ b/addons/mail/mail_group.py
@@ -36,7 +36,7 @@ class mail_group(osv.osv):
A mail_group is a collection of users sharing messages in a discussion
group. Group users are users that follow the mail group, using the
subscription/follow mechanism of OpenSocial. A mail group has nothing
- in common wih res.users.group.
+ in common with res.users.group.
Additional information on fields:
- ``member_ids``: user member of the groups are calculated with
``message_get_subscribers`` method from mail.thread
@@ -49,12 +49,6 @@ class mail_group(osv.osv):
_name = 'mail.group'
_inherit = ['mail.thread']
- def action_group_join(self, cr, uid, ids, context={}):
- return self.message_subscribe(cr, uid, ids, context=context);
-
- def action_group_leave(self, cr, uid, ids, context={}):
- return self.message_unsubscribe(cr, uid, ids, context=context);
-
def onchange_photo(self, cr, uid, ids, value, context=None):
if not value:
return {'value': {'avatar_big': value, 'avatar': value} }
@@ -105,7 +99,7 @@ class mail_group(osv.osv):
message_obj = self.pool.get('mail.message')
for id in ids:
lower_date = (DT.datetime.now() - DT.timedelta(days=30)).strftime(tools.DEFAULT_SERVER_DATE_FORMAT)
- result[id] = message_obj.search(cr, uid, ['&', '&', ('model', '=', self._name), ('res_id', 'in', ids), ('date', '>=', lower_date)], count=True, context=context)
+ result[id] = self.message_search(cr, uid, [id], limit=None, domain=[('date', '>=', lower_date)], count=True, context=context)
return result
def _get_default_photo(self, cr, uid, context=None):
@@ -116,19 +110,33 @@ class mail_group(osv.osv):
'name': fields.char('Name', size=64, required=True),
'description': fields.text('Description'),
'responsible_id': fields.many2one('res.users', string='Responsible',
- ondelete='set null', required=True, select=1,
- help="Responsible of the group that has all rights on the record."),
- 'public': fields.boolean('Public', help='This group is visible by non members. Invisible groups can add members through the invite button.'),
- 'photo_big': fields.binary('Full-size photo', help='Field holding the full-sized PIL-supported and base64 encoded version of the group image. The photo field is used as an interface for this field.'),
- 'photo': fields.function(_get_photo, fnct_inv=_set_photo, string='Photo', type="binary",
+ ondelete='set null', required=True, select=1,
+ help="Responsible of the group that has all rights on the record."),
+ 'public': fields.boolean('Visible by non members', help='This group is visible by non members. \
+ Invisible groups can add members through the invite button.'),
+ 'group_ids': fields.many2many('res.groups', rel='mail_group_res_group_rel',
+ id1='mail_group_id', id2='groups_id', string='Linked groups',
+ help="Members of those groups will automatically added as followers. "\
+ "Note that they will be able to manage their subscription manually "\
+ "if necessary."),
+ 'photo_big': fields.binary('Full-size photo',
+ help='Field holding the full-sized PIL-supported and base64 encoded "\
+ version of the group image. The photo field is used as an "\
+ interface for this field.'),
+ 'photo': fields.function(_get_photo, fnct_inv=_set_photo,
+ string='Photo', type="binary",
store = {
'mail.group': (lambda self, cr, uid, ids, c={}: ids, ['photo_big'], 10),
- }, help='Field holding the automatically resized (128x128) PIL-supported and base64 encoded version of the group image.'),
- 'member_ids': fields.function(get_member_ids, fnct_search=search_member_ids, type='many2many',
- relation='res.users', string='Group members', multi='get_member_ids'),
- 'member_count': fields.function(get_member_ids, type='integer', string='Member count', multi='get_member_ids'),
- 'is_subscriber': fields.function(get_member_ids, type='boolean', string='Joined', multi='get_member_ids'),
- 'last_month_msg_nbr': fields.function(get_last_month_msg_nbr, type='integer', string='Messages count for last month'),
+ },
+ help='Field holding the automatically resized (128x128) PIL-supported and base64 encoded version of the group image.'),
+ 'member_ids': fields.function(get_member_ids, fnct_search=search_member_ids,
+ type='many2many', relation='res.users', string='Group members', multi='get_member_ids'),
+ 'member_count': fields.function(get_member_ids, type='integer',
+ string='Member count', multi='get_member_ids'),
+ 'is_subscriber': fields.function(get_member_ids, type='boolean',
+ string='Joined', multi='get_member_ids'),
+ 'last_month_msg_nbr': fields.function(get_last_month_msg_nbr, type='integer',
+ string='Messages count for last month'),
}
_defaults = {
@@ -136,3 +144,32 @@ class mail_group(osv.osv):
'responsible_id': (lambda s, cr, uid, ctx: uid),
'photo': _get_default_photo,
}
+
+ def _subscribe_user_with_group_m2m_command(self, cr, uid, ids, group_ids_command, context=None):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_group_ids = [command[1] for command in group_ids_command if command[0] == 4]
+ user_group_ids += [id for command in group_ids_command if command[0] == 6 for id in command[2]]
+ # retrieve the user member of those groups
+ user_ids = []
+ res_groups_obj = self.pool.get('res.groups')
+ for group in res_groups_obj.browse(cr, uid, user_group_ids, context=context):
+ user_ids += [user.id for user in group.users]
+ # subscribe the users
+ return self.message_subscribe(cr, uid, ids, user_ids, context=context)
+
+ def create(self, cr, uid, vals, context=None):
+ mail_group_id = super(mail_group, self).create(cr, uid, vals, context=context)
+ if vals.get('group_ids'):
+ self._subscribe_user_with_group_m2m_command(cr, uid, [mail_group_id], vals.get('group_ids'), context=context)
+ return mail_group_id
+
+ def write(self, cr, uid, ids, vals, context=None):
+ if vals.get('group_ids'):
+ self._subscribe_user_with_group_m2m_command(cr, uid, ids, vals.get('group_ids'), context=context)
+ return super(mail_group, self).write(cr, uid, ids, vals, context=context)
+
+ def action_group_join(self, cr, uid, ids, context=None):
+ return self.message_subscribe(cr, uid, ids, context=context)
+
+ def action_group_leave(self, cr, uid, ids, context=None):
+ return self.message_unsubscribe(cr, uid, ids, context=context)
diff --git a/addons/mail/mail_group_view.xml b/addons/mail/mail_group_view.xml
index 97210b8c179..c755209f952 100644
--- a/addons/mail/mail_group_view.xml
+++ b/addons/mail/mail_group_view.xml
@@ -64,6 +64,7 @@
+
diff --git a/addons/mail/mail_message.py b/addons/mail/mail_message.py
index 301657ba4c2..b96b6e3791e 100644
--- a/addons/mail/mail_message.py
+++ b/addons/mail/mail_message.py
@@ -30,66 +30,61 @@ import datetime
from email.header import decode_header
from email.message import Message
-import tools
+from openerp import SUPERUSER_ID
from osv import osv
from osv import fields
+import pytz
+from tools import DEFAULT_SERVER_DATETIME_FORMAT
from tools.translate import _
-from openerp import SUPERUSER_ID
+import tools
_logger = logging.getLogger(__name__)
-def format_date_tz(date, tz=None):
- if not date:
- return 'n/a'
- format = tools.DEFAULT_SERVER_DATETIME_FORMAT
- return tools.server_to_local_timestamp(date, format, format, tz)
-
-def truncate_text(text):
- lines = text and text.split('\n') or []
- if len(lines) > 3:
- res = '\n\t'.join(lines[:3]) + '...'
- else:
- res = '\n\t'.join(lines)
- return res
-
+""" Some tools for parsing / creating email fields """
def decode(text):
"""Returns unicode() string conversion of the the given encoded smtp header text"""
if text:
text = decode_header(text.replace('\r', ''))
return ''.join([tools.ustr(x[0], x[1]) for x in text])
-def to_email(text):
+def mail_tools_to_email(text):
"""Return a list of the email addresses found in ``text``"""
if not text: return []
return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
-class mail_message_common(osv.osv_memory):
- """Common abstract class for holding the main attributes of a
- message object. It could be reused as parent model for any
- database model or wizard screen that needs to hold a kind of
- message"""
+# TODO: remove that after cleaning
+def to_email(text):
+ return mail_tools_to_email(text)
+
+class mail_message_common(osv.TransientModel):
+ """ Common abstract class for holding the main attributes of a
+ message object. It could be reused as parent model for any
+ database model or wizard screen that needs to hold a kind of
+ message.
+ All internal logic should be in another model while this
+ model holds the basics of a message. For example, a wizard for writing
+ emails should inherit from this class and not from mail.message."""
def get_body(self, cr, uid, ids, name, arg, context=None):
- if context is None:
- context = {}
+ """ get correct body version: body_html for html messages, and
+ body_text for plain text messages
+ """
result = dict.fromkeys(ids, '')
for message in self.browse(cr, uid, ids, context=context):
- if message.subtype == 'html':
+ if message.content_subtype == 'html':
result[message.id] = message.body_html
else:
result[message.id] = message.body_text
return result
def search_body(self, cr, uid, obj, name, args, context=None):
- """will receive:
- - obj: mail.message object
- - name: 'body'
- - args: [('body', 'ilike', 'blah')]"""
- return ['|', '&', ('subtype', '=', 'html'), ('body_html', args[0][1], args[0][2]), ('body_text', args[0][1], args[0][2])]
+ # will receive:
+ # - obj: mail.message object
+ # - name: 'body'
+ # - args: [('body', 'ilike', 'blah')]
+ return ['|', '&', ('content_subtype', '=', 'html'), ('body_html', args[0][1], args[0][2]), ('body_text', args[0][1], args[0][2])]
def get_record_name(self, cr, uid, ids, name, arg, context=None):
- if context is None:
- context = {}
result = dict.fromkeys(ids, '')
for message in self.browse(cr, uid, ids, context=context):
if not message.model or not message.res_id:
@@ -116,8 +111,9 @@ class mail_message_common(osv.osv_memory):
'subject': fields.char('Subject', size=512),
'model': fields.char('Related Document Model', size=128, select=1),
'res_id': fields.integer('Related Document ID', select=1),
- 'record_name': fields.function(get_record_name, type='string', string='Message Record Name',
- help="Name of the record, matching the result of the name_get."),
+ 'record_name': fields.function(get_record_name, type='string',
+ string='Message Record Name',
+ help="Name get of the related document."),
'date': fields.datetime('Date'),
'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
'email_to': fields.char('To', size=256, help='Message recipients'),
@@ -125,104 +121,99 @@ class mail_message_common(osv.osv_memory):
'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
'headers': fields.text('Message Headers', readonly=1,
- help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
+ help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
- 'subtype': fields.char('Message Type', size=32, help="Type of message, usually 'html' or 'plain', used to "
- "select plaintext or rich text contents accordingly", readonly=1),
+ 'content_subtype': fields.char('Message content subtype', size=32,
+ oldname="subtype", readonly=1,
+ help="Type of message, usually 'html' or 'plain', used to select "\
+ "plain-text or rich-text contents accordingly"),
'body_text': fields.text('Text Contents', help="Plain-text version of the message"),
'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML version of the message"),
- 'body': fields.function(get_body, fnct_search = search_body, string='Message Content', type='text',
- help="Content of the message. This content equals the body_text field for plain-test messages, and body_html for rich-text/HTML messages. This allows having one field if we want to access the content matching the message subtype."),
- 'parent_id': fields.many2one('mail.message', 'Parent Message', help="Parent message, used for displaying as threads with hierarchy",
- select=True, ondelete='set null',),
- 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
+ 'body': fields.function(get_body, fnct_search = search_body, type='text',
+ string='Message Content', store=True,
+ help="Content of the message. This content equals the body_text field "\
+ "for plain-test messages, and body_html for rich-text/HTML "\
+ "messages. This allows having one field if we want to access "\
+ "the content matching the message content_subtype."),
+ 'parent_id': fields.many2one('mail.message.common', 'Parent Message',
+ select=True, ondelete='set null',
+ help="Parent message, used for displaying as threads with hierarchy"),
}
_defaults = {
- 'subtype': 'plain',
+ 'content_subtype': 'plain',
'date': (lambda *a: fields.datetime.now()),
}
-class mail_message(osv.osv):
- '''Model holding messages: system notification (replacing res.log
- notifications), comments (for OpenSocial feature) and
+class mail_message(osv.Model):
+ """Model holding messages: system notification (replacing res.log
+ notifications), comments (for OpenChatter feature) and
RFC2822 email messages. This model also provides facilities to
parse, queue and send new email messages. Type of messages
- are differentiated using the 'type' column.
-
- The ``display_text`` field will have a slightly different
- presentation for real emails and for log messages.
- '''
+ are differentiated using the 'type' column. """
_name = 'mail.message'
_inherit = 'mail.message.common'
_description = 'Mail Message (email, comment, notification)'
_order = 'date desc'
- # XXX to review - how to determine action to use?
def open_document(self, cr, uid, ids, context=None):
+ """ Open the message related document. Note that only the document of
+ ids[0] will be opened.
+ TODO: how to determine the action to use ?
+ """
action_data = False
- if ids:
- msg = self.browse(cr, uid, ids[0], context=context)
- model = msg.model
- res_id = msg.res_id
-
- ir_act_window = self.pool.get('ir.actions.act_window')
- action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
- if action_ids:
- action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
- action_data.update({
- 'domain' : "[('id','=',%d)]"%(res_id),
+ if not ids:
+ return action_data
+ msg = self.browse(cr, uid, ids[0], context=context)
+ ir_act_window = self.pool.get('ir.actions.act_window')
+ action_ids = ir_act_window.search(cr, uid, [('res_model', '=', msg.model)], context=context)
+ if action_ids:
+ action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
+ action_data.update({
+ 'domain' : "[('id', '=', %d)]" % (msg.res_id),
'nodestroy': True,
'context': {}
})
return action_data
- # XXX to review - how to determine action to use?
def open_attachment(self, cr, uid, ids, context=None):
+ """ Open the message related attachments.
+ TODO: how to determine the action to use ?
+ """
action_data = False
+ if not ids:
+ return action_data
action_pool = self.pool.get('ir.actions.act_window')
- message = self.browse(cr, uid, ids, context=context)[0]
- att_ids = [x.id for x in message.attachment_ids]
- action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
+ messages = self.browse(cr, uid, ids, context=context)
+ att_ids = [x.id for message in messages for x in message.attachment_ids]
+ action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')], context=context)
if action_ids:
action_data = action_pool.read(cr, uid, action_ids[0], context=context)
action_data.update({
- 'domain': [('id','in',att_ids)],
+ 'domain': [('id', 'in', att_ids)],
'nodestroy': True
})
return action_data
-
- def _get_display_text(self, cr, uid, ids, name, arg, context=None):
- if context is None:
- context = {}
- tz = context.get('tz')
- result = {}
-
- # Read message as UID 1 to allow viewing author even if from different company
- for message in self.browse(cr, SUPERUSER_ID, ids):
- msg_txt = ''
- if message.email_from:
- msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
- if message.body_text:
- msg_txt += truncate_text(message.body_text)
- else:
- msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
- msg_txt += (message.subject or '')
- result[message.id] = msg_txt
- return result
_columns = {
'type': fields.selection([
('email', 'email'),
('comment', 'Comment'),
('notification', 'System notification'),
- ], 'Type', help="Message type: email for email message, notification for system message, comment for other messages such as user replies"),
- 'partner_id': fields.many2one('res.partner', 'Related partner'),
+ ], 'Type',
+ help="Message type: email for email message, notification for system "\
+ "message, comment for other messages such as user replies"),
+ 'partner_id': fields.many2one('res.partner', 'Related partner',
+ help="Deprecated field. Use partner_ids instead."),
+ 'partner_ids': fields.many2many('res.partner',
+ 'mail_message_destination_partner_rel',
+ 'message_id', 'partner_id', 'Destination partners',
+ help="When sending emails through the social network composition wizard"\
+ "you may choose to send a copy of the mail to partners."),
'user_id': fields.many2one('res.users', 'Related User', readonly=1),
'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
- 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
'state': fields.selection([
('outgoing', 'Outgoing'),
@@ -231,8 +222,14 @@ class mail_message(osv.osv):
('exception', 'Delivery Failed'),
('cancel', 'Cancelled'),
], 'Status', readonly=True),
- 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
- 'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
+ 'auto_delete': fields.boolean('Auto Delete',
+ help="Permanently delete this email after sending it, to save space"),
+ 'original': fields.binary('Original', readonly=1,
+ help="Original version of the message, as it was sent on the network"),
+ 'parent_id': fields.many2one('mail.message', 'Parent Message',
+ select=True, ondelete='set null',
+ help="Parent message, used for displaying as threads with hierarchy"),
+ 'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
}
_defaults = {
@@ -249,72 +246,124 @@ class mail_message(osv.osv):
if not cr.fetchone():
cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
+ def check(self, cr, uid, ids, mode, context=None, values=None):
+ """Restricts the access to a mail.message, according to referred model
+ """
+ if not ids:
+ return
+ res_ids = {}
+ if isinstance(ids, (int, long)):
+ ids = [ids]
+ cr.execute('SELECT DISTINCT model, res_id FROM mail_message WHERE id = ANY (%s)', (ids,))
+ for rmod, rid in cr.fetchall():
+ if not (rmod and rid):
+ continue
+ res_ids.setdefault(rmod,set()).add(rid)
+ if values:
+ if 'res_model' in values and 'res_id' in values:
+ res_ids.setdefault(values['res_model'],set()).add(values['res_id'])
+
+ ima_obj = self.pool.get('ir.model.access')
+ for model, mids in res_ids.items():
+ # ignore mail messages that are not attached to a resource anymore when checking access rights
+ # (resource was deleted but message was not)
+ mids = self.pool.get(model).exists(cr, uid, mids)
+ ima_obj.check(cr, uid, model, mode)
+ self.pool.get(model).check_access_rule(cr, uid, mids, mode, context=context)
+
+ def create(self, cr, uid, values, context=None):
+ self.check(cr, uid, [], mode='create', context=context, values=values)
+ return super(mail_message, self).create(cr, uid, values, context)
+
+ def read(self, cr, uid, ids, fields_to_read=None, context=None, load='_classic_read'):
+ self.check(cr, uid, ids, 'read', context=context)
+ return super(mail_message, self).read(cr, uid, ids, fields_to_read, context, load)
+
def copy(self, cr, uid, id, default=None, context=None):
"""Overridden to avoid duplicating fields that are unique to each email"""
if default is None:
default = {}
- default.update(message_id=False,original=False,headers=False)
+ self.check(cr, uid, [id], 'read', context=context)
+ default.update(message_id=False, original=False, headers=False)
return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
+
+ def write(self, cr, uid, ids, vals, context=None):
+ self.check(cr, uid, ids, 'write', context=context, values=vals)
+ return super(mail_message, self).write(cr, uid, ids, vals, context)
- def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
- email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
- res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
- context=None):
- """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
- the next time :meth:`process_email_queue` is called explicitly.
+ def unlink(self, cr, uid, ids, context=None):
+ self.check(cr, uid, ids, 'unlink', context=context)
+ return super(mail_message, self).unlink(cr, uid, ids, context)
- :param string email_from: sender email address
- :param list email_to: list of recipient addresses (to be joined with commas)
- :param string subject: email subject (no pre-encoding/quoting necessary)
- :param string body: email body, according to the ``subtype`` (by default, plaintext).
- If html subtype is used, the message will be automatically converted
- to plaintext and wrapped in multipart/alternative.
- :param list email_cc: optional list of string values for CC header (to be joined with commas)
- :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
- :param string model: optional model name of the document this mail is related to (this will also
- be used to generate a tracking id, used to match any response related to the
- same document)
- :param int res_id: optional resource identifier this mail is related to (this will also
- be used to generate a tracking id, used to match any response related to the
- same document)
- :param string reply_to: optional value of Reply-To header
- :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
- must match the format of the ``body`` parameter. Default is 'plain',
- making the content part of the mail "text/plain".
- :param dict attachments: map of filename to filecontents, where filecontents is a string
- containing the bytes of the attachment
- :param dict headers: optional map of headers to set on the outgoing mail (may override the
- other headers, including Subject, Reply-To, Message-Id, etc.)
- :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
- :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
- successfully sent (default to False)
+ def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, type='email',
+ email_cc=None, email_bcc=None, reply_to=False, partner_ids=None, attachments=None,
+ message_id=False, references=False, res_id=False, content_subtype='plain',
+ headers=None, mail_server_id=False, auto_delete=False, context=None):
+ """ Schedule sending a new email message, to be sent the next time the
+ mail scheduler runs, or the next time :meth:`process_email_queue` is
+ called explicitly.
+ :param string email_from: sender email address
+ :param list email_to: list of recipient addresses (to be joined with commas)
+ :param string subject: email subject (no pre-encoding/quoting necessary)
+ :param string body: email body, according to the ``content_subtype``
+ (by default, plaintext). If html content_subtype is used, the
+ message will be automatically converted to plaintext and wrapped
+ in multipart/alternative.
+ :param list email_cc: optional list of string values for CC header
+ (to be joined with commas)
+ :param list email_bcc: optional list of string values for BCC header
+ (to be joined with commas)
+ :param string model: optional model name of the document this mail
+ is related to (this will also be used to generate a tracking id,
+ used to match any response related to the same document)
+ :param int res_id: optional resource identifier this mail is related
+ to (this will also be used to generate a tracking id, used to
+ match any response related to the same document)
+ :param string reply_to: optional value of Reply-To header
+ :param partner_ids: destination partner_ids
+ :param string content_subtype: optional mime content_subtype for
+ the text body (usually 'plain' or 'html'), must match the format
+ of the ``body`` parameter. Default is 'plain', making the content
+ part of the mail "text/plain".
+ :param dict attachments: map of filename to filecontents, where
+ filecontents is a string containing the bytes of the attachment
+ :param dict headers: optional map of headers to set on the outgoing
+ mail (may override the other headers, including Subject,
+ Reply-To, Message-Id, etc.)
+ :param int mail_server_id: optional id of the preferred outgoing
+ mail server for this mail
+ :param bool auto_delete: optional flag to turn on auto-deletion of
+ the message after it has been successfully sent (default to False)
"""
if context is None:
context = {}
if attachments is None:
attachments = {}
+ if partner_ids is None:
+ partner_ids = []
attachment_obj = self.pool.get('ir.attachment')
for param in (email_to, email_cc, email_bcc):
if param and not isinstance(param, list):
param = [param]
msg_vals = {
'subject': subject,
- 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
+ 'date': fields.datetime.now(),
'user_id': uid,
'model': model,
'res_id': res_id,
- 'type': 'email',
- 'body_text': body if subtype != 'html' else False,
- 'body_html': body if subtype == 'html' else False,
+ 'type': type,
+ 'body_text': body if content_subtype != 'html' else False,
+ 'body_html': body if content_subtype == 'html' else False,
'email_from': email_from,
'email_to': email_to and ','.join(email_to) or '',
'email_cc': email_cc and ','.join(email_cc) or '',
'email_bcc': email_bcc and ','.join(email_bcc) or '',
+ 'partner_ids': partner_ids,
'reply_to': reply_to,
'message_id': message_id,
'references': references,
- 'subtype': subtype,
+ 'content_subtype': content_subtype,
'headers': headers, # serialize the dict on the fly
'mail_server_id': mail_server_id,
'state': 'outgoing',
@@ -338,7 +387,10 @@ class mail_message(osv.osv):
return email_msg_id
def mark_outgoing(self, cr, uid, ids, context=None):
- return self.write(cr, uid, ids, {'state':'outgoing'}, context)
+ return self.write(cr, uid, ids, {'state':'outgoing'}, context=context)
+
+ def cancel(self, cr, uid, ids, context=None):
+ return self.write(cr, uid, ids, {'state':'cancel'}, context=context)
def process_email_queue(self, cr, uid, ids=None, context=None):
"""Send immediately queued messages, committing after each
@@ -357,7 +409,7 @@ class mail_message(osv.osv):
if context is None:
context = {}
if not ids:
- filters = [('state', '=', 'outgoing')]
+ filters = ['&', ('state', '=', 'outgoing'), ('type', '=', 'email')]
if 'filters' in context:
filters.extend(context['filters'])
ids = self.search(cr, uid, filters, context=context)
@@ -371,7 +423,7 @@ class mail_message(osv.osv):
_logger.exception("Failed processing mail queue")
return res
- def parse_message(self, message, save_original=False):
+ def parse_message(self, message, save_original=False, context=None):
"""Parses a string or email.message.Message representing an
RFC-2822 email, and returns a generic dict holding the
message details.
@@ -394,7 +446,7 @@ class mail_message(osv.osv):
'headers' : { 'X-Mailer': mailer,
#.. all X- headers...
},
- 'subtype': msg_mime_subtype,
+ 'content_subtype': msg_mime_subtype,
'body_text': plaintext_body
'body_html': html_body,
'attachments': [('file1', 'bytes'),
@@ -428,49 +480,52 @@ class mail_message(osv.osv):
msg_txt['message-id'] = message_id
_logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
- fields = msg_txt.keys()
+ msg_fields = msg_txt.keys()
msg['id'] = message_id
msg['message-id'] = message_id
- if 'Subject' in fields:
+ if 'Subject' in msg_fields:
msg['subject'] = decode(msg_txt.get('Subject'))
- if 'Content-Type' in fields:
+ if 'Content-Type' in msg_fields:
msg['content-type'] = msg_txt.get('Content-Type')
- if 'From' in fields:
+ if 'From' in msg_fields:
msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
- if 'To' in fields:
+ if 'To' in msg_fields:
msg['to'] = decode(msg_txt.get('To'))
- if 'Delivered-To' in fields:
+ if 'Delivered-To' in msg_fields:
msg['to'] = decode(msg_txt.get('Delivered-To'))
- if 'CC' in fields:
+ if 'CC' in msg_fields:
msg['cc'] = decode(msg_txt.get('CC'))
- if 'Cc' in fields:
+ if 'Cc' in msg_fields:
msg['cc'] = decode(msg_txt.get('Cc'))
- if 'Reply-To' in fields:
+ if 'Reply-To' in msg_fields:
msg['reply'] = decode(msg_txt.get('Reply-To'))
- if 'Date' in fields:
+ if 'Date' in msg_fields:
date_hdr = decode(msg_txt.get('Date'))
- msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
+ # convert from email timezone to server timezone
+ date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
+ date_server_datetime_str = date_server_datetime.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+ msg['date'] = date_server_datetime_str
- if 'Content-Transfer-Encoding' in fields:
+ if 'Content-Transfer-Encoding' in msg_fields:
msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
- if 'References' in fields:
+ if 'References' in msg_fields:
msg['references'] = msg_txt.get('References')
- if 'In-Reply-To' in fields:
+ if 'In-Reply-To' in msg_fields:
msg['in-reply-to'] = msg_txt.get('In-Reply-To')
msg['headers'] = {}
- msg['subtype'] = 'plain'
+ msg['content_subtype'] = 'plain'
for item in msg_txt.items():
if item[0].startswith('X-'):
msg['headers'].update({item[0]: item[1]})
@@ -479,7 +534,7 @@ class mail_message(osv.osv):
body = msg_txt.get_payload(decode=True)
if 'text/html' in msg.get('content-type', ''):
msg['body_html'] = body
- msg['subtype'] = 'html'
+ msg['content_subtype'] = 'html'
if body:
body = tools.html2plaintext(body)
msg['body_text'] = tools.ustr(body, encoding)
@@ -488,9 +543,9 @@ class mail_message(osv.osv):
if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
body = ""
if 'multipart/alternative' in msg.get('content-type', ''):
- msg['subtype'] = 'alternative'
+ msg['content_subtype'] = 'alternative'
else:
- msg['subtype'] = 'mixed'
+ msg['content_subtype'] = 'mixed'
for part in msg_txt.walk():
if part.get_content_maintype() == 'multipart':
continue
@@ -504,7 +559,7 @@ class mail_message(osv.osv):
content = tools.ustr(content, encoding)
if part.get_content_subtype() == 'html':
msg['body_html'] = content
- msg['subtype'] = 'html' # html version prevails
+ msg['content_subtype'] = 'html' # html version prevails
body = tools.ustr(tools.html2plaintext(content))
body = body.replace('
', '')
elif part.get_content_subtype() == 'plain':
@@ -521,7 +576,7 @@ class mail_message(osv.osv):
# for backwards compatibility:
msg['body'] = msg['body_text']
- msg['sub_type'] = msg['subtype'] or 'plain'
+ msg['sub_type'] = msg['content_subtype'] or 'plain'
return msg
def _postprocess_sent_message(self, cr, uid, message, context=None):
@@ -535,10 +590,9 @@ class mail_message(osv.osv):
"""
if message.auto_delete:
self.pool.get('ir.attachment').unlink(cr, uid,
- [x.id for x in message.attachment_ids \
- if x.res_model == self._name and \
- x.res_id == message.id],
- context=context)
+ [x.id for x in message.attachment_ids
+ if x.res_model == self._name and x.res_id == message.id],
+ context=context)
message.unlink()
return True
@@ -557,8 +611,6 @@ class mail_message(osv.osv):
transactions (default: False)
:return: True
"""
- if context is None:
- context = {}
ir_mail_server = self.pool.get('ir.mail_server')
self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
for message in self.browse(cr, uid, ids, context=context):
@@ -567,37 +619,45 @@ class mail_message(osv.osv):
for attach in message.attachment_ids:
attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
- body = message.body_html if message.subtype == 'html' else message.body_text
+ body = message.body_html if message.content_subtype == 'html' else message.body_text
body_alternative = None
- subtype_alternative = None
- if message.subtype == 'html' and message.body_text:
+ content_subtype_alternative = None
+ if message.content_subtype == 'html' and message.body_text:
# we have a plain text alternative prepared, pass it to
# build_message instead of letting it build one
body_alternative = message.body_text
- subtype_alternative = 'plain'
+ content_subtype_alternative = 'plain'
+ # handle destination_partners
+ partner_ids_email_to = ''
+ for partner in message.partner_ids:
+ partner_ids_email_to += '%s ' % (partner.email or '')
+ message_email_to = '%s %s' % (partner_ids_email_to, message.email_to or '')
+
+ # build an RFC2822 email.message.Message object and send it
+ # without queuing
msg = ir_mail_server.build_email(
email_from=message.email_from,
- email_to=to_email(message.email_to),
+ email_to=mail_tools_to_email(message_email_to),
subject=message.subject,
body=body,
body_alternative=body_alternative,
- email_cc=to_email(message.email_cc),
- email_bcc=to_email(message.email_bcc),
+ email_cc=mail_tools_to_email(message.email_cc),
+ email_bcc=mail_tools_to_email(message.email_bcc),
reply_to=message.reply_to,
attachments=attachments, message_id=message.message_id,
references = message.references,
object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
- subtype=message.subtype,
- subtype_alternative=subtype_alternative,
+ subtype=message.content_subtype,
+ subtype_alternative=content_subtype_alternative,
headers=message.headers and ast.literal_eval(message.headers))
res = ir_mail_server.send_email(cr, uid, msg,
mail_server_id=message.mail_server_id.id,
context=context)
if res:
- message.write({'state':'sent', 'message_id': res})
+ message.write({'state':'sent', 'message_id': res, 'email_to': message_email_to})
else:
- message.write({'state':'exception'})
+ message.write({'state':'exception', 'email_to': message_email_to})
message.refresh()
if message.state == 'sent':
self._postprocess_sent_message(cr, uid, message, context=context)
@@ -609,8 +669,6 @@ class mail_message(osv.osv):
cr.commit()
return True
- def cancel(self, cr, uid, ids, context=None):
- self.write(cr, uid, ids, {'state':'cancel'}, context=context)
- return True
+
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/mail/mail_message_view.xml b/addons/mail/mail_message_view.xml
index 9159190ab29..634b5317d27 100644
--- a/addons/mail/mail_message_view.xml
+++ b/addons/mail/mail_message_view.xml
@@ -30,17 +30,26 @@
+
-
+
-
+
+
+
+
+
+
+
+
+
@@ -73,6 +82,30 @@
+
+
+
+
+
+
+
+
@@ -85,55 +118,60 @@
@@ -235,13 +273,13 @@
News Feed
- mail.all_feeds
+ mail.wallMy Feeds
- mail.all_feeds
+ mail.wall
diff --git a/addons/mail/mail_subscription_view.xml b/addons/mail/mail_subscription_view.xml
index 7dbaea098dc..c3d79d48308 100644
--- a/addons/mail/mail_subscription_view.xml
+++ b/addons/mail/mail_subscription_view.xml
@@ -52,11 +52,13 @@
tree,form
-
-
+
+ -->
-
-
+
+
diff --git a/addons/mail/mail_thread.py b/addons/mail/mail_thread.py
index cdcaf4f464d..d036150b21c 100644
--- a/addons/mail/mail_thread.py
+++ b/addons/mail/mail_thread.py
@@ -65,7 +65,7 @@ class mail_thread(osv.Model):
def _get_message_ids(self, cr, uid, ids, name, args, context=None):
res = {}
for id in ids:
- message_ids = self.message_load_ids(cr, uid, [id], context=context)
+ message_ids = self.message_search(cr, uid, [id], context=context)
subscriber_ids = self.message_get_subscribers(cr, uid, [id], context=context)
res[id] = {
'message_ids': message_ids,
@@ -130,7 +130,7 @@ class mail_thread(osv.Model):
# delete subscriptions
subscr_to_del_ids = subscr_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
subscr_obj.unlink(cr, uid, subscr_to_del_ids, context=context)
- # delete notifications
+ # delete messages and notifications
msg_to_del_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
msg_obj.unlink(cr, uid, msg_to_del_ids, context=context)
@@ -150,39 +150,13 @@ class mail_thread(osv.Model):
context = {}
message_obj = self.pool.get('mail.message')
- subscription_obj = self.pool.get('mail.subscription')
notification_obj = self.pool.get('mail.notification')
- res_users_obj = self.pool.get('res.users')
- body = vals.get('body_html', '') if vals.get('subtype', 'plain') == 'html' else vals.get('body_text', '')
-
+ body = vals.get('body_html', '') if vals.get('content_subtype') == 'html' else vals.get('body_text', '')
+
# automatically subscribe the writer of the message
if vals['user_id']:
self.message_subscribe(cr, uid, [thread_id], [vals['user_id']], context=context)
-
- # get users that will get a notification pushed
- user_to_push_ids = self.message_create_get_notification_user_ids(cr, uid, [thread_id], vals, context=context)
- user_to_push_from_parse_ids = self.message_parse_users(cr, uid, [thread_id], body, context=context)
-
- # set email_from and email_to for comments and notifications
- if vals.get('type', False) and vals['type'] == 'comment' or vals['type'] == 'notification':
- current_user = res_users_obj.browse(cr, uid, [uid], context=context)[0]
- if not vals.get('email_from', False):
- vals['email_from'] = current_user.user_email
- if not vals.get('email_to', False):
- email_to = ''
- for user in res_users_obj.browse(cr, uid, user_to_push_ids, context=context):
- if not user.notification_email_pref == 'all' and \
- not (user.notification_email_pref == 'comments' and vals['type'] == 'comment') and \
- not (user.notification_email_pref == 'to_me' and user.id in user_to_push_from_parse_ids):
- continue
- if not user.user_email:
- continue
- email_to = '%s, %s' % (email_to, user.user_email)
- email_to = email_to.lstrip(', ')
- if email_to:
- vals['email_to'] = email_to
- vals['state'] = 'outgoing'
-
+
# create message
msg_id = message_obj.create(cr, uid, vals, context=context)
@@ -191,43 +165,50 @@ class mail_thread(osv.Model):
# special: if install mode, do not push demo data
if context.get('install_mode', False):
- return True
-
- # push to users
+ return msg_id
+
+ # get users that will get a notification pushed
+ user_to_push_ids = self.message_get_user_ids_to_notify(cr, uid, [thread_id], vals, context=context)
for id in user_to_push_ids:
notification_obj.create(cr, uid, {'user_id': id, 'message_id': msg_id}, context=context)
-
+
+ # create the email to send
+ email_id = self.message_create_notify_by_email(cr, uid, vals, user_to_push_ids, context=context)
+
return msg_id
-
- def message_create_get_notification_user_ids(self, cr, uid, thread_ids, new_msg_vals, context=None):
- if context is None:
- context = {}
-
- notif_user_ids = []
- body = new_msg_vals.get('body_html', '') if new_msg_vals.get('subtype', 'plain') == 'html' else new_msg_vals.get('body_text', '')
- for thread_id in thread_ids:
- # add subscribers
- notif_user_ids += self.message_get_subscribers(cr, uid, [thread_id], context=context)
- # add users requested via parsing message (@login)
- notif_user_ids += self.message_parse_users(cr, uid, [thread_id], body, context=context)
- # add users requested to perform an action (need_action mechanism)
- if hasattr(self, 'get_needaction_user_ids'):
- notif_user_ids += self.get_needaction_user_ids(cr, uid, [thread_id], context=context)[thread_id]
- # add users notified of the parent messages (because: if parent message contains @login, login must receive the replies)
- if new_msg_vals.get('parent_id'):
- notif_obj = self.pool.get('mail.notification')
- parent_notif_ids = notif_obj.search(cr, uid, [('message_id', '=', new_msg_vals.get('parent_id'))], context=context)
- parent_notifs = notif_obj.read(cr, uid, parent_notif_ids, context=context)
- notif_user_ids += [parent_notif['user_id'][0] for parent_notif in parent_notifs]
+
+ def message_get_user_ids_to_notify(self, cr, uid, thread_ids, new_msg_vals, context=None):
+ subscription_obj = self.pool.get('mail.subscription')
+ # get body
+ body = new_msg_vals.get('body_html', '') if new_msg_vals.get('content_subtype') == 'html' else new_msg_vals.get('body_text', '')
+
+ # get subscribers
+ notif_user_ids = self.message_get_subscribers(cr, uid, thread_ids, context=context)
+
+ # add users requested via parsing message (@login)
+ notif_user_ids += self.message_parse_users(cr, uid, body, context=context)
+
+ # add users requested to perform an action (need_action mechanism)
+ if hasattr(self, 'get_needaction_user_ids'):
+ user_ids_dict = self.get_needaction_user_ids(cr, uid, thread_ids, context=context)
+ for id, user_ids in user_ids_dict.iteritems():
+ notif_user_ids += user_ids
+
+ # add users notified of the parent messages (because: if parent message contains @login, login must receive the replies)
+ if new_msg_vals.get('parent_id'):
+ notif_obj = self.pool.get('mail.notification')
+ parent_notif_ids = notif_obj.search(cr, uid, [('message_id', '=', new_msg_vals.get('parent_id'))], context=context)
+ parent_notifs = notif_obj.read(cr, uid, parent_notif_ids, context=context)
+ notif_user_ids += [parent_notif['user_id'][0] for parent_notif in parent_notifs]
# remove duplicate entries
notif_user_ids = list(set(notif_user_ids))
return notif_user_ids
- def message_parse_users(self, cr, uid, ids, string, context=None):
+ def message_parse_users(self, cr, uid, string, context=None):
"""Parse message content
- if find @login -(^|\s)@((\w|@|\.)*)-: returns the related ids
- this supports login that are emails (such as @admin@lapin.net)
+ this supports login that are emails (such as @raoul@grobedon.net)
"""
regex = re.compile('(^|\s)@((\w|@|\.)*)')
login_lst = [item[1] for item in regex.findall(string)]
@@ -248,46 +229,61 @@ class mail_thread(osv.Model):
return ret_dict
def message_append(self, cr, uid, threads, subject, body_text=None, body_html=None,
- parent_id=False, type='email', subtype='plain', state='received',
- email_to=False, email_from=False, email_cc=None, email_bcc=None,
- reply_to=None, email_date=None, message_id=False, references=None,
- attachments=None, headers=None, original=None, context=None):
- """Creates a new mail.message attached to the current mail.thread,
- containing all the details passed as parameters. All attachments
- will be attached to the thread record as well as to the actual
- message.
- If ``email_from`` is not set or ``type`` not set as 'email',
- a note message is created, without the usual envelope
- attributes (sender, recipients, etc.).
- The creation of the message is done by calling ``message_create``
- method, that will manage automatic pushing of notifications.
+ type='email', email_date=None, parent_id=False,
+ content_subtype='plain', state=None,
+ partner_ids=None, email_from=False, email_to=False,
+ email_cc=None, email_bcc=None, reply_to=None,
+ headers=None, message_id=False, references=None,
+ attachments=None, original=None, context=None):
+ """ Creates a new mail.message through message_create. The new message
+ is attached to the current mail.thread, containing all the details
+ passed as parameters. All attachments will be attached to the
+ thread record as well as to the actual message.
+
+ This method calls message_create that will handle management of
+ subscription and notifications, and effectively create the message.
+
+ If ``email_from`` is not set or ``type`` not set as 'email',
+ a note message is created (comment or system notification),
+ without the usual envelope attributes (sender, recipients, etc.).
- :param threads: list of thread ids, or list of browse_records representing
- threads to which a new message should be attached
- :param subject: subject of the message, or description of the event if this
- is an *event log* entry.
- :param body_text: plaintext contents of the mail or log message
- :param body_html: html contents of the mail or log message
- :param parent_id: id of the parent message (threaded messaging model)
- :param type: optional type of message: 'email', 'comment', 'notification'
- :param subtype: optional subtype of message: 'plain' or 'html', corresponding to the main
- body contents (body_text or body_html).
- :param state: optional state of message; 'received' by default
- :param email_to: Email-To / Recipient address
- :param email_from: Email From / Sender address if any
- :param email_cc: Comma-Separated list of Carbon Copy Emails To addresse if any
- :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To addresses if any
- :param reply_to: reply_to header
- :param email_date: email date string if different from now, in server timezone
- :param message_id: optional email identifier
- :param references: optional email references
- :param headers: mail headers to store
- :param dict attachments: map of attachment filenames to binary contents, if any.
- :param str original: optional full source of the RFC2822 email, for reference
- :param dict context: if a ``thread_model`` value is present
- in the context, its value will be used
- to determine the model of the thread to
- update (instead of the current model).
+ :param threads: list of thread ids, or list of browse_records
+ representing threads to which a new message should be attached
+ :param subject: subject of the message, or description of the event;
+ this is totally optional as subjects are not important except
+ for specific messages (blog post, job offers) or for emails
+ :param body_text: plaintext contents of the mail or log message
+ :param body_html: html contents of the mail or log message
+ :param type: type of message: 'email', 'comment', 'notification';
+ email by default
+ :param email_date: email date string if different from now, in
+ server timezone
+ :param parent_id: id of the parent message (threaded messaging model)
+ :param content_subtype: optional content_subtype of message: 'plain'
+ or 'html', corresponding to the main body contents (body_text or
+ body_html).
+ :param state: state of message
+ :param partner_ids: destination partners of the message, in addition
+ to the now fully optional email_to; this method is supposed to
+ received a list of ids is not None. The specific many2many
+ instruction will be generated by this method.
+ :param email_from: Email From / Sender address if any
+ :param email_to: Email-To / Recipient address
+ :param email_cc: Comma-Separated list of Carbon Copy Emails To
+ addresses if any
+ :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To
+ addresses if any
+ :param reply_to: reply_to header
+ :param headers: mail headers to store
+ :param message_id: optional email identifier
+ :param references: optional email references
+ :param dict attachments: map of attachment filenames to binary
+ contents, if any.
+ :param str original: optional full source of the RFC2822 email, for
+ reference
+ :param dict context: if a ``thread_model`` value is present in the
+ context, its value will be used to determine the model of the
+ thread to update (instead of the current model).
"""
if context is None:
context = {}
@@ -323,10 +319,15 @@ class mail_thread(osv.Model):
'res_id': thread.id,
}
to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context))
-
+ # find related partner: partner_id column in thread object, or self is res.partner model
partner_id = ('partner_id' in thread._columns.keys()) and (thread.partner_id and thread.partner_id.id or False) or False
if not partner_id and thread._name == 'res.partner':
partner_id = thread.id
+ # destination partners
+ if partner_ids is None:
+ partner_ids = []
+ mail_partner_ids = [(6, 0, partner_ids)]
+
data = {
'subject': subject,
'body_text': body_text or (hasattr(thread, 'description') and thread.description or ''),
@@ -334,9 +335,10 @@ class mail_thread(osv.Model):
'parent_id': parent_id,
'date': email_date or fields.datetime.now(),
'type': type,
- 'subtype': subtype,
+ 'content_subtype': content_subtype,
'state': state,
'message_id': message_id,
+ 'partner_ids': mail_partner_ids,
'attachment_ids': [(6, 0, to_attach)],
'user_id': uid,
'model' : thread._name,
@@ -349,7 +351,6 @@ class mail_thread(osv.Model):
if isinstance(param, list):
param = ", ".join(param)
data.update({
- 'subject': subject or _('History'),
'email_to': email_to,
'email_from': email_from or \
(hasattr(thread, 'user_id') and thread.user_id and thread.user_id.user_email),
@@ -385,8 +386,9 @@ class mail_thread(osv.Model):
body_html= msg_dict.get('body_html'),
parent_id = msg_dict.get('parent_id', False),
type = msg_dict.get('type', 'email'),
- subtype = msg_dict.get('subtype', 'plain'),
- state = msg_dict.get('state', 'received'),
+ content_subtype = msg_dict.get('content_subtype'),
+ state = msg_dict.get('state'),
+ partner_ids = msg_dict.get('partner_ids'),
email_from = msg_dict.get('from', msg_dict.get('email_from')),
email_to = msg_dict.get('to', msg_dict.get('email_to')),
email_cc = msg_dict.get('cc', msg_dict.get('email_cc')),
@@ -401,59 +403,109 @@ class mail_thread(osv.Model):
original = msg_dict.get('original'),
context = context)
- def _message_add_ancestor_ids(self, cr, uid, ids, child_ids, root_ids, context=None):
- """ Given message child_ids
- Find their ancestors until root ids"""
- if context is None:
- context = {}
- msg_obj = self.pool.get('mail.message')
- tmp_msgs = msg_obj.read(cr, uid, child_ids, ['id', 'parent_id'], context=context)
- parent_ids = [msg['parent_id'][0] for msg in tmp_msgs if msg['parent_id'] and msg['parent_id'][0] not in root_ids and msg['parent_id'][0] not in child_ids]
+ #------------------------------------------------------
+ # Message loading
+ #------------------------------------------------------
+
+ def _message_search_ancestor_ids(self, cr, uid, ids, child_ids, ancestor_ids, context=None):
+ """ Given message child_ids ids, find their ancestors until ancestor_ids
+ using their parent_id relationship.
+
+ :param child_ids: the first nodes of the search
+ :param ancestor_ids: list of ancestors. When the search reach an
+ ancestor, it stops.
+ """
+ def _get_parent_ids(message_list, ancestor_ids, child_ids):
+ """ Tool function: return the list of parent_ids of messages
+ contained in message_list. Parents that are in ancestor_ids
+ or in child_ids are not returned. """
+ return [message['parent_id'][0] for message in message_list
+ if message['parent_id']
+ and message['parent_id'][0] not in ancestor_ids
+ and message['parent_id'][0] not in child_ids
+ ]
+
+ message_obj = self.pool.get('mail.message')
+ messages_temp = message_obj.read(cr, uid, child_ids, ['id', 'parent_id'], context=context)
+ parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
child_ids += parent_ids
cur_iter = 0; max_iter = 100; # avoid infinite loop
while (parent_ids and (cur_iter < max_iter)):
cur_iter += 1
- tmp_msgs = msg_obj.read(cr, uid, parent_ids, ['id', 'parent_id'], context=context)
- parent_ids = [msg['parent_id'][0] for msg in tmp_msgs if msg['parent_id'] and msg['parent_id'][0] not in root_ids and msg['parent_id'][0] not in child_ids]
+ messages_temp = message_obj.read(cr, uid, parent_ids, ['id', 'parent_id'], context=context)
+ parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
child_ids += parent_ids
if (cur_iter > max_iter):
- _logger.warning("Possible infinite loop in _message_add_ancestor_ids. Note that this algorithm is intended to check for cycle in message graph.")
+ _logger.warning("Possible infinite loop in _message_search_ancestor_ids. "\
+ "Note that this algorithm is intended to check for cycle in "\
+ "message graph, leading to a curious error. Have fun.")
return child_ids
- def message_load_ids(self, cr, uid, ids, limit=100, offset=0, domain=[], ascent=False, root_ids=[], context=None):
- """ OpenChatter feature: return thread messages ids. It searches in
- mail.messages where res_id = ids, (res_)model = current model.
- :param domain: domain to add to the search; especially child_of
- is interesting when dealing with threaded display
- :param ascent: performs an ascended search; will add to fetched msgs
- all their parents until root_ids
- :param root_ids: for ascent search
- :param root_ids: root_ids when performing an ascended search
+ def message_search_get_domain(self, cr, uid, ids, context=None):
+ """ OpenChatter feature: get the domain to search the messages related
+ to a document. mail.thread defines the default behavior as
+ being messages with model = self._name, id in ids.
+ This method should be overridden if a model has to implement a
+ particular behavior.
"""
- if context is None:
- context = {}
- msg_obj = self.pool.get('mail.message')
- msg_ids = msg_obj.search(cr, uid, ['&', ('res_id', 'in', ids), ('model', '=', self._name)] + domain,
- limit=limit, offset=offset, context=context)
- if (ascent): msg_ids = self._message_add_ancestor_ids(cr, uid, ids, msg_ids, root_ids, context=context)
- return msg_ids
+ return ['&', ('res_id', 'in', ids), ('model', '=', self._name)]
- def message_load(self, cr, uid, ids, limit=100, offset=0, domain=[], ascent=False, root_ids=[], context=None):
- """ OpenChatter feature: return thread messages
+ def message_search(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
+ limit=100, offset=0, domain=None, count=False, context=None):
+ """ OpenChatter feature: return thread messages ids according to the
+ search domain given by ``message_search_get_domain``.
+
+ It is possible to add in the search the parent of messages by
+ setting the fetch_ancestors flag to True. In that case, using
+ the parent_id relationship, the method returns the id list according
+ to the search domain, but then calls ``_message_search_ancestor_ids``
+ that will add to the list the ancestors ids. The search is limited
+ to parent messages having an id in ancestor_ids or having
+ parent_id set to False.
+
+ If ``count==True``, the number of ids is returned instead of the
+ id list. The count is done by hand instead of passing it as an
+ argument to the search call because we might want to perform
+ a research including parent messages until some ancestor_ids.
+
+ :param fetch_ancestors: performs an ascended search; will add
+ to fetched msgs all their parents until
+ ancestor_ids
+ :param ancestor_ids: used when fetching ancestors
+ :param domain: domain to add to the search; especially child_of
+ is interesting when dealing with threaded display.
+ Note that the added domain is anded with the
+ default domain.
+ :param limit, offset, count, context: as usual
"""
- msg_ids = self.message_load_ids(cr, uid, ids, limit, offset, domain, ascent, root_ids, context=context)
- msgs = self.pool.get('mail.message').read(cr, uid, msg_ids, [], context=context)
-
- # Set as read
- self.message_check_and_set_read(cr, uid, ids, context=context)
-
+ search_domain = self.message_search_get_domain(cr, uid, ids, context=context)
+ if domain:
+ search_domain += domain
+ message_obj = self.pool.get('mail.message')
+ message_res = message_obj.search(cr, uid, search_domain, limit=limit, offset=offset, count=count, context=context)
+ if not count and fetch_ancestors:
+ message_res += self._message_search_ancestor_ids(cr, uid, ids, message_res, ancestor_ids, context=context)
+ return message_res
+
+ def message_read(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
+ limit=100, offset=0, domain=None, context=None):
+ """ OpenChatter feature: read the messages related to some threads.
+ This method is used mainly the Chatter widget, to directly have
+ read result instead of searching then reading.
+
+ Please see message_search for more information about the parameters.
+ """
+ message_ids = self.message_search(cr, uid, ids, fetch_ancestors, ancestor_ids,
+ limit, offset, domain, context=context)
+ messages = self.pool.get('mail.message').read(cr, uid, message_ids, context=context)
+
""" Retrieve all attachments names """
+ map_id_to_name = dict((attachment_id, '') for message in messages for attachment_id in message['attachment_ids'])
map_id_to_name = {}
-
- for msg in msgs:
+ for msg in messages:
for attach_id in msg["attachment_ids"]:
map_id_to_name[attach_id] = '' # use empty string as a placeholder
-
+
ids = map_id_to_name.keys()
names = self.pool.get('ir.attachment').name_get(cr, uid, ids, context=context)
@@ -462,26 +514,34 @@ class mail_thread(osv.Model):
map_id_to_name[name[0]] = name[1]
# give corresponding ids and names to each message
- for msg in msgs:
+ for msg in messages:
msg["attachments"] = []
for attach_id in msg["attachment_ids"]:
msg["attachments"].append({'id': attach_id, 'name': map_id_to_name[attach_id]})
- """ Sort and return messages """
- msgs = sorted(msgs, key=lambda d: (-d['id']))
- return msgs
+ # Set the threads as read
+ self.message_check_and_set_read(cr, uid, ids, context=context)
+ # Sort and return the messages
+ messages = sorted(messages, key=lambda d: (-d['id']))
+ return messages
- def get_pushed_messages(self, cr, uid, ids, limit=100, offset=0, msg_search_domain=[], ascent=False, root_ids=[], context=None):
- """ OpenChatter: wall: get messages to display (=pushed notifications)
+ def message_get_pushed_messages(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
+ limit=100, offset=0, msg_search_domain=[], context=None):
+ """ OpenChatter: wall: get the pushed notifications and used them
+ to fetch messages to display on the wall.
+
+ :param fetch_ancestors: performs an ascended search; will add
+ to fetched msgs all their parents until
+ ancestor_ids
+ :param ancestor_ids: used when fetching ancestors
:param domain: domain to add to the search; especially child_of
is interesting when dealing with threaded display
:param ascent: performs an ascended search; will add to fetched msgs
all their parents until root_ids
:param root_ids: for ascent search
- :return list of mail.messages sorted by date
+ :return: list of mail.messages sorted by date
"""
- if context is None: context = {}
notification_obj = self.pool.get('mail.notification')
msg_obj = self.pool.get('mail.message')
# update message search
@@ -496,7 +556,7 @@ class mail_thread(osv.Model):
msg_ids = [notification.message_id.id for notification in notifications]
# get messages
msg_ids = msg_obj.search(cr, uid, [('id', 'in', msg_ids)], context=context)
- if (ascent): msg_ids = self._message_add_ancestor_ids(cr, uid, ids, msg_ids, root_ids, context=context)
+ if (fetch_ancestors): msg_ids = self._message_search_ancestor_ids(cr, uid, ids, msg_ids, ancestor_ids, context=context)
msgs = msg_obj.read(cr, uid, msg_ids, context=context)
return msgs
@@ -524,9 +584,9 @@ class mail_thread(osv.Model):
record needs to be created. Ignored
if the thread record already exists.
:param bool save_original: whether to keep a copy of the original
- email source attached to the message after it is imported.
+ email source attached to the message after it is imported.
:param bool strip_attachments: whether to strip all attachments
- before processing the message, in order to save some space.
+ before processing the message, in order to save some space.
"""
# extract message bytes - we are forced to pass the message as binary because
# we don't know its encoding until we parse its headers and hence can't
@@ -540,7 +600,6 @@ class mail_thread(osv.Model):
context.update({'thread_model': model})
mail_message = self.pool.get('mail.message')
- res_id = False
# Parse Message
# Warning: message_from_string doesn't always work correctly on unicode,
@@ -548,8 +607,11 @@ class mail_thread(osv.Model):
if isinstance(message, unicode):
message = message.encode('utf-8')
msg_txt = email.message_from_string(message)
- msg = mail_message.parse_message(msg_txt, save_original=save_original)
+ msg = mail_message.parse_message(msg_txt, save_original=save_original, context=context)
+ # update state
+ msg['state'] = 'received'
+
if strip_attachments and 'attachments' in msg:
del msg['attachments']
@@ -587,6 +649,8 @@ class mail_thread(osv.Model):
res_id = create_record(msg)
# To forward the email to other followers
self.message_forward(cr, uid, model, [res_id], msg_txt, context=context)
+ # Set as Unread
+ model_pool.message_mark_as_unread(cr, uid, [res_id], context=context)
return res_id
def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
@@ -652,9 +716,9 @@ class mail_thread(osv.Model):
return self.message_append_dict(cr, uid, ids, msg_dict, context=context)
def message_thread_followers(self, cr, uid, ids, context=None):
- """Returns a list of email addresses of the people following
- this thread, including the sender of each mail, and the
- people who were in CC of the messages, if any.
+ """ Returns a list of email addresses of the people following
+ this thread, including the sender of each mail, and the
+ people who were in CC of the messages, if any.
"""
res = {}
if isinstance(ids, (str, int, long)):
@@ -748,45 +812,27 @@ class mail_thread(osv.Model):
# Note specific
#------------------------------------------------------
- def message_broadcast(self, cr, uid, ids, subject=None, body=None, parent_id=False, type='notification', subtype='html', context=None):
- if context is None:
- context = {}
- notification_obj = self.pool.get('mail.notification')
- # write message
- msg_ids = self.message_append_note(cr, uid, ids, subject=subject, body=body, parent_id=parent_id, type=type, subtype=subtype, context=context)
- # escape if in install mode or note writing was not successfull
- if 'install_mode' in context:
- return True
- if not isinstance(msg_ids, (list)):
- return True
- # get already existing notigications
- notification_ids = notification_obj.search(cr, uid, [('message_id', 'in', msg_ids)], context=context)
- already_pushed_user_ids = map(itemgetter('user_id'), notification_obj.read(cr, uid, notification_ids, context=context))
- # get base.group_user group
- res = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'base', 'group_user') or False
- group_id = res and res[1] or False
- if not group_id: return True
- group = self.pool.get('res.groups').browse(cr, uid, [group_id], context=context)[0]
- for user in group.users:
- if user.id in already_pushed_user_ids: continue
- for msg_id in msg_ids:
- notification_obj.create(cr, uid, {'user_id': user.id, 'message_id': msg_id}, context=context)
- return True
-
def log(self, cr, uid, id, message, secondary=False, context=None):
- _logger.warning("log() is deprecated. Please use OpenChatter notification system instead of the res.log mechanism.")
+ _logger.warning("log() is deprecated. As this module inherit from \
+ mail.thread, the message will be managed by this \
+ module instead of by the res.log mechanism. Please \
+ use the mail.thread OpenChatter API instead of the \
+ now deprecated res.log.")
self.message_append_note(cr, uid, [id], 'res.log', message, context=context)
- def message_append_note(self, cr, uid, ids, subject=None, body=None, parent_id=False, type='notification', subtype='html', context=None):
- if type in ['notification', 'reply']:
+ def message_append_note(self, cr, uid, ids, subject=None, body=None, parent_id=False,
+ type='notification', content_subtype='html', context=None):
+ if type in ['notification', 'comment']:
subject = None
- if subtype == 'html':
+ if content_subtype == 'html':
body_html = body
body_text = body
else:
body_html = body
body_text = body
- return self.message_append(cr, uid, ids, subject, body_html=body_html, body_text=body_text, parent_id=parent_id, type=type, subtype=subtype, context=context)
+ return self.message_append(cr, uid, ids, subject, body_html, body_text,
+ type, parent_id=parent_id,
+ content_subtype=content_subtype, context=context)
#------------------------------------------------------
# Subscription mechanism
@@ -796,9 +842,9 @@ class mail_thread(osv.Model):
""" Returns the current document followers. Basically this method
checks in mail.subscription for entries with matching res_model,
res_id.
-
- :param get_ids: if set to True, return the ids of users; if set
- to False, returns the result of a read in res.users
+ This method can be overriden to add implicit subscribers, such
+ as project managers, by adding their user_id to the list of
+ ids returned by this method.
"""
subscr_obj = self.pool.get('mail.subscription')
subscr_ids = subscr_obj.search(cr, uid, ['&', ('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
@@ -849,19 +895,97 @@ class mail_thread(osv.Model):
# Trying to unsubscribe somebody not in subscribers: returns False
# if special management is needed; allows to know that an automatically
# subscribed user tries to unsubscribe and allows to warn him
- mail_thread_model = self.pool.get('mail.thread')
- if not user_ids and not uid in mail_thread_model.message_get_subscribers(cr, uid, ids, context=context):
- return False
- subscription_obj = self.pool.get('mail.subscription')
to_unsubscribe_uids = [uid] if user_ids is None else user_ids
+ subscription_obj = self.pool.get('mail.subscription')
to_delete_sub_ids = subscription_obj.search(cr, uid,
['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('user_id', 'in', to_unsubscribe_uids)], context=context)
+ if not to_delete_sub_ids:
+ return False
return subscription_obj.unlink(cr, uid, to_delete_sub_ids, context=context)
#------------------------------------------------------
# Notification API
#------------------------------------------------------
+ def message_create_notify_by_email(self, cr, uid, new_msg_values, user_to_notify_ids, context=None):
+ """ When creating a new message and pushing notifications, emails
+ must be send if users have chosen to receive notifications
+ by email via the notification_email_pref field.
+
+ ``notification_email_pref`` can have 3 values :
+ - all: receive all notification by email (for example for shared
+ users)
+ - to_me: messages send directly to me (@login, messages on res.users)
+ - never: never receive notifications
+ Note that an user should never receive notifications for messages
+ he has created.
+
+ :param new_msg_values: dictionary of message values, those that
+ are given to the create method
+ :param user_to_notify_ids: list of user_ids, user that will
+ receive a notification on their Wall
+ """
+ message_obj = self.pool.get('mail.message')
+ res_users_obj = self.pool.get('res.users')
+ body = new_msg_values.get('body_html', '') if new_msg_values.get('content_subtype') == 'html' else new_msg_values.get('body_text', '')
+
+ # remove message writer
+ if user_to_notify_ids.count(new_msg_values.get('user_id')) > 0:
+ user_to_notify_ids.remove(new_msg_values.get('user_id'))
+
+ # get user_ids directly asked
+ user_to_push_from_parse_ids = self.message_parse_users(cr, uid, body, context=context)
+
+ # try to find an email_to
+ email_to = ''
+ for user in res_users_obj.browse(cr, uid, user_to_notify_ids, context=context):
+ if not user.notification_email_pref == 'all' and \
+ not (user.notification_email_pref == 'to_me' and user.id in user_to_push_from_parse_ids):
+ continue
+ if not user.user_email:
+ continue
+ email_to = '%s, %s' % (email_to, user.user_email)
+ email_to = email_to.lstrip(', ')
+
+ # did not find any email address: not necessary to create an email
+ if not email_to:
+ return
+
+ # try to find an email_from
+ current_user = res_users_obj.browse(cr, uid, [uid], context=context)[0]
+ email_from = new_msg_values.get('email_from')
+ if not email_from:
+ email_from = current_user.user_email
+
+ # get email content, create it (with mail_message.create)
+ email_values = self.message_create_notify_get_email_dict(cr, uid, new_msg_values, email_from, email_to, context)
+ email_id = message_obj.create(cr, uid, email_values, context=context)
+ return email_id
+
+ def message_create_notify_get_email_dict(self, cr, uid, new_msg_values, email_from, email_to, context=None):
+ values = dict(new_msg_values)
+
+ body_html = new_msg_values.get('body_html', '')
+ if body_html:
+ body_html += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
+ body_text = new_msg_values.get('body_text', '')
+ if body_text:
+ body_text += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
+ values.update({
+ 'type': 'email',
+ 'state': 'outgoing',
+ 'email_from': email_from,
+ 'email_to': email_to,
+ 'subject': 'New message',
+ 'content_subtype': new_msg_values.get('content_subtype', 'plain'),
+ 'body_html': body_html,
+ 'body_text': body_text,
+ 'auto_delete': True,
+ 'res_model': '',
+ 'res_id': False,
+ })
+ return values
+
def message_remove_pushed_notifications(self, cr, uid, ids, msg_ids, remove_childs=True, context=None):
notif_obj = self.pool.get('mail.notification')
msg_obj = self.pool.get('mail.message')
diff --git a/addons/mail/mail_thread_view.xml b/addons/mail/mail_thread_view.xml
index a32045efafc..f07e1e467ee 100644
--- a/addons/mail/mail_thread_view.xml
+++ b/addons/mail/mail_thread_view.xml
@@ -2,11 +2,11 @@
-
-
+
+
@@ -22,5 +22,6 @@
+
diff --git a/addons/mail/res_partner.py b/addons/mail/res_partner.py
index 922f3e4b6ed..3dc1f91d857 100644
--- a/addons/mail/res_partner.py
+++ b/addons/mail/res_partner.py
@@ -22,34 +22,18 @@
from osv import osv
from osv import fields
-class res_partner(osv.osv):
+class res_partner_mail(osv.osv):
""" Inherits partner and adds CRM information in the partner form """
_name = "res.partner"
_inherit = ['res.partner', 'mail.thread']
- _columns = {
- 'emails': fields.one2many('mail.message', 'partner_id', 'Emails', readonly=True, domain=[('email_from','!=',False)]),
- }
- def message_load_ids(self, cr, uid, ids, limit=100, offset=0, domain=[], ascent=False, root_ids=[False], context=None):
- """ Override of message_load_ids
- partner discussion page :
- - messages posted on res.partner, partner_id = partner.id
- - messages directly sent to partner
+ def message_search_get_domain(self, cr, uid, ids, context=None):
+ """ Override of message_search_get_domain for partner discussion page.
+ The purpose is to add messages directly sent to the partner.
"""
- msg_obj = self.pool.get('mail.message')
- msg_ids = []
- partner_ids=[]
- for partner in self.browse(cr, uid, ids, context=context):
- msg_ids += msg_obj.search(cr, uid, [ ('res_id', '=', partner.id), ('model', '=' ,self._name)] + domain,
- limit=limit, offset=offset, context=context)
- if self._name=='res.partner':
- partner_ids=msg_obj.search(cr, uid, [ ('partner_id', 'in', ids)] + domain,
- limit=limit, offset=offset, context=context)
- if partner_ids :
- msg_ids+= partner_ids
- if (ascent): msg_ids = self._message_add_ancestor_ids(cr, uid, ids, msg_ids, root_ids, context=context)
- return msg_ids
-
-res_partner()
+ initial_domain = super(res_partner_mail, self).message_search_get_domain(cr, uid, ids, context=context)
+ if self._name == 'res.partner': # to avoid models inheriting from res.partner
+ search_domain = ['|'] + initial_domain + ['|', ('partner_id', 'in', ids), ('partner_ids', 'in', ids)]
+ return search_domain
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/mail/res_partner_view.xml b/addons/mail/res_partner_view.xml
index 2d425f18c7b..d1e2d881739 100644
--- a/addons/mail/res_partner_view.xml
+++ b/addons/mail/res_partner_view.xml
@@ -9,8 +9,8 @@
-
-
+
diff --git a/addons/mail/res_users.py b/addons/mail/res_users.py
index f62e29fb44d..89767473754 100644
--- a/addons/mail/res_users.py
+++ b/addons/mail/res_users.py
@@ -24,24 +24,26 @@ from tools.translate import _
class res_users(osv.osv):
""" Update of res.users class
- - add a preference about sending emails about notificatoins
+ - add a preference about sending emails about notifications
- make a new user follow itself
+ - add a welcome message
"""
_name = 'res.users'
_inherit = ['res.users', 'mail.thread']
_columns = {
'notification_email_pref': fields.selection([
- ('all', 'All feeds'),
- ('comments', 'Only comments'),
- ('to_me', 'Only when sent directly to me'),
- ('none', 'Never')
- ], 'Receive Feeds by Email', required=True,
- help="Choose in which case you want to receive an email when you receive new feeds."),
+ ('all', 'All feeds'),
+ ('comments', 'Only comments'),
+ ('to_me', 'Only when sent directly to me'),
+ ('none', 'Never')
+ ], 'Receive Feeds by Email', required=True,
+ help="Choose in which case you want to receive an email when you "\
+ "receive new feeds."),
}
_defaults = {
- 'notification_email_pref': 'none',
+ 'notification_email_pref': 'to_me',
}
def __init__(self, pool, cr):
@@ -60,25 +62,61 @@ class res_users(osv.osv):
user = self.browse(cr, uid, [user_id], context=context)[0]
# make user follow itself
self.message_subscribe(cr, uid, [user_id], [user_id], context=context)
- # create a welcome message to broadcast
+ # create a welcome message
company_name = user.company_id.name if user.company_id else 'the company'
- message = _('%s has joined %s! You may leave him/her a message to celebrate a new arrival in the company ! You can help him/her doing its first steps on OpenERP.') % (user.name, company_name)
- # TODO: clean the broadcast feature. As this is not cleany specified, temporarily remove the message broadcasting that is not buggy but not very nice.
- #self.message_broadcast(cr, uid, [user.id], 'Welcome notification', message, context=context)
+ message = _('%s has joined %s! Welcome in OpenERP !') % (user.name, company_name)
+ self.message_append_note(cr, uid, [user_id], subject='Welcom to OpenERP', body=message, type='comment', context=context)
return user_id
- def message_load_ids(self, cr, uid, ids, limit=100, offset=0, domain=[], ascent=False, root_ids=[False], context=None):
- """ Override of message_load_ids
- User discussion page :
- - messages posted on res.users, res_id = user.id
- - messages directly sent to user with @user_login
+ def message_search_get_domain(self, cr, uid, ids, context=None):
+ """ Override of message_search_get_domain for partner discussion page.
+ The purpose is to add messages directly sent to user using
+ @user_login.
"""
- if context is None:
- context = {}
- msg_obj = self.pool.get('mail.message')
- msg_ids = []
+ initial_domain = super(res_users, self).message_search_get_domain(cr, uid, ids, context=context)
+ custom_domain = []
for user in self.browse(cr, uid, ids, context=context):
- msg_ids += msg_obj.search(cr, uid, ['|', '|', ('body_text', 'like', '@%s' % (user.login)), ('body_html', 'like', '@%s' % (user.login)), '&', ('res_id', '=', user.id), ('model', '=', self._name)] + domain,
- limit=limit, offset=offset, context=context)
- if (ascent): msg_ids = self._message_add_ancestor_ids(cr, uid, ids, msg_ids, root_ids, context=context)
- return msg_ids
+ if custom_domain:
+ custom_domain += ['|']
+ custom_domain += ['|', ('body_text', 'like', '@%s' % (user.login)), ('body_html', 'like', '@%s' % (user.login))]
+ return ['|'] + initial_domain + custom_domain
+
+class res_users_mail_group(osv.osv):
+ """ Update of res.groups class
+ - if adding/removing users from a group, check mail.groups linked to
+ this user group, and subscribe / unsubscribe them from the discussion
+ group. This is done by overriding the write method.
+ """
+ _name = 'res.users'
+ _inherit = ['res.users', 'mail.thread']
+
+ def write(self, cr, uid, ids, vals, context=None):
+ write_res = super(res_users_mail_group, self).write(cr, uid, ids, vals, context=context)
+ if vals.get('groups_id'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_group_ids = [command[1] for command in vals['groups_id'] if command[0] == 4]
+ user_group_ids += [id for command in vals['groups_id'] if command[0] == 6 for id in command[2]]
+ mail_group_obj = self.pool.get('mail.group')
+ mail_group_ids = mail_group_obj.search(cr, uid, [('group_ids', 'in', user_group_ids)], context=context)
+ mail_group_obj.message_subscribe(cr, uid, mail_group_ids, ids, context=context)
+ return write_res
+
+
+class res_groups_mail_group(osv.osv):
+ """ Update of res.groups class
+ - if adding/removing users from a group, check mail.groups linked to
+ this user group, and subscribe / unsubscribe them from the discussion
+ group. This is done by overriding the write method.
+ """
+ _name = 'res.groups'
+ _inherit = 'res.groups'
+
+ def write(self, cr, uid, ids, vals, context=None):
+ if vals.get('users'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_ids = [command[1] for command in vals['users'] if command[0] == 4]
+ user_ids += [id for command in vals['users'] if command[0] == 6 for id in command[2]]
+ mail_group_obj = self.pool.get('mail.group')
+ mail_group_ids = mail_group_obj.search(cr, uid, [('group_ids', 'in', ids)], context=context)
+ mail_group_obj.message_subscribe(cr, uid, mail_group_ids, user_ids, context=context)
+ return super(res_groups_mail_group, self).write(cr, uid, ids, vals, context=context)
diff --git a/addons/mail/res_users_view.xml b/addons/mail/res_users_view.xml
index 918f77e0ace..e268997ac2c 100644
--- a/addons/mail/res_users_view.xml
+++ b/addons/mail/res_users_view.xml
@@ -9,7 +9,7 @@
-
+
@@ -23,11 +23,11 @@
-
+
-
+
diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv
index 011e8b27dc5..1fb909f4435 100644
--- a/addons/mail/security/ir.model.access.csv
+++ b/addons/mail/security/ir.model.access.csv
@@ -1,7 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_mail_message,mail.message,model_mail_message,,1,0,1,0
-access_mail_message_all,mail.message.all,model_mail_message,base.group_user,1,1,1,1
+access_mail_message_all,mail.message.all,model_mail_message,,1,0,0,0
+access_mail_message_group_user,mail.message.group.user,model_mail_message,base.group_user,1,1,1,1
access_mail_thread,mail.thread,model_mail_thread,base.group_user,1,1,1,0
-access_mail_subscription,mail.subscription,model_mail_subscription,,1,1,1,1
-access_mail_notification,mail.notification,model_mail_notification,,1,1,1,1
+access_mail_subscription_all,mail.subscription.all,model_mail_subscription,,1,1,1,1
+access_mail_notification_all,mail.notification.all,model_mail_notification,,1,1,1,1
access_mail_group,mail.group,model_mail_group,base.group_user,1,1,1,1
diff --git a/addons/mail/static/src/css/mail.css b/addons/mail/static/src/css/mail.css
index 0cf932f9a25..03f1078e6d5 100644
--- a/addons/mail/static/src/css/mail.css
+++ b/addons/mail/static/src/css/mail.css
@@ -1,7 +1,6 @@
-
-/* ------------------------------ */
-/* Wall */
-/* ------------------------------ */
+/* ------------------------------------------------------------ */
+/* Wall
+/* ------------------------------------------------------------ */
.openerp div.oe_mail_wall {
overflow: auto;
@@ -9,6 +8,17 @@
background: white;
}
+.openerp div.oe_mail_wall_main {
+ float: left;
+ width: 560px;
+ margin: 8px;
+}
+
+.openerp div.oe_mail_wall_aside {
+ margin-left: 565px;
+ margin: 8px;
+}
+
.openerp div.oe_mail_wall_action {
padding: 8px;
background: #eee;
@@ -21,36 +31,32 @@
clear: both;
}
-.openerp .oe_mail_wall_action textarea {
+.openerp div.oe_mail_wall_action .oe_mail_msg_content {
+ width: 484px;
+}
+
+.openerp div.oe_mail_wall_action textarea.oe_mail_compose_textarea,
+.openerp div.oe_mail_wall_action div.oe_mail_compose_message_body_text textarea {
width: 474px;
height: 60px;
padding: 4px;
- margin-bottom: 8px;
- float: right;
+ margin-bottom: 2px;
}
-/* 2 columns view */
-.openerp div.oe_mail_wall_main {
- float: left;
- width: 560px;
- margin: 8px;
-}
-
-.openerp div.oe_mail_wall_aside {
- margin-left: 565px;
- margin: 8px;
-}
-
-/* Threads */
-.openerp .oe_mail_wall_threads {
+.openerp ul.oe_mail_wall_threads {
margin-top: 8px;
}
-.openerp .oe_mail_wall_threads textarea {
- height: 40px;
+
+/* Specific display of threads in the wall */
+/* ------------------------------------------------------------ */
+
+.openerp ul.oe_mail_wall_threads .oe_mail_msg_content textarea {
+ width: 434px;
+ height: 30px;
padding: 4px;
}
-.openerp .oe_mail_wall_thread:first .oe_mail_msg_notification {
+.openerp li.oe_mail_wall_thread:first .oe_mail_msg_notification {
border-top: 0;
}
@@ -64,7 +70,7 @@
width: 486px;
}
-.openerp div.oe_mail_msg_content li{
+.openerp div.oe_mail_msg_content li {
float: left;
margin-right: 3px;
}
@@ -79,40 +85,28 @@
}
-/* ------------------------------ */
-/* RecordThread */
-/* ------------------------------ */
+/* ------------------------------------------------------------ */
+/* RecordThread
+/* ------------------------------------------------------------ */
.openerp div.oe_mail_recthread {
overflow: auto;
}
-/* Left-side CSS */
-.openerp div.oe_mail_recthread_actions {
- margin-bottom: 8px;
-}
-
-.openerp div.oe_mail_recthread_followers {
- margin-bottom: 8px;
-}
-
-.openerp div.oe_mail_recthread_followers img.oe_mail_msg_image {
- width: 28px;
- height: 28px;
- margin: 4px;
-}
-
-/* RecordThread: 2 columns view */
-.openerp div.oe_mail_recthread_left {
+.openerp div.oe_mail_recthread_main {
float: left;
width: 560px;
}
-.openerp div.oe_mail_recthread_right {
+.openerp div.oe_mail_recthread_aside {
float: right;
width: 250px;
}
+.openerp div.oe_mail_recthread_actions {
+ margin-bottom: 8px;
+}
+
.openerp div.oe_mail_recthread_actions button {
width: 120px;
}
@@ -144,16 +138,17 @@
background-image: linear-gradient(to bottom, #dc5f59, #b33630);
}
-.openerp textarea.oe_mail_action_textarea {
- height: 60px;
- padding: 5px;
+.openerp div.oe_mail_recthread_followers {
+ margin-bottom: 8px;
}
-/* ------------------------------ */
-/* ThreadDisplay */
-/* ------------------------------ */
+
+/* ------------------------------------------------------------ */
+/* Thread
+/* ------------------------------------------------------------ */
.openerp div.oe_mail_thread_action {
+ display: none;
white-space: normal;
padding: 8px;
background: #eee;
@@ -166,6 +161,26 @@
clear: both;
}
+/* default textarea (oe_mail_compose_textarea), and body_text textarea for compose form view */
+.openerp .oe_mail_msg_content textarea.oe_mail_compose_textarea,
+.openerp .oe_mail_msg_content div.oe_mail_compose_message_body_text textarea {
+ width: 474px;
+ height: 60px;
+ padding: 4px;
+ font-size: 12px;
+ border: 1px solid #cccccc;
+}
+
+/* default textarea (oe_mail_compose_textarea), and body_text textarea for compose form view */
+.openerp .oe_mail_msg_content textarea.oe_mail_compose_textarea:focus,
+.openerp .oe_mail_msg_content div.oe_mail_compose_message_body_text textarea:focus {
+ outline: 0;
+ border-color: rgba(82, 168, 236, 0.8);
+ -moz-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(82, 168, 236, 0.6);
+ -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(82, 168, 236, 0.6);
+ -box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(82, 168, 236, 0.6);
+}
+
.openerp div.oe_mail_thread_display {
white-space: normal;
}
@@ -174,7 +189,7 @@
margin-left: 66px;
}
-.openerp div.oe_mail_thread_subthread .oe_mail_thread_msg:last-child {
+.openerp div.oe_mail_thread_subthread li.oe_mail_thread_msg:last-child {
margin-bottom: 8px;
}
@@ -183,18 +198,23 @@
border-bottom: 1px solid #D2D9E7;
}
-.openerp .oe_mail_thread_msg:after {
+.openerp li.oe_mail_thread_msg:after {
content: "";
display: block;
clear: both;
}
-.openerp .oe_mail_thread_msg > div:after {
+.openerp li.oe_mail_thread_msg > div:after {
content: "";
display: block;
clear: both;
}
+.openerp div.oe_mail_msg {
+ padding: 0;
+ margin: 0 0 4px 0;
+}
+
.openerp .oe_mail_msg_notification,
.openerp .oe_mail_msg_comment,
.openerp .oe_mail_msg_email {
@@ -203,11 +223,6 @@
border-top: 1px solid #ccc;
}
-.openerp .oe_email_icon {
- width: 50px;
- height: 50px;
-}
-
.openerp div.oe_mail_thread_subthread .oe_mail_msg_comment {
background: #eee;
}
@@ -230,9 +245,18 @@
clear: both;
}
-.openerp img.oe_mail_msg_image {
+.openerp img.oe_mail_icon {
width: 50px;
height: 50px;
+}
+
+.openerp img.oe_mail_thumbnail {
+ width: 28px;
+ height: 28px;
+ margin: 4px;
+}
+
+.openerp img.oe_mail_frame {
text-align: center;
overflow: hidden;
-moz-border-radius: 3px;
@@ -247,48 +271,60 @@
clip: rect(5px, 40px, 45px, 0px);
}
-/* ------------------------------ */
-/* Styling (should be openerp) */
-/* ------------------------------ */
+.openerp .oe_mail_invisible {
+ display: none;
+}
-.openerp input.oe_mail, textarea.oe_mail {
- width: 432px;
- padding: 4px;
- font-size: 12px;
+/* ------------------------------------------------------------ */
+/* mail.compose.message form view & OpenERP hacks
+/* ------------------------------------------------------------ */
+
+/* form_view: delete white background */
+.openerp .oe_mail_msg_content div.oe_formview {
+ background-color: transparent;
+}
+
+.openerp .oe_mail_msg_content div.oe_form_nosheet {
+ margin: 0px;
+}
+
+.openerp .oe_mail_msg_content table.oe_form_group {
+ margin: 0px;
+}
+
+.openerp .oe_mail_msg_content table.oe_form_field,
+.openerp .oe_mail_msg_content div.oe_form_field {
+ padding: 0px;
+}
+
+.openerp .oe_mail_msg_content td.oe_form_group_cell {
+ vertical-align: bottom;
+}
+
+/* subject: change width */
+.openerp .oe_mail_msg_content .oe_form .oe_form_field input {
+ width: 472px;
+}
+
+/* body_html: cleditor */
+.openerp .oe_mail_msg_content div.cleditorMain {
border: 1px solid #cccccc;
- -webkit-transition: border linear 0.2s, box-shadow linear 0.2s;
- -moz-transition: border linear 0.2s, box-shadow linear 0.2s;
- -ms-transition: border linear 0.2s, box-shadow linear 0.2s;
- -o-transition: border linear 0.2s, box-shadow linear 0.2s;
- transition: border linear 0.2s, box-shadow linear 0.2s;
- -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
- -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
- -box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
- -moz-border-radius: 3px;
- -webkit-border-radius: 3px;
- border-radius: 3px;
}
-.openerp input.oe_mail:focus, textarea.oe_mail:focus {
- outline: 0;
- border-color: rgba(82, 168, 236, 0.8);
- -moz-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(82, 168, 236, 0.6);
- -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(82, 168, 236, 0.6);
- -box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(82, 168, 236, 0.6);
+/* destination_partner_ids */
+.openerp .oe_mail_msg_content div.text-core {
+ height: 22px !important;
+ width: 472px;
}
-.openerp div.oe_mail_msg {
- padding: 0;
- margin: 0 0 4px 0;
+/* buttons */
+.openerp .oe_mail_msg_content .oe_mail_compose_message_icons button.oe_form_button {
+ padding: 1px;
}
-.openerp .oe_mail_oe_bold {
- font-weight: bold;
-}
-
-/* ------------------------------ */
-/* Messages layout */
-/* ------------------------------ */
+/* ------------------------------------------------------------ */
+/* Messages layout
+/* ------------------------------------------------------------ */
.openerp .oe_mail_msg .oe_mail_msg_title {
margin: 0;
@@ -318,6 +354,10 @@
display: inline;
}
+.openerp .oe_mail_oe_bold {
+ font-weight: bold;
+}
+
/* Read more/less link */
.openerp .oe_mail_msg_content .expand,
.openerp .oe_mail_msg_content .reduce {
@@ -360,35 +400,3 @@
padding: 0;
list-style-type: square;
}
-
-
-/* ------------------------------ */
-/* Group Form */
-/* ------------------------------ */
-
-div.oe_mail_group_advanced_details {
- display: none;
-}
-
-.oe_form_sheetbg.openerp_mail_group_sheet {
- min-height: 0px;
- max-height: none;
-}
-
-.oe_form_sheetbg.openerp_mail_group_sheet .oe_form_sheet {
- min-height: 0px;
- max-height: none;
- padding: 0px 18px;
- max-width: 80%;
-}
-
-/* Resize group logo */
-.oe_form_sheetbg.openerp_mail_group_sheet .oe_form_field_image > img {
- max-width: 100px;
- max-height: 100px;
-}
-
-/* Resize group description */
-.oe_form_sheetbg.openerp_mail_group_sheet .oe_form_field_text > textarea {
- height: 40px;
-}
diff --git a/addons/mail/static/src/css/mail_compose_message.css b/addons/mail/static/src/css/mail_compose_message.css
new file mode 100644
index 00000000000..4cd1339ded4
--- /dev/null
+++ b/addons/mail/static/src/css/mail_compose_message.css
@@ -0,0 +1,16 @@
+/* ------------------------------ */
+/* Compose Message Wizard Form */
+/* ------------------------------ */
+
+.openerp tr td .oe_form_field.oe_mail_compose_message_invisible {
+ display: none;
+}
+
+.openerp .oe_mail_compose_message_icons {
+ text-align: right;
+}
+
+.openerp .oe_mail_compose_message_icons img {
+ width: 20px;
+ height: 20px;
+}
diff --git a/addons/mail/static/src/img/_fp.png b/addons/mail/static/src/img/_fp.png
deleted file mode 100644
index 17dce2a047c..00000000000
Binary files a/addons/mail/static/src/img/_fp.png and /dev/null differ
diff --git a/addons/mail/static/src/img/attachment.png b/addons/mail/static/src/img/attachment.png
new file mode 100644
index 00000000000..5cc0c33257d
Binary files /dev/null and b/addons/mail/static/src/img/attachment.png differ
diff --git a/addons/mail/static/src/img/checklist.png b/addons/mail/static/src/img/checklist.png
new file mode 100644
index 00000000000..d252606ff53
Binary files /dev/null and b/addons/mail/static/src/img/checklist.png differ
diff --git a/addons/mail/static/src/img/feeds-hover.png b/addons/mail/static/src/img/feeds-hover.png
deleted file mode 100644
index 67070d3e7df..00000000000
Binary files a/addons/mail/static/src/img/feeds-hover.png and /dev/null differ
diff --git a/addons/mail/static/src/img/feeds.png b/addons/mail/static/src/img/feeds.png
deleted file mode 100644
index ac2293c0bf5..00000000000
Binary files a/addons/mail/static/src/img/feeds.png and /dev/null differ
diff --git a/addons/mail/static/src/img/formatting.png b/addons/mail/static/src/img/formatting.png
new file mode 100644
index 00000000000..cf45fdf192f
Binary files /dev/null and b/addons/mail/static/src/img/formatting.png differ
diff --git a/addons/mail/static/src/js/mail.js b/addons/mail/static/src/js/mail.js
index 2140bbe474a..8ce050fd144 100644
--- a/addons/mail/static/src/js/mail.js
+++ b/addons/mail/static/src/js/mail.js
@@ -5,210 +5,652 @@ openerp.mail = function(session) {
var mail = session.mail = {};
/**
- * Add records to sorted_comments array
- * @param {Array} records records from mail.message sorted by date desc
- * @returns {Object} cs comments_structure: dict
- * cs.model_to_root_ids = {model: [root_ids], }
- * cs.new_root_ids = [new_root_ids]
- * cs.root_ids = [root_ids]
- * cs.msgs = {record.id: record,}
- * cs.tree_struct = {record.id: {
- * 'level': record_level in hierarchy, 0 is root,
- * 'msg_nbr': number of childs,
- * 'direct_childs': [msg_ids],
- * 'all_childs': [msg_ids],
- * 'for_thread_msgs': [records],
- * 'ancestors': [msg_ids], } }
+ * ------------------------------------------------------------
+ * FormView
+ * ------------------------------------------------------------
+ *
+ * Override of formview do_action method, to catch all return action about
+ * mail.compose.message. The purpose is to bind 'Send by e-mail' buttons
+ * and redirect them to the Chatter.
*/
- function tools_sort_comments(cs, records, parent_id) {
- var cur_iter = 0; var max_iter = 10; var modif = true;
- while ( modif && (cur_iter++) < max_iter) {
- modif = false;
- _(records).each(function (record) {
- // root and not yet recorded
- if ( (record.parent_id == false || record.parent_id[0] == parent_id) && ! cs['msgs'][record.id]) {
- // add to model -> root_list ids
- if (! cs['model_to_root_ids'][record.model]) cs['model_to_root_ids'][record.model] = [record.id];
- else cs['model_to_root_ids'][record.model].push(record.id);
- // add root data
- cs['new_root_ids'].push(record.id);
- // add record
- cs['tree_struct'][record.id] = {'level': 0, 'direct_childs': [], 'all_childs': [], 'for_thread_msgs': [record], 'msg_nbr': -1, 'ancestors': []};
- cs['msgs'][record.id] = record;
- modif = true;
- }
- // not yet recorded, but parent is recorded
- else if (! cs['msgs'][record.id] && cs['msgs'][record.parent_id[0]]) {
- var parent_level = cs['tree_struct'][record.parent_id[0]]['level'];
- // update parent structure
- cs['tree_struct'][record.parent_id[0]]['direct_childs'].push(record.id);
- cs['tree_struct'][record.parent_id[0]]['for_thread_msgs'].push(record);
- // update ancestors structure
- for (ancestor_id in cs['tree_struct'][record.parent_id[0]]['ancestors']) {
- cs['tree_struct'][ancestor_id]['all_childs'].push(record.id);
+
+ session.web.FormView = session.web.FormView.extend({
+ do_action: function(action, on_close) {
+ if (action.res_model == 'mail.compose.message' && this.fields && this.fields.message_ids) {
+ var record_thread = this.fields.message_ids;
+ var thread = record_thread.thread;
+ thread.instantiate_composition_form('comment', true, false, 0, action.context);
+ return false;
+ }
+ else {
+ return this._super(action, on_close);
+ }
+ },
+ });
+
+
+ /**
+ * ------------------------------------------------------------
+ * ChatterUtils
+ * ------------------------------------------------------------
+ *
+ * This class holds a few tools method that will be used by
+ * the various Chatter widgets.
+ */
+
+ mail.ChatterUtils = {
+
+ /**
+ * mail_int_mapping: structure to keep a trace of internal links mapping
+ * mail_int_mapping['model'] = {
+ * 'name_get': [[id,label], [id,label], ...]
+ * 'fetch_ids': [id, id, ...] } */
+ //var mail_int_mapping = {};
+
+ /**
+ * mail_msg_struct: structure to orrganize chatter messages
+ */
+ //var mail_msg_struct = {}; // TODO: USE IT OR NOT :)
+
+ /* generic chatter events binding */
+ bind_events: function(widget) {
+ // event: click on an internal link to a document: model, login
+ widget.$element.delegate('a.oe_mail_internal_link', 'click', function (event) {
+ event.preventDefault();
+ // lazy implementation: fetch data and try to redirect
+ if (! event.srcElement.dataset.resModel) return false;
+ else var res_model = event.srcElement.dataset.resModel;
+ var res_login = event.srcElement.dataset.resLogin;
+ if (! res_login) return false;
+ var ds = new session.web.DataSet(widget, res_model);
+ var defer = ds.call('search', [[['login', '=', res_login]]]).pipe(function (records) {
+ if (records[0]) {
+ widget.do_action({ type: 'ir.actions.act_window', res_model: res_model, res_id: parseInt(records[0]), views: [[false, 'form']]});
}
- // add record
- cs['tree_struct'][record.id] = {'level': parent_level+1, 'direct_childs': [], 'all_childs': [], 'for_thread_msgs': [], 'msg_nbr': -1, 'ancestors': []};
- cs['msgs'][record.id] = record;
- modif = true;
- }
+ else return false;
+ });
});
- }
- return cs;
- }
+ },
+
+ /** get an image in /web/binary/image?... */
+ get_image: function(session_prefix, session_id, model, field, id) {
+ return session_prefix + '/web/binary/image?session_id=' + session_id + '&model=' + model + '&field=' + field + '&id=' + (id || '');
+ },
+
+ /** checks if tue current user is the message author */
+ is_author: function (widget, message_user_id) {
+ return (widget.session && widget.session.uid != 0 && widget.session.uid == message_user_id);
+ },
+
+ /**
+ * Add records to comments_structure array
+ * @param {Array} records records from mail.message sorted by date desc
+ * @returns {Object} cs comments_structure: dict
+ * cs.model_to_root_ids = {model: [root_ids], }
+ * cs.new_root_ids = [new_root_ids]
+ * cs.root_ids = [root_ids]
+ * cs.msgs = {record.id: record,}
+ * cs.tree_struct = {record.id: {
+ * 'level': record_level in hierarchy, 0 is root,
+ * 'msg_nbr': number of childs,
+ * 'direct_childs': [msg_ids],
+ * 'all_childs': [msg_ids],
+ * 'for_thread_msgs': [records],
+ * 'ancestors': [msg_ids], } }
+ */
+ records_struct_add_records: function(cs, records, parent_id) {
+ var cur_iter = 0; var max_iter = 10; var modif = true;
+ while ( modif && (cur_iter++) < max_iter) {
+ modif = false;
+ _(records).each(function (record) {
+ // root and not yet recorded
+ if ( (record.parent_id == false || record.parent_id[0] == parent_id) && ! cs['msgs'][record.id]) {
+ // add to model -> root_list ids
+ if (! cs['model_to_root_ids'][record.model]) cs['model_to_root_ids'][record.model] = [record.id];
+ else cs['model_to_root_ids'][record.model].push(record.id);
+ // add root data
+ cs['new_root_ids'].push(record.id);
+ // add record
+ cs['tree_struct'][record.id] = {'level': 0, 'direct_childs': [], 'all_childs': [], 'for_thread_msgs': [record], 'msg_nbr': -1, 'ancestors': []};
+ cs['msgs'][record.id] = record;
+ modif = true;
+ }
+ // not yet recorded, but parent is recorded
+ else if (! cs['msgs'][record.id] && cs['msgs'][record.parent_id[0]]) {
+ var parent_level = cs['tree_struct'][record.parent_id[0]]['level'];
+ // update parent structure
+ cs['tree_struct'][record.parent_id[0]]['direct_childs'].push(record.id);
+ cs['tree_struct'][record.parent_id[0]]['for_thread_msgs'].push(record);
+ // update ancestors structure
+ for (ancestor_id in cs['tree_struct'][record.parent_id[0]]['ancestors']) {
+ cs['tree_struct'][ancestor_id]['all_childs'].push(record.id);
+ }
+ // add record
+ cs['tree_struct'][record.id] = {'level': parent_level+1, 'direct_childs': [], 'all_childs': [], 'for_thread_msgs': [], 'msg_nbr': -1, 'ancestors': []};
+ cs['msgs'][record.id] = record;
+ modif = true;
+ }
+ });
+ }
+ return cs;
+ },
+
+ /* copy cs.new_root_ids into cs.root_ids */
+ records_struct_update_after_display: function(cs) {
+ // update TODO
+ cs['root_ids'] = _.union(cs['root_ids'], cs['new_root_ids']);
+ cs['new_root_ids'] = [];
+ return cs;
+ },
+
+ /**
+ * CONTENT MANIPULATION
+ *
+ * Regular expressions
+ * - (^|\s)@((\w|@|\.)*): @login@log.log, supports inner '@' for
+ * logins that are emails
+ * 1. '(void)'
+ * 2. login@log.log
+ * - (^|\s)\[(\w+).(\w+),(\d)\|*((\w|[@ .,])*)\]: [ir.attachment,3|My Label],
+ * for internal links to model ir.attachment, id=3, and with
+ * optional label 'My Label'. Note that having a '|Label' is not
+ * mandatory, because the regex should still be correct.
+ * 1. '(void)'
+ * 2. 'ir'
+ * 3. 'attachment'
+ * 4. '3'
+ * 5. 'My Label'
+ */
+
+ /** Removes html tags, except b, em, br, ul, li */
+ do_text_remove_html_tags: function (string) {
+ var html = $('').text(string.replace(/\s+/g, ' ')).html().replace(new RegExp('<(/)?(b|em|br|br /|ul|li|div)\\s*>', 'gi'), '<$1$2>');
+ return html;
+ },
+
+ /** Replaces line breaks by html line breaks (br) */
+ do_text_nl2br: function (str, is_xhtml) {
+ var break_tag = (is_xhtml || typeof is_xhtml === 'undefined') ? ' ' : ' ';
+ return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1'+ break_tag +'$2');
+ },
+
+ /* Add a prefix before each new line of the original string */
+ do_text_quote: function (str, prefix) {
+ return str.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1'+ break_tag +'$2' + prefix || '> ');
+ },
+
+ /**
+ * Replaces some expressions
+ * - @login - shorcut to link to a res.user, given its login
+ * - [ir.attachment,3|My Label] - shortcut to an internal
+ * document
+ * - :name - shortcut to an image
+ */
+ do_replace_expressions: function (string) {
+ var self = this;
+ var icon_list = ['al', 'pinky']
+ /* shortcut to user: @login */
+ var regex_login = new RegExp(/(^|\s)@((\w|@|\.)*)/g);
+ var regex_res = regex_login.exec(string);
+ while (regex_res != null) {
+ var login = regex_res[2];
+ string = string.replace(regex_res[0], regex_res[1] + '@' + login + '');
+ regex_res = regex_login.exec(string);
+ }
+ /* shortcut for internal document */
+ var regex_login = new RegExp(/(^|\s)\[(\w+).(\w+),(\d)\|*((\w|[@ .,])*)\]/g);
+ var regex_res = regex_login.exec(string);
+ while (regex_res != null) {
+ var res_model = regex_res[2] + '.' + regex_res[3];
+ var res_id = regex_res[4];
+ if (! regex_res[5]) {
+ var label = res_model + ':' + res_id }
+ else {
+ var label = regex_res[5];
+ }
+ string = string.replace(regex_res[0], regex_res[1] + '');
+ regex_res = regex_login.exec(string);
+ }
+ return string;
+ },
+
+ /**
+ * Checks a string to find an expression that will be replaced
+ * by an internal link and requiring a name_get to replace
+ * the expression.
+ * :param mapping: structure to keep a trace of internal links mapping
+ * mapping['model'] = {
+ * name_get': [[id,label], [id,label], ...]
+ * 'to_fetch_ids': [id, id, ...]
+ * }
+ * CURRENTLY NOT IMPLEMENTED */
+ do_check_for_name_get_mapping: function(string, mapping) {
+ /* shortcut to user: @login */
+ //var regex_login = new RegExp(/(^|\s)@((\w|@|\.)*)/g);
+ //var regex_res = regex_login.exec(string);
+ //while (regex_res != null) {
+ //var login = regex_res[2];
+ //if (! ('res.users' in this.map_hash)) { this.map_hash['res.users']['name'] = []; }
+ //this.map_hash['res.users']['login'].push(login);
+ //regex_res = regex_login.exec(string);
+ //}
+ /* document link with name_get: [res.model,name] */
+ /* internal link with id: [res.model,id], or [res.model,id|display_name] */
+ //var regex_intlink = new RegExp(/(^|\s)#(\w*[a-zA-Z_]+\w*)\.(\w+[a-zA-Z_]+\w*),(\w+)/g);
+ //regex_res = regex_intlink.exec(string);
+ //while (regex_res != null) {
+ //var res_model = regex_res[2] + '.' + regex_res[3];
+ //var res_name = regex_res[4];
+ //if (! (res_model in this.map_hash)) { this.map_hash[res_model]['name'] = []; }
+ //this.map_hash[res_model]['name'].push(res_name);
+ //regex_res = regex_intlink.exec(string);
+ //}
+ },
+
+ /**
+ * Updates the mapping; check for to_fetch_ids for each recorded
+ * model, and perform a name_get to update the mapping.
+ * CURRENTLY NOT IMPLEMENTED */
+ do_update_name_get_mapping: function(mapping) {
+ },
+ };
+
+
+ /**
+ * ------------------------------------------------------------
+ * ComposeMessage widget
+ * ------------------------------------------------------------
+ *
+ * This widget handles the display of a form to compose a new message.
+ * This form is an OpenERP form_view, build on a mail.compose.message
+ * wizard.
+ */
+
+ mail.ComposeMessage = session.web.Widget.extend({
+ template: 'mail.compose_message',
+
+ /**
+ * @param {Object} parent parent
+ * @param {Object} [params]
+ * @param {String} [params.res_model] res_model of document [REQUIRED]
+ * @param {Number} [params.res_id] res_id of record [REQUIRED]
+ * @param {Number} [params.email_mode] true/false, tells whether
+ * we are in email sending mode
+ * @param {Number} [params.formatting] true/false, tells whether
+ * we are in advance formatting mode
+ * @param {String} [params.model] mail.compose.message.mode (see
+ * composition wizard)
+ * @param {Number} [params.msg_id] id of a message in case we are in
+ * reply mode
+ */
+ init: function(parent, params) {
+ var self = this;
+ this._super(parent);
+ // options
+ this.params = params || {};
+ this.params.context = params.context || {};
+ this.params.email_mode = params.email_mode || false;
+ this.params.formatting = params.formatting || false;
+ this.params.mode = params.mode || 'comment';
+ this.params.form_xml_id = params.form_xml_id || 'email_compose_message_wizard_form_chatter';
+ this.params.form_view_id = false;
+ if (this.params.mode == 'reply') {
+ this.params.active_id = this.params.msg_id;
+ } else {
+ this.params.active_id = this.params.res_id;
+ }
+ this.email_mode = false;
+ this.formatting = false;
+ },
+
+ /**
+ * Reinitialize the widget field values to the default values. The
+ * purpose is to avoid to destroy and re-build a form view. Default
+ * values are therefore given as for an onchange. */
+ reinit: function() {
+ var self = this;
+ if (! this.form_view) return;
+ var call_defer = this.ds_compose.call('default_get', [['subject', 'body_text', 'body_html', 'dest_partner_ids'], this.ds_compose.get_context()]).then(
+ function (result) {
+ self.form_view.on_processed_onchange({'value': result}, []);
+ });
+ return call_defer;
+ },
+
+ /**
+ * Override-hack of do_action: clean the form */
+ do_action: function(action, on_close) {
+ // this.init_comments();
+ return this._super(action, on_close);
+ },
+
+ /**
+ * Widget start function
+ * - builds and initializes the form view */
+ start: function() {
+ var self = this;
+ this._super.apply(this, arguments);
+ // customize display: add avatar, clean previous content
+ var user_avatar = mail.ChatterUtils.get_image(this.session.prefix,
+ this.session.session_id, 'res.users', 'avatar', this.session.uid);
+ this.$element.find('img.oe_mail_icon').attr('src', user_avatar);
+ this.$element.find('div.oe_mail_msg_content').empty();
+ // create a context for the default_get of the compose form
+ var widget_context = {
+ 'active_model': this.params.res_model,
+ 'active_id': this.params.active_id,
+ 'mail.compose.message.mode': this.params.mode,
+ };
+ var context = _.extend({}, this.params.context, widget_context);
+ this.ds_compose = new session.web.DataSetSearch(this, 'mail.compose.message', context);
+ // find the id of the view to display in the chatter form
+ var data_ds = new session.web.DataSetSearch(this, 'ir.model.data');
+ var deferred_form_id =data_ds.call('get_object_reference', ['mail', this.params.form_xml_id]).then( function (result) {
+ if (result) {
+ self.params.form_view_id = result[1];
+ }
+ }).pipe(this.proxy('create_form_view'));
+ return deferred_form_id;
+ },
+
+ /**
+ * Create a FormView, then append it to the to widget DOM. */
+ create_form_view: function () {
+ var self = this;
+ // destroy previous form_view if any
+ if (this.form_view) { this.form_view.destroy(); }
+ // create the FormView
+ this.form_view = new session.web.FormView(this, this.ds_compose, this.params.form_view_id, {
+ action_buttons: false,
+ pager: false,
+ initial_mode: 'edit',
+ });
+ // add the form, bind events, activate the form
+ var msg_node = this.$element.find('div.oe_mail_msg_content');
+ return $.when(this.form_view.appendTo(msg_node)).pipe(function() {
+ self.bind_events();
+ self.form_view.do_show();
+ if (self.params.email_mode) { self.toggle_email_mode(); }
+ if (self.params.formatting) { self.toggle_formatting_mode(); }
+ });
+ },
+
+ destroy: function() {
+ this._super.apply(this, arguments);
+ },
+
+ /**
+ * Bind events in the widget. Each event is slightly described
+ * in the function. */
+ bind_events: function() {
+ var self = this;
+ this.$element.find('button.oe_form_button').click(function (event) {
+ event.preventDefault();
+ });
+ // event: click on 'Send an Email' link that toggles the form for
+ // sending an email (partner_ids)
+ this.$element.find('a.oe_mail_compose_message_email').click(function (event) {
+ event.preventDefault();
+ self.toggle_email_mode();
+ });
+ // event: click on 'Formatting' icon-link that toggles the advanced
+ // formatting options for writing a message (subject, body_html)
+ this.$element.find('a.oe_mail_compose_message_formatting').click(function (event) {
+ event.preventDefault();
+ self.toggle_formatting_mode();
+ });
+ // event: click on 'Attachment' icon-link that opens the dialog to
+ // add an attachment.
+ this.$element.find('a.oe_mail_compose_message_attachment').click(function (event) {
+ event.preventDefault();
+ // not yet implemented
+ self.set_body_value('attachment', 'attachment');
+ });
+ // event: click on 'Checklist' icon-link that toggles the options
+ // for adding checklist.
+ this.$element.find('a.oe_mail_compose_message_checklist').click(function (event) {
+ event.preventDefault();
+ // not yet implemented
+ self.set_body_value('checklist', 'checklist');
+ });
+ },
+
+ /**
+ * Toggle the formatting mode. */
+ toggle_formatting_mode: function() {
+ var self = this;
+ this.formatting = ! this.formatting;
+ // calls onchange
+ var call_defer = this.ds_compose.call('onchange_formatting', [[], this.formatting, this.params.res_model, this.params.res_id]).then(
+ function (result) {
+ self.form_view.on_processed_onchange(result, []);
+ });
+ // update context of datasetsearch
+ this.ds_compose.context.formatting = this.formatting;
+ // toggle display
+ this.$element.find('span.oe_mail_compose_message_subject').toggleClass('oe_mail_compose_message_invisible');
+ this.$element.find('div.oe_mail_compose_message_body_text').toggleClass('oe_mail_compose_message_invisible');
+ this.$element.find('div.oe_mail_compose_message_body_html').toggleClass('oe_mail_compose_message_invisible');
+ },
+
+ /**
+ * Toggle the email mode. */
+ toggle_email_mode: function() {
+ var self = this;
+ this.email_mode = ! this.email_mode;
+ // calls onchange
+ var call_defer = this.ds_compose.call('onchange_email_mode', [[], this.email_mode, this.params.res_model, this.params.res_id]).then(
+ function (result) {
+ self.form_view.on_processed_onchange(result, []);
+ });
+ // update context of datasetsearch
+ this.ds_compose.context.email_mode = this.email_mode;
+ // update 'Post' button -> 'Send'
+ // update 'Send an Email' link -> 'Post a comment'
+ if (this.email_mode) {
+ this.$element.find('button.oe_mail_compose_message_button_send').html('Send');
+ this.$element.find('a.oe_mail_compose_message_email').html('Comment');
+ } else {
+ this.$element.find('button.oe_mail_compose_message_button_send').html('Post');
+ this.$element.find('a.oe_mail_compose_message_email').html('Send an Email');
+ }
+ // toggle display
+ this.$element.find('div.oe_mail_compose_message_partner_ids').toggleClass('oe_mail_compose_message_invisible');
+ },
+
+ /**
+ * Update the values of the composition form; with possible different
+ * values for body_text and body_html. */
+ set_body_value: function(body_text, body_html) {
+ this.form_view.fields.body_text.set_value(body_text);
+ this.form_view.fields.body_html.set_value(body_html);
+ },
+ }),
/**
- * ThreadDisplay widget: this widget handles the display of a thread of
- * messages. The [thread_level] parameter sets the thread level number:
+ * ------------------------------------------------------------
+ * Thread Widget
+ * ------------------------------------------------------------
+ *
+ * This widget handles the display of a thread of messages. The
+ * [thread_level] parameter sets the thread level number:
* - root message
* - - sub message (parent_id = root message)
* - - - sub sub message (parent id = sub message)
* - - sub message (parent_id = root message)
- * This widget has 2 ways of initialization, either you give records to be rendered,
- * either it will fetch [limit] messages related to [res_model]:[res_id].
+ * This widget has 2 ways of initialization, either you give records
+ * to be rendered, either it will fetch [limit] messages related to
+ * [res_model]:[res_id].
*/
+
mail.Thread = session.web.Widget.extend({
- template: 'mail.Thread',
+ template: 'mail.thread',
/**
* @param {Object} parent parent
* @param {Object} [params]
- * @param {String} [params.res_model] res_model of mail.thread object
- * @param {Number} [params.res_id] res_id of record
- * @param {Number} [params.parent_id=false] parent_id of message
- * @param {Number} [params.uid] user id
- * @param {Number} [params.thread_level=0] number of levels in the thread (only 0 or 1 currently)
- * @param {Number} [params.msg_more_limit=100] number of character to display before having a "show more" link;
- * note that the text will not be truncated if it does not have 110% of
- * the parameter (ex: 110 characters needed to be truncated and be displayed
- * as a 100-characters message)
- * @param {Number} [params.limit=10] maximum number of messages to fetch
+ * @param {String} [params.res_model] res_model of document [REQUIRED]
+ * @param {Number} [params.res_id] res_id of record [REQUIRED]
+ * @param {Number} [params.uid] user id [REQUIRED]
+ * @param {Bool} [params.parent_id=false] parent_id of message
+ * @param {Number} [params.thread_level=0] number of levels in the thread
+ * (only 0 or 1 currently)
+ * @param {Bool} [params.is_wall=false] thread is displayed in the wall
+ * @param {Number} [params.msg_more_limit=150] number of character to
+ * display before having a "show more" link; note that the text
+ * will not be truncated if it does not have 110% of the parameter
+ * (ex: 110 characters needed to be truncated and be displayed as
+ * a 100-characters message)
+ * @param {Number} [params.limit=100] maximum number of messages to fetch
* @param {Number} [params.offset=0] offset for fetching messages
* @param {Number} [params.records=null] records to show instead of fetching messages
*/
init: function(parent, params) {
this._super(parent);
+ // options
this.params = params;
this.params.parent_id = this.params.parent_id || false;
this.params.thread_level = this.params.thread_level || 0;
- this.params.msg_more_limit = this.params.msg_more_limit || 290;
+ this.params.is_wall = this.params.is_wall || (this.params.records != undefined) || false;
+ this.params.msg_more_limit = this.params.msg_more_limit || 150;
this.params.limit = this.params.limit || 100;
+ // this.params.limit = 3; // tmp for testing
this.params.offset = this.params.offset || 0;
this.params.records = this.params.records || null;
// datasets and internal vars
- this.ds = new session.web.DataSet(this, this.params.res_model);
- this.ds_users = new session.web.DataSet(this, 'res.users');
- this.ds_msg = new session.web.DataSet(this, 'mail.message');
- this.sorted_comments = {'root_ids': [], 'root_id_msg_list': {}};
+ this.ds = new session.web.DataSetSearch(this, this.params.res_model);
+ this.ds_users = new session.web.DataSetSearch(this, 'res.users');
+ this.ds_msg = new session.web.DataSetSearch(this, 'mail.message');
this.comments_structure = {'root_ids': [], 'new_root_ids': [], 'msgs': {}, 'tree_struct': {}, 'model_to_root_ids': {}};
// display customization vars
this.display = {};
this.display.show_post_comment = this.params.show_post_comment || false;
- this.display.show_reply = (this.params.thread_level > 0);
- this.display.show_delete = true;
- this.display.show_hide = this.params.show_hide || false;
+ this.display.show_reply = (this.params.thread_level > 0 && this.params.is_wall);
+ this.display.show_delete = ! this.params.is_wall;
+ this.display.show_hide = this.params.is_wall;
+ this.display.show_reply_by_email = ! this.params.is_wall;
this.display.show_more = (this.params.thread_level == 0);
- // not used currently
- this.intlinks_mapping = {};
},
start: function() {
- var self = this;
-
this._super.apply(this, arguments);
- // customize display
- if (! this.display.show_post_comment) {
- this.$element.find('div.oe_mail_thread_action').hide();
- }
// add events
- this.add_events();
-
+ this.bind_events();
// display user, fetch comments
this.display_current_user();
-
- if (this.params.records) {
- var display_done = this.display_comments_from_parameters(this.params.records);
- } else {
- var display_done = this.init_comments();
+ if (this.params.records) var display_done = this.display_comments_from_parameters(this.params.records);
+ else var display_done = this.init_comments();
+ // customize display
+ $.when(display_done).then(this.proxy('do_customize_display'));
+ // add message composition form view
+ if (this.display.show_post_comment) {
+ var compose_done = this.instantiate_composition_form();
}
- return display_done
+ return display_done && compose_done;
},
-
- add_events: function() {
+
+ /**
+ * Override-hack of do_action: automatically reload the chatter.
+ * Normally it should be called only when clicking on 'Post/Send'
+ * in the composition form. */
+ do_action: function(action, on_close) {
+ this.init_comments();
+ if (this.compose_message_widget) {
+ this.compose_message_widget.reinit(); }
+ return this._super(action, on_close);
+ },
+
+ instantiate_composition_form: function(mode, email_mode, formatting, msg_id, context) {
+ if (this.compose_message_widget) {
+ this.compose_message_widget.destroy();
+ }
+ this.compose_message_widget = new mail.ComposeMessage(this, {
+ 'extended_mode': false, 'uid': this.params.uid, 'res_model': this.params.res_model,
+ 'res_id': this.params.res_id, 'mode': mode || 'comment', 'msg_id': msg_id,
+ 'email_mode': email_mode || false, 'formatting': formatting || false,
+ 'context': context || false } );
+ var composition_node = this.$element.find('div.oe_mail_thread_action');
+ composition_node.empty();
+ var compose_done = this.compose_message_widget.appendTo(composition_node);
+ return compose_done;
+ },
+
+ do_customize_display: function() {
+ if (this.display.show_post_comment) { this.$element.find('div.oe_mail_thread_action').eq(0).show(); }
+ },
+
+ /**
+ * Bind events in the widget. Each event is slightly described
+ * in the function. */
+ bind_events: function() {
var self = this;
+ // generic events from Chatter Mixin
+ mail.ChatterUtils.bind_events(this);
// event: click on 'more' at bottom of thread
this.$element.find('button.oe_mail_button_more').click(function () {
self.do_more();
});
- // event: writing in textarea
- this.$element.find('textarea.oe_mail_action_textarea').keyup(function (event) {
+ // event: writing in basic textarea of composition form (quick reply)
+ this.$element.find('textarea.oe_mail_compose_textarea').keyup(function (event) {
var charCode = (event.which) ? event.which : window.event.keyCode;
if (event.shiftKey && charCode == 13) { this.value = this.value+"\n"; }
else if (charCode == 13) { return self.do_comment(); }
});
- // event: click on 'reply' in msg
+ // event: click on 'Reply' in msg
this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_msg_reply', 'click', function (event) {
var act_dom = $(this).parents('div.oe_mail_thread_display').find('div.oe_mail_thread_action:first');
act_dom.toggle();
event.preventDefault();
});
- // event: click on 'delete' in msg
- this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_msg_delete', 'click', function (event) {
- //console.log('deleting');
- if (! confirm(_t("Do you really want to delete this message?"))) { return false; }
- var msg_id = event.srcElement.dataset.id;
- if (! msg_id) return false;
- var call_defer = self.ds_msg.unlink([parseInt(msg_id)]);
- $(event.srcElement).parents('.oe_mail_thread_msg').eq(0).hide();
- if (self.params.thread_level > 0) {
- $(event.srcElement).parents('.oe_mail_thread').eq(0).hide();
- }
- return false;
- });
- // event: click on 'hide' in msg
- this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_msg_hide', 'click', function (event) {
- //console.log('hiding');
- if (! confirm(_t("Do you really want to hide this thread ?"))) { return false; }
- var msg_id = event.srcElement.dataset.id;
- if (! msg_id) return false;
- //console.log(msg_id);
- var call_defer = self.ds.call('message_remove_pushed_notifications', [[self.params.res_id], [parseInt(msg_id)], true]);
- $(event.srcElement).parents('.oe_mail_thread_msg').eq(0).hide();
- if (self.params.thread_level > 0) {
- $(event.srcElement).parents('.oe_mail_thread').eq(0).hide();
- }
- return false;
- });
- // event: click on an internal link
- this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_internal_link', 'click', function (event) {
- // lazy implementation: fetch data and try to redirect
- if (! event.srcElement.dataset.resModel) return false;
- else var res_model = event.srcElement.dataset.resModel;
- var res_login = event.srcElement.dataset.resLogin;
- var res_id = event.srcElement.dataset.resId;
- if ((! res_login) && (! res_id)) return false;
- if (! res_id) {
- var ds = new session.web.DataSet(self, res_model);
- var defer = ds.call('search', [[['login', '=', res_login]]]).then(function (records) {
- if (records[0]) {
- self.do_action({ type: 'ir.actions.act_window', res_model: res_model, res_id: parseInt(records[0]), views: [[false, 'form']]});
- }
- else return false;
- });
- }
- else self.do_action({ type: 'ir.actions.act_window', res_model: res_model, res_id: parseInt(res_id), views: [[false, 'form']]});
- });
// event: click on 'attachment(s)' in msg
this.$element.delegate('a.oe_mail_msg_view_attachments', 'click', function (event) {
var act_dom = $(this).parent().parent().parent().find('.oe_mail_msg_attachments');
act_dom.toggle();
- return false;
+ event.preventDefault();
});
- // see more
- this.$element.on('click','a.oe_mail_msg_more', function (event) {
- $(this).siblings('.oe_mail_msg_tail').show();
- $(this).hide();
- return false;
+ // event: click on 'Delete' in msg side menu
+ this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_msg_delete', 'click', function (event) {
+ if (! confirm(_t("Do you really want to delete this message?"))) { return false; }
+ var msg_id = event.srcElement.dataset.id;
+ if (! msg_id) return false;
+ var call_defer = self.ds_msg.unlink([parseInt(msg_id)]);
+ $(event.srcElement).parents('li.oe_mail_thread_msg').eq(0).hide();
+ if (self.params.thread_level > 0) {
+ $(event.srcElement).parents('.oe_mail_thread').eq(0).hide();
+ }
+ event.preventDefault();
+ return call_defer;
+ });
+ // event: click on 'Hide' in msg side menu
+ this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_msg_hide', 'click', function (event) {
+ if (! confirm(_t("Do you really want to hide this thread ?"))) { return false; }
+ var msg_id = event.srcElement.dataset.id;
+ if (! msg_id) return false;
+ var call_defer = self.ds.call('message_remove_pushed_notifications', [[self.params.res_id], [parseInt(msg_id)], true]);
+ $(event.srcElement).parents('li.oe_mail_thread_msg').eq(0).hide();
+ if (self.params.thread_level > 0) {
+ $(event.srcElement).parents('.oe_mail_thread').eq(0).hide();
+ }
+ event.preventDefault();
+ return call_defer;
+ });
+ // event: click on "Reply" in msg side menu (email style)
+ this.$element.find('div.oe_mail_thread_display').delegate('a.oe_mail_msg_reply_by_email', 'click', function (event) {
+ var msg_id = event.srcElement.dataset.msg_id;
+ var email_mode = (event.srcElement.dataset.type == 'email');
+ var formatting = (event.srcElement.dataset.formatting == 'html');
+ if (! msg_id) return false;
+ self.instantiate_composition_form('reply', email_mode, formatting, msg_id);
+ event.preventDefault();
});
},
@@ -219,21 +661,22 @@ openerp.mail = function(session) {
init_comments: function() {
var self = this;
this.params.offset = 0;
- this.sorted_comments = {'root_ids': [], 'root_id_msg_list': {}};
this.comments_structure = {'root_ids': [], 'new_root_ids': [], 'msgs': {}, 'tree_struct': {}, 'model_to_root_ids': {}};
this.$element.find('div.oe_mail_thread_display').empty();
- domain = this.get_fetch_domain(this.sorted_comments);
+ var domain = this.get_fetch_domain(this.comments_structure);
return this.fetch_comments(this.params.limit, this.params.offset, domain).then();
},
fetch_comments: function (limit, offset, domain) {
var self = this;
- var defer = this.ds.call('message_load', [[this.params.res_id], ( (limit+1)||(this.params.limit+1) ), (offset||this.params.offset), (domain||[]), (this.params.thread_level > 0), (this.sorted_comments['root_ids'])]);
- $.when(defer).then(function (records) {
+ var defer = this.ds.call('message_read', [[this.params.res_id], (this.params.thread_level > 0), (this.comments_structure['root_ids']),
+ (limit+1) || (this.params.limit+1), offset||this.params.offset, domain||undefined ]).then(function (records) {
if (records.length <= self.params.limit) self.display.show_more = false;
- else { self.display.show_more = true; records.pop(); }
-
+ // else { self.display.show_more = true; records.pop(); }
+ // else { self.display.show_more = true; records.splice(0, 1); }
+ else { self.display.show_more = true; }
self.display_comments(records);
+ // TODO: move to customize display
if (self.display.show_more == true) self.$element.find('div.oe_mail_thread_more:last').show();
else self.$element.find('div.oe_mail_thread_more:last').hide();
});
@@ -245,6 +688,7 @@ openerp.mail = function(session) {
if (records.length > 0 && records.length < (records[0].child_ids.length+1) ) this.display.show_more = true;
else this.display.show_more = false;
var defer = this.display_comments(records);
+ // TODO: move to customize display
if (this.display.show_more == true) $('div.oe_mail_thread_more').eq(-2).show();
else $('div.oe_mail_thread_more').eq(-2).hide();
return defer;
@@ -252,7 +696,8 @@ openerp.mail = function(session) {
display_comments: function (records) {
var self = this;
-
+ // sort the records
+ mail.ChatterUtils.records_struct_add_records(this.comments_structure, records, this.params.parent_id);
//build attachments download urls and compute time-relative from dates
for (var k in records) {
records[k].timerelative = $.timeago(records[k].date);
@@ -263,11 +708,10 @@ openerp.mail = function(session) {
}
}
}
- this.cs = this.sort_comments_tmp(records);
_(records).each(function (record) {
var sub_msgs = [];
if ((record.parent_id == false || record.parent_id[0] == self.params.parent_id) && self.params.thread_level > 0 ) {
- var sub_list = self.cs['tree_struct'][record.id]['direct_childs'];
+ var sub_list = self.comments_structure['tree_struct'][record.id]['direct_childs'];
_(records).each(function (record) {
//if (record.parent_id == false || record.parent_id[0] == self.params.parent_id) return;
if (_.indexOf(sub_list, record.id) != -1) {
@@ -276,35 +720,40 @@ openerp.mail = function(session) {
});
self.display_comment(record);
self.thread = new mail.Thread(self, {'res_model': self.params.res_model, 'res_id': self.params.res_id, 'uid': self.params.uid,
- 'records': sub_msgs, 'thread_level': (self.params.thread_level-1), 'parent_id': record.id});
- self.$element.find('.oe_mail_thread_msg:last').append('');
+ 'records': sub_msgs, 'thread_level': (self.params.thread_level-1), 'parent_id': record.id,
+ 'is_wall': self.params.is_wall});
+ self.$element.find('li.oe_mail_thread_msg:last').append('');
self.thread.appendTo(self.$element.find('div.oe_mail_thread_subthread:last'));
}
else if (self.params.thread_level == 0) {
self.display_comment(record);
}
});
+ mail.ChatterUtils.records_struct_update_after_display(this.comments_structure);
// update offset for "More" buttons
if (this.params.thread_level == 0) this.params.offset += records.length;
},
- /**
- * Display a record
- */
+ /** Displays a record, performs text/link formatting */
display_comment: function (record) {
- record.body = this.do_text_nl2br(record.body, true);
+ record.body = mail.ChatterUtils.do_text_nl2br(record.body, true);
+ // if (record.type == 'email' && record.state == 'received') {
if (record.type == 'email') {
record.mini_url = ('/mail/static/src/img/email_icon.png');
- } else {
- record.mini_url = this.thread_get_avatar('res.users', 'avatar', record.user_id[0]);
+ } else {
+ record.mini_url = mail.ChatterUtils.get_image(this.session.prefix, this.session.session_id, 'res.users', 'avatar', record.user_id[0]);
}
// body text manipulation
- record.body = this.do_clean_text(record.body);
- record.body = this.do_replace_internal_links(record.body);
-
+ if (record.subtype == 'plain') {
+ record.body = mail.ChatterUtils.do_text_remove_html_tags(record.body);
+ }
+ record.body = mail.ChatterUtils.do_replace_expressions(record.body);
// format date according to the user timezone
record.date = session.web.format_value(record.date, {type:"datetime"});
- var rendered = session.web.qweb.render('mail.Thread.message', {'record': record, 'thread': this, 'params': this.params, 'display': this.display});
+ // is the user the author ?
+ record.is_author = mail.ChatterUtils.is_author(this, record.user_id[0]);
+ // render
+ var rendered = session.web.qweb.render('mail.thread.message', {'record': record, 'thread': this, 'params': this.params, 'display': this.display});
$(rendered).appendTo(this.$element.children('div.oe_mail_thread_display:first'));
// expand feature
this.$element.find('div.oe_mail_msg_body:last').expander({
@@ -316,84 +765,33 @@ openerp.mail = function(session) {
lesClass: 'oe_mail_reduce',
});
},
-
- /**
- * Add records to sorted_comments array
- * @param {Array} records records from mail.message sorted by date desc
- * @returns {Object} sc sorted_comments: dict {
- * 'root_id_list': list or root_ids
- * 'root_id_msg_list': {'record_id': [ancestor_ids]}, still sorted by date desc
- * 'id_to_root': {'root_id': [records]}, still sorted by date desc
- * }
- */
- sort_comments: function (records) {
- var self = this;
- sc = {'root_id_list': [], 'root_id_msg_list': {}, 'id_to_root': {}}
- var cur_iter = 0; var max_iter = 10; var modif = true;
- /* step1: get roots */
- while ( modif && (cur_iter++) < max_iter) {
- modif = false;
- _(records).each(function (record) {
- if ( (record.parent_id == false || record.parent_id[0] == self.params.parent_id) && (_.indexOf(sc['root_id_list'], record.id) == -1)) {
- sc['root_id_list'].push(record.id);
- sc['root_id_msg_list'][record.id] = [];
- self.sorted_comments['root_ids'].push(record.id);
- modif = true;
- }
- else {
- if (_.indexOf(sc['root_id_list'], record.parent_id[0]) != -1) {
- sc['id_to_root'][record.id] = record.parent_id[0];
- modif = true;
- }
- else if ( sc['id_to_root'][record.parent_id[0]] ) {
- sc['id_to_root'][record.id] = sc['id_to_root'][record.parent_id[0]];
- modif = true;
- }
- }
- });
- }
- /* step2: add records */
- _(records).each(function (record) {
- var root_id = sc['id_to_root'][record.id];
- if (! root_id) return;
- sc['root_id_msg_list'][root_id].push(record);
- //self.sorted_comments['root_id_msg_list'][root_id].push(record.id);
- });
- return sc;
- },
-
- /**
- * Add records to comments_structure object: see function for details
- */
- sort_comments_tmp: function(records) {
- return tools_sort_comments(this.comments_structure, records, this.params.parent_id);
- },
-
+
display_current_user: function () {
- return this.$element.find('img.oe_mail_msg_image').attr('src', this.thread_get_avatar('res.users', 'avatar', this.params.uid));
+ var avatar = mail.ChatterUtils.get_image(this.session.prefix, this.session.session_id, 'res.users', 'avatar', this.params.uid);
+ return this.$element.find('img.oe_mail_icon').attr('src', avatar);
},
do_comment: function () {
var comment_node = this.$element.find('textarea');
var body_text = comment_node.val();
comment_node.val('');
- return this.ds.call('message_append_note', [[this.params.res_id], 'Reply', body_text, this.params.parent_id, 'comment', 'html']).then(
+ return this.ds.call('message_append_note', [[this.params.res_id], '', body_text, this.params.parent_id, 'comment', 'plain']).then(
this.proxy('init_comments'));
},
/**
* Create a domain to fetch new comments according to
- * comment already present in sorted_comments
- * @param {Object} sorted_comments (see sort_comments)
+ * comment already present in comments_structure
+ * @param {Object} comments_structure (see chatter utils)
* @returns {Array} fetch_domain (OpenERP domain style)
*/
- get_fetch_domain: function (sorted_comments) {
+ get_fetch_domain: function (comments_structure) {
var domain = [];
- var ids = sorted_comments.root_ids.slice();
+ var ids = comments_structure.root_ids.slice();
var ids2 = [];
// must be child of current parent
if (this.params.parent_id) { domain.push(['id', 'child_of', this.params.parent_id]); }
- _(sorted_comments.root_ids).each(function (id) { // each record
+ _(comments_structure.root_ids).each(function (id) { // each record
ids.push(id);
ids2.push(id);
});
@@ -413,109 +811,46 @@ openerp.mail = function(session) {
},
do_more: function () {
- domain = this.get_fetch_domain(this.sorted_comments);
+ domain = this.get_fetch_domain(this.comments_structure);
return this.fetch_comments(this.params.limit, this.params.offset, domain);
},
-
- /**
- *
- * var regex_login = new RegExp(/(^|\s)@((\w|@|\.)*)/g);
- * var regex_intlink = new RegExp(/(^|\s)#(\w*[a-zA-Z_]+\w*)\.(\w+[a-zA-Z_]+\w*),(\w+)/g);
- */
- do_replace_internal_links: function (string) {
- var self = this;
- var icon_list = ['al', 'pinky']
- /* shortcut to user: @login */
- var regex_login = new RegExp(/(^|\s)@((\w|@|\.)*)/g);
- var regex_res = regex_login.exec(string);
- while (regex_res != null) {
- var login = regex_res[2];
- string = string.replace(regex_res[0], regex_res[1] + '@' + login + '');
- regex_res = regex_login.exec(string);
- }
- /* special shortcut: :name, try to find an icon if in list */
- var regex_login = new RegExp(/(^|\s):((\w)*)/g);
- var regex_res = regex_login.exec(string);
- while (regex_res != null) {
- var icon_name = regex_res[2];
- if (_.include(icon_list, icon_name))
- string = string.replace(regex_res[0], regex_res[1] + '');
- regex_res = regex_login.exec(string);
- }
- return string;
- },
-
- thread_get_avatar: function(model, field, id) {
- return this.session.prefix + '/web/binary/image?session_id=' + this.session.session_id + '&model=' + model + '&field=' + field + '&id=' + (id || '');
- },
-
- /** Removes html tags, except b, em, br */
- do_clean_text: function (string) {
- var html = $('').text(string.replace(/\s+/g, ' ')).html().replace(new RegExp('<(/)?(b|em|br|br /)\\s*>', 'gi'), '<$1$2>');
- return html;
- },
-
- /** Replaces line bracks by html line breaks (br) */
- do_text_nl2br: function (str, is_xhtml) {
- var break_tag = (is_xhtml || typeof is_xhtml === 'undefined') ? ' ' : ' ';
- return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1'+ break_tag +'$2');
- },
-
- /**
- *
- * var regex_login = new RegExp(/(^|\s)@((\w|@|\.)*)/g);
- * var regex_intlink = new RegExp(/(^|\s)#(\w*[a-zA-Z_]+\w*)\.(\w+[a-zA-Z_]+\w*),(\w+)/g);
- */
- do_check_internal_links: function(string) {
- /* shortcut to user: @login */
- var regex_login = new RegExp(/(^|\s)@((\w|@|\.)*)/g);
- var regex_res = regex_login.exec(string);
- while (regex_res != null) {
- var login = regex_res[2];
- if (! ('res.users' in this.map_hash)) { this.map_hash['res.users']['name'] = []; }
- this.map_hash['res.users']['login'].push(login);
- regex_res = regex_login.exec(string);
- }
- /* internal links: #res.model,name */
- var regex_intlink = new RegExp(/(^|\s)#(\w*[a-zA-Z_]+\w*)\.(\w+[a-zA-Z_]+\w*),(\w+)/g);
- regex_res = regex_intlink.exec(string);
- while (regex_res != null) {
- var res_model = regex_res[2] + '.' + regex_res[3];
- var res_name = regex_res[4];
- if (! (res_model in this.map_hash)) { this.map_hash[res_model]['name'] = []; }
- this.map_hash[res_model]['name'].push(res_name);
- regex_res = regex_intlink.exec(string);
- }
- },
-
- /** checks if tue current user is the message author */
- _is_author: function (id) {
- return (this.session.uid == id);
- },
-
});
- session.web.form.widgets.add( 'Thread', 'openerp.mail.Thread');
+
+
+ /**
+ * ------------------------------------------------------------
+ * mail_thread Widget
+ * ------------------------------------------------------------
+ *
+ * This widget handles the display of the Chatter on documents.
+ */
+
+ /* Add mail_thread widget to registry */
+ session.web.form.widgets.add('mail_thread', 'openerp.mail.RecordThread');
/** mail_thread widget: thread of comments */
mail.RecordThread = session.web.form.AbstractField.extend({
- template: 'mail.RecordThread',
+ // QWeb template to use when rendering the object
+ template: 'mail.record_thread',
init: function() {
this._super.apply(this, arguments);
- this.see_subscribers = true;
- this.thread = null;
this.params = this.get_definition_options();
this.params.thread_level = this.params.thread_level || 0;
- // datasets
+ this.params.see_subscribers = true;
+ this.params.see_subscribers_options = this.params.see_subscribers_options || false;
+ this.thread = null;
this.ds = new session.web.DataSet(this, this.view.model);
this.ds_users = new session.web.DataSet(this, 'res.users');
},
start: function() {
- this._super.apply(this, arguments);
var self = this;
- // bind buttons
- this.$element.find('button.oe_mail_button_followers').click(function () { self.do_toggle_followers(); }).hide();
+ this._super.apply(this, arguments);
+ mail.ChatterUtils.bind_events(this);
+ this.$element.find('button.oe_mail_button_followers').click(function () { self.do_toggle_followers(); });
+ if (! this.params.see_subscribers_options) {
+ this.$element.find('button.oe_mail_button_followers').hide(); }
this.$element.find('button.oe_mail_button_follow').click(function () { self.do_follow(); })
.mouseover(function () { $(this).html('Follow').removeClass('oe_mail_button_mouseout').addClass('oe_mail_button_mouseover'); })
.mouseleave(function () { $(this).html('Not following').removeClass('oe_mail_button_mouseover').addClass('oe_mail_button_mouseout'); });
@@ -530,7 +865,8 @@ openerp.mail = function(session) {
},
reinit: function() {
- this.see_subscribers = true;
+ this.params.see_subscribers = true;
+ this.params.see_subscribers_options = this.params.see_subscribers_options || false;
this.$element.find('button.oe_mail_button_followers').html('Hide followers')
this.$element.find('button.oe_mail_button_follow').hide();
this.$element.find('button.oe_mail_button_unfollow').hide();
@@ -548,11 +884,11 @@ openerp.mail = function(session) {
// fetch followers
var fetch_sub_done = this.fetch_subscribers();
// create and render Thread widget
- this.$element.find('div.oe_mail_recthread_left').empty();
+ this.$element.find('div.oe_mail_recthread_main').empty();
if (this.thread) this.thread.destroy();
this.thread = new mail.Thread(this, {'res_model': this.view.model, 'res_id': this.view.datarecord.id, 'uid': this.session.uid,
- 'thread_level': this.params.thread_level, 'show_post_comment': true, 'limit': 15});
- var thread_done = this.thread.appendTo(this.$element.find('div.oe_mail_recthread_left'));
+ 'thread_level': this.params.thread_level, 'show_post_comment': true, 'limit': 15});
+ var thread_done = this.thread.appendTo(this.$element.find('div.oe_mail_recthread_main'));
return fetch_sub_done && thread_done;
},
@@ -567,9 +903,8 @@ openerp.mail = function(session) {
this.$element.find('div.oe_mail_recthread_followers h4').html('Followers (' + records.length + ')');
_(records).each(function (record) {
if (record.id == self.session.uid) { self.is_subscriber = true; }
- var mini_url = self.thread_get_avatar('res.users', 'avatar', record.id);
- $('
+
+
diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py
index 1b19a383c10..d26511464f8 100644
--- a/addons/mail/wizard/mail_compose_message.py
+++ b/addons/mail/wizard/mail_compose_message.py
@@ -33,7 +33,7 @@ from ..mail_message import to_email
# main mako-like expression pattern
EXPRESSION_PATTERN = re.compile('(\$\{.+?\})')
-class mail_compose_message(osv.osv_memory):
+class mail_compose_message(osv.TransientModel):
"""Generic Email composition wizard. This wizard is meant to be inherited
at model and view level to provide specific wizard features.
@@ -41,13 +41,12 @@ class mail_compose_message(osv.osv_memory):
parameters, among which are:
* mail.compose.message.mode: if set to 'reply', the wizard is in
- reply mode and pre-populated with the original quote.
- If set to 'mass_mail', the wizard is in mass mailing
- where the mail details can contain template placeholders
- that will be merged with actual data before being sent
- to each recipient. Recipients will be derived from the
- records determined via ``context['active_model']`` and
- ``context['active_ids']``.
+ reply to a previous message mode and pre-populated with the original
+ quote. If set to 'comment', it means you are writing a new message to
+ be attached to a document. If set to 'mass_mail', the wizard is in
+ mass mailing where the mail details can contain template placeholders
+ that will be merged with actual data before being sent to each
+ recipient.
* active_model: model name of the document to which the mail being
composed is related
* active_id: id of the document to which the mail being composed is
@@ -61,119 +60,179 @@ class mail_compose_message(osv.osv_memory):
_description = 'Email composition wizard'
def default_get(self, cr, uid, fields, context=None):
- """Overridden to provide specific defaults depending on the context
- parameters.
+ """ Overridden to provide specific defaults depending on the context
+ parameters.
+
+ Composition mode
+ - comment: default mode; active_model, active_id = model and ID of a
+ document we are commenting,
+ - reply: active_id = ID of a mail.message to which we are replying.
+ From this message we can find the related model and res_id,
+ - mass_mailing mode: active_model, active_id = model and ID of a
+ document we are commenting,
:param dict context: several context values will modify the behavior
of the wizard, cfr. the class description.
"""
if context is None:
context = {}
+ compose_mode = context.get('mail.compose.message.mode', 'comment')
+ active_model = context.get('active_model')
+ active_id = context.get('active_id')
result = super(mail_compose_message, self).default_get(cr, uid, fields, context=context)
+
+ # get default values according to the composition mode
vals = {}
- reply_mode = context.get('mail.compose.message.mode') == 'reply'
- if (not reply_mode) and context.get('active_model') and context.get('active_id'):
- # normal mode when sending an email related to any document, as specified by
- # active_model and active_id in context
- vals = self.get_value(cr, uid, context.get('active_model'), context.get('active_id'), context)
- elif reply_mode and context.get('active_id'):
- # reply mode, consider active_id is the ID of a mail.message to which we're
- # replying
- vals = self.get_message_data(cr, uid, int(context['active_id']), context)
- else:
- # default mode
- result['model'] = context.get('active_model', False)
+ if compose_mode in ['reply']:
+ vals = self.get_message_data(cr, uid, int(context['active_id']), context=context)
+ elif compose_mode in ['comment', 'mass_mail'] and active_model and active_id:
+ vals = self.get_value(cr, uid, active_model, active_id, context)
for field in vals:
if field in fields:
- result.update({field : vals[field]})
+ result[field] = vals[field]
# link to model and record if not done yet
- if not result.get('model') or not result.get('res_id'):
- active_model = context.get('active_model')
- res_id = context.get('active_id')
- if active_model and active_model not in (self._name, 'mail.message'):
- result['model'] = active_model
- if res_id:
- result['res_id'] = res_id
+ if not result.get('model') and active_model:
+ result['model'] = active_model
+ if not result.get('res_id') and active_id:
+ result['res_id'] = active_id
# Try to provide default email_from if not specified yet
if not result.get('email_from'):
- current_user = self.pool.get('res.users').browse(cr, uid, uid, context)
+ current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
result['email_from'] = current_user.user_email or False
return result
_columns = {
+ 'dest_partner_ids': fields.many2many('res.partner',
+ 'email_message_send_partner_rel',
+ 'wizard_id', 'partner_id', 'Destination partners',
+ help="When sending emails through the social network composition wizard"\
+ "you may choose to send a copy of the mail to partners."),
'attachment_ids': fields.many2many('ir.attachment','email_message_send_attachment_rel', 'wizard_id', 'attachment_id', 'Attachments'),
'auto_delete': fields.boolean('Auto Delete', help="Permanently delete emails after sending"),
'filter_id': fields.many2one('ir.filters', 'Filters'),
}
def get_value(self, cr, uid, model, res_id, context=None):
- """Returns a defaults-like dict with initial values for the composition
- wizard when sending an email related to the document record identified
- by ``model`` and ``res_id``.
+ """ Returns a defaults-like dict with initial values for the composition
+ wizard when sending an email related to the document record
+ identified by ``model`` and ``res_id``.
- The default implementation returns an empty dictionary, and is meant
- to be overridden by subclasses.
+ The default implementation returns an empty dictionary, and is meant
+ to be overridden by subclasses.
- :param str model: model name of the document record this mail is related to.
- :param int res_id: id of the document record this mail is related to.
- :param dict context: several context values will modify the behavior
- of the wizard, cfr. the class description.
+ :param str model: model name of the document record this mail is
+ related to.
+ :param int res_id: id of the document record this mail is related to.
+ :param dict context: several context values will modify the behavior
+ of the wizard, cfr. the class description.
"""
- return {}
+ result = {}
+ user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
+ result.update({
+ 'model': model,
+ 'res_id': res_id,
+ 'email_from': user.user_email or tools.config.get('email_from', False),
+ 'body_html': False,
+ 'body_text': False,
+ 'subject': False,
+ 'dest_partner_ids': [],
+ })
+ return result
+
+ def onchange_email_mode(self, cr, uid, ids, value, model, res_id, context=None):
+ """ email_mode (values: True or False). This onchange on the email mode
+ allows to have some specific behavior when going in email mode, or
+ when going out of email mode.
+ Basically, dest_partner_ids is reset when going out of email
+ mode.
+ This method can be overridden for models that want to have their
+ specific behavior.
+ Note that currently, this onchange is used in mail.js and called
+ manually on the form instantiated in the Chatter.
+ """
+ if not value:
+ return {'value': {'dest_partner_ids': []}}
+ return {'value': {}}
+
+ def onchange_formatting(self, cr, uid, ids, value, model, res_id, context=None):
+ """ onchange_formatting (values: True or False). This onchange on the
+ formatting allows to have some specific behavior when going in
+ formatting mode, or when going out of formatting.
+ Basically, subject is reset when going out of formatting mode.
+ This method can be overridden for models that want to have their
+ specific behavior.
+ Note that currently, this onchange is used in mail.js and called
+ manually on the form instantiated in the Chatter.
+ """
+ if not value:
+ return {'value': {'subject': False}}
+ return {'value': {}}
def get_message_data(self, cr, uid, message_id, context=None):
- """Returns a defaults-like dict with initial values for the composition
- wizard when replying to the given message (e.g. including the quote
- of the initial message, and the correct recipient).
- Should not be called unless ``context['mail.compose.message.mode'] == 'reply'``.
+ """ Returns a defaults-like dict with initial values for the composition
+ wizard when replying to the given message (e.g. including the quote
+ of the initial message, and the correct recipient). It should not be
+ called unless ``context['mail.compose.message.mode'] == 'reply'``.
- :param int message_id: id of the mail.message to which the user
- is replying.
- :param dict context: several context values will modify the behavior
- of the wizard, cfr. the class description.
- When calling this method, the ``'mail'`` value
- in the context should be ``'reply'``.
+ :param int message_id: id of the mail.message to which the user
+ is replying.
+ :param dict context: several context values will modify the behavior
+ of the wizard, cfr. the class description.
"""
if context is None:
context = {}
result = {}
- mail_message = self.pool.get('mail.message')
- if message_id:
- message_data = mail_message.browse(cr, uid, message_id, context)
- subject = tools.ustr(message_data.subject or '')
- # we use the plain text version of the original mail, by default,
- # as it is easier to quote than the HTML version.
- # XXX TODO: make it possible to switch to HTML on the fly
- current_user = self.pool.get('res.users').browse(cr, uid, uid, context)
- body = message_data.body_text or current_user.signature or ''
- if context.get('mail.compose.message.mode') == 'reply':
- sent_date = _('On %(date)s, ') % {'date': message_data.date} if message_data.date else ''
- sender = _('%(sender_name)s wrote:') % {'sender_name': tools.ustr(message_data.email_from or _('You'))}
- quoted_body = '> %s' % tools.ustr(body.replace('\n', "\n> ") or '')
- body = '\n'.join(["\n", (sent_date + sender), quoted_body])
- body += "\n" + (current_user.signature or '')
- re_prefix = _("Re:")
- if not (subject.startswith('Re:') or subject.startswith(re_prefix)):
- subject = "%s %s" % (re_prefix, subject)
- result.update({
- 'subtype' : 'plain', # default to the text version due to quoting
- 'body_text' : body,
- 'subject' : subject,
- 'attachment_ids' : [],
- 'model' : message_data.model or False,
- 'res_id' : message_data.res_id or False,
- 'email_from' : current_user.user_email or message_data.email_to or False,
- 'email_to' : message_data.reply_to or message_data.email_from or False,
- 'email_cc' : message_data.email_cc or False,
- 'user_id' : uid,
+ if not message_id:
+ return result
- # pass msg-id and references of mail we're replying to, to construct the
- # new ones later when sending
- 'message_id' : message_data.message_id or False,
- 'references' : message_data.references and tools.ustr(message_data.references) or False,
- })
+ current_user = self.pool.get('res.users').browse(cr, uid, uid, context)
+ message_data = self.pool.get('mail.message').browse(cr, uid, message_id, context)
+ # Form the subject
+ re_prefix = _("Re:")
+ reply_subject = tools.ustr(message_data.subject or '')
+ if not (reply_subject.startswith('Re:') or reply_subject.startswith(re_prefix)):
+ reply_subject = "%s %s" % (re_prefix, reply_subject)
+ # Form the bodies (text and html). We use the plain text version of the
+ # original mail, by default, as it is easier to quote than the HTML
+ # version. TODO: make it possible to switch to HTML on the fly
+ sent_date = _('On %(date)s, ') % {'date': message_data.date} if message_data.date else ''
+ sender = _('%(sender_name)s wrote:') % {'sender_name': tools.ustr(message_data.email_from or _('You'))}
+ body_text = message_data.body_text or ''
+ body_html = message_data.body_html or ''
+ quoted_body_text = '> %s' % tools.ustr(body_text.replace('\n', "\n> ") or '')
+ quoted_body_html = '
Quantity
diff --git a/addons/point_of_sale/report/pos_order_report_view.xml b/addons/point_of_sale/report/pos_order_report_view.xml
index 255af0cdd30..00e0eb91f2f 100644
--- a/addons/point_of_sale/report/pos_order_report_view.xml
+++ b/addons/point_of_sale/report/pos_order_report_view.xml
@@ -35,7 +35,14 @@
-
+
+
+
+
+
+
- Point of Sale Analysis
+ Orders Analysisreport.pos.orderformtree
- {'search_default_year':1,'search_default_This Month':1,'search_default_today':1,'search_default_User':1,'group_by_no_leaf':1,'group_by':[]}
+ {'search_default_year':1,'search_default_today':1,'group_by_no_leaf':1,'group_by':['product_id']}
diff --git a/addons/point_of_sale/report/pos_users_product.py.WORK b/addons/point_of_sale/report/pos_users_product.py.WORK
index f26be9c7172..51ab021e650 100644
--- a/addons/point_of_sale/report/pos_users_product.py.WORK
+++ b/addons/point_of_sale/report/pos_users_product.py.WORK
@@ -39,16 +39,13 @@ class pos_user_product(report_sxw.rml_parse):
data={}
for o in object :
sql1=""" SELECT distinct(o.id) from account_bank_statement s, account_bank_statement_line l,pos_order o,pos_order_line i where i.order_id=o.id and o.state in ('paid','invoiced') and l.statement_id=s.id and l.pos_statement_id=o.id and s.id=%d"""%(o.id)
- print sql1
self.cr.execute(sql1)
data = self.cr.dictfetchall()
- print "DATA",data
a_l=[]
for r in data:
if r['id']:
a_l.append(r['id'])
a = ','.join(map(str,a_l))
- print a_l, a
if len(a_l):
sql2="""SELECT sum(qty) as qty,l.price_unit*sum(l.qty) as amt,t.name as name from product_product p, product_template t, pos_order_line l where order_id in (%s) and p.product_tmpl_id=t.id and l.product_id=p.id group by t.name, l.price_unit"""%(a)
self.cr.execute(sql2)
diff --git a/addons/point_of_sale/report/report_cash_register.py b/addons/point_of_sale/report/report_cash_register.py
deleted file mode 100644
index 4e99d2010b1..00000000000
--- a/addons/point_of_sale/report/report_cash_register.py
+++ /dev/null
@@ -1,68 +0,0 @@
-# -*- coding: utf-8 -*-
-##############################################################################
-#
-# OpenERP, Open Source Management Solution
-# Copyright (C) 2004-2010 Tiny SPRL ().
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-#
-##############################################################################
-
-import tools
-from osv import fields,osv
-
-class report_cash_register(osv.osv):
- _name = "report.cash.register"
- _description = "Point of Sale Cash Register Analysis"
- _auto = False
- _columns = {
- 'date': fields.date('Create Date', readonly=True),
- 'year': fields.char('Year', size=4),
- 'month':fields.selection([('01','January'), ('02','February'), ('03','March'), ('04','April'),
- ('05','May'), ('06','June'), ('07','July'), ('08','August'), ('09','September'),
- ('10','October'), ('11','November'), ('12','December')], 'Month',readonly=True),
- 'day': fields.char('Day', size=128, readonly=True),
- 'user_id':fields.many2one('res.users', 'User', readonly=True),
- 'state': fields.selection([('draft', 'Quotation'),('open','Open'),('confirm', 'Confirmed')],'Status'),
- 'journal_id': fields.many2one('account.journal', 'Journal'),
- 'balance_start': fields.float('Opening Balance'),
- 'balance_end_real': fields.float('Closing Balance'),
- }
- _order = 'date desc'
-
- def init(self, cr):
- tools.drop_view_if_exists(cr, 'report_cash_register')
- cr.execute("""
- create or replace view report_cash_register as (
- select
- min(s.id) as id,
- to_date(to_char(s.create_date, 'dd-MM-YYYY'),'dd-MM-YYYY') as date,
- s.user_id as user_id,
- s.journal_id as journal_id,
- s.state as state,
- s.balance_start as balance_start,
- s.balance_end_real as balance_end_real,
- to_char(s.create_date, 'YYYY') as year,
- to_char(s.create_date, 'MM') as month,
- to_char(s.create_date, 'YYYY-MM-DD') as day
- from account_bank_statement as s
- group by
- s.user_id,s.journal_id, s.balance_start, s.balance_end_real,s.state,to_char(s.create_date, 'dd-MM-YYYY'),
- to_char(s.create_date, 'YYYY'),
- to_char(s.create_date, 'MM'),
- to_char(s.create_date, 'YYYY-MM-DD'))""")
-
-report_cash_register()
-
-# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
\ No newline at end of file
diff --git a/addons/point_of_sale/report/report_cash_register_view.xml b/addons/point_of_sale/report/report_cash_register_view.xml
deleted file mode 100644
index afd769692df..00000000000
--- a/addons/point_of_sale/report/report_cash_register_view.xml
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
- report.cash.register.tree
- report.cash.register
- tree
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- report.cash.register.search
- report.cash.register
- search
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Register Analysis
- report.cash.register
- form
- tree,form
-
- {'search_default_year':1,'search_default_This Month':1,'search_default_today':1,'search_default_User':1,'group_by_no_leaf':1,'group_by':[]}
-
-
-
-
-
diff --git a/addons/point_of_sale/report/user_label.xml b/addons/point_of_sale/report/user_label.xml
new file mode 100644
index 00000000000..60c4f950d76
--- /dev/null
+++ b/addons/point_of_sale/report/user_label.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/addons/point_of_sale/report/user_label.xsl b/addons/point_of_sale/report/user_label.xsl
new file mode 100644
index 00000000000..321cd830a7a
--- /dev/null
+++ b/addons/point_of_sale/report/user_label.xsl
@@ -0,0 +1,91 @@
+
+
+ 24.5
+ 0.5
+ 4.8
+ 10
+ 3cm
+ 9.3cm
+ 2
+ 16
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cm
+
+
+
+ cm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/point_of_sale/res_users.py b/addons/point_of_sale/res_users.py
new file mode 100644
index 00000000000..d64f05d1391
--- /dev/null
+++ b/addons/point_of_sale/res_users.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+from osv import osv, fields
+import math
+
+def is_pair(x):
+ return not x%2
+# This code is a duplicate of product#check_ean function
+def check_ean(eancode):
+ if not eancode:
+ return True
+ if len(eancode) <> 13:
+ return False
+ try:
+ int(eancode)
+ except:
+ return False
+ oddsum=0
+ evensum=0
+ total=0
+ eanvalue=eancode
+ reversevalue = eanvalue[::-1]
+ finalean=reversevalue[1:]
+
+ for i in range(len(finalean)):
+ if is_pair(i):
+ oddsum += int(finalean[i])
+ else:
+ evensum += int(finalean[i])
+ total=(oddsum * 3) + evensum
+
+ check = int(10 - math.ceil(total % 10.0)) %10
+
+ if check != int(eancode[-1]):
+ return False
+ return True
+
+class res_users(osv.osv):
+ _inherit = 'res.users'
+ _columns = {
+ 'ean13' : fields.char('EAN13', size=13, help="BarCode"),
+ 'pos_config' : fields.many2one('pos.config', 'Default Point of Sale', domain=[('state', '=', 'active')]),
+ }
+
+ def _check_ean(self, cr, uid, ids, context=None):
+ return all(
+ check_ean(user.ean13) == True
+ for user in self.browse(cr, uid, ids, context=context)
+ )
+
+ _constraints = [
+ (_check_ean, "Error: Invalid ean code", ['ean13'],),
+ ]
+
diff --git a/addons/point_of_sale/res_users_view.xml b/addons/point_of_sale/res_users_view.xml
new file mode 100644
index 00000000000..def4a578252
--- /dev/null
+++ b/addons/point_of_sale/res_users_view.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+ res.users.form.view
+ res.users
+ form
+
+
+
+
+
+
+
+
+
+ res.users.form.view
+ res.users
+ form
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/point_of_sale/security/ir.model.access.csv b/addons/point_of_sale/security/ir.model.access.csv
index 9eb77e81244..06673fc3da8 100644
--- a/addons/point_of_sale/security/ir.model.access.csv
+++ b/addons/point_of_sale/security/ir.model.access.csv
@@ -1,11 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_pos_config_journal,pos.config.journal,model_pos_config_journal,group_pos_user,1,0,0,0
access_pos_order,pos.order,model_pos_order,group_pos_user,1,1,1,1
access_pos_order_line,pos.order.line,model_pos_order_line,group_pos_user,1,1,1,1
access_pos_order_manager,pos.order manager,model_pos_order,group_pos_manager,1,0,0,0
access_pos_order_line_manager,pos.order.line manager,model_pos_order_line,group_pos_manager,1,0,0,0
access_report_transaction_pos,report.transaction.pos,model_report_transaction_pos,group_pos_manager,1,1,1,1
-access_pos_config_journal_manager,pos.config.journal.manager,model_pos_config_journal,group_pos_manager,1,1,1,1
access_account_journal_pos_user,account.journal pos_user,account.model_account_journal,group_pos_user,1,0,0,0
access_account_move_pos_user,account.move pos_user,account.model_account_move,group_pos_user,1,1,1,0
access_account_account_pos_user,account.account pos_user,account.model_account_account,group_pos_user,1,0,0,0
@@ -32,6 +30,7 @@ access_account_period_pos_manager,account.period pos manager,account.model_accou
access_account_fiscalyear_pos_user,account.fiscalyear user,account.model_account_fiscalyear,group_pos_user,1,1,1,0
access_account_fiscalyear_pos_manager,account.fiscalyear manager,account.model_account_fiscalyear,group_pos_manager,1,0,0,0
access_account_cashbox_line,account.cashbox.line,account.model_account_cashbox_line,group_pos_user,1,1,1,0
+access_account_journal_cashbox_line,account.cashbox.journal.line,account.model_account_journal_cashbox_line,group_pos_user,1,1,1,0
access_account_cashbox_line_manager,account.cashbox.line manager,account.model_account_cashbox_line,group_pos_manager,1,1,1,1
access_product_product,product.product,product.model_product_product,group_pos_user,1,0,0,0
access_product_template_pos_user,product.template pos user,product.model_product_template,group_pos_user,1,0,0,0
@@ -49,7 +48,6 @@ access_account_analytic_line,account.analytic.line,analytic.model_account_analyt
access_account_analytic_account,account.analytic.account,analytic.model_account_analytic_account,group_pos_user,1,1,1,0
access_account_journal_column,account.journal.column,account.model_account_journal_column,group_pos_user,1,1,1,0
access_account_journal_column_manager,account.journal.column manager,account.model_account_journal_column,group_pos_manager,1,0,0,0
-access_report_check_register,report.cash.register,model_report_cash_register,group_pos_manager,1,1,1,1
access_ir_property_pos_manager,ir.property manager,base.model_ir_property,group_pos_manager,1,1,1,1
access_account_bank_statement_line_manager,account.bank.statement.line manager,account.model_account_bank_statement_line,group_pos_manager,1,1,1,1
access_account_invoice_manager,account.invoice manager,account.model_account_invoice,group_pos_manager,1,1,1,1
@@ -64,3 +62,5 @@ access_product_category_manager,product.category manager,product.model_product_c
access_product_pricelist_manager,product.pricelist manager,product.model_product_pricelist,group_pos_manager,1,0,0,0
access_product_category_pos_manager,pos.category manager,model_pos_category,group_pos_manager,1,1,1,"1"""
access_product_category_pos_user,pos.category user,model_pos_category,group_pos_user,1,0,0,"0"""
+access_pos_session_user,pos.session user,model_pos_session,group_pos_user,1,1,1,0
+access_pos_config_user,pos.config user,model_pos_config,group_pos_user,1,1,1,0
diff --git a/addons/point_of_sale/static/lib/backbone/backbone-0.9.2.js b/addons/point_of_sale/static/lib/backbone/backbone-0.9.2.js
new file mode 100644
index 00000000000..3373c952bfa
--- /dev/null
+++ b/addons/point_of_sale/static/lib/backbone/backbone-0.9.2.js
@@ -0,0 +1,1431 @@
+// Backbone.js 0.9.2
+
+// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
+// Backbone may be freely distributed under the MIT license.
+// For all details and documentation:
+// http://backbonejs.org
+
+(function(){
+
+ // Initial Setup
+ // -------------
+
+ // Save a reference to the global object (`window` in the browser, `global`
+ // on the server).
+ var root = this;
+
+ // Save the previous value of the `Backbone` variable, so that it can be
+ // restored later on, if `noConflict` is used.
+ var previousBackbone = root.Backbone;
+
+ // Create a local reference to slice/splice.
+ var slice = Array.prototype.slice;
+ var splice = Array.prototype.splice;
+
+ // The top-level namespace. All public Backbone classes and modules will
+ // be attached to this. Exported for both CommonJS and the browser.
+ var Backbone;
+ if (typeof exports !== 'undefined') {
+ Backbone = exports;
+ } else {
+ Backbone = root.Backbone = {};
+ }
+
+ // Current version of the library. Keep in sync with `package.json`.
+ Backbone.VERSION = '0.9.2';
+
+ // Require Underscore, if we're on the server, and it's not already present.
+ var _ = root._;
+ if (!_ && (typeof require !== 'undefined')) _ = require('underscore');
+
+ // For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable.
+ var $ = root.jQuery || root.Zepto || root.ender;
+
+ // Set the JavaScript library that will be used for DOM manipulation and
+ // Ajax calls (a.k.a. the `$` variable). By default Backbone will use: jQuery,
+ // Zepto, or Ender; but the `setDomLibrary()` method lets you inject an
+ // alternate JavaScript library (or a mock library for testing your views
+ // outside of a browser).
+ Backbone.setDomLibrary = function(lib) {
+ $ = lib;
+ };
+
+ // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable
+ // to its previous owner. Returns a reference to this Backbone object.
+ Backbone.noConflict = function() {
+ root.Backbone = previousBackbone;
+ return this;
+ };
+
+ // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option
+ // will fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and
+ // set a `X-Http-Method-Override` header.
+ Backbone.emulateHTTP = false;
+
+ // Turn on `emulateJSON` to support legacy servers that can't deal with direct
+ // `application/json` requests ... will encode the body as
+ // `application/x-www-form-urlencoded` instead and will send the model in a
+ // form param named `model`.
+ Backbone.emulateJSON = false;
+
+ // Backbone.Events
+ // -----------------
+
+ // Regular expression used to split event strings
+ var eventSplitter = /\s+/;
+
+ // A module that can be mixed in to *any object* in order to provide it with
+ // custom events. You may bind with `on` or remove with `off` callback functions
+ // to an event; trigger`-ing an event fires all callbacks in succession.
+ //
+ // var object = {};
+ // _.extend(object, Backbone.Events);
+ // object.on('expand', function(){ alert('expanded'); });
+ // object.trigger('expand');
+ //
+ var Events = Backbone.Events = {
+
+ // Bind one or more space separated events, `events`, to a `callback`
+ // function. Passing `"all"` will bind the callback to all events fired.
+ on: function(events, callback, context) {
+
+ var calls, event, node, tail, list;
+ if (!callback) return this;
+ events = events.split(eventSplitter);
+ calls = this._callbacks || (this._callbacks = {});
+
+ // Create an immutable callback list, allowing traversal during
+ // modification. The tail is an empty object that will always be used
+ // as the next node.
+ while (event = events.shift()) {
+ list = calls[event];
+ node = list ? list.tail : {};
+ node.next = tail = {};
+ node.context = context;
+ node.callback = callback;
+ calls[event] = {tail: tail, next: list ? list.next : node};
+ }
+
+ return this;
+ },
+
+ // Remove one or many callbacks. If `context` is null, removes all callbacks
+ // with that function. If `callback` is null, removes all callbacks for the
+ // event. If `events` is null, removes all bound callbacks for all events.
+ off: function(events, callback, context) {
+ var event, calls, node, tail, cb, ctx;
+
+ // No events, or removing *all* events.
+ if (!(calls = this._callbacks)) return;
+ if (!(events || callback || context)) {
+ delete this._callbacks;
+ return this;
+ }
+
+ // Loop through the listed events and contexts, splicing them out of the
+ // linked list of callbacks if appropriate.
+ events = events ? events.split(eventSplitter) : _.keys(calls);
+ while (event = events.shift()) {
+ node = calls[event];
+ delete calls[event];
+ if (!node || !(callback || context)) continue;
+ // Create a new list, omitting the indicated callbacks.
+ tail = node.tail;
+ while ((node = node.next) !== tail) {
+ cb = node.callback;
+ ctx = node.context;
+ if ((callback && cb !== callback) || (context && ctx !== context)) {
+ this.on(event, cb, ctx);
+ }
+ }
+ }
+
+ return this;
+ },
+
+ // Trigger one or many events, firing all bound callbacks. Callbacks are
+ // passed the same arguments as `trigger` is, apart from the event name
+ // (unless you're listening on `"all"`, which will cause your callback to
+ // receive the true name of the event as the first argument).
+ trigger: function(events) {
+ var event, node, calls, tail, args, all, rest;
+ if (!(calls = this._callbacks)) return this;
+ all = calls.all;
+ events = events.split(eventSplitter);
+ rest = slice.call(arguments, 1);
+
+ // For each event, walk through the linked list of callbacks twice,
+ // first to trigger the event, then to trigger any `"all"` callbacks.
+ while (event = events.shift()) {
+ if (node = calls[event]) {
+ tail = node.tail;
+ while ((node = node.next) !== tail) {
+ node.callback.apply(node.context || this, rest);
+ }
+ }
+ if (node = all) {
+ tail = node.tail;
+ args = [event].concat(rest);
+ while ((node = node.next) !== tail) {
+ node.callback.apply(node.context || this, args);
+ }
+ }
+ }
+
+ return this;
+ }
+
+ };
+
+ // Aliases for backwards compatibility.
+ Events.bind = Events.on;
+ Events.unbind = Events.off;
+
+ // Backbone.Model
+ // --------------
+
+ // Create a new model, with defined attributes. A client id (`cid`)
+ // is automatically generated and assigned for you.
+ var Model = Backbone.Model = function(attributes, options) {
+ var defaults;
+ attributes || (attributes = {});
+ if (options && options.parse) attributes = this.parse(attributes);
+ if (defaults = getValue(this, 'defaults')) {
+ attributes = _.extend({}, defaults, attributes);
+ }
+ if (options && options.collection) this.collection = options.collection;
+ this.attributes = {};
+ this._escapedAttributes = {};
+ this.cid = _.uniqueId('c');
+ this.changed = {};
+ this._silent = {};
+ this._pending = {};
+ this.set(attributes, {silent: true});
+ // Reset change tracking.
+ this.changed = {};
+ this._silent = {};
+ this._pending = {};
+ this._previousAttributes = _.clone(this.attributes);
+ this.initialize.apply(this, arguments);
+ };
+
+ // Attach all inheritable methods to the Model prototype.
+ _.extend(Model.prototype, Events, {
+
+ // A hash of attributes whose current and previous value differ.
+ changed: null,
+
+ // A hash of attributes that have silently changed since the last time
+ // `change` was called. Will become pending attributes on the next call.
+ _silent: null,
+
+ // A hash of attributes that have changed since the last `'change'` event
+ // began.
+ _pending: null,
+
+ // The default name for the JSON `id` attribute is `"id"`. MongoDB and
+ // CouchDB users may want to set this to `"_id"`.
+ idAttribute: 'id',
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // Return a copy of the model's `attributes` object.
+ toJSON: function(options) {
+ return _.clone(this.attributes);
+ },
+
+ // Get the value of an attribute.
+ get: function(attr) {
+ return this.attributes[attr];
+ },
+
+ // Get the HTML-escaped value of an attribute.
+ escape: function(attr) {
+ var html;
+ if (html = this._escapedAttributes[attr]) return html;
+ var val = this.get(attr);
+ return this._escapedAttributes[attr] = _.escape(val == null ? '' : '' + val);
+ },
+
+ // Returns `true` if the attribute contains a value that is not null
+ // or undefined.
+ has: function(attr) {
+ return this.get(attr) != null;
+ },
+
+ // Set a hash of model attributes on the object, firing `"change"` unless
+ // you choose to silence it.
+ set: function(key, value, options) {
+ var attrs, attr, val;
+
+ // Handle both `"key", value` and `{key: value}` -style arguments.
+ if (_.isObject(key) || key == null) {
+ attrs = key;
+ options = value;
+ } else {
+ attrs = {};
+ attrs[key] = value;
+ }
+
+ // Extract attributes and options.
+ options || (options = {});
+ if (!attrs) return this;
+ if (attrs instanceof Model) attrs = attrs.attributes;
+ if (options.unset) for (attr in attrs) attrs[attr] = void 0;
+
+ // Run validation.
+ if (!this._validate(attrs, options)) return false;
+
+ // Check for changes of `id`.
+ if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];
+
+ var changes = options.changes = {};
+ var now = this.attributes;
+ var escaped = this._escapedAttributes;
+ var prev = this._previousAttributes || {};
+
+ // For each `set` attribute...
+ for (attr in attrs) {
+ val = attrs[attr];
+
+ // If the new and current value differ, record the change.
+ if (!_.isEqual(now[attr], val) || (options.unset && _.has(now, attr))) {
+ delete escaped[attr];
+ (options.silent ? this._silent : changes)[attr] = true;
+ }
+
+ // Update or delete the current value.
+ options.unset ? delete now[attr] : now[attr] = val;
+
+ // If the new and previous value differ, record the change. If not,
+ // then remove changes for this attribute.
+ if (!_.isEqual(prev[attr], val) || (_.has(now, attr) != _.has(prev, attr))) {
+ this.changed[attr] = val;
+ if (!options.silent) this._pending[attr] = true;
+ } else {
+ delete this.changed[attr];
+ delete this._pending[attr];
+ }
+ }
+
+ // Fire the `"change"` events.
+ if (!options.silent) this.change(options);
+ return this;
+ },
+
+ // Remove an attribute from the model, firing `"change"` unless you choose
+ // to silence it. `unset` is a noop if the attribute doesn't exist.
+ unset: function(attr, options) {
+ (options || (options = {})).unset = true;
+ return this.set(attr, null, options);
+ },
+
+ // Clear all attributes on the model, firing `"change"` unless you choose
+ // to silence it.
+ clear: function(options) {
+ (options || (options = {})).unset = true;
+ return this.set(_.clone(this.attributes), options);
+ },
+
+ // Fetch the model from the server. If the server's representation of the
+ // model differs from its current attributes, they will be overriden,
+ // triggering a `"change"` event.
+ fetch: function(options) {
+ options = options ? _.clone(options) : {};
+ var model = this;
+ var success = options.success;
+ options.success = function(resp, status, xhr) {
+ if (!model.set(model.parse(resp, xhr), options)) return false;
+ if (success) success(model, resp);
+ };
+ options.error = Backbone.wrapError(options.error, model, options);
+ return (this.sync || Backbone.sync).call(this, 'read', this, options);
+ },
+
+ // Set a hash of model attributes, and sync the model to the server.
+ // If the server returns an attributes hash that differs, the model's
+ // state will be `set` again.
+ save: function(key, value, options) {
+ var attrs, current;
+
+ // Handle both `("key", value)` and `({key: value})` -style calls.
+ if (_.isObject(key) || key == null) {
+ attrs = key;
+ options = value;
+ } else {
+ attrs = {};
+ attrs[key] = value;
+ }
+ options = options ? _.clone(options) : {};
+
+ // If we're "wait"-ing to set changed attributes, validate early.
+ if (options.wait) {
+ if (!this._validate(attrs, options)) return false;
+ current = _.clone(this.attributes);
+ }
+
+ // Regular saves `set` attributes before persisting to the server.
+ var silentOptions = _.extend({}, options, {silent: true});
+ if (attrs && !this.set(attrs, options.wait ? silentOptions : options)) {
+ return false;
+ }
+
+ // After a successful server-side save, the client is (optionally)
+ // updated with the server-side state.
+ var model = this;
+ var success = options.success;
+ options.success = function(resp, status, xhr) {
+ var serverAttrs = model.parse(resp, xhr);
+ if (options.wait) {
+ delete options.wait;
+ serverAttrs = _.extend(attrs || {}, serverAttrs);
+ }
+ if (!model.set(serverAttrs, options)) return false;
+ if (success) {
+ success(model, resp);
+ } else {
+ model.trigger('sync', model, resp, options);
+ }
+ };
+
+ // Finish configuring and sending the Ajax request.
+ options.error = Backbone.wrapError(options.error, model, options);
+ var method = this.isNew() ? 'create' : 'update';
+ var xhr = (this.sync || Backbone.sync).call(this, method, this, options);
+ if (options.wait) this.set(current, silentOptions);
+ return xhr;
+ },
+
+ // Destroy this model on the server if it was already persisted.
+ // Optimistically removes the model from its collection, if it has one.
+ // If `wait: true` is passed, waits for the server to respond before removal.
+ destroy: function(options) {
+ options = options ? _.clone(options) : {};
+ var model = this;
+ var success = options.success;
+
+ var triggerDestroy = function() {
+ model.trigger('destroy', model, model.collection, options);
+ };
+
+ if (this.isNew()) {
+ triggerDestroy();
+ return false;
+ }
+
+ options.success = function(resp) {
+ if (options.wait) triggerDestroy();
+ if (success) {
+ success(model, resp);
+ } else {
+ model.trigger('sync', model, resp, options);
+ }
+ };
+
+ options.error = Backbone.wrapError(options.error, model, options);
+ var xhr = (this.sync || Backbone.sync).call(this, 'delete', this, options);
+ if (!options.wait) triggerDestroy();
+ return xhr;
+ },
+
+ // Default URL for the model's representation on the server -- if you're
+ // using Backbone's restful methods, override this to change the endpoint
+ // that will be called.
+ url: function() {
+ var base = getValue(this, 'urlRoot') || getValue(this.collection, 'url') || urlError();
+ if (this.isNew()) return base;
+ return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id);
+ },
+
+ // **parse** converts a response into the hash of attributes to be `set` on
+ // the model. The default implementation is just to pass the response along.
+ parse: function(resp, xhr) {
+ return resp;
+ },
+
+ // Create a new model with identical attributes to this one.
+ clone: function() {
+ return new this.constructor(this.attributes);
+ },
+
+ // A model is new if it has never been saved to the server, and lacks an id.
+ isNew: function() {
+ return this.id == null;
+ },
+
+ // Call this method to manually fire a `"change"` event for this model and
+ // a `"change:attribute"` event for each changed attribute.
+ // Calling this will cause all objects observing the model to update.
+ change: function(options) {
+ options || (options = {});
+ var changing = this._changing;
+ this._changing = true;
+
+ // Silent changes become pending changes.
+ for (var attr in this._silent) this._pending[attr] = true;
+
+ // Silent changes are triggered.
+ var changes = _.extend({}, options.changes, this._silent);
+ this._silent = {};
+ for (var attr in changes) {
+ this.trigger('change:' + attr, this, this.get(attr), options);
+ }
+ if (changing) return this;
+
+ // Continue firing `"change"` events while there are pending changes.
+ while (!_.isEmpty(this._pending)) {
+ this._pending = {};
+ this.trigger('change', this, options);
+ // Pending and silent changes still remain.
+ for (var attr in this.changed) {
+ if (this._pending[attr] || this._silent[attr]) continue;
+ delete this.changed[attr];
+ }
+ this._previousAttributes = _.clone(this.attributes);
+ }
+
+ this._changing = false;
+ return this;
+ },
+
+ // Determine if the model has changed since the last `"change"` event.
+ // If you specify an attribute name, determine if that attribute has changed.
+ hasChanged: function(attr) {
+ if (!arguments.length) return !_.isEmpty(this.changed);
+ return _.has(this.changed, attr);
+ },
+
+ // Return an object containing all the attributes that have changed, or
+ // false if there are no changed attributes. Useful for determining what
+ // parts of a view need to be updated and/or what attributes need to be
+ // persisted to the server. Unset attributes will be set to undefined.
+ // You can also pass an attributes object to diff against the model,
+ // determining if there *would be* a change.
+ changedAttributes: function(diff) {
+ if (!diff) return this.hasChanged() ? _.clone(this.changed) : false;
+ var val, changed = false, old = this._previousAttributes;
+ for (var attr in diff) {
+ if (_.isEqual(old[attr], (val = diff[attr]))) continue;
+ (changed || (changed = {}))[attr] = val;
+ }
+ return changed;
+ },
+
+ // Get the previous value of an attribute, recorded at the time the last
+ // `"change"` event was fired.
+ previous: function(attr) {
+ if (!arguments.length || !this._previousAttributes) return null;
+ return this._previousAttributes[attr];
+ },
+
+ // Get all of the attributes of the model at the time of the previous
+ // `"change"` event.
+ previousAttributes: function() {
+ return _.clone(this._previousAttributes);
+ },
+
+ // Check if the model is currently in a valid state. It's only possible to
+ // get into an *invalid* state if you're using silent changes.
+ isValid: function() {
+ return !this.validate(this.attributes);
+ },
+
+ // Run validation against the next complete set of model attributes,
+ // returning `true` if all is well. If a specific `error` callback has
+ // been passed, call that instead of firing the general `"error"` event.
+ _validate: function(attrs, options) {
+ if (options.silent || !this.validate) return true;
+ attrs = _.extend({}, this.attributes, attrs);
+ var error = this.validate(attrs, options);
+ if (!error) return true;
+ if (options && options.error) {
+ options.error(this, error, options);
+ } else {
+ this.trigger('error', this, error, options);
+ }
+ return false;
+ }
+
+ });
+
+ // Backbone.Collection
+ // -------------------
+
+ // Provides a standard collection class for our sets of models, ordered
+ // or unordered. If a `comparator` is specified, the Collection will maintain
+ // its models in sort order, as they're added and removed.
+ var Collection = Backbone.Collection = function(models, options) {
+ options || (options = {});
+ if (options.model) this.model = options.model;
+ if (options.comparator) this.comparator = options.comparator;
+ this._reset();
+ this.initialize.apply(this, arguments);
+ if (models) this.reset(models, {silent: true, parse: options.parse});
+ };
+
+ // Define the Collection's inheritable methods.
+ _.extend(Collection.prototype, Events, {
+
+ // The default model for a collection is just a **Backbone.Model**.
+ // This should be overridden in most cases.
+ model: Model,
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // The JSON representation of a Collection is an array of the
+ // models' attributes.
+ toJSON: function(options) {
+ return this.map(function(model){ return model.toJSON(options); });
+ },
+
+ // Add a model, or list of models to the set. Pass **silent** to avoid
+ // firing the `add` event for every new model.
+ add: function(models, options) {
+ var i, index, length, model, cid, id, cids = {}, ids = {}, dups = [];
+ options || (options = {});
+ models = _.isArray(models) ? models.slice() : [models];
+
+ // Begin by turning bare objects into model references, and preventing
+ // invalid models or duplicate models from being added.
+ for (i = 0, length = models.length; i < length; i++) {
+ if (!(model = models[i] = this._prepareModel(models[i], options))) {
+ throw new Error("Can't add an invalid model to a collection");
+ }
+ cid = model.cid;
+ id = model.id;
+ if (cids[cid] || this._byCid[cid] || ((id != null) && (ids[id] || this._byId[id]))) {
+ dups.push(i);
+ continue;
+ }
+ cids[cid] = ids[id] = model;
+ }
+
+ // Remove duplicates.
+ i = dups.length;
+ while (i--) {
+ models.splice(dups[i], 1);
+ }
+
+ // Listen to added models' events, and index models for lookup by
+ // `id` and by `cid`.
+ for (i = 0, length = models.length; i < length; i++) {
+ (model = models[i]).on('all', this._onModelEvent, this);
+ this._byCid[model.cid] = model;
+ if (model.id != null) this._byId[model.id] = model;
+ }
+
+ // Insert models into the collection, re-sorting if needed, and triggering
+ // `add` events unless silenced.
+ this.length += length;
+ index = options.at != null ? options.at : this.models.length;
+ splice.apply(this.models, [index, 0].concat(models));
+ if (this.comparator) this.sort({silent: true});
+ if (options.silent) return this;
+ for (i = 0, length = this.models.length; i < length; i++) {
+ if (!cids[(model = this.models[i]).cid]) continue;
+ options.index = i;
+ model.trigger('add', model, this, options);
+ }
+ return this;
+ },
+
+ // Remove a model, or a list of models from the set. Pass silent to avoid
+ // firing the `remove` event for every model removed.
+ remove: function(models, options) {
+ var i, l, index, model;
+ options || (options = {});
+ models = _.isArray(models) ? models.slice() : [models];
+ for (i = 0, l = models.length; i < l; i++) {
+ model = this.getByCid(models[i]) || this.get(models[i]);
+ if (!model) continue;
+ delete this._byId[model.id];
+ delete this._byCid[model.cid];
+ index = this.indexOf(model);
+ this.models.splice(index, 1);
+ this.length--;
+ if (!options.silent) {
+ options.index = index;
+ model.trigger('remove', model, this, options);
+ }
+ this._removeReference(model);
+ }
+ return this;
+ },
+
+ // Add a model to the end of the collection.
+ push: function(model, options) {
+ model = this._prepareModel(model, options);
+ this.add(model, options);
+ return model;
+ },
+
+ // Remove a model from the end of the collection.
+ pop: function(options) {
+ var model = this.at(this.length - 1);
+ this.remove(model, options);
+ return model;
+ },
+
+ // Add a model to the beginning of the collection.
+ unshift: function(model, options) {
+ model = this._prepareModel(model, options);
+ this.add(model, _.extend({at: 0}, options));
+ return model;
+ },
+
+ // Remove a model from the beginning of the collection.
+ shift: function(options) {
+ var model = this.at(0);
+ this.remove(model, options);
+ return model;
+ },
+
+ // Get a model from the set by id.
+ get: function(id) {
+ if (id == null) return void 0;
+ return this._byId[id.id != null ? id.id : id];
+ },
+
+ // Get a model from the set by client id.
+ getByCid: function(cid) {
+ return cid && this._byCid[cid.cid || cid];
+ },
+
+ // Get the model at the given index.
+ at: function(index) {
+ return this.models[index];
+ },
+
+ // Return models with matching attributes. Useful for simple cases of `filter`.
+ where: function(attrs) {
+ if (_.isEmpty(attrs)) return [];
+ return this.filter(function(model) {
+ for (var key in attrs) {
+ if (attrs[key] !== model.get(key)) return false;
+ }
+ return true;
+ });
+ },
+
+ // Force the collection to re-sort itself. You don't need to call this under
+ // normal circumstances, as the set will maintain sort order as each item
+ // is added.
+ sort: function(options) {
+ options || (options = {});
+ if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
+ var boundComparator = _.bind(this.comparator, this);
+ if (this.comparator.length == 1) {
+ this.models = this.sortBy(boundComparator);
+ } else {
+ this.models.sort(boundComparator);
+ }
+ if (!options.silent) this.trigger('reset', this, options);
+ return this;
+ },
+
+ // Pluck an attribute from each model in the collection.
+ pluck: function(attr) {
+ return _.map(this.models, function(model){ return model.get(attr); });
+ },
+
+ // When you have more items than you want to add or remove individually,
+ // you can reset the entire set with a new list of models, without firing
+ // any `add` or `remove` events. Fires `reset` when finished.
+ reset: function(models, options) {
+ models || (models = []);
+ options || (options = {});
+ for (var i = 0, l = this.models.length; i < l; i++) {
+ this._removeReference(this.models[i]);
+ }
+ this._reset();
+ this.add(models, _.extend({silent: true}, options));
+ if (!options.silent) this.trigger('reset', this, options);
+ return this;
+ },
+
+ // Fetch the default set of models for this collection, resetting the
+ // collection when they arrive. If `add: true` is passed, appends the
+ // models to the collection instead of resetting.
+ fetch: function(options) {
+ options = options ? _.clone(options) : {};
+ if (options.parse === undefined) options.parse = true;
+ var collection = this;
+ var success = options.success;
+ options.success = function(resp, status, xhr) {
+ collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options);
+ if (success) success(collection, resp);
+ };
+ options.error = Backbone.wrapError(options.error, collection, options);
+ return (this.sync || Backbone.sync).call(this, 'read', this, options);
+ },
+
+ // Create a new instance of a model in this collection. Add the model to the
+ // collection immediately, unless `wait: true` is passed, in which case we
+ // wait for the server to agree.
+ create: function(model, options) {
+ var coll = this;
+ options = options ? _.clone(options) : {};
+ model = this._prepareModel(model, options);
+ if (!model) return false;
+ if (!options.wait) coll.add(model, options);
+ var success = options.success;
+ options.success = function(nextModel, resp, xhr) {
+ if (options.wait) coll.add(nextModel, options);
+ if (success) {
+ success(nextModel, resp);
+ } else {
+ nextModel.trigger('sync', model, resp, options);
+ }
+ };
+ model.save(null, options);
+ return model;
+ },
+
+ // **parse** converts a response into a list of models to be added to the
+ // collection. The default implementation is just to pass it through.
+ parse: function(resp, xhr) {
+ return resp;
+ },
+
+ // Proxy to _'s chain. Can't be proxied the same way the rest of the
+ // underscore methods are proxied because it relies on the underscore
+ // constructor.
+ chain: function () {
+ return _(this.models).chain();
+ },
+
+ // Reset all internal state. Called when the collection is reset.
+ _reset: function(options) {
+ this.length = 0;
+ this.models = [];
+ this._byId = {};
+ this._byCid = {};
+ },
+
+ // Prepare a model or hash of attributes to be added to this collection.
+ _prepareModel: function(model, options) {
+ options || (options = {});
+ if (!(model instanceof Model)) {
+ var attrs = model;
+ options.collection = this;
+ model = new this.model(attrs, options);
+ if (!model._validate(model.attributes, options)) model = false;
+ } else if (!model.collection) {
+ model.collection = this;
+ }
+ return model;
+ },
+
+ // Internal method to remove a model's ties to a collection.
+ _removeReference: function(model) {
+ if (this == model.collection) {
+ delete model.collection;
+ }
+ model.off('all', this._onModelEvent, this);
+ },
+
+ // Internal method called every time a model in the set fires an event.
+ // Sets need to update their indexes when models change ids. All other
+ // events simply proxy through. "add" and "remove" events that originate
+ // in other collections are ignored.
+ _onModelEvent: function(event, model, collection, options) {
+ if ((event == 'add' || event == 'remove') && collection != this) return;
+ if (event == 'destroy') {
+ this.remove(model, options);
+ }
+ if (model && event === 'change:' + model.idAttribute) {
+ delete this._byId[model.previous(model.idAttribute)];
+ this._byId[model.id] = model;
+ }
+ this.trigger.apply(this, arguments);
+ }
+
+ });
+
+ // Underscore methods that we want to implement on the Collection.
+ var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find',
+ 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any',
+ 'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex',
+ 'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf',
+ 'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy'];
+
+ // Mix in each Underscore method as a proxy to `Collection#models`.
+ _.each(methods, function(method) {
+ Collection.prototype[method] = function() {
+ return _[method].apply(_, [this.models].concat(_.toArray(arguments)));
+ };
+ });
+
+ // Backbone.Router
+ // -------------------
+
+ // Routers map faux-URLs to actions, and fire events when routes are
+ // matched. Creating a new one sets its `routes` hash, if not set statically.
+ var Router = Backbone.Router = function(options) {
+ options || (options = {});
+ if (options.routes) this.routes = options.routes;
+ this._bindRoutes();
+ this.initialize.apply(this, arguments);
+ };
+
+ // Cached regular expressions for matching named param parts and splatted
+ // parts of route strings.
+ var namedParam = /:\w+/g;
+ var splatParam = /\*\w+/g;
+ var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g;
+
+ // Set up all inheritable **Backbone.Router** properties and methods.
+ _.extend(Router.prototype, Events, {
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // Manually bind a single named route to a callback. For example:
+ //
+ // this.route('search/:query/p:num', 'search', function(query, num) {
+ // ...
+ // });
+ //
+ route: function(route, name, callback) {
+ Backbone.history || (Backbone.history = new History);
+ if (!_.isRegExp(route)) route = this._routeToRegExp(route);
+ if (!callback) callback = this[name];
+ Backbone.history.route(route, _.bind(function(fragment) {
+ var args = this._extractParameters(route, fragment);
+ callback && callback.apply(this, args);
+ this.trigger.apply(this, ['route:' + name].concat(args));
+ Backbone.history.trigger('route', this, name, args);
+ }, this));
+ return this;
+ },
+
+ // Simple proxy to `Backbone.history` to save a fragment into the history.
+ navigate: function(fragment, options) {
+ Backbone.history.navigate(fragment, options);
+ },
+
+ // Bind all defined routes to `Backbone.history`. We have to reverse the
+ // order of the routes here to support behavior where the most general
+ // routes can be defined at the bottom of the route map.
+ _bindRoutes: function() {
+ if (!this.routes) return;
+ var routes = [];
+ for (var route in this.routes) {
+ routes.unshift([route, this.routes[route]]);
+ }
+ for (var i = 0, l = routes.length; i < l; i++) {
+ this.route(routes[i][0], routes[i][1], this[routes[i][1]]);
+ }
+ },
+
+ // Convert a route string into a regular expression, suitable for matching
+ // against the current location hash.
+ _routeToRegExp: function(route) {
+ route = route.replace(escapeRegExp, '\\$&')
+ .replace(namedParam, '([^\/]+)')
+ .replace(splatParam, '(.*?)');
+ return new RegExp('^' + route + '$');
+ },
+
+ // Given a route, and a URL fragment that it matches, return the array of
+ // extracted parameters.
+ _extractParameters: function(route, fragment) {
+ return route.exec(fragment).slice(1);
+ }
+
+ });
+
+ // Backbone.History
+ // ----------------
+
+ // Handles cross-browser history management, based on URL fragments. If the
+ // browser does not support `onhashchange`, falls back to polling.
+ var History = Backbone.History = function() {
+ this.handlers = [];
+ _.bindAll(this, 'checkUrl');
+ };
+
+ // Cached regex for cleaning leading hashes and slashes .
+ var routeStripper = /^[#\/]/;
+
+ // Cached regex for detecting MSIE.
+ var isExplorer = /msie [\w.]+/;
+
+ // Has the history handling already been started?
+ History.started = false;
+
+ // Set up all inheritable **Backbone.History** properties and methods.
+ _.extend(History.prototype, Events, {
+
+ // The default interval to poll for hash changes, if necessary, is
+ // twenty times a second.
+ interval: 50,
+
+ // Gets the true hash value. Cannot use location.hash directly due to bug
+ // in Firefox where location.hash will always be decoded.
+ getHash: function(windowOverride) {
+ var loc = windowOverride ? windowOverride.location : window.location;
+ var match = loc.href.match(/#(.*)$/);
+ return match ? match[1] : '';
+ },
+
+ // Get the cross-browser normalized URL fragment, either from the URL,
+ // the hash, or the override.
+ getFragment: function(fragment, forcePushState) {
+ if (fragment == null) {
+ if (this._hasPushState || forcePushState) {
+ fragment = window.location.pathname;
+ var search = window.location.search;
+ if (search) fragment += search;
+ } else {
+ fragment = this.getHash();
+ }
+ }
+ if (!fragment.indexOf(this.options.root)) fragment = fragment.substr(this.options.root.length);
+ return fragment.replace(routeStripper, '');
+ },
+
+ // Start the hash change handling, returning `true` if the current URL matches
+ // an existing route, and `false` otherwise.
+ start: function(options) {
+ if (History.started) throw new Error("Backbone.history has already been started");
+ History.started = true;
+
+ // Figure out the initial configuration. Do we need an iframe?
+ // Is pushState desired ... is it available?
+ this.options = _.extend({}, {root: '/'}, this.options, options);
+ this._wantsHashChange = this.options.hashChange !== false;
+ this._wantsPushState = !!this.options.pushState;
+ this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState);
+ var fragment = this.getFragment();
+ var docMode = document.documentMode;
+ var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7));
+
+ if (oldIE) {
+ this.iframe = $('').hide().appendTo('body')[0].contentWindow;
+ this.navigate(fragment);
+ }
+
+ // Depending on whether we're using pushState or hashes, and whether
+ // 'onhashchange' is supported, determine how we check the URL state.
+ if (this._hasPushState) {
+ $(window).bind('popstate', this.checkUrl);
+ } else if (this._wantsHashChange && ('onhashchange' in window) && !oldIE) {
+ $(window).bind('hashchange', this.checkUrl);
+ } else if (this._wantsHashChange) {
+ this._checkUrlInterval = setInterval(this.checkUrl, this.interval);
+ }
+
+ // Determine if we need to change the base url, for a pushState link
+ // opened by a non-pushState browser.
+ this.fragment = fragment;
+ var loc = window.location;
+ var atRoot = loc.pathname == this.options.root;
+
+ // If we've started off with a route from a `pushState`-enabled browser,
+ // but we're currently in a browser that doesn't support it...
+ if (this._wantsHashChange && this._wantsPushState && !this._hasPushState && !atRoot) {
+ this.fragment = this.getFragment(null, true);
+ window.location.replace(this.options.root + '#' + this.fragment);
+ // Return immediately as browser will do redirect to new url
+ return true;
+
+ // Or if we've started out with a hash-based route, but we're currently
+ // in a browser where it could be `pushState`-based instead...
+ } else if (this._wantsPushState && this._hasPushState && atRoot && loc.hash) {
+ this.fragment = this.getHash().replace(routeStripper, '');
+ window.history.replaceState({}, document.title, loc.protocol + '//' + loc.host + this.options.root + this.fragment);
+ }
+
+ if (!this.options.silent) {
+ return this.loadUrl();
+ }
+ },
+
+ // Disable Backbone.history, perhaps temporarily. Not useful in a real app,
+ // but possibly useful for unit testing Routers.
+ stop: function() {
+ $(window).unbind('popstate', this.checkUrl).unbind('hashchange', this.checkUrl);
+ clearInterval(this._checkUrlInterval);
+ History.started = false;
+ },
+
+ // Add a route to be tested when the fragment changes. Routes added later
+ // may override previous routes.
+ route: function(route, callback) {
+ this.handlers.unshift({route: route, callback: callback});
+ },
+
+ // Checks the current URL to see if it has changed, and if it has,
+ // calls `loadUrl`, normalizing across the hidden iframe.
+ checkUrl: function(e) {
+ var current = this.getFragment();
+ if (current == this.fragment && this.iframe) current = this.getFragment(this.getHash(this.iframe));
+ if (current == this.fragment) return false;
+ if (this.iframe) this.navigate(current);
+ this.loadUrl() || this.loadUrl(this.getHash());
+ },
+
+ // Attempt to load the current URL fragment. If a route succeeds with a
+ // match, returns `true`. If no defined routes matches the fragment,
+ // returns `false`.
+ loadUrl: function(fragmentOverride) {
+ var fragment = this.fragment = this.getFragment(fragmentOverride);
+ var matched = _.any(this.handlers, function(handler) {
+ if (handler.route.test(fragment)) {
+ handler.callback(fragment);
+ return true;
+ }
+ });
+ return matched;
+ },
+
+ // Save a fragment into the hash history, or replace the URL state if the
+ // 'replace' option is passed. You are responsible for properly URL-encoding
+ // the fragment in advance.
+ //
+ // The options object can contain `trigger: true` if you wish to have the
+ // route callback be fired (not usually desirable), or `replace: true`, if
+ // you wish to modify the current URL without adding an entry to the history.
+ navigate: function(fragment, options) {
+ if (!History.started) return false;
+ if (!options || options === true) options = {trigger: options};
+ var frag = (fragment || '').replace(routeStripper, '');
+ if (this.fragment == frag) return;
+
+ // If pushState is available, we use it to set the fragment as a real URL.
+ if (this._hasPushState) {
+ if (frag.indexOf(this.options.root) != 0) frag = this.options.root + frag;
+ this.fragment = frag;
+ window.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, frag);
+
+ // If hash changes haven't been explicitly disabled, update the hash
+ // fragment to store history.
+ } else if (this._wantsHashChange) {
+ this.fragment = frag;
+ this._updateHash(window.location, frag, options.replace);
+ if (this.iframe && (frag != this.getFragment(this.getHash(this.iframe)))) {
+ // Opening and closing the iframe tricks IE7 and earlier to push a history entry on hash-tag change.
+ // When replace is true, we don't want this.
+ if(!options.replace) this.iframe.document.open().close();
+ this._updateHash(this.iframe.location, frag, options.replace);
+ }
+
+ // If you've told us that you explicitly don't want fallback hashchange-
+ // based history, then `navigate` becomes a page refresh.
+ } else {
+ window.location.assign(this.options.root + fragment);
+ }
+ if (options.trigger) this.loadUrl(fragment);
+ },
+
+ // Update the hash location, either replacing the current entry, or adding
+ // a new one to the browser history.
+ _updateHash: function(location, fragment, replace) {
+ if (replace) {
+ location.replace(location.toString().replace(/(javascript:|#).*$/, '') + '#' + fragment);
+ } else {
+ location.hash = fragment;
+ }
+ }
+ });
+
+ // Backbone.View
+ // -------------
+
+ // Creating a Backbone.View creates its initial element outside of the DOM,
+ // if an existing element is not provided...
+ var View = Backbone.View = function(options) {
+ this.cid = _.uniqueId('view');
+ this._configure(options || {});
+ this._ensureElement();
+ this.initialize.apply(this, arguments);
+ this.delegateEvents();
+ };
+
+ // Cached regex to split keys for `delegate`.
+ var delegateEventSplitter = /^(\S+)\s*(.*)$/;
+
+ // List of view options to be merged as properties.
+ var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName'];
+
+ // Set up all inheritable **Backbone.View** properties and methods.
+ _.extend(View.prototype, Events, {
+
+ // The default `tagName` of a View's element is `"div"`.
+ tagName: 'div',
+
+ // jQuery delegate for element lookup, scoped to DOM elements within the
+ // current view. This should be prefered to global lookups where possible.
+ $: function(selector) {
+ return this.$el.find(selector);
+ },
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // **render** is the core function that your view should override, in order
+ // to populate its element (`this.el`), with the appropriate HTML. The
+ // convention is for **render** to always return `this`.
+ render: function() {
+ return this;
+ },
+
+ // Remove this view from the DOM. Note that the view isn't present in the
+ // DOM by default, so calling this method may be a no-op.
+ remove: function() {
+ this.$el.remove();
+ return this;
+ },
+
+ // For small amounts of DOM Elements, where a full-blown template isn't
+ // needed, use **make** to manufacture elements, one at a time.
+ //
+ // var el = this.make('li', {'class': 'row'}, this.model.escape('title'));
+ //
+ make: function(tagName, attributes, content) {
+ var el = document.createElement(tagName);
+ if (attributes) $(el).attr(attributes);
+ if (content) $(el).html(content);
+ return el;
+ },
+
+ // Change the view's element (`this.el` property), including event
+ // re-delegation.
+ setElement: function(element, delegate) {
+ if (this.$el) this.undelegateEvents();
+ this.$el = (element instanceof $) ? element : $(element);
+ this.el = this.$el[0];
+ if (delegate !== false) this.delegateEvents();
+ return this;
+ },
+
+ // Set callbacks, where `this.events` is a hash of
+ //
+ // *{"event selector": "callback"}*
+ //
+ // {
+ // 'mousedown .title': 'edit',
+ // 'click .button': 'save'
+ // 'click .open': function(e) { ... }
+ // }
+ //
+ // pairs. Callbacks will be bound to the view, with `this` set properly.
+ // Uses event delegation for efficiency.
+ // Omitting the selector binds the event to `this.el`.
+ // This only works for delegate-able events: not `focus`, `blur`, and
+ // not `change`, `submit`, and `reset` in Internet Explorer.
+ delegateEvents: function(events) {
+ if (!(events || (events = getValue(this, 'events')))) return;
+ this.undelegateEvents();
+ for (var key in events) {
+ var method = events[key];
+ if (!_.isFunction(method)) method = this[events[key]];
+ if (!method) throw new Error('Method "' + events[key] + '" does not exist');
+ var match = key.match(delegateEventSplitter);
+ var eventName = match[1], selector = match[2];
+ method = _.bind(method, this);
+ eventName += '.delegateEvents' + this.cid;
+ if (selector === '') {
+ this.$el.bind(eventName, method);
+ } else {
+ this.$el.delegate(selector, eventName, method);
+ }
+ }
+ },
+
+ // Clears all callbacks previously bound to the view with `delegateEvents`.
+ // You usually don't need to use this, but may wish to if you have multiple
+ // Backbone views attached to the same DOM element.
+ undelegateEvents: function() {
+ this.$el.unbind('.delegateEvents' + this.cid);
+ },
+
+ // Performs the initial configuration of a View with a set of options.
+ // Keys with special meaning *(model, collection, id, className)*, are
+ // attached directly to the view.
+ _configure: function(options) {
+ if (this.options) options = _.extend({}, this.options, options);
+ for (var i = 0, l = viewOptions.length; i < l; i++) {
+ var attr = viewOptions[i];
+ if (options[attr]) this[attr] = options[attr];
+ }
+ this.options = options;
+ },
+
+ // Ensure that the View has a DOM element to render into.
+ // If `this.el` is a string, pass it through `$()`, take the first
+ // matching element, and re-assign it to `el`. Otherwise, create
+ // an element from the `id`, `className` and `tagName` properties.
+ _ensureElement: function() {
+ if (!this.el) {
+ var attrs = getValue(this, 'attributes') || {};
+ if (this.id) attrs.id = this.id;
+ if (this.className) attrs['class'] = this.className;
+ this.setElement(this.make(this.tagName, attrs), false);
+ } else {
+ this.setElement(this.el, false);
+ }
+ }
+
+ });
+
+ // The self-propagating extend function that Backbone classes use.
+ var extend = function (protoProps, classProps) {
+ var child = inherits(this, protoProps, classProps);
+ child.extend = this.extend;
+ return child;
+ };
+
+ // Set up inheritance for the model, collection, and view.
+ Model.extend = Collection.extend = Router.extend = View.extend = extend;
+
+ // Backbone.sync
+ // -------------
+
+ // Map from CRUD to HTTP for our default `Backbone.sync` implementation.
+ var methodMap = {
+ 'create': 'POST',
+ 'update': 'PUT',
+ 'delete': 'DELETE',
+ 'read': 'GET'
+ };
+
+ // Override this function to change the manner in which Backbone persists
+ // models to the server. You will be passed the type of request, and the
+ // model in question. By default, makes a RESTful Ajax request
+ // to the model's `url()`. Some possible customizations could be:
+ //
+ // * Use `setTimeout` to batch rapid-fire updates into a single request.
+ // * Send up the models as XML instead of JSON.
+ // * Persist models via WebSockets instead of Ajax.
+ //
+ // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
+ // as `POST`, with a `_method` parameter containing the true HTTP method,
+ // as well as all requests with the body as `application/x-www-form-urlencoded`
+ // instead of `application/json` with the model in a param named `model`.
+ // Useful when interfacing with server-side languages like **PHP** that make
+ // it difficult to read the body of `PUT` requests.
+ Backbone.sync = function(method, model, options) {
+ var type = methodMap[method];
+
+ // Default options, unless specified.
+ options || (options = {});
+
+ // Default JSON-request options.
+ var params = {type: type, dataType: 'json'};
+
+ // Ensure that we have a URL.
+ if (!options.url) {
+ params.url = getValue(model, 'url') || urlError();
+ }
+
+ // Ensure that we have the appropriate request data.
+ if (!options.data && model && (method == 'create' || method == 'update')) {
+ params.contentType = 'application/json';
+ params.data = JSON.stringify(model.toJSON());
+ }
+
+ // For older servers, emulate JSON by encoding the request into an HTML-form.
+ if (Backbone.emulateJSON) {
+ params.contentType = 'application/x-www-form-urlencoded';
+ params.data = params.data ? {model: params.data} : {};
+ }
+
+ // For older servers, emulate HTTP by mimicking the HTTP method with `_method`
+ // And an `X-HTTP-Method-Override` header.
+ if (Backbone.emulateHTTP) {
+ if (type === 'PUT' || type === 'DELETE') {
+ if (Backbone.emulateJSON) params.data._method = type;
+ params.type = 'POST';
+ params.beforeSend = function(xhr) {
+ xhr.setRequestHeader('X-HTTP-Method-Override', type);
+ };
+ }
+ }
+
+ // Don't process data on a non-GET request.
+ if (params.type !== 'GET' && !Backbone.emulateJSON) {
+ params.processData = false;
+ }
+
+ // Make the request, allowing the user to override any Ajax options.
+ return $.ajax(_.extend(params, options));
+ };
+
+ // Wrap an optional error callback with a fallback error event.
+ Backbone.wrapError = function(onError, originalModel, options) {
+ return function(model, resp) {
+ resp = model === originalModel ? resp : model;
+ if (onError) {
+ onError(originalModel, resp, options);
+ } else {
+ originalModel.trigger('error', originalModel, resp, options);
+ }
+ };
+ };
+
+ // Helpers
+ // -------
+
+ // Shared empty constructor function to aid in prototype-chain creation.
+ var ctor = function(){};
+
+ // Helper function to correctly set up the prototype chain, for subclasses.
+ // Similar to `goog.inherits`, but uses a hash of prototype properties and
+ // class properties to be extended.
+ var inherits = function(parent, protoProps, staticProps) {
+ var child;
+
+ // The constructor function for the new subclass is either defined by you
+ // (the "constructor" property in your `extend` definition), or defaulted
+ // by us to simply call the parent's constructor.
+ if (protoProps && protoProps.hasOwnProperty('constructor')) {
+ child = protoProps.constructor;
+ } else {
+ child = function(){ parent.apply(this, arguments); };
+ }
+
+ // Inherit class (static) properties from parent.
+ _.extend(child, parent);
+
+ // Set the prototype chain to inherit from `parent`, without calling
+ // `parent`'s constructor function.
+ ctor.prototype = parent.prototype;
+ child.prototype = new ctor();
+
+ // Add prototype properties (instance properties) to the subclass,
+ // if supplied.
+ if (protoProps) _.extend(child.prototype, protoProps);
+
+ // Add static properties to the constructor function, if supplied.
+ if (staticProps) _.extend(child, staticProps);
+
+ // Correctly set child's `prototype.constructor`.
+ child.prototype.constructor = child;
+
+ // Set a convenience property in case the parent's prototype is needed later.
+ child.__super__ = parent.prototype;
+
+ return child;
+ };
+
+ // Helper function to get a value from a Backbone object as a property
+ // or as a function.
+ var getValue = function(object, prop) {
+ if (!(object && object[prop])) return null;
+ return _.isFunction(object[prop]) ? object[prop]() : object[prop];
+ };
+
+ // Throw an error when a URL is needed, and none is supplied.
+ var urlError = function() {
+ throw new Error('A "url" property or function must be specified');
+ };
+
+}).call(this);
diff --git a/addons/point_of_sale/static/lib/mousewheel/jquery.mousewheel-3.0.6.js b/addons/point_of_sale/static/lib/mousewheel/jquery.mousewheel-3.0.6.js
new file mode 100755
index 00000000000..38b60951b20
--- /dev/null
+++ b/addons/point_of_sale/static/lib/mousewheel/jquery.mousewheel-3.0.6.js
@@ -0,0 +1,84 @@
+/*! Copyright (c) 2011 Brandon Aaron (http://brandonaaron.net)
+ * Licensed under the MIT License (LICENSE.txt).
+ *
+ * Thanks to: http://adomas.org/javascript-mouse-wheel/ for some pointers.
+ * Thanks to: Mathias Bank(http://www.mathias-bank.de) for a scope bug fix.
+ * Thanks to: Seamus Leahy for adding deltaX and deltaY
+ *
+ * Version: 3.0.6
+ *
+ * Requires: 1.2.2+
+ */
+
+(function($) {
+
+var types = ['DOMMouseScroll', 'mousewheel'];
+
+if ($.event.fixHooks) {
+ for ( var i=types.length; i; ) {
+ $.event.fixHooks[ types[--i] ] = $.event.mouseHooks;
+ }
+}
+
+$.event.special.mousewheel = {
+ setup: function() {
+ if ( this.addEventListener ) {
+ for ( var i=types.length; i; ) {
+ this.addEventListener( types[--i], handler, false );
+ }
+ } else {
+ this.onmousewheel = handler;
+ }
+ },
+
+ teardown: function() {
+ if ( this.removeEventListener ) {
+ for ( var i=types.length; i; ) {
+ this.removeEventListener( types[--i], handler, false );
+ }
+ } else {
+ this.onmousewheel = null;
+ }
+ }
+};
+
+$.fn.extend({
+ mousewheel: function(fn) {
+ return fn ? this.bind("mousewheel", fn) : this.trigger("mousewheel");
+ },
+
+ unmousewheel: function(fn) {
+ return this.unbind("mousewheel", fn);
+ }
+});
+
+
+function handler(event) {
+ var orgEvent = event || window.event, args = [].slice.call( arguments, 1 ), delta = 0, returnValue = true, deltaX = 0, deltaY = 0;
+ event = $.event.fix(orgEvent);
+ event.type = "mousewheel";
+
+ // Old school scrollwheel delta
+ if ( orgEvent.wheelDelta ) { delta = orgEvent.wheelDelta/120; }
+ if ( orgEvent.detail ) { delta = -orgEvent.detail/3; }
+
+ // New school multidimensional scroll (touchpads) deltas
+ deltaY = delta;
+
+ // Gecko
+ if ( orgEvent.axis !== undefined && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
+ deltaY = 0;
+ deltaX = -1*delta;
+ }
+
+ // Webkit
+ if ( orgEvent.wheelDeltaY !== undefined ) { deltaY = orgEvent.wheelDeltaY/120; }
+ if ( orgEvent.wheelDeltaX !== undefined ) { deltaX = -1*orgEvent.wheelDeltaX/120; }
+
+ // Add event and delta to the front of the arguments
+ args.unshift(event, delta, deltaX, deltaY);
+
+ return ($.event.dispatch || $.event.handle).apply(this, args);
+}
+
+})(jQuery);
diff --git a/addons/point_of_sale/static/src/css/keyboard.css b/addons/point_of_sale/static/src/css/keyboard.css
new file mode 100644
index 00000000000..3a06970929f
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/keyboard.css
@@ -0,0 +1,146 @@
+/* Onscreen Keyboard http://net.tutsplus.com/tutorials/javascript-ajax/creating-a-keyboard-with-css-and-jquery/ */
+
+.point-of-sale .keyboard_frame{
+ display: none;
+ position:absolute;
+ left: 0;
+ bottom: 0px;
+ margin: 0;
+ padding: 0;
+ padding-top: 15px;
+ width: 100%;
+ height: 0px; /* 235px, animated via jquery */
+ background-color: #BBB;
+ overflow:hidden;
+ -webkit-box-shadow: 0px 0px 10px rgba(0,0,0, 0.3);
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ z-index:10000;
+
+}
+.point-of-sale .keyboard_frame .close_button{
+ height:40px;
+ width:60px;
+ text-align:center;
+ background-color: #DDD;
+ font-size: 12px;
+ line-height:40px;
+ border: 1px solid #CCC;
+ -webkit-border-radius: 5px;
+ -webkit-box-shadow: 0px 2px 5px rgba(0,0,0, 0.2);
+ position:absolute;
+ top:0;
+ right:15px;
+ cursor: pointer;
+}
+.point-of-sale .keyboard li {
+ float: left;
+ text-align: center;
+ background-color: #fff;
+ border: 1px solid #f0f0f0;
+ top:0;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ -webkit-box-shadow: 0px 2px 5px rgba(0,0,0, 0.2);
+ -webkit-transition-property: top, background-color;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: linear;
+}
+.point-of-sale .keyboard li:hover {
+ position: relative;
+ top: 2px;
+ left: 0px;
+ border-color: #ddd;
+ background-color:#e5e5e5;
+ cursor: pointer;
+ -webkit-transition-property: top, background-color;
+ -webkit-transition-duration: 0.1s;
+ -webkit-transition-timing-function: ease-out;
+}
+.point-of-sale .uppercase {
+ text-transform: uppercase;
+}
+.point-of-sale .on {
+ display: none;
+}
+.point-of-sale .firstitem{
+ clear: left;
+}
+.point-of-sale .keyboard .lastitem {
+ margin-right: 0;
+}
+
+/* ---- full sized keyboard ---- */
+
+.point-of-sale .full_keyboard {
+ list-style: none;
+ font-size: 14px;
+ width: 680px;
+ height: 100%;
+ margin-left: auto;
+ margin-right: auto;
+}
+.point-of-sale .full_keyboard li{
+ margin: 0 5px 5px 0;
+ width: 40px;
+ height: 40px;
+ line-height: 40px;
+}
+.point-of-sale .full_keyboard .tab, .point-of-sale .full_keyboard .delete {
+ width: 70px;
+}
+.point-of-sale .full_keyboard .capslock {
+ width: 80px;
+}
+.point-of-sale .full_keyboard .return {
+ width: 77px;
+}
+.point-of-sale .full_keyboard .left-shift {
+ width: 95px;
+}
+.point-of-sale .full_keyboard .right-shift {
+ width: 109px;
+}
+.point-of-sale .full_keyboard .space {
+ clear: left;
+ width: 673px;
+}
+
+/* ---- simplified keyboard ---- */
+
+.point-of-sale .simple_keyboard {
+ list-style: none;
+ font-size: 16px;
+ width: 545px;
+ height: 220px;
+ margin-left: auto;
+ margin-right: auto;
+}
+.point-of-sale .simple_keyboard li{
+ margin: 0 5px 5px 0;
+ width: 49px;
+ height: 49px;
+ line-height: 49px;
+}
+.point-of-sale .simple_keyboard .firstitem.row_asdf{
+ margin-left:25px;
+}
+.point-of-sale .simple_keyboard .firstitem.row_zxcv{
+ margin-left:55px;
+}
+.point-of-sale .simple_keyboard .delete{
+ width: 103px;
+}
+.point-of-sale .simple_keyboard .return{
+ width: 103px;
+}
+.point-of-sale .simple_keyboard .space{
+ width:268px;
+}
+.point-of-sale .simple_keyboard .numlock{
+ width:103px;
+}
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
index 09fbe3818f2..ba89b09eab5 100644
--- a/addons/point_of_sale/static/src/css/pos.css
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -1,3 +1,7 @@
+@font-face{
+ font-family: 'Inconsolata';
+ src: url(../fonts/Inconsolata.otf);
+}
.point-of-sale {
padding: 0;
@@ -11,17 +15,53 @@
top: 0;
width: 100%;
height: 100%;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
}
-.point-of-sale table {
- border-spacing: 0;
- border-collapse: collapse;
+
+.point-of-sale .shadow-top{
+ position: absolute;
+ top:0;
+ left:0;
+ right:0;
+ height:10px;
+ background: -webkit-linear-gradient(top,rgba(0,0,0,0.09),rgba(0,0,0,0));
+ background: -moz-linear-gradient(top,rgba(0,0,0,0.09),rgba(0,0,0,0));
+ background: -ms-linear-gradient(top,rgba(0,0,0,0.09),rgba(0,0,0,0));
+ background: linear-gradient(top,rgba(0,0,0,0.09),rgba(0,0,0,0));
}
-.point-of-sale td {
- border: 1px solid #e9eaec;
+.point-of-sale .darker-shadow-top{
+ position: absolute;
+ top:0;
+ left:0;
+ right:0;
+ height:10px;
+ background: -webkit-linear-gradient(top,rgba(0,0,0,0.15),rgba(0,0,0,0));
+ background: -moz-linear-gradient(top,rgba(0,0,0,0.15),rgba(0,0,0,0));
+ background: -ms-linear-gradient(top,rgba(0,0,0,0.15),rgba(0,0,0,0));
+ background: linear-gradient(top,rgba(0,0,0,0.15),rgba(0,0,0,0));
}
-.point-of-sale input {
- color: #555555;
+/* ********* The black loading screen ********* */
+
+.point-of-sale .loader{
+ background-color: #222;
+ position:absolute;
+ left:0px;
+ top:0px;
+ width:100%;
+ height:100%;
+ z-index: 999;
+ text-align: center;
}
+.point-of-sale .loader img{
+ position:absolute;
+ top:50%;
+ left:50%;
+}
+
+/* ********* Generic element styling ********* */
+
.point-of-sale a {
text-decoration: none;
color: #555555;
@@ -32,12 +72,12 @@
padding: 4px 10px;
font-size: 11px;
border: 1px solid #cacaca;
- -moz-border-radius: 4px;
- -webkit-border-radius: 4px;
border-radius: 4px;
background: #e2e2e2;
- background: -moz-linear-gradient(#f0f0f0, #e2e2e2);
background: -webkit-gradient(linear, left top, left bottom, from(#f0f0f0), to(#e2e2e2));
+ background: -moz-linear-gradient(#f0f0f0, #e2e2e2);
+ background: -ms-linear-gradient(#f0f0f0, #e2e2e2);
+ background: linear-gradient(#f0f0f0, #e2e2e2);
}
.point-of-sale ul, .point-of-sale ol {
padding: 0;
@@ -46,22 +86,26 @@
.point-of-sale li {
list-style-type: none;
}
-.point-of-sale button img {
- vertical-align: bottom;
-}
.point-of-sale .pos-right-align {
text-align: right;
}
.point-of-sale .pos-right-align input {
text-align: right;
+ border: 1px solid #cecbcb;
+ border-radius: 4px;
}
-.point-of-sale #container {
- width: 100%;
- height: 100%;
-}
+
+/* ********* The black header bar ********* */
+
+
.point-of-sale #topheader {
+ position:absolute;
+ left:0;
+ top:0;
width: 100%;
- height: 54px;
+ height: 33px;
+ margin:0;
+ padding:0;
color: gray;
border-top: solid 1px #d3d3d3;
border-bottom: solid 1px black;
@@ -69,57 +113,129 @@
background: -moz-linear-gradient(#7b7979, #393939);
background: -webkit-gradient(linear, left top, left bottom, from(#7b7979), to(#393939));
}
-.point-of-sale #topheader button {
- color: black;
- border: 1px solid black;
- background: #7f82ac;
- background: -moz-linear-gradient(#b2b3d7, #7f82ac);
- background: -webkit-gradient(linear, left top, left bottom, from(#b2b3d7), to(#7f82ac));
-}
-.point-of-sale #branding, .point-of-sale #rightheader {
- float: left;
- overflow: hidden;
- height: 35px;
- padding: 10px;
-}
-.point-of-sale #rightheader {
- float: none;
- margin-left: 440px;
-}
-.point-of-sale #branding {
+
+/* a) The left part of the top-bar */
+
+.point-of-sale #branding{
+ position: absolute;
+ display: table-cell;
+ left:0;
+ top:0;
+ width:439px;
+ height:100%;
+ margin:0;
+ padding:0;
border-right: 1px solid #373737;
- text-align: left;
- width: 419px;
+ text-align:left;
+ line-height:100%;
+ vertical-align: middle;
}
.point-of-sale #branding img {
height: 32px;
width: 116px;
+ margin-left:5px;
+ vertical-align:middle;
}
-.point-of-sale #neworder-button {
+.point-of-sale #branding .username{
+ float:right;
+ color:#DDD;
+ font-size:16px;
+ margin-right:32px;
+ margin-top:10px;
+ font-style:italic;
+}
+
+/* b) The right part of the top-bar */
+
+.point-of-sale #rightheader {
+ position: absolute;
+ left:440px;
+ right:0;
+ top:0;
+ height:100%;
+}
+
+.point-of-sale #rightheader button {
+ color: black;
+ height:29px;
+ margin:2px;
+ margin-right:0px;
+ border: 1px solid black;
+ background: #7f82ac;
+ background: -webkit-gradient(linear, left top, left bottom, from(#b2b3d7), to(#7f82ac));
+ background: -moz-linear-gradient(#b2b3d7, #7f82ac);
+ background: -ms-linear-gradient(#b2b3d7, #7f82ac);
+ background: linear-gradient(#b2b3d7, #7f82ac);
+}
+
+.point-of-sale #rightheader button.neworder-button {
width: 32px;
- padding: 4px 10px;
+ margin-left:4px;
+ margin-right:4px;
}
-.point-of-sale #loggedas {
- float: right;
- padding: 5px 9px;
- text-align: center;
- color: white;
- border-left: 1px solid #373737;
+
+.point-of-sale div#order-selector {
+ display: inline;
}
-.point-of-sale #loggedas p {
- margin: 0 0 3px 0;
+.point-of-sale ol#orders {
+ display: inline;
}
+.point-of-sale li.order-selector-button {
+ display: inline;
+}
+.point-of-sale li.selected-order button {
+ font-weight: 900;
+}
+
+/* c) The notifications indicator */
+
+.point-of-sale .oe_pos_synch-notification{
+ float:right;
+ color: rgba(255,255,255,0.4);
+ padding: 8px;
+ line-height:16px;
+ font-size:16px;
+ vertical-align:middle;
+ font-style: italic;
+ cursor:pointer;
+}
+
+.point-of-sale .oe_pos_synch-notification .oe_status_red{
+ display:inline-block;
+ cursor:pointer;
+ width:16px; height:16px;
+ background: url("../img/gtk-no.png") no-repeat ;
+}
+
+.point-of-sale .oe_pos_synch-notification .oe_status_green{
+ display:inline-block;
+ width:16px; height:16px;
+ background: url("../img/gtk-yes.png") no-repeat;
+}
+
+/* ********* Contains everything below the bar ********* */
+
.point-of-sale #content {
width: 100%;
position: absolute;
- top: 56px;
+ top: 35px;
bottom: 0;
+ background: #F0EEEE;
}
+
+/* ********* The leftpane contains the order, numpad and paypad ********* */
+
.point-of-sale #leftpane {
- height: 100%;
- width: 440px;
- position: relative;
- border-right: solid 1px #afafb6;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ position:absolute;
+ left:0;
+ width:440px;
+ top:0px;
+ bottom:105px;
+ border-right: solid 1px #CECBCB;
background-color: white;
}
.point-of-sale #leftpane footer {
@@ -127,69 +243,12 @@
bottom: 0;
left: 0;
width: 100%;
- background-color: #e0e0e0;
- background-image: url(../img/headerbackground.jpg);
+ background: #F0EEEE;
white-space: nowrap;
}
-.point-of-sale #current-order {
- width: 100%;
- position: absolute;
- top: 0;
- bottom: 271px;
- overflow: auto;
-}
-.point-of-sale #current-order thead {
- background-color: #cccccc;
- background-image: url(../img/headerbackground.jpg);
- border: 0px;
- font-size: 12px;
- width: 440px;
-}
-.point-of-sale #current-order thead td {
- text-align: center;
- padding: 8px 0px;
- min-width: 40px;
- font-size: 12px;
-}
-.point-of-sale #current-order td {
- padding: 6px 4px;
- font-size: 11px;
- text-align: right;
- min-width: 40px;
- white-space: nowrap;
-}
-.point-of-sale #current-order td:first-child {
- width: 320px;
- padding: 6px;
- text-align: left;
- text-overflow: ellipsis;
-}
-.point-of-sale #current-order td:last-child {
- border-right: none;
-}
-.point-of-sale #current-order tr.selected {
- background-color: #e9eaf2;
-}
-.point-of-sale #current-order tr.selected td {
- border-top: 2px solid #d5d6e0;
- border-bottom: 1px solid #d5d6e0;
- padding-top: 5px;
- color: #555555;
-}
-.point-of-sale #amounts {
- background: white;
- border-bottom: solid 1px #d2d2d2;
- border-top: solid 1px #e9eaec;
- font-weight: bold;
- text-align: right;
- -webkit-margin-before: 0;
- -webkit-margin-after: 0;
-}
-.point-of-sale #amounts li {
- display: inline-block;
- padding: 8px;
- width: 29%;
-}
+
+/* ********* The paypad contains the payment buttons ********* */
+
.point-of-sale #paypad {
padding: 8px 4px 8px 8px;
display: inline-block;
@@ -204,13 +263,19 @@
vertical-align: middle;
color: #555555;
border-top: 1px solid #efefef;
+ font-size: 14px;
}
.point-of-sale #paypad button:hover {
color: white;
background: #7f82ac;
- background: -moz-linear-gradient(#9d9fc5, #7f82ac);
background: -webkit-gradient(linear, left top, left bottom, from(#9d9fc5), to(#7f82ac));
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -ms-linear-gradient(#9d9fc5, #7f82ac);
+ background: linear-gradient(#9d9fc5, #7f82ac);
}
+
+/* ********* The Numpad ********* */
+
.point-of-sale #numpad {
padding: 8px 8px 8px 4px;
display: inline-block;
@@ -228,17 +293,18 @@
.point-of-sale #numpad button:hover {
color: white;
background: #7f82ac;
- background: -moz-linear-gradient(#9d9fc5, #7f82ac);
background: -webkit-gradient(linear, left top, left bottom, from(#9d9fc5), to(#7f82ac));
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -ms-linear-gradient(#9d9fc5, #7f82ac);
+ background: linear-gradient(#9d9fc5, #7f82ac);
}
.point-of-sale #numpad .selected-mode {
color: white;
background: #7f82ac;
- background: -moz-linear-gradient(#9d9fc5, #7f82ac);
background: -webkit-gradient(linear, left top, left bottom, from(#9d9fc5), to(#7f82ac));
-}
-.point-of-sale .payment-button {
- font-size: 14px;
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -ms-linear-gradient(#9d9fc5, #7f82ac);
+ background: linear-gradient(#9d9fc5, #7f82ac);
}
.point-of-sale .input-button {
font-size: 24px;
@@ -246,15 +312,18 @@
.point-of-sale .mode-button, .point-of-sale #numpad-delete, .point-of-sale #numpad-minus {
font-size: 14px;
}
+
+/* ********* The right pane contains the screens and headers ********* */
+
.point-of-sale #rightpane {
position: absolute;
top: 0;
- bottom: 0;
- left: 441px;
+ bottom: 105px;
+ left: 440px;
right: 0;
- height: 100%;
vertical-align: top;
}
+
.point-of-sale #rightpane header {
padding: 0;
height: 32px;
@@ -263,12 +332,33 @@
background: -moz-linear-gradient(white, #d3d3d3);
background: -webkit-gradient(linear, left top, left bottom, from(white), to(#d3d3d3));
}
+
+/* ********* The product list ********* */
+
.point-of-sale .product-list {
- overflow: auto;
- position: absolute;
- top: 72px;
- bottom: 0;
+ padding:10px;
}
+
+.point-of-sale .product-list-scroller{
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ width:100%;
+ height:100%;
+ overflow: hidden;
+}
+.point-of-sale .product-list-container {
+ position:absolute;
+ top:0px;
+ bottom:0px;
+ left:0px;
+ right:0px;
+ background: #eeedff;
+}
+
+/* a) the product list navigation bar */
+
.point-of-sale .breadcrumb li {
float: left;
line-height: 32px;
@@ -294,14 +384,15 @@
height: 19px;
margin: 6px 0;
}
+
+/* b) the search box */
+
.point-of-sale .searchbox {
position: absolute;
right: 2px;
}
.point-of-sale .searchbox input {
width: 130px;
- -moz-border-radius: 11px;
- -webkit-border-radius: 11px;
border-radius: 11px;
border: 1px solid #cecbcb;
padding: 3px 19px;
@@ -316,46 +407,106 @@
cursor: pointer;
display: none;
}
+
+/* c) the categories list */
+
.point-of-sale #categories {
+ /*background:#f0f0f0;*/
+ position: relative;
+ background-image: url('../img/bg.png');
border-bottom: 1px solid #cecbcb;
}
+.point-of-sale #categories .white-gradient{
+ position: absolute;
+ top:50%;
+ left:0;
+ right:0;
+ bottom:0;
+ background: -webkit-linear-gradient(bottom,rgba(255,255,255,0.5),rgba(255,255,255,0));
+ background: -moz-linear-gradient(bottom,rgba(255,255,255,0.5),rgba(255,255,255,0));
+ background: -ms-linear-gradient(bottom,rgba(255,255,255,0.5),rgba(255,255,255,0));
+ background: linear-gradient(bottom,rgba(255,255,255,0.5),rgba(255,255,255,0));
+}
.point-of-sale #categories h4 {
display: inline-block;
margin: 9px 5px;
}
-.point-of-sale #categories ol {
- display: inline;
+
+.point-of-sale .category-list{
+ padding:10px;
}
-.point-of-sale #categories li {
- display: inline-block;
-}
-.point-of-sale #categories .button {
- padding: 6px 14px;
- margin: 4px 0;
- font-size: 12px;
-}
-.point-of-sale .product {
+/* d) the category button */
+
+.point-of-sale .category-button {
+ position: relative;
vertical-align: top;
display: inline-block;
font-size: 11px;
margin: 5px;
- max-width: 120px;
- border: 1px solid lightgray;
- -moz-border-radius: 4px;
- -webkit-border-radius: 4px;
- border-radius: 4px;
- -moz-box-shadow: 0px 1px 4px #777777;
- -webkit-box-shadow: 0px 1px 4px #777777;
- -box-shadow: 0px 1px 4px #777777;
+ width: 120px;
+ height:120px;
+ background:#fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ -webkit-box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ -moz-box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ cursor: pointer;
}
-.point-of-sale .product-img {
+
+.point-of-sale .category-button .category-img {
+ position: relative;
+ width: 120px;
+ height: 100px;
+ text-align: center;
+ cursor: pointer;
+}
+
+.point-of-sale .category-button .category-name {
+ position: absolute;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ top:auto;
+ left: 2px;
+ right: 2px;
+ bottom: 2px;
+ background: #8a89ba; /*-webkit-linear-gradient(-90deg,rgba(138,137,186,0),rgba(138,137,186,1), rgba(138,137,186,1));*/
+ padding: 3px;
+ /*color:#8a89ba;*/
+ color: #FFF;
+ cursor: pointer;
+ border-radius: 3px;
+}
+
+/* e) the product */
+
+.point-of-sale .product {
+ position:relative;
+ vertical-align: top;
+ display: inline-block;
+ font-size: 11px;
+ margin: 5px;
+ width: 120px;
+ height:120px;
+ background:#fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ -webkit-box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ -moz-box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+}
+
+.point-of-sale .product .product-img {
position: relative;
width: 120px;
height: 100px;
background: white;
text-align: center;
}
-.point-of-sale .price-tag {
+
+.point-of-sale .product .price-tag {
position: absolute;
top: 2px;
right: 2px;
@@ -363,41 +514,77 @@
color: white;
background: #7f82ac;
padding: 2px 5px;
- -moz-border-radius: 3px;
- -webkit-border-radius: 3px;
border-radius: 3px;
}
-.point-of-sale .product-name {
+
+.point-of-sale .product .product-name {
+ position: absolute;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ bottom:0;
+ top:auto;
+ width:100%;
+ background: -webkit-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ background: -moz-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ background: -ms-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ /*background:#FFF;*/
padding: 3px;
-}
-.point-of-sale #login-form label, .point-of-sale #login-form input {
- display: block;
-}
-.point-of-sale #login-form input {
- margin: 4px 0 12px;
- padding: 4px;
- width: 96%;
-}
-.point-of-sale div#order-selector {
- display: inline;
-}
-.point-of-sale ol#orders {
- display: inline;
-}
-.point-of-sale li.order-selector-button {
- display: inline;
-}
-.point-of-sale li.selected-order button {
- font-weight: 900;
+ padding-top:15px;
}
-.point-of-sale .step-screen {
+
+/* ********* The Screens ********* */
+
+.point-of-sale .screen {
+ position:absolute;
text-align: center;
+ top:0px;
+ bottom:0px;
+ width:100%;
}
-.point-of-sale .step-screen header h2 {
+.point-of-sale .screen header h2 {
margin-top: 0px;
padding-top: 7px;
}
+.point-of-sale .screen p{
+ font-size: 18px;
+}
+
+/* a) Layout for the Product Screen */
+
+.point-of-sale .screen .layout-table {
+ border:none;
+ width:100%;
+ height:100%;
+}
+
+.point-of-sale .screen .header-row {
+ border:none;
+ width:100%;
+ height:0px;
+}
+
+.point-of-sale .screen .header-cell{
+ border:none;
+ width:100%;
+ height:0px;
+}
+.point-of-sale .screen .content-row {
+ width:100%;
+ height:100%;
+}
+.point-of-sale .screen .content-cell{
+ width:100%;
+}
+.point-of-sale .screen .content-cell .content-container{
+ height:100%;
+ position:relative;
+}
+
+/* b) The payment screen */
.point-of-sale .pos-step-container {
display: inline-block;
@@ -406,30 +593,48 @@
.point-of-sale .pos-payment-container {
text-align: left;
}
-.point-of-sale .pos-payment-container .payment-due {
- display: block;
- margin-top: 10px;
- margin-bottom: 10px;
- padding: 3px 6px 0px 6px;
- background-color: white;
- border:1px solid grey;
- border-radius: 3px;
+.point-of-sale .pos-payment-container .left-block{
+ display: inline-block;
+ width:49%;
+ margin:0;
+ padding:0;
+ text-align:left;
+}
+.point-of-sale .pos-payment-container .header{
+ margin-top: 50px;
+ margin-bottom:20px;
+ font-weight: bold;
+}
+.point-of-sale .pos-payment-container .infoline{
+ margin-top:5px;
+ margin-bottom:5px;
+}
+.point-of-sale .pos-payment-container .right-block{
+ display: inline-block;
+ width:49%;
+ margin:0;
+ padding:0;
+ text-align:right;
}
.point-of-sale .pos-payment-container table {
width: 100%;
margin-bottom: 20px;
}
+.point-of-sale .pos-payment-container td {
+ vertical-align: middle;
+}
.point-of-sale .pos-payment-container .paymentline-type {
- font-size: 0.8em;
+ font-size: 1em;
font-weight: bold;
+ margin-right:10px;
}
-.point-of-sale .step-screen button {
- width: 50%;
- text-align: center;
- padding: 7px 0 7px 0;
- font-size: 0.8em;
- font-weight: bold;
+
+/* c) The receipt screen */
+
+.point-of-sale .pos-receipt-container {
+ font-size: 0.75em;
}
+
.point-of-sale .pos-sale-ticket {
text-align: left;
width: 300px;
@@ -445,25 +650,6 @@
.point-of-sale .pos-sale-ticket table td {
border: 0;
}
-.point-of-sale .pos-receipt-container {
- font-size: 0.75em;
-}
-
-.point-of-sale .oe_pos_synch-notification-button {
- color: white;
- border: 1px solid black;
- border-radius: 3px;
- padding: 2px 3px 2px 3px;
- background-color: #D92A2A;
-}
-
-.receipt-buttons {
- white-space: nowrap;
-}
-
-.pos-payment-buttons {
- white-space: nowrap;
-}
@media print {
#oe_header, #oe_menu, .point-of-sale #topheader, .point-of-sale #leftpane {
@@ -476,7 +662,7 @@
left: 0px;
background-color: white;
}
- #receipt-screen header, .receipt-buttons {
+ #receipt-screen header {
display: none;
}
#receipt-screen {
@@ -487,3 +673,444 @@
}
}
+/* d) The Scale screen */
+
+.point-of-sale .scale-screen .display{
+ position:relative;
+ width:600px;
+ height:190px;
+ margin-top: 100px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.point-of-sale .scale-screen .product-picture {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ display: float;
+ float: right;
+ margin: 5px;
+ width: 180px;
+ height:180px;
+ line-height:180px;
+ cursor:pointer;
+
+ background:#fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ -webkit-box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ -moz-box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+ box-shadow: 0px 1px 8px rgba(0,0,0,0.2);
+}
+
+.point-of-sale .scale-screen .product-picture img{
+ vertical-align: middle;
+ cursor:pointer;
+}
+
+.point-of-sale .scale-screen .product-picture .product-price{
+ position: absolute;
+ top:8px;
+ right:8px;
+ width:auto;
+ height:auto;
+ line-height:1;
+ color:white;
+ background: #7f82ac;
+ padding: 2px 5px;
+ border-radius: 3px;
+ cursor:pointer;
+}
+
+.point-of-sale .scale-screen .product-name {
+ position: absolute;
+ left:0;
+ top:20px;
+ height:50px;
+ font-size:40px;
+ line-height:50px;
+ text-align:right;
+ right:225px;
+}
+.point-of-sale .scale-screen .weight{
+ position: absolute;
+ left:0;
+ height:90px;
+ bottom:15px;
+ right:220px;
+ padding:5px;
+}
+.point-of-sale .scale-screen .weight p{
+ display: inline-block;
+ text-align:right;
+ line-height: 90px;
+ font-size: 80px;
+ width:100%;
+ height:100%;
+ margin:0;
+ font-family: "Inconsolata";
+}
+
+/* ********* The OrderWidget ********* */
+
+.point-of-sale .order-container{
+ position: absolute;
+ top: 0px;
+ bottom: 232px;
+ width:100%;
+ background: #F0EEEE;
+}
+
+.point-of-sale .order-scroller{
+ width:100%;
+ height:100%;
+ overflow:hidden;
+}
+
+.point-of-sale .order{
+ background: #F00;
+ background: -webkit-linear-gradient(0deg,rgba(245,245,245,1),rgba(255,255,255,1), rgba(245,245,245,1));
+ background: -moz-linear-gradient(0deg,rgba(245,245,245,1),rgba(255,255,255,1), rgba(245,245,245,1));
+ background: -ms-linear-gradient(0deg,rgba(245,245,245,1),rgba(255,255,255,1), rgba(245,245,245,1));
+ background: linear-gradient(0deg,rgba(245,245,245,1),rgba(255,255,255,1), rgba(245,245,245,1));
+ padding-bottom:15px;
+ padding-top:15px;
+ margin-left:16px;
+ margin-right:16px;
+ margin-top:16px;
+ margin-bottom:16px;
+ font-size:16px;
+ -webkit-box-shadow: 0px 5px 16px rgba(0,0,0, 0.3);
+ -moz-box-shadow: 0px 5px 16px rgba(0,0,0, 0.3);
+ box-shadow: 0px 5px 16px rgba(0,0,0, 0.3);
+
+}
+
+.point-of-sale .order .empty{
+ text-align:center;
+ margin-top: 15px;
+ margin-bottom: 5px;
+ color:#999;
+ font-weight: normal;
+}
+
+.point-of-sale .order .summary{
+ width:100%;
+ text-align:right;
+ font-weight: bold;
+ margin-top:20px;
+ margin-bottom:10px;
+}
+.point-of-sale .order .summary .line{
+ margin-right:15px;
+ padding-top:5px;
+ border-top: solid 2px;
+ border-color:#777;
+}
+.point-of-sale .order .summary .line.empty{
+ border-color:#BBB;
+ color:#999;
+}
+
+/* ********* The OrderLineWidget ********* */
+
+.point-of-sale .order .orderline{
+ width:100%;
+ margin:0px;
+ padding-top:3px;
+ padding-bottom:10px;
+ padding-left:15px;
+ padding-right:15px;
+ cursor: pointer;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ -webkit-transition: background 250ms ease-in-out;
+ -moz-transition: background 250ms ease-in-out;
+ transition: background 250ms ease-in-out;
+}
+.point-of-sale .order .orderline:hover{
+ background: rgba(140,143,183,0.05);
+ -webkit-transition: background 50ms ease-in-out;
+ -moz-transition: background 50ms ease-in-out;
+ transition: background 50ms ease-in-out;
+}
+
+.point-of-sale .order .orderline.selected{
+ background: rgba(140,143,183,0.2);
+ -webkit-transition: background 250ms ease-in-out;
+ -moz-transition: background 250ms ease-in-out;
+ transition: background 250ms ease-in-out;
+ cursor: default;
+}
+.point-of-sale .order .orderline .product-name{
+ padding:0;
+ display:inline-block;
+ font-weight: bold;
+ width:80%;
+ overflow:hidden;
+}
+.point-of-sale .order .orderline .price{
+ padding:0;
+ font-weight: bold;
+ float:right;
+}
+.point-of-sale .order .orderline .info-list{
+ color: #888;
+ margin-left:10px;
+}
+.point-of-sale .order .orderline .info-list em{
+ color: #777;
+ font-weight: bold;
+ font-style:normal;
+}
+
+/* ********* The ActionBarWidget ********* */
+
+.point-of-sale .pos-actionbar{
+ position:absolute;
+ left: 0;
+ bottom: 0px;
+ height: 105px;
+ width: 100%;
+ margin: 0;
+ background: #f5f5f5; /*#ebebeb;*/
+ border-top: solid 1px #afafb6;
+ z-index:900;
+}
+
+.point-of-sale .pos-actionbar ul{
+ list-style: none;
+}
+
+.point-of-sale .pos-actionbar-left-pane{
+ height: 100%;
+ width: 434px;
+ margin: 0px;
+ padding-left:3px;
+ padding-right:3px;
+ border-right: solid 1px #dfdfdf;
+ float: left;
+}
+
+.point-of-sale .pos-actionbar-button-list{
+ height: 100%;
+ margin: 0px;
+ padding-left:3px;
+ padding-right:3px;
+ overflow:hidden;
+}
+
+.point-of-sale .pos-actionbar .button{
+ width: 90px;
+ height: 90px;
+ text-align:center;
+ margin:3px;
+ margin-top:6px;
+ float:left;
+
+ font-size: 14px;
+ font-weight: bold;
+
+ cursor: pointer;
+
+ border: 1px solid #cacaca;
+ border-radius: 4px;
+
+ background: #e2e2e2;
+ background: -webkit-linear-gradient(#f0f0f0, #e2e2e2);
+ background: -moz-linear-gradient(#f0f0f0, #e2e2e2);
+ background: -ms-linear-gradient(#f0f0f0, #e2e2e2);
+ background: linear-gradient(#f0f0f0, #e2e2e2);
+ -webkit-box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
+ -moz-box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
+ box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
+}
+.point-of-sale .pos-actionbar .button .label{
+ margin-top: 37px;
+}
+.point-of-sale .pos-actionbar .button .icon{
+ margin-top: 10px;
+}
+.point-of-sale .pos-actionbar .button:hover {
+ color: white;
+ background: #7f82ac;
+ border: 1px solid #7f82ac;
+ background: -webkit-linear-gradient(#9d9fc5, #7f82ac);
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -ms-linear-gradient(#9d9fc5, #7f82ac);
+ background: linear-gradient(#9d9fc5, #7f82ac);
+
+ -webkit-transition-property: background, border;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: ease-out;
+}
+
+.point-of-sale .pos-actionbar .button.rightalign{
+ float:right;
+}
+
+/* ********* The PopupWidgets ********* */
+
+.point-of-sale .modal-dialog{
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height:100%;
+ background-color: rgba(0,0,0,0.5);
+ z-index:1000;
+}
+.point-of-sale .modal-dialog .popup{
+ position: absolute;
+ left:50%;
+ top:50%;
+ width:500px;
+ height:400px;
+ margin-left: -250px;
+ margin-top: -200px;
+ padding:10px;
+ padding-top:20px;
+ text-align:center;
+ font-size:20px;
+ font-weight:bold;
+ background-color: #F0EEEE;
+ border: 1px solid #E0DDDD;
+ -webkit-box-shadow: 0px 10px 20px rgba(0,0,0, 0.3);
+ -moz-box-shadow: 0px 10px 20px rgba(0,0,0, 0.3);
+ -ms-box-shadow: 0px 10px 20px rgba(0,0,0, 0.3);
+ z-index:1200;
+}
+.point-of-sale .popup .footer{
+ position:absolute;
+ bottom:0;
+ left:0;
+ width:100%;
+ height:60px;
+ border-top: 1px solid #E0DDDD;
+}
+.point-of-sale .popup .button{
+ float:right;
+ width: 110px;
+ height: 40px;
+ line-height:40px;
+ text-align:center;
+ margin:3px;
+ margin-top:10px;
+ margin-right:10px;
+
+ font-size: 14px;
+ font-weight: bold;
+
+ cursor: pointer;
+
+ border: 1px solid #cacaca;
+ border-radius: 4px;
+
+ background: #e2e2e2;
+ background: -webkit-linear-gradient(#f0f0f0, #e2e2e2);
+ background: -moz-linear-gradient(#f0f0f0, #e2e2e2);
+ background: -ms-linear-gradient(#f0f0f0, #e2e2e2);
+ background: linear-gradient(#f0f0f0, #e2e2e2);
+ -webkit-box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
+ -moz-box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
+ box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
+}
+.point-of-sale .popup .button:hover {
+ color: white;
+ background: #7f82ac;
+ border: 1px solid #7f82ac;
+ background: -webkit-linear-gradient(#9d9fc5, #7f82ac);
+ background: -moz-linear-gradient(#9d9fc5, #7f82ac);
+ background: -ms-linear-gradient(#9d9fc5, #7f82ac);
+ background: linear-gradient(#9d9fc5, #7f82ac);
+
+ -webkit-transition-property: background, border;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: ease-out;
+}
+
+.point-of-sale .popup .button.big-left{
+ position:absolute;
+ top: 120px;
+ left:40px;
+ width: 180px;
+ height: 180px;
+ line-height:180px;
+}
+
+.point-of-sale .popup .button.big-right{
+ position:absolute;
+ top: 120px;
+ right:40px;
+ width: 180px;
+ height: 180px;
+ line-height:180px;
+}
+
+/* ********* The ScrollBarWidget ********* */
+
+.point-of-sale .scrollbar{
+ position:absolute;
+ top:7px;
+ right:7px;
+ width:48px;
+ bottom:7px;
+ background: rgba(0,0,0,0.1);
+
+}
+.point-of-sale .scrollbar .button{
+ width:100%;
+ height: 48px;
+ line-height: 38px;
+ text-align: center;
+ font-size:48px;
+ border-radius: 4px;
+ cursor: pointer;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ transition: all 250ms ease-in-out;
+}
+.point-of-sale .scrollbar .button{
+ color:white;
+ background: rgba(0,0,0,0.6);
+ -webkit-box-shadow: 0px 1px 4px rgba(0,0,0,0.01);
+ -moz-box-shadow: 0px 1px 4px rgba(0,0,0,0.01);
+ box-shadow: 0px 1px 4px rgba(0,0,0,0.01);
+ text-shadow: rgba(255,255,255,0.5) 0px 0px 10px;
+ -webkit-transition: all 250ms ease-in-out;
+ -moz-transition: all 250ms ease-in-out;
+ transition: all 250ms ease-in-out;
+}
+.point-of-sale .scrollbar .button:hover{
+ text-shadow: rgba(255,255,255,0.8) 0px 0px 15px;
+}
+.point-of-sale .scrollbar .button.disabled{
+ background: rgba(0,0,0,0.3);
+ color:rgba(255,255,255,0.5);
+ -webkit-transition: all 250ms ease-in-out;
+ -moz-transition: all 250ms ease-in-out;
+ transition: all 250ms ease-in-out;
+}
+.point-of-sale .scrollbar .down-button{
+ position:absolute;
+ bottom:0px;
+}
+.point-of-sale .scrollbar .up-button{
+ position:absolute;
+ top:0px;
+}
+.point-of-sale .scrollbar .scroller{
+ position:absolute;
+ top:33%;
+ bottom:50%;
+ width:100%;
+ background: rgba(0,0,0,0.1);
+ border-radius: 4px;
+}
+
diff --git a/addons/point_of_sale/static/src/fonts/Inconsolata.otf b/addons/point_of_sale/static/src/fonts/Inconsolata.otf
new file mode 100644
index 00000000000..348889828d8
Binary files /dev/null and b/addons/point_of_sale/static/src/fonts/Inconsolata.otf differ
diff --git a/addons/point_of_sale/static/src/img/bancontact.png b/addons/point_of_sale/static/src/img/bancontact.png
new file mode 100644
index 00000000000..5c34fefb0f9
Binary files /dev/null and b/addons/point_of_sale/static/src/img/bancontact.png differ
diff --git a/addons/point_of_sale/static/src/img/bg.png b/addons/point_of_sale/static/src/img/bg.png
new file mode 100644
index 00000000000..d54b6302d5a
Binary files /dev/null and b/addons/point_of_sale/static/src/img/bg.png differ
diff --git a/addons/point_of_sale/static/src/img/gtk-no.png b/addons/point_of_sale/static/src/img/gtk-no.png
new file mode 100644
index 00000000000..047ddcd9289
Binary files /dev/null and b/addons/point_of_sale/static/src/img/gtk-no.png differ
diff --git a/addons/point_of_sale/static/src/img/gtk-yes.png b/addons/point_of_sale/static/src/img/gtk-yes.png
new file mode 100644
index 00000000000..01fb373c251
Binary files /dev/null and b/addons/point_of_sale/static/src/img/gtk-yes.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/face-monkey.png b/addons/point_of_sale/static/src/img/icons/png48/face-monkey.png
new file mode 100644
index 00000000000..1a57c9e0696
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/face-monkey.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/go-next.png b/addons/point_of_sale/static/src/img/icons/png48/go-next.png
new file mode 100644
index 00000000000..bcd343dde94
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/go-next.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/go-previous.png b/addons/point_of_sale/static/src/img/icons/png48/go-previous.png
new file mode 100644
index 00000000000..0a084ad66eb
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/go-previous.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/help.png b/addons/point_of_sale/static/src/img/icons/png48/help.png
new file mode 100644
index 00000000000..8c56bd4f962
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/help.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/printer.png b/addons/point_of_sale/static/src/img/icons/png48/printer.png
new file mode 100644
index 00000000000..9747f37ddcc
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/printer.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/scale.png b/addons/point_of_sale/static/src/img/icons/png48/scale.png
new file mode 100644
index 00000000000..3070c56ce1c
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/scale.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/shut-down.png b/addons/point_of_sale/static/src/img/icons/png48/shut-down.png
new file mode 100644
index 00000000000..8478ab19686
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/shut-down.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/system-log-out.png b/addons/point_of_sale/static/src/img/icons/png48/system-log-out.png
new file mode 100644
index 00000000000..edd6e3446a0
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/system-log-out.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/png48/validate.png b/addons/point_of_sale/static/src/img/icons/png48/validate.png
new file mode 100644
index 00000000000..75372c89533
Binary files /dev/null and b/addons/point_of_sale/static/src/img/icons/png48/validate.png differ
diff --git a/addons/point_of_sale/static/src/img/icons/printer.svg b/addons/point_of_sale/static/src/img/icons/printer.svg
new file mode 100644
index 00000000000..4c702dc48e2
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/icons/printer.svg
@@ -0,0 +1,502 @@
+
+
+
diff --git a/addons/point_of_sale/static/src/img/icons/scale.svg b/addons/point_of_sale/static/src/img/icons/scale.svg
new file mode 100644
index 00000000000..a04a62adeba
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/icons/scale.svg
@@ -0,0 +1,797 @@
+
+
\ No newline at end of file
diff --git a/addons/point_of_sale/static/src/img/icons/validate.svg b/addons/point_of_sale/static/src/img/icons/validate.svg
new file mode 100644
index 00000000000..be4c67536ad
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/icons/validate.svg
@@ -0,0 +1,201 @@
+
+
+
+
diff --git a/addons/point_of_sale/static/src/img/loader.gif b/addons/point_of_sale/static/src/img/loader.gif
new file mode 100644
index 00000000000..967288f55af
Binary files /dev/null and b/addons/point_of_sale/static/src/img/loader.gif differ
diff --git a/addons/point_of_sale/static/src/img/scale.png b/addons/point_of_sale/static/src/img/scale.png
new file mode 100644
index 00000000000..8f66d1f9ead
Binary files /dev/null and b/addons/point_of_sale/static/src/img/scale.png differ
diff --git a/addons/point_of_sale/static/src/img/scan.png b/addons/point_of_sale/static/src/img/scan.png
new file mode 100644
index 00000000000..e5eafbe821d
Binary files /dev/null and b/addons/point_of_sale/static/src/img/scan.png differ
diff --git a/addons/point_of_sale/static/src/js/pos.js b/addons/point_of_sale/static/src/js/pos.js
deleted file mode 100644
index d62e56cab30..00000000000
--- a/addons/point_of_sale/static/src/js/pos.js
+++ /dev/null
@@ -1,1415 +0,0 @@
-openerp.point_of_sale = function(db) {
-
- db.point_of_sale = {};
-
- var __extends = function(child, parent) {
- var __hasProp = Object.prototype.hasOwnProperty;
- for (var key in parent) {
- if (__hasProp.call(parent, key))
- child[key] = parent[key];
- }
- function ctor() {
- this.constructor = child;
- }
-
- ctor.prototype = parent.prototype;
- child.prototype = new ctor;
- child.__super__ = parent.prototype;
- return child;
- };
-
- var QWeb = db.web.qweb;
- var qweb_template = function(template) {
- return function(ctx) {
- return QWeb.render(template, _.extend({}, ctx,{
- 'currency': pos.get('currency'),
- 'format_amount': function(amount) {
- if (pos.get('currency').position == 'after') {
- return amount + ' ' + pos.get('currency').symbol;
- } else {
- return pos.get('currency').symbol + ' ' + amount;
- }
- },
- }));
- };
- };
- var _t = db.web._t;
-
- var DAOInterface = {
- add_operation: function(operation) {},
- remove_operation: function(id) {},
- get_operations: function() {},
- };
- var LocalStorageDAO = db.web.Class.extend({
- add_operation: function(operation) {
- var self = this;
- return $.async_when().pipe(function() {
- var tmp = self._get('oe_pos_operations', []);
- var last_id = self._get('oe_pos_operations_sequence', 1);
- tmp.push({'id': last_id, 'data': operation});
- self._set('oe_pos_operations', tmp);
- self._set('oe_pos_operations_sequence', last_id + 1);
- });
- },
- remove_operation: function(id) {
- var self = this;
- return $.async_when().pipe(function() {
- var tmp = self._get('oe_pos_operations', []);
- tmp = _.filter(tmp, function(el) {
- return el.id !== id;
- });
- self._set('oe_pos_operations', tmp);
- });
- },
- get_operations: function() {
- var self = this;
- return $.async_when().pipe(function() {
- return self._get('oe_pos_operations', []);
- });
- },
- _get: function(key, default_) {
- var txt = localStorage[key];
- if (! txt)
- return default_;
- return JSON.parse(txt);
- },
- _set: function(key, value) {
- localStorage[key] = JSON.stringify(value);
- },
- });
-
- var fetch = function(osvModel, fields, domain) {
- var dataSetSearch;
- dataSetSearch = new db.web.DataSetSearch(null, osvModel, {}, domain);
- return dataSetSearch.read_slice(fields, 0);
- };
-
- /*
- Gets all the necessary data from the OpenERP web client (session, shop data etc.)
- */
- var Pos = Backbone.Model.extend({
- initialize: function(session, attributes) {
- Backbone.Model.prototype.initialize.call(this, attributes);
- this.dao = new LocalStorageDAO();
- this.ready = $.Deferred();
- this.flush_mutex = new $.Mutex();
- this.build_tree = _.bind(this.build_tree, this);
- this.session = session;
- this.set({'nbr_pending_operations': 0,
- 'currency': {symbol: '$', position: 'after'},
- 'shop': {},
- 'company': {},
- 'user': {}});
-
- var self = this;
- var cat_def = fetch('pos.category', ['name', 'parent_id', 'child_id']).pipe(function(result) {
- return self.set({'categories': result});
- });
- var prod_def = fetch('product.product', ['name', 'list_price', 'pos_categ_id', 'taxes_id',
- 'product_image_small'], [['pos_categ_id', '!=', 'false']]).then(function(result) {
- return self.set({'product_list': result});
- });
- var bank_def = fetch('account.bank.statement', ['account_id', 'currency', 'journal_id', 'state', 'name'],
- [['state', '=', 'open'], ['user_id', '=', this.session.uid]]).then(function(result) {
- return self.set({'bank_statements': result});
- });
- var tax_def = fetch('account.tax', ['amount', 'price_include', 'type']).then(function(result) {
- return self.set({'taxes': result});
- });
- $.when(cat_def, prod_def, bank_def, tax_def, this.get_app_data(), this.flush())
- .pipe(_.bind(this.build_tree, this));
- },
- get_app_data: function() {
- var self = this;
- return $.when(new db.web.Model("sale.shop").get_func("search_read")([]).pipe(function(result) {
- self.set({'shop': result[0]});
- var company_id = result[0]['company_id'][0];
- return new db.web.Model("res.company").get_func("read")(company_id, ['currency_id', 'name', 'phone']).pipe(function(result) {
- self.set({'company': result});
- var currency_id = result['currency_id'][0]
- return new db.web.Model("res.currency").get_func("read")([currency_id],
- ['symbol', 'position']).pipe(function(result) {
- self.set({'currency': result[0]});
-
- });
- });
- }), new db.web.Model("res.users").get_func("read")(this.session.uid, ['name']).pipe(function(result) {
- self.set({'user': result});
- }));
- },
- pushOrder: function(record) {
- var self = this;
- return this.dao.add_operation(record).pipe(function() {
- return self.flush();
- });
- },
- flush: function() {
- return this.flush_mutex.exec(_.bind(function() {
- return this._int_flush();
- }, this));
- },
- _int_flush : function() {
- var self = this;
- this.dao.get_operations().pipe(function(ops) {
- self.set({"nbr_pending_operations": ops.length});
- if (ops.length === 0)
- return $.when();
- var op = ops[0].data;
- var op_id = ops[0].id;
- /* we prevent the default error handler and assume errors
- * are a normal use case, except we stop the current iteration
- */
- return new db.web.Model("pos.order").get_func("create_from_ui")([op]).fail(function(unused, event) {
- event.preventDefault();
- }).pipe(function() {
- console.debug('saved 1 record');
- self.dao.remove_operation(op_id).pipe(function() {
- return self._int_flush();
- });
- }, function() {
- return $.when();
- });
- });
- },
- categories: {},
- build_tree: function() {
- var c, id, _i, _len, _ref, _ref2;
- _ref = this.get('categories');
- for (_i = 0, _len = _ref.length; _i < _len; _i++) {
- c = _ref[_i];
- this.categories[c.id] = {
- id: c.id,
- name: c.name,
- children: c.child_id,
- parent: c.parent_id[0],
- ancestors: [c.id],
- subtree: [c.id]
- };
- }
- _ref2 = this.categories;
- for (id in _ref2) {
- c = _ref2[id];
- this.current_category = c;
- this.build_ancestors(c.parent);
- this.build_subtree(c);
- }
- this.categories[0] = {
- ancestors: [],
- children: (function() {
- var _j, _len2, _ref3, _results;
- _ref3 = this.get('categories');
- _results = [];
- for (_j = 0, _len2 = _ref3.length; _j < _len2; _j++) {
- c = _ref3[_j];
- if (!(c.parent_id[0] != null)) {
- _results.push(c.id);
- }
- }
- return _results;
- }).call(this),
- subtree: (function() {
- var _j, _len2, _ref3, _results;
- _ref3 = this.get('categories');
- _results = [];
- for (_j = 0, _len2 = _ref3.length; _j < _len2; _j++) {
- c = _ref3[_j];
- _results.push(c.id);
- }
- return _results;
- }).call(this)
- };
- return this.ready.resolve();
- },
- build_ancestors: function(parent) {
- if (parent != null) {
- this.current_category.ancestors.unshift(parent);
- return this.build_ancestors(this.categories[parent].parent);
- }
- },
- build_subtree: function(category) {
- var c, _i, _len, _ref, _results;
- _ref = category.children;
- _results = [];
- for (_i = 0, _len = _ref.length; _i < _len; _i++) {
- c = _ref[_i];
- this.current_category.subtree.push(c);
- _results.push(this.build_subtree(this.categories[c]));
- }
- return _results;
- }
- });
-
- /* global variable */
- var pos;
-
- /*
- ---
- Models
- ---
- */
- var CashRegister = (function() {
- __extends(CashRegister, Backbone.Model);
- function CashRegister() {
- CashRegister.__super__.constructor.apply(this, arguments);
- }
-
- return CashRegister;
- })();
- var CashRegisterCollection = (function() {
- __extends(CashRegisterCollection, Backbone.Collection);
- function CashRegisterCollection() {
- CashRegisterCollection.__super__.constructor.apply(this, arguments);
- }
-
- CashRegisterCollection.prototype.model = CashRegister;
- return CashRegisterCollection;
- })();
- var Product = (function() {
- __extends(Product, Backbone.Model);
- function Product() {
- Product.__super__.constructor.apply(this, arguments);
- }
-
- return Product;
- })();
- var ProductCollection = (function() {
- __extends(ProductCollection, Backbone.Collection);
- function ProductCollection() {
- ProductCollection.__super__.constructor.apply(this, arguments);
- }
-
- ProductCollection.prototype.model = Product;
- return ProductCollection;
- })();
- var Category = (function() {
- __extends(Category, Backbone.Model);
- function Category() {
- Category.__super__.constructor.apply(this, arguments);
- }
-
- return Category;
- })();
- var CategoryCollection = (function() {
- __extends(CategoryCollection, Backbone.Collection);
- function CategoryCollection() {
- CategoryCollection.__super__.constructor.apply(this, arguments);
- }
-
- CategoryCollection.prototype.model = Category;
- return CategoryCollection;
- })();
- /*
- Each Order contains zero or more Orderlines (i.e. the content of the "shopping cart".)
- There should only ever be one Orderline per distinct product in an Order.
- To add more of the same product, just update the quantity accordingly.
- The Order also contains payment information.
- */
- var Orderline = Backbone.Model.extend({
- defaults: {
- quantity: 1,
- list_price: 0,
- discount: 0
- },
- initialize: function(attributes) {
- Backbone.Model.prototype.initialize.apply(this, arguments);
- this.bind('change:quantity', function(unused, qty) {
- if (qty == 0)
- this.trigger('killme');
- }, this);
- },
- incrementQuantity: function() {
- return this.set({
- quantity: (this.get('quantity')) + 1
- });
- },
- getPriceWithoutTax: function() {
- return this.getAllPrices().priceWithoutTax;
- },
- getPriceWithTax: function() {
- return this.getAllPrices().priceWithTax;
- },
- getTax: function() {
- return this.getAllPrices().tax;
- },
- getAllPrices: function() {
- var self = this;
- var base = (this.get('quantity')) * (this.get('list_price')) * (1 - (this.get('discount')) / 100);
- var totalTax = base;
- var totalNoTax = base;
-
- var product_list = pos.get('product_list');
- var product = _.detect(product_list, function(el) {return el.id === self.get('id');});
- var taxes_ids = product.taxes_id;
- var taxes = pos.get('taxes');
- var taxtotal = 0;
- _.each(taxes_ids, function(el) {
- var tax = _.detect(taxes, function(t) {return t.id === el;});
- if (tax.price_include) {
- var tmp;
- if (tax.type === "percent") {
- tmp = base - (base / (1 + tax.amount));
- } else if (tax.type === "fixed") {
- tmp = tax.amount * self.get('quantity');
- } else {
- throw "This type of tax is not supported by the point of sale: " + tax.type;
- }
- taxtotal += tmp;
- totalNoTax -= tmp;
- } else {
- var tmp;
- if (tax.type === "percent") {
- tmp = tax.amount * base;
- } else if (tax.type === "fixed") {
- tmp = tax.amount * self.get('quantity');
- } else {
- throw "This type of tax is not supported by the point of sale: " + tax.type;
- }
- taxtotal += tmp;
- totalTax += tmp;
- }
- });
- return {
- "priceWithTax": totalTax,
- "priceWithoutTax": totalNoTax,
- "tax": taxtotal,
- };
- },
- exportAsJSON: function() {
- var result;
- result = {
- qty: this.get('quantity'),
- price_unit: this.get('list_price'),
- discount: this.get('discount'),
- product_id: this.get('id')
- };
- return result;
- },
- });
- var OrderlineCollection = Backbone.Collection.extend({
- model: Orderline,
- });
- /*
- Every PaymentLine has all the attributes of the corresponding CashRegister.
- */
- var Paymentline = (function() {
- __extends(Paymentline, Backbone.Model);
- function Paymentline() {
- Paymentline.__super__.constructor.apply(this, arguments);
- }
-
- Paymentline.prototype.defaults = {
- amount: 0
- };
- Paymentline.prototype.getAmount = function() {
- return this.get('amount');
- };
- Paymentline.prototype.exportAsJSON = function() {
- var result;
- result = {
- name: db.web.datetime_to_str(new Date()),
- statement_id: this.get('id'),
- account_id: (this.get('account_id'))[0],
- journal_id: (this.get('journal_id'))[0],
- amount: this.getAmount()
- };
- return result;
- };
- return Paymentline;
- })();
- var PaymentlineCollection = (function() {
- __extends(PaymentlineCollection, Backbone.Collection);
- function PaymentlineCollection() {
- PaymentlineCollection.__super__.constructor.apply(this, arguments);
- }
-
- PaymentlineCollection.prototype.model = Paymentline;
- return PaymentlineCollection;
- })();
- var Order = (function() {
- __extends(Order, Backbone.Model);
- function Order() {
- Order.__super__.constructor.apply(this, arguments);
- }
-
- Order.prototype.defaults = {
- validated: false,
- step: 'products',
- };
- Order.prototype.initialize = function() {
- this.set({creationDate: new Date});
- this.set({
- orderLines: new OrderlineCollection
- });
- this.set({
- paymentLines: new PaymentlineCollection
- });
- this.bind('change:validated', this.validatedChanged);
- return this.set({
- name: "Order " + this.generateUniqueId()
- });
- };
- Order.prototype.events = {
- 'change:validated': 'validatedChanged'
- };
- Order.prototype.validatedChanged = function() {
- if (this.get("validated") && !this.previous("validated")) {
- this.set({'step': 'receipt'});
- }
- }
- Order.prototype.generateUniqueId = function() {
- return new Date().getTime();
- };
- Order.prototype.addProduct = function(product) {
- var existing;
- existing = (this.get('orderLines')).get(product.id);
- if (existing != null) {
- existing.incrementQuantity();
- } else {
- var line = new Orderline(product.toJSON());
- this.get('orderLines').add(line);
- line.bind('killme', function() {
- this.get('orderLines').remove(line);
- }, this);
- }
- };
- Order.prototype.addPaymentLine = function(cashRegister) {
- var newPaymentline;
- newPaymentline = new Paymentline(cashRegister);
- /* TODO: Should be 0 for cash-like accounts */
- newPaymentline.set({
- amount: this.getDueLeft()
- });
- return (this.get('paymentLines')).add(newPaymentline);
- };
- Order.prototype.getName = function() {
- return this.get('name');
- };
- Order.prototype.getTotal = function() {
- return (this.get('orderLines')).reduce((function(sum, orderLine) {
- return sum + orderLine.getPriceWithTax();
- }), 0);
- };
- Order.prototype.getTotalTaxExcluded = function() {
- return (this.get('orderLines')).reduce((function(sum, orderLine) {
- return sum + orderLine.getPriceWithoutTax();
- }), 0);
- };
- Order.prototype.getTax = function() {
- return (this.get('orderLines')).reduce((function(sum, orderLine) {
- return sum + orderLine.getTax();
- }), 0);
- };
- Order.prototype.getPaidTotal = function() {
- return (this.get('paymentLines')).reduce((function(sum, paymentLine) {
- return sum + paymentLine.getAmount();
- }), 0);
- };
- Order.prototype.getChange = function() {
- return this.getPaidTotal() - this.getTotal();
- };
- Order.prototype.getDueLeft = function() {
- return this.getTotal() - this.getPaidTotal();
- };
- Order.prototype.exportAsJSON = function() {
- var orderLines, paymentLines, result;
- orderLines = [];
- (this.get('orderLines')).each(_.bind( function(item) {
- return orderLines.push([0, 0, item.exportAsJSON()]);
- }, this));
- paymentLines = [];
- (this.get('paymentLines')).each(_.bind( function(item) {
- return paymentLines.push([0, 0, item.exportAsJSON()]);
- }, this));
- result = {
- name: this.getName(),
- amount_paid: this.getPaidTotal(),
- amount_total: this.getTotal(),
- amount_tax: this.getTax(),
- amount_return: this.getChange(),
- lines: orderLines,
- statement_ids: paymentLines
- };
- return result;
- };
- return Order;
- })();
- var OrderCollection = (function() {
- __extends(OrderCollection, Backbone.Collection);
- function OrderCollection() {
- OrderCollection.__super__.constructor.apply(this, arguments);
- }
-
- OrderCollection.prototype.model = Order;
- return OrderCollection;
- })();
- var Shop = (function() {
- __extends(Shop, Backbone.Model);
- function Shop() {
- Shop.__super__.constructor.apply(this, arguments);
- }
-
- Shop.prototype.initialize = function() {
- this.set({
- orders: new OrderCollection(),
- products: new ProductCollection()
- });
- this.set({
- cashRegisters: new CashRegisterCollection(pos.get('bank_statements')),
- });
- return (this.get('orders')).bind('remove', _.bind( function(removedOrder) {
- if ((this.get('orders')).isEmpty()) {
- this.addAndSelectOrder(new Order);
- }
- if ((this.get('selectedOrder')) === removedOrder) {
- return this.set({
- selectedOrder: (this.get('orders')).last()
- });
- }
- }, this));
- };
- Shop.prototype.addAndSelectOrder = function(newOrder) {
- (this.get('orders')).add(newOrder);
- return this.set({
- selectedOrder: newOrder
- });
- };
- return Shop;
- })();
- /*
- The numpad handles both the choice of the property currently being modified
- (quantity, price or discount) and the edition of the corresponding numeric value.
- */
- var NumpadState = Backbone.Model.extend({
- defaults: {
- buffer: "0",
- mode: "quantity"
- },
- appendNewChar: function(newChar) {
- var oldBuffer;
- oldBuffer = this.get('buffer');
- if (oldBuffer === '0') {
- this.set({
- buffer: newChar
- });
- } else if (oldBuffer === '-0') {
- this.set({
- buffer: "-" + newChar
- });
- } else {
- this.set({
- buffer: (this.get('buffer')) + newChar
- });
- }
- this.updateTarget();
- },
- deleteLastChar: function() {
- var tempNewBuffer;
- tempNewBuffer = (this.get('buffer')).slice(0, -1) || "0";
- if (isNaN(tempNewBuffer)) {
- tempNewBuffer = "0";
- }
- this.set({
- buffer: tempNewBuffer
- });
- this.updateTarget();
- },
- switchSign: function() {
- var oldBuffer;
- oldBuffer = this.get('buffer');
- this.set({
- buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
- });
- this.updateTarget();
- },
- changeMode: function(newMode) {
- this.set({
- buffer: "0",
- mode: newMode
- });
- },
- reset: function() {
- this.set({
- buffer: "0",
- mode: "quantity"
- });
- },
- updateTarget: function() {
- var bufferContent, params;
- bufferContent = this.get('buffer');
- if (bufferContent && !isNaN(bufferContent)) {
- this.trigger('setValue', parseFloat(bufferContent));
- }
- },
- });
- /*
- ---
- Views
- ---
- */
- var NumpadWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.state = new NumpadState();
- },
- start: function() {
- this.state.bind('change:mode', this.changedMode, this);
- this.changedMode();
- this.$element.find('button#numpad-backspace').click(_.bind(this.clickDeleteLastChar, this));
- this.$element.find('button#numpad-minus').click(_.bind(this.clickSwitchSign, this));
- this.$element.find('button.number-char').click(_.bind(this.clickAppendNewChar, this));
- this.$element.find('button.mode-button').click(_.bind(this.clickChangeMode, this));
- },
- clickDeleteLastChar: function() {
- return this.state.deleteLastChar();
- },
- clickSwitchSign: function() {
- return this.state.switchSign();
- },
- clickAppendNewChar: function(event) {
- var newChar;
- newChar = event.currentTarget.innerText || event.currentTarget.textContent;
- return this.state.appendNewChar(newChar);
- },
- clickChangeMode: function(event) {
- var newMode = event.currentTarget.attributes['data-mode'].nodeValue;
- return this.state.changeMode(newMode);
- },
- changedMode: function() {
- var mode = this.state.get('mode');
- $('.selected-mode').removeClass('selected-mode');
- $(_.str.sprintf('.mode-button[data-mode="%s"]', mode), this.$element).addClass('selected-mode');
- },
- });
- /*
- Gives access to the payment methods (aka. 'cash registers')
- */
- var PaypadWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.shop = options.shop;
- },
- start: function() {
- this.$element.find('button').click(_.bind(this.performPayment, this));
- },
- performPayment: function(event) {
- if (this.shop.get('selectedOrder').get('step') === 'receipt')
- return;
- var cashRegister, cashRegisterCollection, cashRegisterId;
- /* set correct view */
- this.shop.get('selectedOrder').set({'step': 'payment'});
-
- cashRegisterId = event.currentTarget.attributes['cash-register-id'].nodeValue;
- cashRegisterCollection = this.shop.get('cashRegisters');
- cashRegister = cashRegisterCollection.find(_.bind( function(item) {
- return (item.get('id')) === parseInt(cashRegisterId, 10);
- }, this));
- return (this.shop.get('selectedOrder')).addPaymentLine(cashRegister);
- },
- renderElement: function() {
- this.$element.empty();
- return (this.shop.get('cashRegisters')).each(_.bind( function(cashRegister) {
- var button = new PaymentButtonWidget();
- button.model = cashRegister;
- button.appendTo(this.$element);
- }, this));
- }
- });
- var PaymentButtonWidget = db.web.OldWidget.extend({
- template_fct: qweb_template('pos-payment-button-template'),
- renderElement: function() {
- this.$element.html(this.template_fct({
- id: this.model.get('id'),
- name: (this.model.get('journal_id'))[1]
- }));
- return this;
- }
- });
- /*
- There are 3 steps in a POS workflow:
- 1. prepare the order (i.e. chose products, quantities etc.)
- 2. choose payment method(s) and amount(s)
- 3. validae order and print receipt
- It should be possible to go back to any step as long as step 3 hasn't been completed.
- Modifying an order after validation shouldn't be allowed.
- */
- var StepSwitcher = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.shop = options.shop;
- this.change_order();
- this.shop.bind('change:selectedOrder', this.change_order, this);
- },
- change_order: function() {
- if (this.selected_order) {
- this.selected_order.unbind('change:step', this.change_step);
- }
- this.selected_order = this.shop.get('selectedOrder');
- if (this.selected_order) {
- this.selected_order.bind('change:step', this.change_step, this);
- }
- this.change_step();
- },
- change_step: function() {
- var new_step = this.selected_order ? this.selected_order.get('step') : 'products';
- $('.step-screen').hide();
- $('#' + new_step + '-screen').show();
- },
- });
- /*
- Shopping carts.
- */
- var OrderlineWidget = db.web.OldWidget.extend({
- tagName: 'tr',
- template_fct: qweb_template('pos-orderline-template'),
- init: function(parent, options) {
- this._super(parent);
- this.model = options.model;
- this.model.bind('change', _.bind( function() {
- this.refresh();
- }, this));
- this.model.bind('remove', _.bind( function() {
- this.$element.remove();
- }, this));
- this.order = options.order;
- },
- start: function() {
- this.$element.click(_.bind(this.clickHandler, this));
- this.refresh();
- },
- clickHandler: function() {
- this.select();
- },
- renderElement: function() {
- this.$element.html(this.template_fct(this.model.toJSON()));
- this.select();
- },
- refresh: function() {
- this.renderElement();
- var heights = _.map(this.$element.prevAll(), function(el) {return $(el).outerHeight();});
- heights.push($('#current-order thead').outerHeight());
- var position = _.reduce(heights, function(memo, num){ return memo + num; }, 0);
- $('#current-order').scrollTop(position);
- },
- select: function() {
- $('tr.selected').removeClass('selected');
- this.$element.addClass('selected');
- this.order.selected = this.model;
- this.on_selected();
- },
- on_selected: function() {},
- });
- var OrderWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.shop = options.shop;
- this.setNumpadState(options.numpadState);
- this.shop.bind('change:selectedOrder', this.changeSelectedOrder, this);
- this.bindOrderLineEvents();
- },
- setNumpadState: function(numpadState) {
- if (this.numpadState) {
- this.numpadState.unbind('setValue', this.setValue);
- }
- this.numpadState = numpadState;
- if (this.numpadState) {
- this.numpadState.bind('setValue', this.setValue, this);
- this.numpadState.reset();
- }
- },
- setValue: function(val) {
- var param = {};
- param[this.numpadState.get('mode')] = val;
- var order = this.shop.get('selectedOrder');
- if (order.get('orderLines').length !== 0) {
- order.selected.set(param);
- } else {
- this.shop.get('selectedOrder').destroy();
- }
- },
- changeSelectedOrder: function() {
- this.currentOrderLines.unbind();
- this.bindOrderLineEvents();
- this.renderElement();
- },
- bindOrderLineEvents: function() {
- this.currentOrderLines = (this.shop.get('selectedOrder')).get('orderLines');
- this.currentOrderLines.bind('add', this.addLine, this);
- this.currentOrderLines.bind('remove', this.renderElement, this);
- },
- addLine: function(newLine) {
- var line = new OrderlineWidget(null, {
- model: newLine,
- order: this.shop.get('selectedOrder')
- });
- line.on_selected.add(_.bind(this.selectedLine, this));
- this.selectedLine();
- line.appendTo(this.$element);
- this.updateSummary();
- },
- selectedLine: function() {
- var reset = false;
- if (this.currentSelected !== this.shop.get('selectedOrder').selected) {
- reset = true;
- }
- this.currentSelected = this.shop.get('selectedOrder').selected;
- if (reset && this.numpadState)
- this.numpadState.reset();
- this.updateSummary();
- },
- renderElement: function() {
- this.$element.empty();
- this.currentOrderLines.each(_.bind( function(orderLine) {
- var line = new OrderlineWidget(null, {
- model: orderLine,
- order: this.shop.get('selectedOrder')
- });
- line.on_selected.add(_.bind(this.selectedLine, this));
- line.appendTo(this.$element);
- }, this));
- this.updateSummary();
- },
- updateSummary: function() {
- var currentOrder, tax, total, totalTaxExcluded;
- currentOrder = this.shop.get('selectedOrder');
- total = currentOrder.getTotal();
- totalTaxExcluded = currentOrder.getTotalTaxExcluded();
- tax = currentOrder.getTax();
- $('#subtotal').html(totalTaxExcluded.toFixed(2)).hide().fadeIn();
- $('#tax').html(tax.toFixed(2)).hide().fadeIn();
- $('#total').html(total.toFixed(2)).hide().fadeIn();
- },
- });
- /*
- "Products" step.
- */
- var CategoryWidget = db.web.OldWidget.extend({
- start: function() {
- this.$element.find(".oe_pos_categories_list a").click(_.bind(this.changeCategory, this));
- },
- template_fct: qweb_template('pos-category-template'),
- renderElement: function() {
- var self = this;
- var c;
- this.$element.html(this.template_fct({
- breadcrumb: (function() {
- var _i, _len, _results;
- _results = [];
- for (_i = 0, _len = self.ancestors.length; _i < _len; _i++) {
- c = self.ancestors[_i];
- _results.push(pos.categories[c]);
- }
- return _results;
- })(),
- categories: (function() {
- var _i, _len, _results;
- _results = [];
- for (_i = 0, _len = self.children.length; _i < _len; _i++) {
- c = self.children[_i];
- _results.push(pos.categories[c]);
- }
- return _results;
- })()
- }));
- },
- changeCategory: function(a) {
- var id = $(a.target).data("category-id");
- this.on_change_category(id);
- },
- on_change_category: function(id) {},
- });
- var ProductWidget = db.web.OldWidget.extend({
- tagName:'li',
- template_fct: qweb_template('pos-product-template'),
- init: function(parent, options) {
- this._super(parent);
- this.model = options.model;
- this.shop = options.shop;
- },
- start: function(options) {
- $("a", this.$element).click(_.bind(this.addToOrder, this));
- },
- addToOrder: function(event) {
- /* Preserve the category URL */
- event.preventDefault();
- return (this.shop.get('selectedOrder')).addProduct(this.model);
- },
- renderElement: function() {
- this.$element.addClass("product");
- this.$element.html(this.template_fct(this.model.toJSON()));
- return this;
- },
- });
- var ProductListWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.model = options.model;
- this.shop = options.shop;
- this.shop.get('products').bind('reset', this.renderElement, this);
- },
- renderElement: function() {
- this.$element.empty();
- (this.shop.get('products')).each(_.bind( function(product) {
- var p = new ProductWidget(null, {
- model: product,
- shop: this.shop
- });
- p.appendTo(this.$element);
- }, this));
- return this;
- },
- });
- /*
- "Payment" step.
- */
- var PaymentlineWidget = db.web.OldWidget.extend({
- tagName: 'tr',
- template_fct: qweb_template('pos-paymentline-template'),
- init: function(parent, options) {
- this._super(parent);
- this.model = options.model;
- this.model.bind('change', this.changedAmount, this);
- },
- on_delete: function() {},
- changeAmount: function(event) {
- var newAmount;
- newAmount = event.currentTarget.value;
- if (newAmount && !isNaN(newAmount)) {
- this.amount = parseFloat(newAmount);
- this.model.set({
- amount: this.amount,
- });
- }
- },
- changedAmount: function() {
- if (this.amount !== this.model.get('amount'))
- this.renderElement();
- },
- renderElement: function() {
- this.amount = this.model.get('amount');
- this.$element.html(this.template_fct({
- name: (this.model.get('journal_id'))[1],
- amount: this.amount,
- }));
- this.$element.addClass('paymentline');
- $('input', this.$element).keyup(_.bind(this.changeAmount, this));
- $('.delete-payment-line', this.$element).click(this.on_delete);
- },
- });
- var PaymentWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.model = options.model;
- this.shop = options.shop;
- this.shop.bind('change:selectedOrder', this.changeSelectedOrder, this);
- this.bindPaymentLineEvents();
- this.bindOrderLineEvents();
- },
- paymentLineList: function() {
- return this.$element.find('#paymentlines');
- },
- start: function() {
- $('button#validate-order', this.$element).click(_.bind(this.validateCurrentOrder, this));
- $('.oe_back_to_products', this.$element).click(_.bind(this.back, this));
- },
- back: function() {
- this.shop.get('selectedOrder').set({"step": "products"});
- },
- validateCurrentOrder: function() {
- var callback, currentOrder;
- currentOrder = this.shop.get('selectedOrder');
- $('button#validate-order', this.$element).attr('disabled', 'disabled');
- pos.pushOrder(currentOrder.exportAsJSON()).then(_.bind(function() {
- $('button#validate-order', this.$element).removeAttr('disabled');
- return currentOrder.set({
- validated: true
- });
- }, this));
- },
- bindPaymentLineEvents: function() {
- this.currentPaymentLines = (this.shop.get('selectedOrder')).get('paymentLines');
- this.currentPaymentLines.bind('add', this.addPaymentLine, this);
- this.currentPaymentLines.bind('remove', this.renderElement, this);
- this.currentPaymentLines.bind('all', this.updatePaymentSummary, this);
- },
- bindOrderLineEvents: function() {
- this.currentOrderLines = (this.shop.get('selectedOrder')).get('orderLines');
- this.currentOrderLines.bind('all', this.updatePaymentSummary, this);
- },
- changeSelectedOrder: function() {
- this.currentPaymentLines.unbind();
- this.bindPaymentLineEvents();
- this.currentOrderLines.unbind();
- this.bindOrderLineEvents();
- this.renderElement();
- },
- addPaymentLine: function(newPaymentLine) {
- var x = new PaymentlineWidget(null, {
- model: newPaymentLine
- });
- x.on_delete.add(_.bind(this.deleteLine, this, x));
- x.appendTo(this.paymentLineList());
- },
- renderElement: function() {
- this.paymentLineList().empty();
- this.currentPaymentLines.each(_.bind( function(paymentLine) {
- this.addPaymentLine(paymentLine);
- }, this));
- this.updatePaymentSummary();
- },
- deleteLine: function(lineWidget) {
- this.currentPaymentLines.remove([lineWidget.model]);
- },
- updatePaymentSummary: function() {
- var currentOrder, dueTotal, paidTotal, remaining, remainingAmount;
- currentOrder = this.shop.get('selectedOrder');
- paidTotal = currentOrder.getPaidTotal();
- dueTotal = currentOrder.getTotal();
- this.$element.find('#payment-due-total').html(dueTotal.toFixed(2));
- this.$element.find('#payment-paid-total').html(paidTotal.toFixed(2));
- remainingAmount = dueTotal - paidTotal;
- remaining = remainingAmount > 0 ? 0 : (-remainingAmount).toFixed(2);
- $('#payment-remaining').html(remaining);
- },
- setNumpadState: function(numpadState) {
- if (this.numpadState) {
- this.numpadState.unbind('setValue', this.setValue);
- this.numpadState.unbind('change:mode', this.setNumpadMode);
- }
- this.numpadState = numpadState;
- if (this.numpadState) {
- this.numpadState.bind('setValue', this.setValue, this);
- this.numpadState.bind('change:mode', this.setNumpadMode, this);
- this.numpadState.reset();
- this.setNumpadMode();
- }
- },
- setNumpadMode: function() {
- this.numpadState.set({mode: 'payment'});
- },
- setValue: function(val) {
- this.currentPaymentLines.last().set({amount: val});
- },
- });
- var ReceiptWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.model = options.model;
- this.shop = options.shop;
- this.user = pos.get('user');
- this.company = pos.get('company');
- this.shop_obj = pos.get('shop');
- },
- start: function() {
- this.shop.bind('change:selectedOrder', this.changeSelectedOrder, this);
- this.changeSelectedOrder();
- },
- renderElement: function() {
- this.$element.html(qweb_template('pos-receipt-view'));
- $('button#pos-finish-order', this.$element).click(_.bind(this.finishOrder, this));
- $('button#print-the-ticket', this.$element).click(_.bind(this.print, this));
- },
- print: function() {
- window.print();
- },
- finishOrder: function() {
- this.shop.get('selectedOrder').destroy();
- },
- changeSelectedOrder: function() {
- if (this.currentOrderLines)
- this.currentOrderLines.unbind();
- this.currentOrderLines = (this.shop.get('selectedOrder')).get('orderLines');
- this.currentOrderLines.bind('add', this.refresh, this);
- this.currentOrderLines.bind('change', this.refresh, this);
- this.currentOrderLines.bind('remove', this.refresh, this);
- if (this.currentPaymentLines)
- this.currentPaymentLines.unbind();
- this.currentPaymentLines = (this.shop.get('selectedOrder')).get('paymentLines');
- this.currentPaymentLines.bind('all', this.refresh, this);
- this.refresh();
- },
- refresh: function() {
- this.currentOrder = this.shop.get('selectedOrder');
- $('.pos-receipt-container', this.$element).html(qweb_template('pos-ticket')({widget:this}));
- },
- });
- var OrderButtonWidget = db.web.OldWidget.extend({
- tagName: 'li',
- template_fct: qweb_template('pos-order-selector-button-template'),
- init: function(parent, options) {
- this._super(parent);
- this.order = options.order;
- this.shop = options.shop;
- this.order.bind('destroy', _.bind( function() {
- this.destroy();
- }, this));
- this.shop.bind('change:selectedOrder', _.bind( function(shop) {
- var selectedOrder;
- selectedOrder = shop.get('selectedOrder');
- if (this.order === selectedOrder) {
- this.setButtonSelected();
- }
- }, this));
- },
- start: function() {
- $('button.select-order', this.$element).click(_.bind(this.selectOrder, this));
- $('button.close-order', this.$element).click(_.bind(this.closeOrder, this));
- },
- selectOrder: function(event) {
- this.shop.set({
- selectedOrder: this.order
- });
- },
- setButtonSelected: function() {
- $('.selected-order').removeClass('selected-order');
- this.$element.addClass('selected-order');
- },
- closeOrder: function(event) {
- this.order.destroy();
- },
- renderElement: function() {
- this.$element.html(this.template_fct({widget:this}));
- this.$element.addClass('order-selector-button');
- }
- });
- var ShopWidget = db.web.OldWidget.extend({
- init: function(parent, options) {
- this._super(parent);
- this.shop = options.shop;
- },
- start: function() {
- $('button#neworder-button', this.$element).click(_.bind(this.createNewOrder, this));
-
- (this.shop.get('orders')).bind('add', this.orderAdded, this);
- (this.shop.get('orders')).add(new Order);
- this.productListView = new ProductListWidget(null, {
- shop: this.shop
- });
- this.productListView.$element = $("#products-screen-ol");
- this.productListView.renderElement();
- this.productListView.start();
- this.paypadView = new PaypadWidget(null, {
- shop: this.shop
- });
- this.paypadView.$element = $('#paypad');
- this.paypadView.renderElement();
- this.paypadView.start();
- this.numpadView = new NumpadWidget(null);
- this.numpadView.$element = $('#numpad');
- this.numpadView.start();
- this.orderView = new OrderWidget(null, {
- shop: this.shop,
- });
- this.orderView.$element = $('#current-order-content');
- this.orderView.start();
- this.paymentView = new PaymentWidget(null, {
- shop: this.shop
- });
- this.paymentView.$element = $('#payment-screen');
- this.paymentView.renderElement();
- this.paymentView.start();
- this.receiptView = new ReceiptWidget(null, {
- shop: this.shop,
- });
- this.receiptView.replace($('#receipt-screen'));
- this.stepSwitcher = new StepSwitcher(this, {shop: this.shop});
- this.shop.bind('change:selectedOrder', this.changedSelectedOrder, this);
- this.changedSelectedOrder();
- },
- createNewOrder: function() {
- var newOrder;
- newOrder = new Order;
- (this.shop.get('orders')).add(newOrder);
- this.shop.set({
- selectedOrder: newOrder
- });
- },
- orderAdded: function(newOrder) {
- var newOrderButton;
- newOrderButton = new OrderButtonWidget(null, {
- order: newOrder,
- shop: this.shop
- });
- newOrderButton.appendTo($('#orders'));
- newOrderButton.selectOrder();
- },
- changedSelectedOrder: function() {
- if (this.currentOrder) {
- this.currentOrder.unbind('change:step', this.changedStep);
- }
- this.currentOrder = this.shop.get('selectedOrder');
- this.currentOrder.bind('change:step', this.changedStep, this);
- this.changedStep();
- },
- changedStep: function() {
- var step = this.currentOrder.get('step');
- this.orderView.setNumpadState(null);
- this.paymentView.setNumpadState(null);
- if (step === 'products') {
- this.orderView.setNumpadState(this.numpadView.state);
- } else if (step === 'payment') {
- this.paymentView.setNumpadState(this.numpadView.state);
- }
- },
- });
- var App = (function() {
- function App($element) {
- this.initialize($element);
- }
-
- App.prototype.initialize = function($element) {
- this.shop = new Shop;
- this.shopView = new ShopWidget(null, {
- shop: this.shop
- });
- this.shopView.$element = $element;
- this.shopView.start();
- this.categoryView = new CategoryWidget(null, 'products-screen-categories');
- this.categoryView.on_change_category.add_last(_.bind(this.category, this));
- this.category();
- };
- App.prototype.category = function(id) {
- var c, product_list;
- if (id == null) {
- id = 0;
- }
- c = pos.categories[id];
- this.categoryView.ancestors = c.ancestors;
- this.categoryView.children = c.children;
- this.categoryView.renderElement();
- this.categoryView.start();
- product_list = pos.get('product_list').filter( function(p) {
- var _ref;
- return _ref = p.pos_categ_id[0], _.indexOf(c.subtree, _ref) >= 0;
- });
- (this.shop.get('products')).reset(product_list);
- var self = this;
- $('.searchbox input').keyup(function() {
- var m, s;
- s = $(this).val().toLowerCase();
- if (s) {
- m = product_list.filter( function(p) {
- return p.name.toLowerCase().indexOf(s) != -1;
- });
- $('.search-clear').fadeIn();
- } else {
- m = product_list;
- $('.search-clear').fadeOut();
- }
- return (self.shop.get('products')).reset(m);
- });
- return $('.search-clear').click( function() {
- (self.shop.get('products')).reset(product_list);
- $('.searchbox input').val('').focus();
- return $('.search-clear').fadeOut();
- });
- };
- return App;
- })();
-
- db.point_of_sale.SynchNotification = db.web.OldWidget.extend({
- template: "pos-synch-notification",
- init: function() {
- this._super.apply(this, arguments);
- this.nbr_pending = 0;
- },
- renderElement: function() {
- this._super.apply(this, arguments);
- $('.oe_pos_synch-notification-button', this.$element).click(this.on_synch);
- },
- on_change_nbr_pending: function(nbr_pending) {
- this.nbr_pending = nbr_pending;
- this.renderElement();
- },
- on_synch: function() {}
- });
-
- db.web.client_actions.add('pos.ui', 'db.point_of_sale.PointOfSale');
- db.point_of_sale.PointOfSale = db.web.OldWidget.extend({
- init: function() {
- this._super.apply(this, arguments);
-
- if (pos)
- throw "It is not possible to instantiate multiple instances "+
- "of the point of sale at the same time.";
- pos = new Pos(this.session);
- },
- start: function() {
- var self = this;
- return pos.ready.then(_.bind(function() {
- this.renderElement();
- this.synch_notification = new db.point_of_sale.SynchNotification(this);
- this.synch_notification.replace($('.oe_pos_synch-notification', this.$element));
- this.synch_notification.on_synch.add(_.bind(pos.flush, pos));
-
- pos.bind('change:nbr_pending_operations', this.changed_pending_operations, this);
- this.changed_pending_operations();
-
- this.$element.find("#loggedas button").click(function() {
- self.try_close();
- });
-
- pos.app = new App(self.$element);
- db.webclient.set_content_full_screen(true);
-
- if (pos.get('bank_statements').length === 0)
- return new db.web.Model("ir.model.data").get_func("search_read")([['name', '=', 'action_pos_open_statement']], ['res_id']).pipe(
- _.bind(function(res) {
- return this.rpc('/web/action/load', {'action_id': res[0]['res_id']}).pipe(_.bind(function(result) {
- var action = result.result;
- this.do_action(action);
- }, this));
- }, this));
- }, this));
- },
- render: function() {
- return qweb_template("PointOfSale")();
- },
- changed_pending_operations: function () {
- this.synch_notification.on_change_nbr_pending(pos.get('nbr_pending_operations'));
- },
- try_close: function() {
- pos.flush().then(_.bind(function() {
- var close = _.bind(this.close, this);
- if (pos.get('nbr_pending_operations') > 0) {
- var confirm = false;
- $(QWeb.render('pos-close-warning')).dialog({
- resizable: false,
- height:160,
- modal: true,
- title: "Warning",
- buttons: {
- "Yes": function() {
- confirm = true;
- $( this ).dialog( "close" );
- },
- "No": function() {
- $( this ).dialog( "close" );
- }
- },
- close: function() {
- if (confirm)
- close();
- }
- });
- } else {
- close();
- }
- }, this));
- },
- close: function() {
- return new db.web.Model("ir.model.data").get_func("search_read")([['name', '=', 'action_pos_close_statement']], ['res_id']).pipe(
- _.bind(function(res) {
- return this.rpc('/web/action/load', {'action_id': res[0]['res_id']}).pipe(_.bind(function(result) {
- var action = result.result;
- action.context = _.extend(action.context || {}, {'cancel_action': {type: 'ir.actions.client', tag: 'default_home'}});
- this.do_action(action);
- }, this));
- }, this));
- },
- destroy: function() {
- db.webclient.set_content_full_screen(false);
- pos = undefined;
- this._super();
- }
- });
-}
diff --git a/addons/point_of_sale/static/src/js/pos_basewidget.js b/addons/point_of_sale/static/src/js/pos_basewidget.js
new file mode 100644
index 00000000000..62097f39db3
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_basewidget.js
@@ -0,0 +1,45 @@
+function openerp_pos_basewidget(instance, module){ //module is instance.point_of_sale
+
+ // This is a base class for all Widgets in the POS. It exposes relevant data to the
+ // templates :
+ // - widget.currency : { symbol: '$' | '€' | ..., position: 'before' | 'after }
+ // - widget.format_currency(amount) : this method returns a formatted string based on the
+ // symbol, the position, and the amount of money.
+ // if the PoS is not fully loaded when you instanciate the widget, the currency might not
+ // yet have been initialized. Use __build_currency_template() to recompute with correct values
+ // before rendering.
+
+ module.PosBaseWidget = instance.web.Widget.extend({
+ init:function(parent,options){
+ this._super(parent);
+ options = options || {};
+ this.pos = options.pos || (parent ? parent.pos : undefined);
+ this.pos_widget = options.pos_widget || (parent ? parent.pos_widget : undefined);
+ this.build_currency_template();
+ },
+ build_currency_template: function(){
+
+ if(this.pos && this.pos.get('currency')){
+ this.currency = this.pos.get('currency');
+ }else{
+ this.currency = {symbol: '$', position: 'after'};
+ }
+
+ this.format_currency = function(amount){
+ if(this.currency.position === 'after'){
+ return Math.round(amount*100)/100 + ' ' + this.currency.symbol;
+ }else{
+ return this.currency.symbol + ' ' + Math.round(amount*100)/100;
+ }
+ }
+
+ },
+ show: function(){
+ this.$element.show();
+ },
+ hide: function(){
+ this.$element.hide();
+ },
+ });
+
+}
diff --git a/addons/point_of_sale/static/src/js/pos_devices.js b/addons/point_of_sale/static/src/js/pos_devices.js
new file mode 100644
index 00000000000..2857fc39937
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_devices.js
@@ -0,0 +1,432 @@
+
+function openerp_pos_devices(instance,module){ //module is instance.point_of_sale
+
+ var debug_devices = new (instance.web.Class.extend({
+ active: false,
+ payment_status: 'waiting_for_payment',
+ weight: 0,
+ activate: function(){
+ this.active = true;
+ },
+ deactivate: function(){
+ this.active = false;
+ },
+ set_weight: function(weight){ this.activate(); this.weight = weight; },
+ accept_payment: function(){ this.activate(); this.payment_status = 'payment_accepted'; },
+ reject_payment: function(){ this.activate(); this.payment_status = 'payment_rejected'; },
+ delay_payment: function(){ this.activate(); this.payment_status = 'waiting_for_payment'; },
+ }))();
+
+ //window.debug_devices = debug_devices;
+
+ // this object interfaces with the local proxy to communicate to the various hardware devices
+ // connected to the Point of Sale. As the communication only goes from the POS to the proxy,
+ // methods are used both to signal an event, and to fetch information.
+
+ module.ProxyDevice = instance.web.Class.extend({
+ init: function(options){
+ options = options || {};
+ url = options.url || 'http://localhost:8069';
+
+ this.weight = 0;
+ this.weighting = false;
+
+ this.paying = false;
+ this.payment_status = 'waiting_for_payment';
+
+ this.connection = new instance.web.JsonRPC();
+ this.connection.setup(url);
+
+ },
+ message : function(name,params,success_callback, error_callback){
+ success_callback = success_callback || function(){};
+ error_callback = error_callback || function(){};
+
+ if(debug_devices && debug_devices.active){
+ console.log('PROXY:',name,params);
+ }else{
+ this.connection.rpc('/pos/'+name, params || {}, success_callback, error_callback);
+ }
+ },
+
+ //a product has been scanned and recognized with success
+ scan_item_success: function(){
+ this.message('scan_item_success');
+ },
+
+ //a product has been scanned but not recognized
+ scan_item_error_unrecognized: function(){
+ this.message('scan_item_error_unrecognized');
+ },
+
+ //the client is asking for help
+ help_needed: function(){
+ this.message('help_needed');
+ },
+
+ //the client does not need help anymore
+ help_canceled: function(){
+ this.message('help_canceled');
+ },
+
+ //the client is starting to weight
+ weighting_start: function(){
+ this.weight = 0;
+ if(debug_devices){
+ debug_devices.weigth = 0;
+ }
+ this.weighting = true;
+ this.message('weighting_start');
+ },
+
+ //returns the weight on the scale.
+ // is called at regular interval (up to 10x/sec) between a weighting_start()
+ // and a weighting_end()
+ weighting_read_kg: function(){
+ var self = this;
+ if(debug_devices && debug_devices.active){
+ return debug_devices.weight;
+ }else{
+ this.message('weighting_read_kg',{},function(weight){
+ if(self.weighting){
+ self.weight = weight;
+ }
+ });
+ return self.weight;
+ }
+ },
+
+ // the client has finished weighting products
+ weighting_end: function(){
+ this.weight = 0;
+ this.weighting = false;
+ this.message('weighting_end');
+ },
+
+ // the pos asks the client to pay 'price' units
+ // method: 'mastercard' | 'cash' | ... ? TBD
+ // info: 'extra information to display on the payment terminal' ... ? TBD
+ payment_request: function(price, method, info){
+ this.paying = true;
+ this.payment_status = 'waiting_for_payment';
+ if(debug_devices){
+ debug_devices.payment_status = 'waiting_for_payment';
+ }
+ this.message('payment_request',{'price':price,'method':method,'info':info});
+ },
+
+ // is called at regular interval after a payment request to see if the client
+ // has paid the required money
+ // returns 'waiting_for_payment' | 'payment_accepted' | 'payment_rejected'
+ is_payment_accepted: function(){
+ var self = this;
+ if(debug_devices.active){
+ return debug_devices.payment_status;
+ }else{
+ this.message('is_payment_accepted', {}, function(payment_status){
+ if(self.paying){
+ self.payment_status = payment_status;
+ }
+ });
+ return self.payment_status;
+ }
+ },
+
+ // the client cancels his payment
+ payment_canceled: function(){
+ this.paying = false;
+ this.payment_status = 'waiting_for_payment';
+ this.message('payment_canceled');
+ },
+
+ // called when the client logs in or starts to scan product
+ transaction_start: function(){
+ this.message('transaction_start');
+ },
+
+ // called when the clients has finished his interaction with the machine
+ transaction_end: function(){
+ this.message('transaction_end');
+ },
+
+ // called when the POS turns to cashier mode
+ cashier_mode_activated: function(){
+ this.message('cashier_mode_activated');
+ },
+
+ // called when the POS turns to client mode
+ cashier_mode_deactivated: function(){
+ this.message('cashier_mode_deactivated');
+ },
+
+ // ask for the cashbox (the physical box where you store the cash) to be opened
+ open_cashbox: function(){
+ this.message('open_cashbox');
+ },
+
+ /* ask the printer to print a receipt
+ * receipt is a JSON object with the following specs:
+ * receipt{
+ * - orderlines : list of orderlines :
+ * {
+ * quantity: (number) the number of items, or the weight,
+ * unit_name: (string) the name of the item's unit (kg, dozen, ...)
+ * list_price: (number) the price of one unit of the item before discount
+ * discount: (number) the discount on the product in % [0,100]
+ * product_name: (string) the name of the product
+ * price_with_tax: (number) the price paid for this orderline, tax included
+ * price_without_tax: (number) the price paid for this orderline, without taxes
+ * tax: (number) the price paid in taxes on this orderline
+ * }
+ * - paymentlines : list of paymentlines :
+ * {
+ * amount: (number) the amount paid
+ * journal: (string) the name of the journal on wich the payment has been made
+ * }
+ * - total_with_tax: (number) the total of the receipt tax included
+ * - total_without_tax: (number) the total of the receipt without taxes
+ * - total_tax: (number) the total amount of taxes paid
+ * - total_paid: (number) the total sum paid by the client
+ * - change: (number) the amount of change given back to the client
+ * - name: (string) a unique name for this order
+ * - client: (string) name of the client. or null if no client is logged
+ * - cashier: (string) the name of the cashier
+ * - date: { the date at wich the payment has been done
+ * year: (number) the year [2012, ...]
+ * month: (number) the month [0,11]
+ * date: (number) the day of the month [1,31]
+ * day: (number) the day of the week [0,6]
+ * hour: (number) the hour [0,23]
+ * minute: (number) the minute [0,59]
+ * }
+ */
+ print_receipt: function(receipt){
+ this.message('print_receipt',{receipt: receipt});
+ },
+ });
+
+ // this module interfaces with the barcode reader. It assumes the barcode reader
+ // is set-up to act like a keyboard. Use connect() and disconnect() to activate
+ // and deactivate the barcode reader. Use set_action_callbacks to tell it
+ // what to do when it reads a barcode.
+ module.BarcodeReader = instance.web.Class.extend({
+ actions:[
+ 'product',
+ 'cashier',
+ 'client',
+ 'discount',
+ ],
+ init: function(attributes){
+ this.pos = attributes.pos;
+ this.action_callback = {};
+
+ this.action_callback_stack = [];
+
+ this.price_prefix_set = attributes.price_prefix_set || {'02':'', '22':'', '24':'', '26':'', '28':''};
+ this.weight_prefix_set = attributes.weight_prefix_set || {'21':'','23':'','27':'','29':'','25':''};
+ this.client_prefix_set = attributes.weight_prefix_set || {'42':''};
+ this.cashier_prefix_set = attributes.weight_prefix_set || {'40':''};
+ this.discount_prefix_set = attributes.weight_prefix_set || {'44':''};
+ },
+ save_callbacks: function(){
+ var callbacks = {};
+ for(name in this.action_callback){
+ callbacks[name] = this.action_callback[name];
+ }
+ this.action_callback_stack.push(callbacks);
+ },
+ restore_callbacks: function(){
+ if(this.action_callback_stack.length){
+ var callbacks = this.action_callback_stack.pop();
+ this.action_callback = callbacks;
+ }
+ },
+
+ // when an ean is scanned and parsed, the callback corresponding
+ // to its type is called with the parsed_ean as a parameter.
+ // (parsed_ean is the result of parse_ean(ean))
+ //
+ // callbacks is a Map of 'actions' : callback(parsed_ean)
+ // that sets the callback for each action. if a callback for the
+ // specified action already exists, it is replaced.
+ //
+ // possible actions include :
+ // 'product' | 'cashier' | 'client' | 'discount'
+
+ set_action_callback: function(action, callback){
+ if(arguments.length == 2){
+ this.action_callback[action] = callback;
+ }else{
+ var actions = arguments[0];
+ for(action in actions){
+ this.set_action_callback(action,actions[action]);
+ }
+ }
+ },
+
+ //remove all action callbacks
+ reset_action_callbacks: function(){
+ for(action in this.action_callback){
+ this.action_callback[action] = undefined;
+ }
+ },
+
+
+ // returns true if the ean is a valid EAN codebar number by checking the control digit.
+ // ean must be a string
+ check_ean: function(ean){
+ var code = ean.split('');
+ for(var i = 0; i < code.length; i++){
+ code[i] = Number(code[i]);
+ }
+ var st1 = code.slice();
+ var st2 = st1.slice(0,st1.length-1).reverse();
+ // some EAN13 barcodes have a length of 12, as they start by 0
+ while (st2.length < 12) {
+ st2.push(0);
+ }
+ var countSt3 = 1;
+ var st3 = 0;
+ $.each(st2, function() {
+ if (countSt3%2 === 1) {
+ st3 += this;
+ }
+ countSt3 ++;
+ });
+ st3 *= 3;
+ var st4 = 0;
+ var countSt4 = 1;
+ $.each(st2, function() {
+ if (countSt4%2 === 0) {
+ st4 += this;
+ }
+ countSt4 ++;
+ });
+ var st5 = st3 + st4;
+ var cd = (10 - (st5%10)) % 10;
+ return code[code.length-1] === cd;
+ },
+
+ // attempts to interpret an ean (string encoding an ean)
+ // it will check its validity then return an object containing various
+ // information about the ean.
+ // most importantly :
+ // - ean : the ean
+ // - type : the type of the ean:
+ // 'price' | 'weight' | 'unit' | 'cashier' | 'client' | 'discount' | 'error'
+ //
+ // - prefix : the prefix that has ben used to determine the type
+ // - id : the part of the ean that identifies something
+ // - value : if the id encodes a numerical value, it will be put there
+ // - unit : if the encoded value has a unit, it will be put there.
+ // not to be confused with the 'unit' type, which represent an unit of a
+ // unique product
+
+ parse_ean: function(ean){
+ var parse_result = {
+ type:'unknown', //
+ prefix:'',
+ ean:ean,
+ id:'',
+ value: 0,
+ unit: 'none',
+ };
+ var prefix2 = ean.substring(0,2);
+
+ if(!this.check_ean(ean)){
+ parse_result.type = 'error';
+ }else if (prefix2 in this.price_prefix_set){
+ parse_result.type = 'price';
+ parse_result.prefix = prefix2;
+ parse_result.id = ean.substring(0,7);
+ parse_result.value = Number(ean.substring(7,12))/100.0;
+ parse_result.unit = 'euro';
+ } else if (prefix2 in this.weight_prefix_set){
+ parse_result.type = 'weight';
+ parse_result.prefix = prefix2;
+ parse_result.id = ean.substring(0,7);
+ parse_result.value = Number(ean.substring(7,12))/1000.0;
+ parse_result.unit = 'Kg';
+ }else if (prefix2 in this.client_prefix_set){
+ parse_result.type = 'client';
+ parse_result.prefix = prefix2;
+ parse_result.id = ean.substring(0,7);
+ }else if (prefix2 in this.cashier_prefix_set){
+ parse_result.type = 'cashier';
+ parse_result.prefix = prefix2;
+ parse_result.id = ean.substring(0,7);
+ }else if (prefix2 in this.discount_prefix_set){
+ parse_result.type = 'discount';
+ parse_result.prefix = prefix2;
+ parse_result.id = ean.substring(0,7);
+ parse_result.value = Number(ean.substring(7,12))/100.0;
+ parse_result.unit = '%';
+ }else{
+ parse_result.type = 'unit';
+ parse_result.prefix = '';
+ parse_result.id = ean;
+ }
+ return parse_result;
+ },
+
+ // starts catching keyboard events and tries to interpret codebar
+ // calling the callbacks when needed.
+ connect: function(){
+ var self = this;
+ var codeNumbers = [];
+ var timeStamp = 0;
+ var lastTimeStamp = 0;
+
+ // The barcode readers acts as a keyboard, we catch all keyup events and try to find a
+ // barcode sequence in the typed keys, then act accordingly.
+ $('body').delegate('','keyup', function (e){
+
+ //We only care about numbers
+ if (!isNaN(Number(String.fromCharCode(e.keyCode)))) {
+
+ // The barcode reader sends keystrokes with a specific interval.
+ // We look if the typed keys fit in the interval.
+ if (codeNumbers.length==0) {
+ timeStamp = new Date().getTime();
+ } else {
+ if (lastTimeStamp + 30 < new Date().getTime()) {
+ // not a barcode reader
+ codeNumbers = [];
+ timeStamp = new Date().getTime();
+ }
+ }
+ codeNumbers.push(e.keyCode - 48);
+ lastTimeStamp = new Date().getTime();
+ if (codeNumbers.length == 13) {
+ //We have found what seems to be a valid codebar
+ var parse_result = self.parse_ean(codeNumbers.join(''));
+
+ if (parse_result.type === 'error') { //most likely a checksum error, raise warning
+ console.error('ERROR: barcode checksum error:',parse_result);
+ }else if(parse_result.type in {'unit':'', 'weight':'', 'price':''}){ //ean is associated to a product
+ if(self.action_callback['product']){
+ self.action_callback['product'](parse_result);
+ }
+ //this.trigger("codebar",parse_result );
+ }else{
+ if(self.action_callback[parse_result.type]){
+ self.action_callback[parse_result.type](parse_result);
+ }
+ }
+
+ codeNumbers = [];
+ }
+ } else {
+ // NaN
+ codeNumbers = [];
+ }
+ });
+ },
+
+ // stops catching keyboard events
+ disconnect: function(){
+ $('body').undelegate('', 'keyup')
+ },
+ });
+
+}
diff --git a/addons/point_of_sale/static/src/js/pos_keyboard_widget.js b/addons/point_of_sale/static/src/js/pos_keyboard_widget.js
new file mode 100644
index 00000000000..2e683edb1e1
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_keyboard_widget.js
@@ -0,0 +1,185 @@
+
+function openerp_pos_keyboard(instance, module){ //module is instance.point_of_sale
+// ---------- OnScreen Keyboard Widget ----------
+
+ // A Widget that displays an onscreen keyboard.
+ // There are two options when creating the widget :
+ //
+ // * 'keyboard_model' : 'simple' | 'full' (default)
+ // The 'full' emulates a PC keyboard, while 'simple' emulates an 'android' one.
+ //
+ // * 'input_selector : (default: '.searchbox input')
+ // defines the dom element that the keyboard will write to.
+ //
+ // The widget is initially hidden. It can be shown with this.show(), and is
+ // automatically shown when the input_selector gets focused.
+
+ module.OnscreenKeyboardWidget = instance.web.Widget.extend({
+ template: 'OnscreenKeyboardSimple',
+ init: function(parent, options){
+ var self = this;
+ this._super(parent,options);
+ options = options || {};
+
+ this.keyboard_model = options.keyboard_model || 'full';
+ if(this.keyboard_model === 'full'){
+ this.template = 'OnscreenKeyboardFull';
+ }
+
+ this.input_selector = options.input_selector || '.searchbox input';
+
+ //show the keyboard when the input zone is clicked.
+ $(this.input_selector).focus(function(){self.show();});
+
+ //Keyboard state
+ this.capslock = false;
+ this.shift = false;
+ this.numlock = false;
+ },
+
+ connect : function(){
+ var self = this;
+ $(this.input_selector).focus(function(){self.show();});
+ },
+
+ // Write a character to the input zone
+ writeCharacter: function(character){
+ var $input = $(this.input_selector);
+ $input[0].value += character;
+ $input.keydown();
+ $input.keyup();
+ },
+
+ // Sends a 'return' character to the input zone. TODO
+ sendReturn: function(){
+ },
+
+ // Removes the last character from the input zone.
+ deleteCharacter: function(){
+ var $input = $(this.input_selector);
+ var input_value = $input[0].value;
+ $input[0].value = input_value.substr(0, input_value.length - 1);
+ $input.keydown();
+ $input.keyup();
+ },
+
+ // Clears the content of the input zone.
+ deleteAllCharacters: function(){
+ var $input = $(this.input_selector);
+ $input[0].value = "";
+ $input.keydown();
+ $input.keyup();
+ },
+
+ // Makes the keyboard show and slide from the bottom of the screen.
+ show: function(){
+ $('.keyboard_frame').show().animate({'height':'235px'}, 500, 'swing');
+ },
+
+ // Makes the keyboard hide by sliding to the bottom of the screen.
+ hide: function(){
+ var self = this;
+ var frame = $('.keyboard_frame');
+ frame.animate({'height':'0'}, 500, 'swing', function(){ frame.hide(); self.reset(); });
+ },
+
+ //What happens when the shift key is pressed : toggle case, remove capslock
+ toggleShift: function(){
+ $('.letter').toggleClass('uppercase');
+ $('.symbol span').toggle();
+
+ self.shift = (self.shift === true) ? false : true;
+ self.capslock = false;
+ },
+
+ //what happens when capslock is pressed : toggle case, set capslock
+ toggleCapsLock: function(){
+ $('.letter').toggleClass('uppercase');
+ self.capslock = true;
+ },
+
+ //What happens when numlock is pressed : toggle symbols and numlock label
+ toggleNumLock: function(){
+ $('.symbol span').toggle();
+ $('.numlock span').toggle();
+ self.numlock = (self.numlock === true ) ? false : true;
+ },
+
+ //After a key is pressed, shift is disabled.
+ removeShift: function(){
+ if (self.shift === true) {
+ $('.symbol span').toggle();
+ if (this.capslock === false) $('.letter').toggleClass('uppercase');
+
+ self.shift = false;
+ }
+ },
+
+ // Resets the keyboard to its original state; capslock: false, shift: false, numlock: false
+ reset: function(){
+ if(this.shift){
+ this.toggleShift();
+ }
+ if(this.capslock){
+ this.toggleCapsLock();
+ }
+ if(this.numlock){
+ this.toggleNumLock();
+ }
+ },
+
+ //called after the keyboard is in the DOM, sets up the key bindings.
+ start: function(){
+ var self = this;
+
+ //this.show();
+
+
+ $('.close_button').click(function(){
+ self.deleteAllCharacters();
+ self.hide();
+ });
+
+ // Keyboard key click handling
+ $('.keyboard li').click(function(){
+
+ var $this = $(this),
+ character = $this.html(); // If it's a lowercase letter, nothing happens to this variable
+
+ if ($this.hasClass('left-shift') || $this.hasClass('right-shift')) {
+ self.toggleShift();
+ return false;
+ }
+
+ if ($this.hasClass('capslock')) {
+ self.toggleCapsLock();
+ return false;
+ }
+
+ if ($this.hasClass('delete')) {
+ self.deleteCharacter();
+ return false;
+ }
+
+ if ($this.hasClass('numlock')){
+ self.toggleNumLock();
+ return false;
+ }
+
+ // Special characters
+ if ($this.hasClass('symbol')) character = $('span:visible', $this).html();
+ if ($this.hasClass('space')) character = ' ';
+ if ($this.hasClass('tab')) character = "\t";
+ if ($this.hasClass('return')) character = "\n";
+
+ // Uppercase letter
+ if ($this.hasClass('uppercase')) character = character.toUpperCase();
+
+ // Remove shift once a key is clicked.
+ self.removeShift();
+
+ self.writeCharacter(character);
+ });
+ },
+ });
+}
diff --git a/addons/point_of_sale/static/src/js/pos_main.js b/addons/point_of_sale/static/src/js/pos_main.js
new file mode 100644
index 00000000000..71ac69c6067
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_main.js
@@ -0,0 +1,25 @@
+
+openerp.point_of_sale = function(instance) {
+
+ instance.point_of_sale = {};
+
+ var module = instance.point_of_sale;
+
+ openerp_pos_models(instance,module); // import pos_models.js
+
+ openerp_pos_basewidget(instance,module); // import pos_basewidget.js
+
+ openerp_pos_keyboard(instance,module); // import pos_keyboard_widget.js
+
+ openerp_pos_scrollbar(instance,module); // import pos_scrollbar_widget.js
+
+ openerp_pos_screens(instance,module); // import pos_screens.js
+
+ openerp_pos_widgets(instance,module); // import pos_widgets.js
+
+ openerp_pos_devices(instance,module); // import pos_devices.js
+
+ instance.web.client_actions.add('pos.ui', 'instance.point_of_sale.PosWidget');
+};
+
+
diff --git a/addons/point_of_sale/static/src/js/pos_models.js b/addons/point_of_sale/static/src/js/pos_models.js
new file mode 100644
index 00000000000..fd1e3fb384f
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_models.js
@@ -0,0 +1,1026 @@
+function openerp_pos_models(instance, module){ //module is instance.point_of_sale
+ var QWeb = instance.web.qweb;
+
+ module.LocalStorageDAO = instance.web.Class.extend({
+ add_operation: function(operation) {
+ var self = this;
+ return $.async_when().pipe(function() {
+ var tmp = self._get('oe_pos_operations', []);
+ var last_id = self._get('oe_pos_operations_sequence', 1);
+ tmp.push({'id': last_id, 'data': operation});
+ self._set('oe_pos_operations', tmp);
+ self._set('oe_pos_operations_sequence', last_id + 1);
+ });
+ },
+ remove_operation: function(id) {
+ var self = this;
+ return $.async_when().pipe(function() {
+ var tmp = self._get('oe_pos_operations', []);
+ tmp = _.filter(tmp, function(el) {
+ return el.id !== id;
+ });
+ self._set('oe_pos_operations', tmp);
+ });
+ },
+ get_operations: function() {
+ var self = this;
+ return $.async_when().pipe(function() {
+ return self._get('oe_pos_operations', []);
+ });
+ },
+ _get: function(key, default_) {
+ var txt = localStorage['oe_pos_dao_'+key];
+ if (! txt)
+ return default_;
+ return JSON.parse(txt);
+ },
+ _set: function(key, value) {
+ localStorage['oe_pos_dao_'+key] = JSON.stringify(value);
+ },
+ reset_stored_data: function(){
+ for(key in localStorage){
+ if(key.indexOf('oe_pos_dao_') === 0){
+ delete localStorage[key];
+ }
+ }
+ },
+ });
+
+ var fetch = function(model, fields, domain, ctx){
+ return new instance.web.Model(model).query(fields).filter(domain).context(ctx).all()
+ };
+
+ // The PosModel contains the Point Of Sale's representation of the backend.
+ // Since the PoS must work in standalone ( Without connection to the server )
+ // it must contains a representation of the server's PoS backend.
+ // (taxes, product list, configuration options, etc.) this representation
+ // is fetched and stored by the PosModel at the initialisation.
+ // this is done asynchronously, a ready deferred alows the GUI to wait interactively
+ // for the loading to be completed
+ // There is a single instance of the PosModel for each Front-End instance, it is usually called
+ // 'pos' and is available to almost all widgets.
+
+ module.PosModel = Backbone.Model.extend({
+ initialize: function(session, attributes) {
+ Backbone.Model.prototype.initialize.call(this, attributes);
+ var self = this;
+ this.dao = new module.LocalStorageDAO(); // used to store the order's data on the Hard Drive
+ this.ready = $.Deferred(); // used to notify the GUI that the PosModel has loaded all resources
+ this.flush_mutex = new $.Mutex(); // used to make sure the orders are sent to the server once at time
+ //this.build_tree = _.bind(this.build_tree, this); // ???
+ this.session = session;
+ this.categories = {};
+ this.root_category = null;
+ this.weightable_categories = []; // a flat list of all categories that directly contain weightable products
+ this.barcode_reader = new module.BarcodeReader({'pos': this}); // used to read barcodes
+ this.proxy = new module.ProxyDevice(); // used to communicate to the hardware devices via a local proxy
+
+ // pos settings
+ this.use_scale = false;
+ this.use_proxy_printer = false;
+ this.use_virtual_keyboard = false;
+ this.use_websql = false;
+ this.use_barcode_scanner = false;
+
+ // default attributes values. If null, it will be loaded below.
+ this.set({
+ 'nbr_pending_operations': 0,
+
+ 'currency': {symbol: '$', position: 'after'},
+ 'shop': null,
+ 'company': null,
+ 'user': null, // the user that loaded the pos
+ 'user_list': null, // list of all users
+ 'cashier': null, // the logged cashier, if different from user
+
+ 'orders': new module.OrderCollection(),
+ //this is the product list as seen by the product list widgets, it will change based on the category filters
+ 'products': new module.ProductCollection(),
+ 'cashRegisters': null,
+
+ 'product_list': null, // the list of all products, does not change.
+ 'bank_statements': null,
+ 'taxes': null,
+ 'pos_session': null,
+ 'pos_config': null,
+ 'categories': null,
+
+ 'selectedOrder': undefined,
+ });
+
+ this.get('orders').bind('remove', function(){ self.on_removed_order(); });
+
+ // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched,
+ // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched.
+
+ var user_def = fetch('res.users',['name','company_id'],[['id','=',this.session.uid]])
+ .pipe(function(users){
+ var user = users[0];
+ self.set('user',user);
+
+ return fetch('res.company',
+ [
+ 'currency_id',
+ 'email',
+ 'website',
+ 'company_registry',
+ //TODO contact_address
+ 'vat',
+ 'name',
+ 'phone'
+ ],
+ [['id','=',user.company_id[0]]])
+ }).pipe(function(companies){
+ var company = companies[0];
+ self.set('company',company);
+
+ return fetch('res.currency',['symbol','position'],[['id','=',company.currency_id[0]]]);
+ }).pipe(function (currencies){
+ self.set('currency',currencies[0]);
+ });
+
+ var cat_def = fetch('pos.category', ['id','name', 'parent_id', 'child_id', 'category_image_small'])
+ .pipe(function(result){
+ return self.set({'categories': result});
+ });
+
+ var uom_def = fetch( //unit of measure
+ 'product.uom',
+ null,
+ null
+ ).then(function(result){
+ self.set({'units': result});
+ var units_by_id = {};
+ for(var i = 0, len = result.length; i < len; i++){
+ units_by_id[result[i].id] = result[i];
+ }
+ self.set({'units_by_id':units_by_id});
+ });
+
+ var pack_def = fetch(
+ 'product.packaging',
+ null,
+ null
+ ).then(function(packaging){
+ self.set('product.packaging',packaging);
+ });
+
+ var users_def = fetch(
+ 'res.users',
+ ['name','ean13'],
+ [['ean13', '!=', false]]
+ ).then(function(result){
+ self.set({'user_list':result});
+ });
+
+ var tax_def = fetch('account.tax', ['amount','price_include','type'])
+ .then(function(result){
+ self.set({'taxes': result});
+ });
+
+ var session_def = fetch( // loading the PoS Session.
+ 'pos.session',
+ ['id', 'journal_ids','name','user_id','config_id','start_at','stop_at'],
+ [['state', '=', 'opened'], ['user_id', '=', this.session.uid]]
+ ).pipe(function(result) {
+
+ // some data are associated with the pos session, like the pos config and bank statements.
+ // we must have a valid session before we can read those.
+
+ var session_data_def = new $.Deferred();
+
+ if( result.length !== 0 ) {
+ var pos_session = result[0];
+
+ self.set({'pos_session': pos_session});
+
+ var pos_config_def = fetch(
+ 'pos.config',
+ ['name','journal_ids','shop_id','journal_id',
+ 'iface_self_checkout', 'iface_websql', 'iface_led', 'iface_cashdrawer',
+ 'iface_payment_terminal', 'iface_electronic_scale', 'iface_barscan', 'iface_vkeyboard',
+ 'iface_print_via_proxy','iface_cashdrawer','state','sequence_id','session_ids'],
+ [['id','=', pos_session.config_id[0]]]
+ ).pipe(function(result){
+ var pos_config = result[0]
+
+ self.set({'pos_config': pos_config});
+ self.use_scale = pos_config.iface_electronic_scale || false;
+ self.use_proxy_printer = pos_config.iface_print_via_proxy || false;
+ self.use_virtual_keyboard = pos_config.iface_vkeyboard || false;
+ self.use_websql = pos_config.iface_websql || false;
+ self.use_barcode_scanner = pos_config.iface_barscan || false;
+ self.use_selfcheckout = pos_config.iface_self_checkout || false;
+ self.use_cashbox = pos_config.iface_cashdrawer || false;
+
+ return shop_def = fetch('sale.shop',[], [['id','=',pos_config.shop_id[0]]])
+ }).pipe(function(shops){
+ self.set('shop',shops[0]);
+ return fetch(
+ 'product.product',
+ //context {pricelist: shop.pricelist_id[0]}
+ ['name', 'list_price','price','pos_categ_id', 'taxes_id','product_image_small', 'ean13', 'to_weight', 'uom_id', 'uos_id', 'uos_coeff', 'mes_type'],
+ [['pos_categ_id','!=', false]],
+ {pricelist: shops[0].pricelist_id[0]} // context for price
+ );
+ }).pipe( function(product_list){
+ self.set({'product_list': product_list});
+ });
+
+ var bank_def = fetch(
+ 'account.bank.statement',
+ ['account_id','currency','journal_id','state','name','user_id','pos_session_id'],
+ [['state','=','open'],['pos_session_id', '=', pos_session.id]]
+ ).then(function(result){
+ self.set({'bank_statements':result});
+ });
+
+ var journal_def = fetch(
+ 'account.journal',
+ undefined,
+ [['user_id','=',pos_session.user_id[0]]]
+ ).then(function(result){
+ self.set({'journals':result});
+ });
+
+ // associate the bank statements with their journals.
+ var bank_process_def = $.when(bank_def, journal_def)
+ .then(function(){
+ var bank_statements = self.get('bank_statements');
+ var journals = self.get('journals');
+ for(var i = 0, ilen = bank_statements.length; i < ilen; i++){
+ for(var j = 0, jlen = journals.length; j < jlen; j++){
+ if(bank_statements[i].journal_id[0] === journals[j].id){
+ bank_statements[i].journal = journals[j];
+ bank_statements[i].self_checkout_payment_method = journals[j].self_checkout_payment_method;
+ }
+ }
+ }
+ });
+
+ session_data_def = $.when(pos_config_def,bank_def,journal_def,bank_process_def);
+
+ }else{
+ session_data_def.reject();
+ }
+ return session_data_def;
+ });
+
+ // associate the products with their categories
+ var prod_process_def = $.when(cat_def, session_def)
+ .pipe(function(){
+ var product_list = self.get('product_list');
+ var categories = self.get('categories');
+ var cat_by_id = {};
+ for(var i = 0; i < categories.length; i++){
+ cat_by_id[categories[i].id] = categories[i];
+ }
+ //set the parent in the category
+ for(var i = 0; i < categories.length; i++){
+ categories[i].parent_category = cat_by_id[categories[i].parent_id[0]];
+ }
+ for(var i = 0; i < product_list.length; i++){
+ product_list[i].pos_category = cat_by_id[product_list[i].pos_categ_id[0]];
+ }
+ });
+
+ // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used.
+ $.when(pack_def, cat_def, user_def, users_def, uom_def, session_def, tax_def, prod_process_def, user_def, this.flush())
+ .then(function(){
+ self.build_categories();
+ self.set({'cashRegisters' : new module.CashRegisterCollection(self.get('bank_statements'))});
+ //self.log_loaded_data(); //Uncomment if you want to log the data to the console for easier debugging
+ self.ready.resolve();
+ },function(){
+ //we failed to load some backend data, or the backend was badly configured.
+ //the error messages will be displayed in PosWidget
+ self.ready.reject();
+ });
+ },
+
+ // logs the usefull posmodel data to the console for debug purposes
+ log_loaded_data: function(){
+ console.log('PosModel data has been loaded:');
+ console.log('PosModel: categories:',this.get('categories'));
+ console.log('PosModel: product_list:',this.get('product_list'));
+ console.log('PosModel: units:',this.get('units'));
+ console.log('PosModel: bank_statements:',this.get('bank_statements'));
+ console.log('PosModel: journals:',this.get('journals'));
+ console.log('PosModel: taxes:',this.get('taxes'));
+ console.log('PosModel: pos_session:',this.get('pos_session'));
+ console.log('PosModel: pos_config:',this.get('pos_config'));
+ console.log('PosModel: cashRegisters:',this.get('cashRegisters'));
+ console.log('PosModel: shop:',this.get('shop'));
+ console.log('PosModel: company:',this.get('company'));
+ console.log('PosModel: currency:',this.get('currency'));
+ console.log('PosModel: user_list:',this.get('user_list'));
+ console.log('PosModel: user:',this.get('user'));
+ console.log('PosModel.session:',this.session);
+ console.log('PosModel.categories:',this.categories);
+ console.log('PosModel end of data log.');
+ },
+
+ // this is called when an order is removed from the order collection. It ensures that there is always an existing
+ // order and a valid selected order
+ on_removed_order: function(removed_order){
+ if( this.get('orders').isEmpty()){
+ this.add_and_select_order(new module.Order({ pos: this }));
+ }
+ if( this.get('selectedOrder') === removed_order){
+ this.set({ selectedOrder: this.get('orders').last() });
+ }
+ },
+
+ // saves the order locally and try to send it to the backend. 'record' is a bizzarely defined JSON version of the Order
+ push_order: function(record) {
+ var self = this;
+ return this.dao.add_operation(record).pipe(function(){
+ return self.flush();
+ });
+ },
+
+ add_and_select_order: function(newOrder) {
+ (this.get('orders')).add(newOrder);
+ return this.set({
+ selectedOrder: newOrder
+ });
+ },
+
+ // attemps to send all pending orders ( stored in the DAO ) to the server.
+ // it will do it one by one, and remove the successfully sent ones from the DAO once
+ // it has been confirmed that they have been received.
+ flush: function() {
+ //this makes sure only one _int_flush is called at the same time
+ return this.flush_mutex.exec(_.bind(function() {
+ return this._int_flush();
+ }, this));
+ },
+ _int_flush : function() {
+ var self = this;
+
+ this.dao.get_operations().pipe(function(operations) {
+ // operations are really Orders that are converted to json.
+ // they are saved to disk and then we attempt to send them to the backend so that they can
+ // be applied.
+ // since the network is not reliable we potentially have many 'pending operations' that have not been sent.
+ self.set( {'nbr_pending_operations':operations.length} );
+ if(operations.length === 0){
+ return $.when();
+ }
+ var order = operations[0];
+
+ // we prevent the default error handler and assume errors
+ // are a normal use case, except we stop the current iteration
+
+ return (new instance.web.Model('pos.order')).get_func('create_from_ui')([order])
+ .fail(function(unused, event){
+ // wtf ask niv
+ event.preventDefault();
+ })
+ .pipe(function(){
+ // success: remove the successfully sent operation, and try to send the next one
+ self.dao.remove_operation(operations[0].id).pipe(function(){
+ return self._int_flush();
+ });
+ }, function(){
+ // in case of error we just sit there and do nothing. wtf ask niv
+ return $.when();
+ });
+ });
+ },
+
+ // this adds several properties to the categories in order to make it easier to diplay them
+ // fields added include the list of product relevant to each category, list of child categories,
+ // list of ancestors, etc.
+ build_categories : function(){
+ var categories = this.get('categories');
+ var products = this.get('product_list');
+
+ //append the content of array2 into array1
+ function append(array1, array2){
+ for(var i = 0, len = array2.length; i < len; i++){
+ array1.push(array2[i]);
+ }
+ }
+
+ function appendSet(set1, set2){
+ for(key in set2){
+ set1[key] = set2[key];
+ }
+ }
+
+ var categories_by_id = {};
+ for(var i = 0; i < categories.length; i++){
+ categories_by_id[categories[i].id] = categories[i];
+ }
+ this.categories_by_id = categories_by_id;
+
+ var root_category = {
+ name : 'Root',
+ id : 0,
+ parent : null,
+ childrens : [],
+ };
+
+ // add parent and childrens field to categories, find root_categories
+ for(var i = 0; i < categories.length; i++){
+ var cat = categories[i];
+
+ cat.parent = categories_by_id[cat.parent_id[0]];
+ if(!cat.parent){
+ root_category.childrens.push(cat);
+ cat.parent = root_category;
+ }
+
+ cat.childrens = [];
+ for(var j = 0; j < cat.child_id.length; j++){
+ cat.childrens.push(categories_by_id[ cat.child_id[j] ]);
+ }
+ }
+
+ categories.push(root_category);
+
+ // set some default fields for next steps
+ for(var i = 0; i < categories.length; i++){
+ var cat = categories[i];
+
+ cat.product_list = []; //list of all products in the category
+ cat.product_set = {}; // [product.id] === true if product is in category
+ cat.weightable_product_list = [];
+ cat.weightable_product_set = {};
+ cat.weightable = false; //true if directly contains weightable products
+ }
+
+ this.root_category = root_category;
+
+ //we add the products to the categories.
+ for(var i = 0, len = products.length; i < len; i++){
+ var product = products[i];
+ var cat = categories_by_id[product.pos_categ_id[0]];
+ if(cat){
+ cat.product_list.push(product);
+ cat.product_set[product.id] = true;
+ if(product.to_weight){
+ cat.weightable_product_list.push(product);
+ cat.weightable_product_set[product.id] = true;
+ cat.weightable = true;
+ }
+ }
+ }
+
+ // we build a flat list of all categories that directly contains weightable products
+ this.weightable_categories = [];
+ for(var i = 0, len = categories.length; i < len; i++){
+ var cat = categories[i];
+ if(cat.weightable){
+ this.weightable_categories.push(cat);
+ }
+ }
+
+ // add ancestor field to categories, contains the list of parents of parents, from root to parent
+ function make_ancestors(cat, ancestors){
+ cat.ancestors = ancestors.slice(0);
+ ancestors.push(cat);
+
+ for(var i = 0; i < cat.childrens.length; i++){
+ make_ancestors(cat.childrens[i], ancestors.slice(0));
+ }
+ }
+
+ //add the products of the subcategories to the parent categories
+ function make_products(cat){
+ for(var i = 0; i < cat.childrens.length; i++){
+ make_products(cat.childrens[i]);
+
+ append(cat.product_list, cat.childrens[i].product_list);
+ append(cat.weightable_product_list, cat.childrens[i].weightable_product_list);
+
+ appendSet(cat.product_set, cat.childrens[i].product_set);
+ appendSet(cat.weightable_product_set, cat.childrens[i].weightable_product_set);
+ }
+ }
+
+ make_ancestors(root_category,[]);
+ make_products(root_category);
+ },
+ });
+
+ module.CashRegister = Backbone.Model.extend({
+ });
+
+ module.CashRegisterCollection = Backbone.Collection.extend({
+ model: module.CashRegister,
+ });
+
+ module.Product = Backbone.Model.extend({
+ });
+
+ module.ProductCollection = Backbone.Collection.extend({
+ model: module.Product,
+ });
+
+ // An orderline represent one element of the content of a client's shopping cart.
+ // An orderline contains a product, its quantity, its price, discount. etc.
+ // An Order contains zero or more Orderlines.
+ module.Orderline = Backbone.Model.extend({
+ initialize: function(attr,options){
+ this.pos = options.pos;
+ this.order = options.order;
+ this.product = options.product;
+ this.price = options.product.get('list_price');
+ this.quantity = 1;
+ this.discount = 0;
+ this.type = 'unit';
+ this.selected = false;
+ },
+ // sets a discount [0,100]%
+ set_discount: function(discount){
+ this.discount = Math.max(0,Math.min(100,discount));
+ this.trigger('change');
+ },
+ // returns the discount [0,100]%
+ get_discount: function(){
+ return this.discount;
+ },
+ // FIXME
+ get_product_type: function(){
+ return this.type;
+ },
+ // sets the quantity of the product. The quantity will be rounded according to the
+ // product's unity of measure properties. Quantities greater than zero will not get
+ // rounded to zero
+ set_quantity: function(quantity){
+ if(_.isNaN(quantity)){
+ this.order.removeOrderline(this);
+ }else if(quantity !== undefined){
+ this.quantity = Math.max(0,quantity);
+ var unit = this.get_unit();
+ if(unit && this.quantity > 0 ){
+ this.quantity = Math.max(unit.rounding, Math.round(quantity / unit.rounding) * unit.rounding);
+ }
+ }
+ this.trigger('change');
+ },
+ // return the quantity of product
+ get_quantity: function(){
+ return this.quantity;
+ },
+ // return the unit of measure of the product
+ get_unit: function(){
+ var unit_id = (this.product.get('uos_id') || this.product.get('uom_id'));
+ if(!unit_id){
+ return undefined;
+ }
+ unit_id = unit_id[0];
+ if(!this.pos){
+ return undefined;
+ }
+ return this.pos.get('units_by_id')[unit_id];
+ },
+ // return the product of this orderline
+ get_product: function(){
+ return this.product;
+ },
+ // return the base price of this product (for this orderline)
+ get_list_price: function(){
+ return this.price;
+ },
+ // changes the base price of the product for this orderline
+ set_list_price: function(price){
+ this.price = price;
+ this.trigger('change');
+ },
+ // selects or deselects this orderline
+ set_selected: function(selected){
+ this.selected = selected;
+ this.trigger('change');
+ },
+ // returns true if this orderline is selected
+ is_selected: function(){
+ return this.selected;
+ },
+ // when we add an new orderline we want to merge it with the last line to see reduce the number of items
+ // in the orderline. This returns true if it makes sense to merge the two
+ can_be_merged_with: function(orderline){
+ if( this.get_product().get('id') !== orderline.get_product().get('id')){ //only orderline of the same product can be merged
+ return false;
+ }else if(this.get_product_type() !== orderline.get_product_type()){
+ return false;
+ }else if(this.get_discount() > 0){ // we don't merge discounted orderlines
+ return false;
+ }else if(this.get_product_type() === 'unit'){
+ return true;
+ }else if(this.get_product_type() === 'weight'){
+ return true;
+ }else if(this.get_product_type() === 'price'){
+ return this.get_product().get('list_price') === orderline.get_product().get('list_price');
+ }else{
+ console.error('point_of_sale/pos_models.js/Orderline.can_be_merged_with() : unknown product type:',this.get('product_type'));
+ return false;
+ }
+ },
+ merge: function(orderline){
+ this.set_quantity(this.get_quantity() + orderline.get_quantity());
+ },
+ export_as_JSON: function() {
+ return {
+ qty: this.get_quantity(),
+ price_unit: this.get_list_price(),
+ discount: this.get_discount(),
+ product_id: this.get_product().get('id'),
+ };
+ },
+ //used to create a json of the ticket, to be sent to the printer
+ export_for_printing: function(){
+ return {
+ quantity: this.get_quantity(),
+ unit_name: this.get_unit().name,
+ list_price: this.get_list_price(),
+ discount: this.get_discount(),
+ product_name: this.get_product().get('name'),
+ price_with_tax : this.get_price_with_tax(),
+ price_without_tax: this.get_price_without_tax(),
+ tax: this.get_tax(),
+ };
+ },
+ get_price_without_tax: function(){
+ return this.get_all_prices().priceWithoutTax;
+ },
+ get_price_with_tax: function(){
+ return this.get_all_prices().priceWithTax;
+ },
+ get_tax: function(){
+ return this.get_all_prices().tax;
+ },
+ get_all_prices: function() {
+ var self = this;
+ var base = this.get_quantity() * this.price * (1 - (this.get_discount() / 100));
+ var totalTax = base;
+ var totalNoTax = base;
+
+ var product_list = this.pos.get('product_list');
+ var product = this.get_product();
+ var taxes_ids = product.taxes_id;
+ var taxes = self.pos.get('taxes');
+ var taxtotal = 0;
+ _.each(taxes_ids, function(el) {
+ var tax = _.detect(taxes, function(t) {return t.id === el;});
+ if (tax.price_include) {
+ var tmp;
+ if (tax.type === "percent") {
+ tmp = base - (base / (1 + tax.amount));
+ } else if (tax.type === "fixed") {
+ tmp = tax.amount * self.get_quantity();
+ } else {
+ throw "This type of tax is not supported by the point of sale: " + tax.type;
+ }
+ taxtotal += tmp;
+ totalNoTax -= tmp;
+ } else {
+ var tmp;
+ if (tax.type === "percent") {
+ tmp = tax.amount * base;
+ } else if (tax.type === "fixed") {
+ tmp = tax.amount * self.get_quantity();
+ } else {
+ throw "This type of tax is not supported by the point of sale: " + tax.type;
+ }
+ taxtotal += tmp;
+ totalTax += tmp;
+ }
+ });
+ return {
+ "priceWithTax": totalTax,
+ "priceWithoutTax": totalNoTax,
+ "tax": taxtotal,
+ };
+ },
+ });
+
+ module.OrderlineCollection = Backbone.Collection.extend({
+ model: module.Orderline,
+ });
+
+ // Every PaymentLine contains a cashregister and an amount of money.
+ module.Paymentline = Backbone.Model.extend({
+ initialize: function(attributes, options) {
+ this.amount = 0;
+ this.cashregister = options.cashRegister;
+ },
+ //sets the amount of money on this payment line
+ set_amount: function(value){
+ this.amount = value;
+ this.trigger('change');
+ },
+ // returns the amount of money on this paymentline
+ get_amount: function(){
+ return this.amount;
+ },
+ // returns the associated cashRegister
+ get_cashregister: function(){
+ return this.cashregister;
+ },
+ //exports as JSON for server communication
+ export_as_JSON: function(){
+ return {
+ name: instance.web.datetime_to_str(new Date()),
+ statement_id: this.cashregister.get('id'),
+ account_id: (this.cashregister.get('account_id'))[0],
+ journal_id: (this.cashregister.get('journal_id'))[0],
+ amount: this.get_amount()
+ };
+ },
+ //exports as JSON for receipt printing
+ export_for_printing: function(){
+ return {
+ amount: this.get_amount(),
+ journal: this.cashregister.get('journal_id')[1],
+ };
+ },
+ });
+
+ module.PaymentlineCollection = Backbone.Collection.extend({
+ model: module.Paymentline,
+ });
+
+
+ // An order more or less represents the content of a client's shopping cart (the OrderLines)
+ // plus the associated payment information (the PaymentLines)
+ // there is always an active ('selected') order in the Pos, a new one is created
+ // automaticaly once an order is completed and sent to the server.
+ module.Order = Backbone.Model.extend({
+ initialize: function(attributes){
+ Backbone.Model.prototype.initialize.apply(this, arguments);
+ this.set({
+ creationDate: new Date(),
+ orderLines: new module.OrderlineCollection(),
+ paymentLines: new module.PaymentlineCollection(),
+ name: "Order " + this.generateUniqueId(),
+ client: null,
+ });
+ this.pos = attributes.pos;
+ this.selected_orderline = undefined;
+ this.screen_data = {}; // see ScreenSelector
+ return this;
+ },
+ generateUniqueId: function() {
+ return new Date().getTime();
+ },
+ addProduct: function(product, options){
+ options = options || {};
+ var attr = product.toJSON();
+ attr.pos = this.pos;
+ attr.order = this;
+ var line = new module.Orderline({}, {pos: this.pos, order: this, product: product});
+
+ if(options.quantity !== undefined){
+ line.set_quantity(options.quantity);
+ }
+ if(options.price !== undefined){
+ line.set_list_price(options.price);
+ }
+
+ var last_orderline = this.getLastOrderline();
+ if( last_orderline && last_orderline.can_be_merged_with(line) && options.merge !== false){
+ last_orderline.merge(line);
+ }else{
+ this.get('orderLines').add(line);
+ }
+ this.selectLine(this.getLastOrderline());
+ },
+ removeOrderline: function( line ){
+ this.get('orderLines').remove(line);
+ this.selectLine(this.getLastOrderline());
+ },
+ getLastOrderline: function(){
+ return this.get('orderLines').at(this.get('orderLines').length -1);
+ },
+ addPaymentLine: function(cashRegister) {
+ var paymentLines = this.get('paymentLines');
+ var newPaymentline = new module.Paymentline({},{cashRegister:cashRegister});
+ if(cashRegister.get('journal').type !== 'cash'){
+ newPaymentline.set_amount( this.getDueLeft() );
+ }
+ paymentLines.add(newPaymentline);
+ },
+ getName: function() {
+ return this.get('name');
+ },
+ getTotal: function() {
+ return (this.get('orderLines')).reduce((function(sum, orderLine) {
+ return sum + orderLine.get_price_with_tax();
+ }), 0);
+ },
+ getTotalTaxExcluded: function() {
+ return (this.get('orderLines')).reduce((function(sum, orderLine) {
+ return sum + orderLine.get_price_without_tax();
+ }), 0);
+ },
+ getTax: function() {
+ return (this.get('orderLines')).reduce((function(sum, orderLine) {
+ return sum + orderLine.get_tax();
+ }), 0);
+ },
+ getPaidTotal: function() {
+ return (this.get('paymentLines')).reduce((function(sum, paymentLine) {
+ return sum + paymentLine.get_amount();
+ }), 0);
+ },
+ getChange: function() {
+ return this.getPaidTotal() - this.getTotal();
+ },
+ getDueLeft: function() {
+ return this.getTotal() - this.getPaidTotal();
+ },
+ // the client related to the current order.
+ set_client: function(client){
+ this.set('client',client);
+ },
+ get_client: function(){
+ return this.get('client');
+ },
+ // the order also stores the screen status, as the PoS supports
+ // different active screens per order. This method is used to
+ // store the screen status.
+ set_screen_data: function(key,value){
+ if(arguments.length === 2){
+ this.screen_data[key] = value;
+ }else if(arguments.length === 1){
+ for(key in arguments[0]){
+ this.screen_data[key] = arguments[0][key];
+ }
+ }
+ },
+ //see set_screen_data
+ get_screen_data: function(key){
+ return this.screen_data[key];
+ },
+ // exports a JSON for receipt printing
+ export_for_printing: function(){
+ var orderlines = [];
+ this.get('orderLines').each(function(orderline){
+ orderlines.push(orderline.export_for_printing());
+ });
+
+ var paymentlines = [];
+ this.get('paymentLines').each(function(paymentline){
+ paymentlines.push(paymentline.export_for_printing());
+ });
+ var client = this.get('client');
+ var cashier = this.pos.get('cashier') || this.pos.get('user');
+ var company = this.pos.get('company');
+ var shop = this.pos.get('shop');
+ var date = new Date();
+
+ return {
+ orderlines: orderlines,
+ paymentlines: paymentlines,
+ total_with_tax: this.getTotal(),
+ total_without_tax: this.getTotalTaxExcluded(),
+ total_tax: this.getTax(),
+ total_paid: this.getPaidTotal(),
+ change: this.getChange(),
+ name : this.getName(),
+ client: client ? client.name : null ,
+ cashier: cashier ? cashier.name : null,
+ date: {
+ year: date.getFullYear(),
+ month: date.getMonth(),
+ date: date.getDate(), // day of the month
+ day: date.getDay(), // day of the week
+ hour: date.getHours(),
+ minute: date.getMinutes()
+ },
+ company:{
+ email: company.email,
+ website: company.website,
+ company_registry: company.company_registry,
+ contact_address: null, //TODO
+ vat: company.vat,
+ name: company.name,
+ phone: company.phone,
+ },
+ shop:{
+ name: shop.name,
+ },
+ currency: this.pos.get('currency'),
+ };
+ },
+ exportAsJSON: function() {
+ var orderLines, paymentLines;
+ orderLines = [];
+ (this.get('orderLines')).each(_.bind( function(item) {
+ return orderLines.push([0, 0, item.export_as_JSON()]);
+ }, this));
+ paymentLines = [];
+ (this.get('paymentLines')).each(_.bind( function(item) {
+ return paymentLines.push([0, 0, item.export_as_JSON()]);
+ }, this));
+ return {
+ name: this.getName(),
+ amount_paid: this.getPaidTotal(),
+ amount_total: this.getTotal(),
+ amount_tax: this.getTax(),
+ amount_return: this.getChange(),
+ lines: orderLines,
+ statement_ids: paymentLines,
+ pos_session_id: this.pos.get('pos_session').id,
+ partner_id: this.pos.get('client') ? this.pos.get('client').id : undefined,
+ user_id: this.pos.get('cashier') ? this.pos.get('cashier').id : this.pos.get('user').id,
+ };
+ },
+ getSelectedLine: function(){
+ return this.selected_orderline;
+ },
+ selectLine: function(line){
+ if(line){
+ if(line !== this.selected_orderline){
+ if(this.selected_orderline){
+ this.selected_orderline.set_selected(false);
+ }
+ this.selected_orderline = line;
+ this.selected_orderline.set_selected(true);
+ }
+ }else{
+ this.selected_orderline = undefined;
+ }
+ },
+ });
+
+ module.OrderCollection = Backbone.Collection.extend({
+ model: module.Order,
+ });
+
+ /*
+ The numpad handles both the choice of the property currently being modified
+ (quantity, price or discount) and the edition of the corresponding numeric value.
+ */
+ module.NumpadState = Backbone.Model.extend({
+ defaults: {
+ buffer: "0",
+ mode: "quantity"
+ },
+ appendNewChar: function(newChar) {
+ var oldBuffer;
+ oldBuffer = this.get('buffer');
+ if (oldBuffer === '0') {
+ this.set({
+ buffer: newChar
+ });
+ } else if (oldBuffer === '-0') {
+ this.set({
+ buffer: "-" + newChar
+ });
+ } else {
+ this.set({
+ buffer: (this.get('buffer')) + newChar
+ });
+ }
+ this.updateTarget();
+ },
+ deleteLastChar: function() {
+ var tempNewBuffer = this.get('buffer').slice(0, -1);
+
+ if(!tempNewBuffer){
+ this.set({ buffer: "0" });
+ this.killTarget();
+ }else{
+ if (isNaN(tempNewBuffer)) {
+ tempNewBuffer = "0";
+ }
+ this.set({ buffer: tempNewBuffer });
+ this.updateTarget();
+ }
+ },
+ switchSign: function() {
+ var oldBuffer;
+ oldBuffer = this.get('buffer');
+ this.set({
+ buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
+ });
+ this.updateTarget();
+ },
+ changeMode: function(newMode) {
+ this.set({
+ buffer: "0",
+ mode: newMode
+ });
+ },
+ reset: function() {
+ this.set({
+ buffer: "0",
+ mode: "quantity"
+ });
+ },
+ updateTarget: function() {
+ var bufferContent, params;
+ bufferContent = this.get('buffer');
+ if (bufferContent && !isNaN(bufferContent)) {
+ this.trigger('set_value', parseFloat(bufferContent));
+ }
+ },
+ killTarget: function(){
+ this.trigger('set_value',Number.NaN);
+ },
+ });
+}
diff --git a/addons/point_of_sale/static/src/js/pos_screens.js b/addons/point_of_sale/static/src/js/pos_screens.js
new file mode 100644
index 00000000000..eeb45e8a27d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_screens.js
@@ -0,0 +1,772 @@
+
+// this file contains the screens definitions. Screens are the
+// content of the right pane of the pos, containing the main functionalities.
+// screens are contained in the PosWidget, in pos_widget.js
+// all screens are present in the dom at all time, but only one is shown at the
+// same time.
+//
+// transition between screens is made possible by the use of the screen_selector,
+// which is responsible of hiding and showing the screens, as well as maintaining
+// the state of the screens between different orders.
+//
+// all screens inherit from ScreenWidget. the only addition from the base widgets
+// are show() and hide() which shows and hides the screen but are also used to
+// bind and unbind actions on widgets and devices. The screen_selector guarantees
+// that only one screen is shown at the same time and that show() is called after all
+// hide()s
+
+function openerp_pos_screens(instance, module){ //module is instance.point_of_sale
+ var QWeb = instance.web.qweb;
+
+ module.ScreenSelector = instance.web.Class.extend({
+ init: function(options){
+ this.pos = options.pos;
+
+ this.screen_set = options.screen_set || {};
+
+ this.popup_set = options.popup_set || {};
+
+ this.default_client_screen = options.default_client_screen;
+ this.default_cashier_screen = options.default_cashier_screen;
+
+ this.current_popup = null;
+
+ this.current_mode = options.default_mode || 'client';
+
+ this.current_screen = null;
+
+ for(screen_name in this.screen_set){
+ this.screen_set[screen_name].hide();
+ }
+
+ for(popup_name in this.popup_set){
+ this.popup_set[popup_name].hide();
+ }
+
+ this.selected_order = this.pos.get('selectedOrder');
+ this.selected_order.set_screen_data({
+ client_screen: this.default_client_screen,
+ cashier_screen: this.default_cashier_screen,
+ });
+
+ this.pos.bind('change:selectedOrder', this.load_saved_screen, this);
+ },
+ add_screen: function(screen_name, screen){
+ screen.hide();
+ this.screen_set[screen_name] = screen;
+ return this;
+ },
+ show_popup: function(name){
+ if(this.current_popup){
+ this.close_popup();
+ }
+ this.current_popup = this.popup_set[name];
+ this.current_popup.show();
+ },
+ close_popup: function(){
+ if(this.current_popup){
+ this.current_popup.hide();
+ this.current_popup = null;
+ }
+ },
+ load_saved_screen: function(){
+ this.close_popup();
+
+ var selectedOrder = this.pos.get('selectedOrder');
+
+ if(this.current_mode === 'client'){
+ this.set_current_screen(selectedOrder.get_screen_data('client_screen') || this.default_client_screen,null,'refresh');
+ }else if(this.current_mode === 'cashier'){
+ this.set_current_screen(selectedOrder.get_screen_data('cashier_screen') || this.default_cashier_screen,null,'refresh');
+ }
+ this.selected_order = selectedOrder;
+ },
+ set_user_mode: function(user_mode){
+ if(user_mode !== this.current_mode){
+ this.close_popup();
+ this.current_mode = user_mode;
+ this.load_saved_screen();
+ }
+ },
+ get_user_mode: function(){
+ return this.current_mode;
+ },
+ set_current_screen: function(screen_name,params,refresh){
+ var screen = this.screen_set[screen_name];
+ if(!screen){
+ console.error("ERROR: set_current_screen("+screen_name+") : screen not found");
+ }
+
+ this.close_popup();
+ var selectedOrder = this.pos.get('selectedOrder');
+ if(this.current_mode === 'client'){
+ selectedOrder.set_screen_data('client_screen',screen_name);
+ if(params){
+ selectedOrder.set_screen_data('client_screen_params',params);
+ }
+ }else{
+ selectedOrder.set_screen_data('cashier_screen',screen_name);
+ if(params){
+ selectedOrder.set_screen_data('cashier_screen_params',params);
+ }
+ }
+
+ if(screen && (refresh || screen !== this.current_screen)){
+ if(this.current_screen){
+ this.current_screen.close();
+ this.current_screen.hide();
+ }
+ this.current_screen = screen;
+ this.current_screen.show();
+ }
+ },
+ get_current_screen_param: function(param){
+ var selected_order = this.pos.get('selectedOrder');
+ if(this.current_mode === 'client'){
+ var params = selected_order.get_screen_data('client_screen_params');
+ }else{
+ var params = selected_order.get_screen_data('cashier_screen_params');
+ }
+ if(params){
+ return params[param];
+ }else{
+ return undefined;
+ }
+ },
+ set_default_screen: function(){
+ this.set_current_screen(this.current_mode === 'client' ? this.default_client_screen : this.default_cashier_screen);
+ },
+ });
+
+ module.ScreenWidget = module.PosBaseWidget.extend({
+
+ show_numpad: true,
+ show_leftpane: true,
+
+ init: function(parent,options){
+ this._super(parent,options);
+ this.hidden = false;
+ },
+
+ help_button_action: function(){
+ this.pos_widget.screen_selector.show_popup('help');
+ },
+
+ logout_button_action: function(){
+ this.pos_widget.screen_selector.set_user_mode('client');
+ },
+
+ close_button_action: function(){
+ this.pos_widget.try_close();
+ },
+
+ barcode_product_screen: 'products', //if defined, this screen will be loaded when a product is scanned
+ barcode_product_error_popup: 'error', //if defined, this popup will be loaded when there's an error in the popup
+
+ // what happens when a product is scanned :
+ // it will add the product to the order and go to barcode_product_screen. Or show barcode_product_error_popup if
+ // there's an error.
+ barcode_product_action: function(ean){
+ if(this.pos_widget.scan_product(ean)){
+ this.pos.proxy.scan_item_success();
+ if(this.barcode_product_screen){
+ this.pos_widget.screen_selector.set_current_screen(this.barcode_product_screen);
+ }
+ }else{
+ if(this.barcode_product_error_popup){
+ this.pos_widget.screen_selector.show_popup(this.barcode_product_error_popup);
+ }
+ }
+ },
+
+ // what happens when a cashier id barcode is scanned.
+ // the default behavior is the following :
+ // - if there's a user with a matching ean, put it as the active 'cashier', go to cashier mode, and return true
+ // - else : do nothing and return false. You probably want to extend this to show and appropriate error popup...
+ barcode_cashier_action: function(ean){
+ var users = this.pos.get('user_list');
+ for(var i = 0, len = users.length; i < len; i++){
+ if(users[i].ean13 === ean.ean){
+ this.pos.set('cashier',users[i]);
+ this.pos_widget.username.refresh();
+ this.pos.proxy.cashier_mode_activated();
+ this.pos_widget.screen_selector.set_user_mode('cashier');
+ return true;
+ }
+ }
+ return false;
+ },
+
+ // what happens when a client id barcode is scanned.
+ // the default behavior is the following :
+ // - if there's a user with a matching ean, put it as the active 'client' and return true
+ // - else : return false.
+ barcode_client_action: function(ean){
+ var users = this.pos.get('user_list');
+ for(var i = 0, len = users.length; i < len; i++){
+ if(users[i].ean13 === ean.ean){
+ this.pos.get('selectedOrder').set_client(users[i]);
+ this.pos_widget.username.refresh();
+ return true;
+ }
+ }
+ return false;
+ //TODO start the transaction
+ },
+
+ // what happens when a discount barcode is scanned : the default behavior
+ // is to set the discount on the last order.
+ barcode_discount_action: function(ean){
+ var last_orderline = this.pos.get('selectedOrder').getLastOrderline();
+ if(last_orderline){
+ last_orderline.set_discount(ean.value)
+ }
+ },
+
+ // this method shows the screen and sets up all the widget related to this screen. Extend this method
+ // if you want to alter the behavior of the screen.
+ show: function(){
+ this.hidden = false;
+ if(this.$element){
+ this.$element.show();
+ }
+
+ var self = this;
+ var cashier_mode = this.pos_widget.screen_selector.get_user_mode() === 'cashier';
+
+ this.pos_widget.set_numpad_visible(this.show_numpad && cashier_mode);
+ this.pos_widget.set_leftpane_visible(this.show_leftpane);
+ this.pos_widget.set_cashier_controls_visible(cashier_mode);
+ this.pos_widget.action_bar.set_element_visible('help-button', !cashier_mode, function(){ self.help_button_action(); });
+ this.pos_widget.action_bar.set_element_visible('logout-button', cashier_mode && this.pos.use_selfcheckout, function(){ self.logout_button_action(); });
+ this.pos_widget.action_bar.set_element_visible('close-button', cashier_mode, function(){ self.close_button_action(); });
+
+ this.pos_widget.username.set_user_mode(this.pos_widget.screen_selector.get_user_mode());
+
+ this.pos.barcode_reader.set_action_callback({
+ 'cashier': self.barcode_cashier_action ? function(ean){ self.barcode_cashier_action(ean); } : undefined ,
+ 'product': self.barcode_product_action ? function(ean){ self.barcode_product_action(ean); } : undefined ,
+ 'client' : self.barcode_client_action ? function(ean){ self.barcode_client_action(ean); } : undefined ,
+ 'discount': self.barcode_discount_action ? function(ean){ self.barcode_discount_action(ean); } : undefined,
+ });
+ },
+
+
+ // this method is called when the screen is closed to make place for a new screen. this is a good place
+ // to put your cleanup stuff as it is guaranteed that for each show() there is one and only one close()
+ close: function(){
+ if(this.pos.barcode_reader){
+ this.pos.barcode_reader.reset_action_callbacks();
+ }
+ if(this.pos_widget.action_bar){
+ this.pos_widget.action_bar.destroy_buttons();
+ }
+ },
+
+ // this methods hides the screen. It's not a good place to put your cleanup stuff as it is called on the
+ // POS initialization.
+ hide: function(){
+ this.hidden = true;
+ if(this.$element){
+ this.$element.hide();
+ }
+ },
+
+ // we need this because some screens re-render themselves when they are hidden
+ // (due to some events, or magic, or both...) we must make sure they remain hidden.
+ // the good solution would probably be to make them not re-render themselves when they
+ // are hidden.
+ renderElement: function(){
+ this._super();
+ if(this.hidden){
+ if(this.$element){
+ this.$element.hide();
+ }
+ }
+ },
+ });
+
+ module.PopUpWidget = module.PosBaseWidget.extend({
+ show: function(){
+ if(this.$element){
+ this.$element.show();
+ }
+ },
+ hide: function(){
+ if(this.$element){
+ this.$element.hide();
+ }
+ },
+ });
+
+ module.HelpPopupWidget = module.PopUpWidget.extend({
+ template:'HelpPopupWidget',
+ show: function(){
+ this._super();
+ this.pos.proxy.help_needed();
+ var self = this;
+
+ this.$element.find('.button').off('click').click(function(){
+ self.pos_widget.screen_selector.close_popup();
+ self.pos.proxy.help_canceled();
+ });
+ },
+ });
+
+ module.ErrorPopupWidget = module.PopUpWidget.extend({
+ template:'ErrorPopupWidget',
+ show: function(){
+ var self = this;
+ this._super();
+ this.pos.proxy.help_needed();
+ this.pos.proxy.scan_item_error_unrecognized();
+
+ this.pos.barcode_reader.save_callbacks();
+ this.pos.barcode_reader.reset_action_callbacks();
+ this.pos.barcode_reader.set_action_callback({
+ 'cashier': function(ean){
+ clearInterval(this.intervalID);
+ self.pos.proxy.cashier_mode_activated();
+ self.pos_widget.screen_selector.set_user_mode('cashier');
+ },
+ });
+ },
+ close:function(){
+ this._super();
+ this.pos.proxy.help_canceled();
+ this.pos.barcode_reader.restore_callbacks();
+ },
+ });
+
+ module.ErrorProductNotRecognizedPopupWidget = module.ErrorPopupWidget.extend({
+ template:'ErrorProductNotRecognizedPopupWidget',
+ });
+
+ module.ErrorNoSessionPopupWidget = module.ErrorPopupWidget.extend({
+ template:'ErrorNoSessionPopupWidget',
+ });
+
+ module.ScaleInviteScreenWidget = module.ScreenWidget.extend({
+ template:'ScaleInviteScreenWidget',
+
+ show: function(){
+ this._super();
+ var self = this;
+
+ self.pos.proxy.weighting_start();
+
+ this.intervalID = setInterval(function(){
+ var weight = self.pos.proxy.weighting_read_kg();
+ if(weight > 0.001){
+ clearInterval(this.intervalID);
+ self.pos_widget.screen_selector.set_current_screen('scale');
+ }
+ },500);
+
+ this.pos_widget.action_bar.add_new_button(
+ {
+ label: 'back',
+ icon: '/point_of_sale/static/src/img/icons/png48/go-previous.png',
+ click: function(){
+ clearInterval(this.intervalID);
+ self.pos.proxy.weighting_end();
+ self.pos_widget.screen_selector.set_current_screen('products');
+ }
+ }
+ );
+ },
+ close: function(){
+ this._super();
+ clearInterval(this.intervalID);
+ },
+ });
+
+ module.ScaleScreenWidget = module.ScreenWidget.extend({
+ template:'ScaleScreenWidget',
+ show: function(){
+ this._super();
+ this.renderElement();
+ var self = this;
+
+ this.pos_widget.action_bar.add_new_button({
+ label: 'back',
+ icon: '/point_of_sale/static/src/img/icons/png48/go-previous.png',
+ click: function(){
+ self.pos_widget.screen_selector.set_current_screen('products');
+ }
+ });
+
+ this.validate_button = this.pos_widget.action_bar.add_new_button({
+ label: 'Validate',
+ icon: '/point_of_sale/static/src/img/icons/png48/validate.png',
+ click: function(){
+ self.order_product();
+ self.pos_widget.screen_selector.set_current_screen('products');
+ },
+ });
+
+ this.pos.proxy.weighting_start();
+ this.intervalID = setInterval(function(){
+ var weight = self.pos.proxy.weighting_read_kg();
+ if(weight != self.weight){
+ self.weight = weight;
+ self.renderElement();
+ }
+ },200);
+ },
+ renderElement: function(){
+ var self = this;
+ this._super();
+ this.$('.product-picture').click(function(){
+ self.order_product();
+ self.pos_widget.screen_selector.set_current_screen('products');
+ });
+ },
+ get_product: function(){
+ var ss = this.pos_widget.screen_selector;
+ if(ss){
+ return ss.get_current_screen_param('product');
+ }else{
+ return undefined;
+ }
+ },
+ order_product: function(){
+ var weight = this.pos.proxy.weighting_read_kg();
+ this.pos.get('selectedOrder').addProduct(this.get_product(),{ quantity:weight });
+ },
+ get_product_name: function(){
+ var product = this.get_product();
+ return (product ? product.get('name') : undefined) || 'Unnamed Product';
+ },
+ get_product_price: function(){
+ var product = this.get_product();
+ return (product ? product.get('list_price') : 0) || 0;
+ },
+ get_product_image: function(){
+ var product = this.get_product();
+ return product ? product.get('product_image_small') : undefined;
+ },
+ get_product_weight: function(){
+ return this.weight || 0;
+ },
+ close: function(){
+ this._super();
+ clearInterval(this.intervalID);
+ this.pos.proxy.weighting_end();
+ },
+ });
+
+ module.ClientPaymentScreenWidget = module.ScreenWidget.extend({
+ template:'ClientPaymentScreenWidget',
+ show: function(){
+ this._super();
+ var self = this;
+
+ this.pos.proxy.payment_request(this.pos.get('selectedOrder').getDueLeft(),'card','info'); //TODO TOTAL
+
+ this.intervalID = setInterval(function(){
+ var payment = self.pos.proxy.is_payment_accepted();
+ if(payment === 'payment_accepted'){
+ clearInterval(this.intervalID);
+
+ var currentOrder = self.pos.get('selectedOrder');
+
+ //we get the first cashregister marked as self-checkout
+ var selfCheckoutRegisters = [];
+ for(var i = 0; i < this.pos.get('cashRegisters').models.length; i++){
+ var cashregister = this.pos.get('cashRegisters').models[i];
+ if(cashregister.self_checkout_payment_method){
+ selfCheckoutRegisters.push(cashregister);
+ }
+ }
+
+ var cashregister = selfCheckoutRegisters[0] || this.pos.get('cashRegisters').models[0];
+ currentOrder.addPaymentLine(cashregister);
+
+ self.pos.push_order(currentOrder.exportAsJSON()).then(function() {
+ currentOrder.destroy();
+ self.pos.proxy.transaction_end();
+ self.pos_widget.screen_selector.set_current_screen('welcome');
+ });
+ }else if(payment === 'payment_rejected'){
+ clearInterval(this.intervalID);
+ //TODO show a tryagain thingie ?
+ }
+ },500);
+
+ this.pos_widget.action_bar.add_new_button(
+ {
+ label: 'back',
+ icon: '/point_of_sale/static/src/img/icons/png48/go-previous.png',
+ click: function(){ //TODO Go to ask for weighting screen
+ clearInterval(this.intervalID);
+ self.pos.proxy.payment_canceled();
+ self.pos_widget.screen_selector.set_current_screen('products');
+ }
+ }
+ );
+ },
+ close: function(){
+ this._super();
+ clearInterval(this.intervalID);
+ },
+ });
+
+ module.WelcomeScreenWidget = module.ScreenWidget.extend({
+ template:'WelcomeScreenWidget',
+
+ show_numpad: false,
+ show_leftpane: false,
+
+ barcode_client_action: function(ean){
+ this._super(ean);
+ this.pos_widget.screen_selector.set_current_screen('products');
+ },
+
+ show: function(){
+ this._super();
+ var self = this;
+ },
+ });
+
+ module.ProductScreenWidget = module.ScreenWidget.extend({
+ template:'ProductScreenWidget',
+
+ show_numpad: true,
+ show_leftpane: true,
+
+ start: function(){ //FIXME this should work as renderElement... but then the categories aren't properly set. explore why
+ var self = this;
+ this.product_categories_widget = new module.ProductCategoriesWidget(this,{});
+ this.product_categories_widget.replace($('.placeholder-ProductCategoriesWidget'));
+
+ this.product_list_widget = new module.ProductListWidget(this,{
+ click_product_action: function(product){
+ if(product.get('to_weight') && self.pos.use_scale){
+ self.pos_widget.screen_selector.set_current_screen('scale_invite', {product: product});
+ }else{
+ self.pos.get('selectedOrder').addProduct(product);
+ }
+ },
+ });
+ this.product_list_widget.replace($('.placeholder-ProductListWidget'));
+ },
+
+ show: function(){
+ this._super();
+ var self = this;
+
+ this.product_categories_widget.reset_category();
+
+ this.pos_widget.order_widget.set_numpad_state(this.pos_widget.numpad.state);
+ if(this.pos.use_virtual_keyboard){
+ this.pos_widget.onscreen_keyboard.connect();
+ }
+
+ if(this.pos_widget.screen_selector.current_mode === 'client'){
+ this.pos_widget.action_bar.add_new_button({
+ label: 'pay',
+ icon: '/point_of_sale/static/src/img/icons/png48/go-next.png',
+ click: function(){
+ self.pos_widget.screen_selector.set_current_screen('client_payment');
+ }
+ });
+ }
+ },
+
+ close: function(){
+ this._super();
+ this.pos_widget.order_widget.set_numpad_state(null);
+ this.pos_widget.payment_screen.set_numpad_state(null);
+ },
+
+ });
+
+ module.ReceiptScreenWidget = module.ScreenWidget.extend({
+ template: 'ReceiptScreenWidget',
+
+ show_numpad: true,
+ show_leftpane: true,
+
+ init: function(parent, options) {
+ this._super(parent,options);
+ this.model = options.model;
+ this.user = this.pos.get('user');
+ this.company = this.pos.get('company');
+ this.shop_obj = this.pos.get('shop');
+ },
+ renderElement: function() {
+ this._super();
+ this.pos.bind('change:selectedOrder', this.change_selected_order, this);
+ this.change_selected_order();
+ },
+ show: function(){
+ this._super();
+ var self = this;
+
+ this.pos_widget.action_bar.add_new_button({
+ label: 'Print',
+ icon: '/point_of_sale/static/src/img/icons/png48/printer.png',
+ click: function(){ self.print(); },
+ });
+
+ this.pos_widget.action_bar.add_new_button({
+ label: 'Next Order',
+ icon: '/point_of_sale/static/src/img/icons/png48/go-next.png',
+ click: function() { self.finishOrder(); },
+ });
+ },
+ print: function() {
+ window.print();
+ },
+ finishOrder: function() {
+ this.pos.get('selectedOrder').destroy();
+ },
+ change_selected_order: function() {
+ if (this.currentOrderLines)
+ this.currentOrderLines.unbind();
+ this.currentOrderLines = (this.pos.get('selectedOrder')).get('orderLines');
+ this.currentOrderLines.bind('add', this.refresh, this);
+ this.currentOrderLines.bind('change', this.refresh, this);
+ this.currentOrderLines.bind('remove', this.refresh, this);
+ if (this.currentPaymentLines)
+ this.currentPaymentLines.unbind();
+ this.currentPaymentLines = (this.pos.get('selectedOrder')).get('paymentLines');
+ this.currentPaymentLines.bind('all', this.refresh, this);
+ this.refresh();
+ },
+ refresh: function() {
+ this.currentOrder = this.pos.get('selectedOrder');
+ $('.pos-receipt-container', this.$element).html(QWeb.render('PosTicket',{widget:this}));
+ },
+ });
+
+ module.PaymentScreenWidget = module.ScreenWidget.extend({
+ template: 'PaymentScreenWidget',
+ init: function(parent, options) {
+ this._super(parent,options);
+ this.model = options.model;
+ this.pos.bind('change:selectedOrder', this.change_selected_order, this);
+ this.bindPaymentLineEvents();
+ this.bind_orderline_events();
+ },
+ show: function(){
+ this._super();
+ var self = this;
+
+ if(this.pos.use_cashbox){
+ this.pos.proxy.open_cashbox();
+ }
+
+ this.set_numpad_state(this.pos_widget.numpad.state);
+
+ this.back_button = this.pos_widget.action_bar.add_new_button({
+ label: 'Back',
+ icon: '/point_of_sale/static/src/img/icons/png48/go-previous.png',
+ click: function(){
+ self.pos_widget.screen_selector.set_current_screen('products');
+ },
+ });
+
+ this.validate_button = this.pos_widget.action_bar.add_new_button({
+ label: 'Validate',
+ icon: '/point_of_sale/static/src/img/icons/png48/validate.png',
+ click: function(){
+ self.validateCurrentOrder();
+ },
+ });
+ },
+ close: function(){
+ this._super();
+ this.pos_widget.order_widget.set_numpad_state(null);
+ this.pos_widget.payment_screen.set_numpad_state(null);
+ },
+ back: function() {
+ this.pos_widget.screen_selector.set_current_screen('products');
+ },
+ validateCurrentOrder: function() {
+ var self = this;
+ var currentOrder = this.pos.get('selectedOrder');
+
+ this.validate_button.$element.attr('disabled','disabled'); //FIXME is the css actually using this attr ?
+
+ this.pos.push_order(currentOrder.exportAsJSON())
+ .then(function() {
+ self.validate_button.$element.removeAttr('disabled');
+ if(self.pos.use_proxy_printer){
+ self.pos.proxy.print_receipt(currentOrder.export_for_printing());
+ self.pos.get('selectedOrder').destroy(); //finish order and go back to scan screen
+ }else{
+ self.pos_widget.screen_selector.set_current_screen('receipt');
+ }
+ });
+ },
+ bindPaymentLineEvents: function() {
+ this.currentPaymentLines = (this.pos.get('selectedOrder')).get('paymentLines');
+ this.currentPaymentLines.bind('add', this.addPaymentLine, this);
+ this.currentPaymentLines.bind('remove', this.renderElement, this);
+ this.currentPaymentLines.bind('all', this.updatePaymentSummary, this);
+ },
+ bind_orderline_events: function() {
+ this.currentOrderLines = (this.pos.get('selectedOrder')).get('orderLines');
+ this.currentOrderLines.bind('all', this.updatePaymentSummary, this);
+ },
+ change_selected_order: function() {
+ this.currentPaymentLines.unbind();
+ this.bindPaymentLineEvents();
+ this.currentOrderLines.unbind();
+ this.bind_orderline_events();
+ this.renderElement();
+ },
+ addPaymentLine: function(newPaymentLine) {
+ var x = new module.PaymentlineWidget(null, {
+ payment_line: newPaymentLine
+ });
+ x.on_delete.add(_.bind(this.deleteLine, this, x));
+ x.appendTo(this.$('#paymentlines'));
+ },
+ renderElement: function() {
+ this._super();
+ this.$('#paymentlines').empty();
+ this.currentPaymentLines.each(_.bind( function(paymentLine) {
+ this.addPaymentLine(paymentLine);
+ }, this));
+ this.updatePaymentSummary();
+ },
+ deleteLine: function(lineWidget) {
+ this.currentPaymentLines.remove([lineWidget.payment_line]);
+ },
+ updatePaymentSummary: function() {
+ var currentOrder = this.pos.get('selectedOrder');
+ var paidTotal = currentOrder.getPaidTotal();
+ var dueTotal = currentOrder.getTotal();
+ var remaining = dueTotal > paidTotal ? dueTotal - paidTotal : 0;
+ var change = paidTotal > dueTotal ? paidTotal - dueTotal : 0;
+
+ this.$('#payment-due-total').html(dueTotal.toFixed(2));
+ this.$('#payment-paid-total').html(paidTotal.toFixed(2));
+ this.$('#payment-remaining').html(remaining.toFixed(2));
+ this.$('#payment-change').html(change.toFixed(2));
+ },
+ set_numpad_state: function(numpadState) {
+ if (this.numpadState) {
+ this.numpadState.unbind('set_value', this.set_value);
+ this.numpadState.unbind('change:mode', this.setNumpadMode);
+ }
+ this.numpadState = numpadState;
+ if (this.numpadState) {
+ this.numpadState.bind('set_value', this.set_value, this);
+ this.numpadState.bind('change:mode', this.setNumpadMode, this);
+ this.numpadState.reset();
+ this.setNumpadMode();
+ }
+ },
+ setNumpadMode: function() {
+ this.numpadState.set({mode: 'payment'});
+ },
+ set_value: function(val) {
+ this.currentPaymentLines.last().set({amount: val});
+ },
+ });
+
+}
diff --git a/addons/point_of_sale/static/src/js/pos_scrollbar_widget.js b/addons/point_of_sale/static/src/js/pos_scrollbar_widget.js
new file mode 100644
index 00000000000..d4f6903c435
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_scrollbar_widget.js
@@ -0,0 +1,268 @@
+/*
+ * This Widget provides a javascript scrollbar that is suitable to use with resistive
+ * tactile screens.
+ *
+ * Options:
+ * target_widget : the widget that will be scrolled.
+ * target_selector : if you don't want to scroll the root element of the the widget, you can provide a
+ * jquery selector string that will match on the widget's dom element. If there is no widget provided,
+ * it will match on the document
+ * step: on each click, the target will be scrolled by it's deplayed size multiplied by this value.
+ * duration: this is the duration of the scrolling animation
+ * wheel_step: the target will be scrolled by wheel_step pixels on each mouse scroll.
+ * track_bottom: the target will be kept on bottom when it's on the bottom and the size has changed
+ * on_show: this function will be called with the scrollbar as sole argument when the scrollbar is shown
+ * on_hide: this function will be called with the scrollbar as sole argument when the scrollbar is hidden
+ */
+function openerp_pos_scrollbar(instance, module){ //module is instance.point_of_sale
+
+ module.ScrollbarWidget = instance.web.Widget.extend({
+ template:'ScrollbarWidget',
+
+ init: function(parent,options){
+ var self = this;
+ options = options || {};
+ this._super(parent,options);
+ this.target_widget = options.target_widget;
+ this.target_selector = options.target_selector;
+ this.scroll_target = this.target().scrollTop();
+ this.scroll_step = options.step || 0.8;
+ this.scroll_duration = options.duration || 250;
+ this.wheel_step = options.wheel_step || 80;
+ this.name = options.name || 'unnamed';
+ this.bottom = false; // true if the scroller cannot be scrolled further
+ this.track_bottom = options.track_bottom || false;
+ this.on_show = options.on_show || function(){};
+ this.on_hide = options.on_hide || function(){};
+
+ // these handlers are declared once for the object's lifetime so that we can bind and unbind them.
+ this.resize_handler = function(){
+ setTimeout(function(){
+ if(self.bottom && self.track_bottom){
+ self.set_position(Number.MAX_VALUE);
+ }
+ self.update_scroller_dimensions();
+ self.update_button_status();
+ self.auto_hide(false);
+ },0);
+ };
+ this.target_mousewheel_handler = function(event,delta){
+ self.scroll(delta*self.wheel_step);
+ }
+ },
+
+ renderElement: function(){
+ this._super();
+ var self = this;
+ this.$('.up-button').off('click').click(function(){
+ self.page_up();
+ });
+ this.$('.down-button').off('click').click(function(){
+ self.page_down();
+ });
+ this.update_scroller_dimensions(false);
+ this.update_button_status();
+ this.auto_hide(false);
+ this.$element.bind('mousewheel',function(event,delta){
+ self.scroll(delta*self.wheel_step);
+ });
+ this.$element.bind('click',function(event){
+ var vpos = event.pageY - self.$element.offset().top;
+ var spos = self.scroller_dimensions();
+ if(vpos > spos.bar_pos && vpos < spos.pos){
+ self.page_up();
+ }else if( (vpos < spos.bar_pos + spos.bar_height) &&
+ (vpos > spos.pos + spos.height) ){
+ self.page_down();
+ }
+ });
+ // FIXME: use the event bus to handle window resize events
+ $(window).unbind('resize',this.resize_handler);
+ $(window).bind('resize',this.resize_handler);
+
+ this.target().unbind('mousewheel',this.target_mousweheel_handler);
+ this.target().bind('mousewheel',this.target_mousewheel_handler);
+
+ // because the rendering is asynchronous we must wait for the next javascript update
+ // for good dimensions values
+ setTimeout(function(){
+ self.update_scroller_dimensions(false);
+ self.update_button_status();
+ self.auto_hide(false);
+ },0);
+ },
+
+ // binds the window resize and the target scrolling events.
+ // it is good advice not to bind these multiple_times
+ bind_events:function(){
+ $(window).resize(function(){
+ });
+ this.target().bind('mousewheel',function(event,delta){
+ self.scroll(delta*self.wheel_step);
+ });
+ },
+
+ // shows the scrollbar. if animated is true, it will do it in an animated fashion
+ show: function(animated){ //FIXME: animated show and hide don't work ... ?
+ if(animated){
+ this.$element.show().animate({'width':'48px'}, 500, 'swing');
+ }else{
+ this.$element.show().css('width','48px');
+ }
+ this.on_show(this);
+ },
+
+ // hides the scrollbar. if animated is true, it will do it in a animated fashion
+ hide: function(animated){
+ var self = this;
+ if(animated){
+ this.$element.animate({'width':'0px'}, 500, 'swing', function(){ self.$element.hide();});
+ }else{
+ this.$element.hide().css('width','0px');
+ }
+ this.on_hide(this);
+ },
+
+ // returns the scroller position and other information as a dictionnary with the following fields:
+ // pos: the position in pixels of the top of the scroller starting from the top of the scrollbar
+ // height: the height of the scroller in pixels
+ // bar_pos: the position of the top of the scrollbar's inner region, starting from the top
+ // bar_height: the height of the scrollbar's inner region
+ scroller_dimensions: function(){
+ var target = this.target()[0];
+ var scroller_height = target.clientHeight / target.scrollHeight || 0;
+ var scroller_pos = this.scroll_target / target.scrollHeight || 0;
+ var button_up_height = this.$('.up-button')[0].offsetHeight || 48;
+ var button_down_height = this.$('.down-button')[0].offsetHeight || 48;
+
+ var bar_height = this.$element[0].offsetHeight || 96;
+ var scrollbar_height = bar_height - button_up_height - button_down_height;
+
+ scroller_pos = scroller_pos * scrollbar_height + button_up_height;
+ scroller_height = scroller_height * scrollbar_height;
+
+ return { pos: Math.round(scroller_pos),
+ height: Math.round(scroller_height),
+ bar_pos: button_up_height,
+ bar_height: scrollbar_height };
+ },
+
+
+ //checks if it should show or hide the scrollbar based on the target content and then show or hide it
+ // if animated is true, then the scrollbar will be shown or hidden with an animation
+ auto_hide: function(animated){
+ var target = this.target()[0];
+ if(target.clientHeight && (target.clientHeight === target.scrollHeight)){
+ this.hide(animated);
+ }else{
+ this.show(animated);
+ }
+ },
+
+ //returns the pageup/down scrolling distance in pixels
+ get_scroll_step: function(){
+ var target = this.target()[0];
+ var step = target.clientHeight * this.scroll_step;
+ var c = target.scrollHeight / step;
+ var c = Math.max(1,Math.ceil(c));
+ return target.scrollHeight / c;
+ },
+
+ //sets the scroller to the correct size and position based on the target scrolling status
+ //if animated is true, the scroller will move smoothly to its destination
+ update_scroller_dimensions: function(animated){
+ var dim = this.scroller_dimensions();
+ var target = this.target()[0];
+ if(animated){
+ this.$('.scroller').animate({'top':dim.pos+'px', 'height': dim.height+'px'},this.scroll_duration);
+ }else{
+ this.$('.scroller').css({'top':dim.pos+'px', 'height': dim.height+'px'});
+ }
+ if(this.scroll_target + target.clientHeight >= target.scrollHeight){
+ this.bottom = true;
+ }else{
+ this.bottom = false;
+ }
+ },
+
+ //disable or enable the up/down buttons according to the scrolled position
+ update_button_status: function(){
+ var target = this.target()[0];
+ this.$('.up-button').removeClass('disabled');
+ this.$('.down-button').removeClass('disabled');
+ if(this.scroll_target === 0){
+ this.$('.up-button').addClass('disabled');
+ }
+ if(this.scroll_target + target.clientHeight >= target.scrollHeight){
+ this.$('.down-button').addClass('disabled');
+ }
+ },
+
+ //returns the jquery object of the scrolling target
+ target: function(){
+ if(this.target_widget){
+ if(this.target_selector){
+ return this.target_widget.$(this.target_selector);
+ }else{
+ return this.target_widget.$element;
+ }
+ }else if(this.target_selector){
+ return $(this.target_selector);
+ }else{
+ return undefined;
+ }
+ },
+
+ //scroll one page up
+ page_up: function(){
+ return this.set_position(this.scroll_target - this.get_scroll_step(), true);
+ },
+
+ //scroll one page down
+ page_down: function(){
+ return this.set_position(this.scroll_target + this.get_scroll_step(), true);
+ },
+
+ //scrolls up or down by pixels
+ scroll: function(pixels){
+ return this.set_position(this.scroll_target - pixels, false);
+ },
+
+ //scroll to a specific position (in pixels).
+ //if animated is true, it will do this in an animated fashion with a duration equal to scroll_duration
+ set_position: function(position,animated){
+ var self = this;
+ var target = this.target()[0];
+ var bottom = target.scrollHeight-target.clientHeight;
+ this.scroll_target = Math.max(0,Math.min(bottom,position));
+ if(this.scroll_target === 0){
+ this.position = 'top';
+ }else if(this.scroll_target === 'bottom'){
+ this.position = 'bottom';
+ }else{
+ this.position = 'center';
+ }
+ if(animated){
+ this.target().animate({'scrollTop':this.scroll_target},this.scroll_duration);
+ this.update_button_status();
+ this.update_scroller_dimensions(true);
+ }else{
+ this.target().scrollTop(this.scroll_target);
+ this.update_scroller_dimensions(false);
+ this.update_button_status();
+ }
+ return this.scroll_target;
+ },
+
+ //returns the current position of the scrollbar
+ get_position: function(){
+ return this.scroll_target;
+ },
+
+ //returns true if it cannot be scrolled further down
+ is_at_bottom: function(){
+ return this.bottom;
+ }
+
+ });
+}
diff --git a/addons/point_of_sale/static/src/js/pos_widgets.js b/addons/point_of_sale/static/src/js/pos_widgets.js
new file mode 100644
index 00000000000..8ec2d9c329c
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/pos_widgets.js
@@ -0,0 +1,893 @@
+function openerp_pos_widgets(instance, module){ //module is instance.point_of_sale
+ var QWeb = instance.web.qweb;
+
+ module.NumpadWidget = module.PosBaseWidget.extend({
+ template:'NumpadWidget',
+ init: function(parent, options) {
+ this._super(parent);
+ this.state = new module.NumpadState();
+ },
+ start: function() {
+ this.state.bind('change:mode', this.changedMode, this);
+ this.changedMode();
+ this.$element.find('button#numpad-backspace').click(_.bind(this.clickDeleteLastChar, this));
+ this.$element.find('button#numpad-minus').click(_.bind(this.clickSwitchSign, this));
+ this.$element.find('button.number-char').click(_.bind(this.clickAppendNewChar, this));
+ this.$element.find('button.mode-button').click(_.bind(this.clickChangeMode, this));
+ },
+ clickDeleteLastChar: function() {
+ return this.state.deleteLastChar();
+ },
+ clickSwitchSign: function() {
+ return this.state.switchSign();
+ },
+ clickAppendNewChar: function(event) {
+ var newChar;
+ newChar = event.currentTarget.innerText || event.currentTarget.textContent;
+ return this.state.appendNewChar(newChar);
+ },
+ clickChangeMode: function(event) {
+ var newMode = event.currentTarget.attributes['data-mode'].nodeValue;
+ return this.state.changeMode(newMode);
+ },
+ changedMode: function() {
+ var mode = this.state.get('mode');
+ $('.selected-mode').removeClass('selected-mode');
+ $(_.str.sprintf('.mode-button[data-mode="%s"]', mode), this.$element).addClass('selected-mode');
+ },
+ });
+
+ // The paypad allows to select the payment method (cashRegisters)
+ // used to pay the order.
+ module.PaypadWidget = module.PosBaseWidget.extend({
+ template: 'PaypadWidget',
+ renderElement: function() {
+ var self = this;
+ this._super();
+
+ this.pos.get('cashRegisters').each(function(cashRegister) {
+ var button = new module.PaypadButtonWidget(self,{
+ pos: self.pos,
+ pos_widget : self.pos_widget,
+ cashRegister: cashRegister,
+ });
+ button.appendTo(self.$element);
+ });
+ }
+ });
+
+ module.PaypadButtonWidget = module.PosBaseWidget.extend({
+ template: 'PaypadButtonWidget',
+ init: function(parent, options){
+ this._super(parent, options);
+ this.cashRegister = options.cashRegister;
+ },
+ renderElement: function() {
+ var self = this;
+ this._super();
+
+ this.$element.click(function(){
+ if (self.pos.get('selectedOrder').get('screen') === 'receipt'){ //TODO Why ?
+ console.warn('TODO should not get there...?');
+ return;
+ }
+ self.pos.get('selectedOrder').addPaymentLine(self.cashRegister);
+ self.pos_widget.screen_selector.set_current_screen('payment');
+ });
+ },
+ });
+
+ module.OrderlineWidget = module.PosBaseWidget.extend({
+ template: 'OrderlineWidget',
+ init: function(parent, options) {
+ this._super(parent,options);
+
+ this.model = options.model;
+ this.order = options.order;
+
+ this.model.bind('change', _.bind( function() {
+ this.refresh();
+ }, this));
+ },
+ click_handler: function() {
+ this.order.selectLine(this.model);
+ this.on_selected();
+ },
+ renderElement: function() {
+ this._super();
+ this.$element.click(_.bind(this.click_handler, this));
+ if(this.model.is_selected()){
+ this.$element.addClass('selected');
+ }
+ },
+ refresh: function(){
+ this.renderElement();
+ this.on_refresh();
+ },
+ on_selected: function() {},
+ on_refresh: function(){},
+ });
+
+ module.OrderWidget = module.PosBaseWidget.extend({
+ template:'OrderWidget',
+ init: function(parent, options) {
+ this._super(parent,options);
+ this.compact = false;
+ this.set_numpad_state(options.numpadState);
+ this.pos.bind('change:selectedOrder', this.change_selected_order, this);
+ this.bind_orderline_events();
+ },
+ set_numpad_state: function(numpadState) {
+ if (this.numpadState) {
+ this.numpadState.unbind('set_value', this.set_value);
+ }
+ this.numpadState = numpadState;
+ if (this.numpadState) {
+ this.numpadState.bind('set_value', this.set_value, this);
+ this.numpadState.reset();
+ }
+ },
+ set_value: function(val) {
+ var order = this.pos.get('selectedOrder');
+ if (order.get('orderLines').length !== 0) {
+ var mode = this.numpadState.get('mode');
+ if( mode === 'quantity'){
+ order.getSelectedLine().set_quantity(val);
+ }else if( mode === 'discount'){
+ order.getSelectedLine().set_discount(val);
+ }else if( mode === 'list_price'){
+ order.getSelectedLine().set_list_price(val);
+ }
+ } else {
+ this.pos.get('selectedOrder').destroy();
+ }
+ },
+ change_selected_order: function() {
+ this.currentOrderLines.unbind();
+ this.bind_orderline_events();
+ this.renderElement();
+ },
+ bind_orderline_events: function() {
+ this.currentOrderLines = (this.pos.get('selectedOrder')).get('orderLines');
+ this.currentOrderLines.bind('add', this.renderElement, this);
+ this.currentOrderLines.bind('remove', this.renderElement, this);
+ },
+ update_numpad: function() {
+ var reset = false;
+ if (this.selected_line !== this.pos.get('selectedOrder').getSelectedLine()) {
+ reset = true;
+ }
+ this.selected_line = this.pos.get('selectedOrder').getSelectedLine();
+ if (reset && this.numpadState)
+ this.numpadState.reset();
+ },
+ renderElement: function() {
+ var self = this;
+ this._super();
+
+ if(!this.compact){
+ $('.point-of-sale .order-container').css({'bottom':'0px'});
+ }
+
+ var $content = this.$('.orderlines');
+ this.currentOrderLines.each(_.bind( function(orderLine) {
+ var line = new module.OrderlineWidget(this, {
+ model: orderLine,
+ order: this.pos.get('selectedOrder'),
+ });
+ line.on_selected.add(_.bind(this.update_numpad, this));
+ line.on_refresh.add(_.bind(this.update_summary, this));
+ line.appendTo($content);
+ }, this));
+ this.update_numpad();
+ this.update_summary();
+
+ var position = this.scrollbar ? this.scrollbar.get_position() : 0;
+ var at_bottom = this.scrollbar ? this.scrollbar.is_at_bottom() : false;
+
+ this.scrollbar = new module.ScrollbarWidget(this,{
+ target_widget: this,
+ target_selector: '.order-scroller',
+ name: 'order',
+ track_bottom: true,
+ on_show: function(){
+ self.$('.order-scroller').css({'width':'89%'},100);
+ },
+ on_hide: function(){
+ self.$('.order-scroller').css({'width':'100%'},100);
+ },
+ });
+
+ this.scrollbar.replace(this.$('.placeholder-ScrollbarWidget'));
+ this.scrollbar.set_position(position);
+
+ if( at_bottom ){
+ this.scrollbar.set_position(Number.MAX_VALUE, false);
+ }
+
+ },
+ update_summary: function(){
+ var order = this.pos.get('selectedOrder');
+ var total = order ? order.getTotal() : 0;
+ this.$('.summary .value.total').html(this.format_currency(total));
+ },
+ set_compact: function(compact){
+ if(this.compact !== compact){
+ this.compact = compact;
+ this.renderElement();
+ }
+ },
+ });
+
+ module.ProductWidget = module.PosBaseWidget.extend({
+ template: 'ProductWidget',
+ init: function(parent, options) {
+ this._super(parent,options);
+ this.model = options.model;
+ this.model.attributes.weight = options.weight;
+ this.next_screen = options.next_screen;
+ this.click_product_action = options.click_product_action;
+ },
+ add_to_order: function(event) {
+ /* Preserve the category URL */
+ event.preventDefault();
+ return (this.pos.get('selectedOrder')).addProduct(this.model);
+ },
+ set_weight: function(weight){
+ this.model.attributes.weight = weight;
+ this.renderElement();
+ },
+ renderElement: function() {
+ this._super();
+ var self = this;
+ $("a", this.$element).click(function(e){
+ if(self.click_product_action){
+ self.click_product_action(self.model);
+ }
+ });
+ },
+ });
+
+ module.PaymentlineWidget = module.PosBaseWidget.extend({
+ template: 'PaymentlineWidget',
+ init: function(parent, options) {
+ this._super(parent,options);
+ this.payment_line = options.payment_line;
+ this.payment_line.bind('change', this.changedAmount, this);
+ },
+ on_delete: function() {},
+ changeAmount: function(event) {
+ var newAmount;
+ newAmount = event.currentTarget.value;
+ if (newAmount && !isNaN(newAmount)) {
+ this.amount = parseFloat(newAmount);
+ this.payment_line.set_amount(this.amount);
+ }
+ },
+ changedAmount: function() {
+ if (this.amount !== this.payment_line.get_amount())
+ this.renderElement();
+ },
+ renderElement: function() {
+ this.name = this.payment_line.get_cashregister().get('journal_id')[1];
+ this._super();
+ this.$('input').keyup(_.bind(this.changeAmount, this));
+ this.$('.delete-payment-line').click(this.on_delete);
+ },
+ });
+
+ module.OrderButtonWidget = module.PosBaseWidget.extend({
+ template:'OrderButtonWidget',
+ init: function(parent, options) {
+ this._super(parent,options);
+ this.order = options.order;
+ this.order.bind('destroy', _.bind( function() {
+ this.destroy();
+ }, this));
+ this.pos.bind('change:selectedOrder', _.bind( function(pos) {
+ var selectedOrder;
+ selectedOrder = pos.get('selectedOrder');
+ if (this.order === selectedOrder) {
+ this.setButtonSelected();
+ }
+ }, this));
+ },
+ renderElement:function(){
+ this._super();
+ this.$('button.select-order').click(_.bind(this.selectOrder, this));
+ this.$('button.close-order').click(_.bind(this.closeOrder, this));
+ },
+ selectOrder: function(event) {
+ this.pos.set({
+ selectedOrder: this.order
+ });
+ },
+ setButtonSelected: function() {
+ $('.selected-order').removeClass('selected-order');
+ this.$element.addClass('selected-order');
+ },
+ closeOrder: function(event) {
+ this.order.destroy();
+ },
+ });
+
+ module.ActionButtonWidget = instance.web.Widget.extend({
+ template:'ActionButtonWidget',
+ init: function(parent, options){
+ this._super(parent, options);
+ this.label = options.label || 'button';
+ this.rightalign = options.rightalign || false;
+ this.click_action = options.click;
+ if(options.icon){
+ this.icon = options.icon;
+ this.template = 'ActionButtonWidgetWithIcon';
+ }
+ },
+ renderElement: function(){
+ this._super();
+ if(this.click_action){
+ this.$element.click(_.bind(this.click_action, this));
+ }
+ },
+ });
+
+ module.ActionBarWidget = instance.web.Widget.extend({
+ template:'ActionBarWidget',
+ init: function(parent, options){
+ this._super(parent,options);
+ this.button_list = [];
+ this.fake_buttons = {};
+ this.visibility = {};
+ },
+ set_element_visible: function(element, visible, action){
+ if(visible != this.visibility[element]){
+ this.visibility[element] = visible;
+ if(visible){
+ this.$('.'+element).show();
+ }else{
+ this.$('.'+element).hide();
+ }
+ }
+ if(visible && action){
+ this.$('.'+element).off('click').click(action);
+ }
+ },
+ destroy_buttons:function(){
+ for(var i = 0; i < this.button_list.length; i++){
+ this.button_list[i].destroy();
+ }
+ this.button_list = [];
+ return this;
+ },
+ add_new_button: function(button_options){
+ if(arguments.length == 1){
+ var button = new module.ActionButtonWidget(this,button_options);
+ this.button_list.push(button);
+ button.appendTo($('.pos-actionbar-button-list'));
+ return button;
+ }else{
+ for(var i = 0; i < arguments.length; i++){
+ this.add_new_button(arguments[i]);
+ }
+ }
+ return undefined;
+ },
+ });
+
+ module.ProductCategoriesWidget = module.PosBaseWidget.extend({
+ template: 'ProductCategoriesWidget',
+ init: function(parent, options){
+ var self = this;
+ this._super(parent,options);
+ this.product_type = options.product_type || 'all'; // 'all' | 'weightable'
+ this.onlyWeightable = options.onlyWeightable || false;
+ this.category = this.pos.root_category;
+ this.breadcrumb = [];
+ this.subcategories = [];
+ this.set_category();
+ },
+
+ // changes the category. if undefined, sets to root category
+ set_category : function(category){
+ if(!category){
+ this.category = this.pos.root_category;
+ }else{
+ this.category = category;
+ }
+
+ this.breadcrumb = [];
+ for(var i = 1; i < this.category.ancestors.length; i++){
+ this.breadcrumb.push(this.category.ancestors[i]);
+ }
+ if(this.category !== this.pos.root_category){
+ this.breadcrumb.push(this.category);
+ }
+ if(this.product_type === 'weightable'){
+ this.subcategories = [];
+ for(var i = 0; i < this.category.childrens.length; i++){
+ if(this.category.childrens[i].weightable_product_list.length > 0){
+ this.subcategories.push( this.category.childrens[i]);
+ }
+ }
+ }else{
+ this.subcategories = this.category.childrens || [];
+ }
+ },
+
+ renderElement: function(){
+ var self = this;
+ this._super();
+ _.each(this.subcategories, function(category){
+ var button = QWeb.render('CategoryButton',{category:category});
+ button = _.str.trim(button);
+
+ $(button).appendTo(this.$('.category-list')).click(function(event){
+ var id = category.id;
+ var cat = self.pos.categories_by_id[id];
+ self.set_category(cat);
+ self.renderElement();
+ self.search_and_categories(cat);
+ });
+ });
+ // breadcrumb click actions
+ this.$(".oe-pos-categories-list a").click(function(event){
+ var id = $(event.target).data("category-id");
+ var category = self.pos.categories_by_id[id];
+ self.set_category(category);
+ self.renderElement();
+ self.search_and_categories(category);
+ });
+ this.search_and_categories();
+ },
+
+ set_product_type: function(type){ // 'all' | 'weightable'
+ this.product_type = type;
+ this.reset_category();
+ },
+
+ // resets the current category to the root category
+ reset_category: function(){
+ this.set_category();
+ this.renderElement();
+ this.search_and_categories();
+ },
+
+ // filters the products, and sets up the search callbacks
+ search_and_categories: function(category){
+ var self = this;
+
+ var all_products = this.pos.get('product_list');
+ var all_packages = this.pos.get('product.packaging');
+
+ // find all products belonging to the current category
+ var products = [];
+ if(this.product_type === 'weightable'){
+ products = all_products.filter( function(product){
+ return self.category.weightable_product_set[product.id];
+ });
+ }else{
+ products = all_products.filter( function(product){
+ return self.category.product_set[product.id];
+ });
+ }
+
+ // product lists watch for reset events on 'products' to re-render.
+ // FIXME that means all productlist widget re-render... even the hidden ones !
+ this.pos.get('products').reset(products);
+
+ // find all the products whose name match the query in the searchbox
+ this.$('.searchbox input').keyup(function(){
+ var results, search_str;
+ search_str = $(this).val().toLowerCase();
+ if(search_str){
+ results = products.filter( function(p){
+ return p.name.toLowerCase().indexOf(search_str) != -1 ||
+ (p.ean13 && p.ean13.indexOf(search_str) != -1);
+ });
+ self.$element.find('.search-clear').fadeIn();
+ }else{
+ results = products;
+ self.$element.find('.search-clear').fadeOut();
+ }
+ self.pos.get('products').reset(results);
+ });
+ this.$('.searchbox input').click(function(){
+ });
+
+ //reset the search when clicking on reset
+ this.$('.search-clear').click(function(){
+ self.pos.get('products').reset(products);
+ self.$('.searchbox input').val('').focus();
+ self.$('.search-clear').fadeOut();
+ });
+ },
+ });
+
+ module.ProductListWidget = module.ScreenWidget.extend({
+ template:'ProductListWidget',
+ init: function(parent, options) {
+ var self = this;
+ this._super(parent,options);
+ this.model = options.model;
+ this.product_list = [];
+ this.weight = options.weight || 0;
+ this.show_scale = options.show_scale || false;
+ this.next_screen = options.next_screen || false;
+ this.click_product_action = options.click_product_action;
+
+ this.pos.get('products').bind('reset', function(){
+ self.renderElement();
+ });
+ },
+ set_weight: function(weight){
+ for(var i = 0; i < this.product_list.length; i++){
+ this.product_list[i].set_weight(weight);
+ }
+ },
+ renderElement: function() {
+ var self = this;
+ this._super();
+ this.product_list = [];
+ this.pos.get('products')
+ .chain()
+ .map(function(product) {
+ var product = new module.ProductWidget(self, {
+ model: product,
+ weight: self.weight,
+ click_product_action: self.click_product_action,
+ })
+ self.product_list.push(product);
+ return product;
+ })
+ .invoke('appendTo', this.$('.product-list'));
+
+ this.scrollbar = new module.ScrollbarWidget(this,{
+ target_widget: this,
+ target_selector: '.product-list-scroller',
+ on_show: function(){
+ self.$('.product-list-scroller').css({'padding-right':'62px'},100);
+ },
+ on_hide: function(){
+ self.$('.product-list-scroller').css({'padding-right':'0px'},100);
+ },
+ });
+
+ this.scrollbar.replace(this.$('.placeholder-ScrollbarWidget'));
+
+ },
+ });
+
+ module.UsernameWidget = module.PosBaseWidget.extend({
+ template: 'UsernameWidget',
+ init: function(parent, options){
+ var options = options || {};
+ this._super(parent,options);
+ this.mode = options.mode || 'cashier';
+ },
+ set_user_mode: function(mode){
+ this.mode = mode;
+ this.refresh();
+ },
+ refresh: function(){
+ this.renderElement();
+ },
+ get_name: function(){
+ var user;
+ if(this.mode === 'cashier'){
+ user = this.pos.get('cashier') || this.pos.get('user');
+ }else{
+ user = this.pos.get('selectedOrder').get_client() || this.pos.get('user');
+ }
+ if(user){
+ return user.name;
+ }else{
+ return "";
+ }
+ },
+ });
+
+// ---------- Main Point of Sale Widget ----------
+
+ // this is used to notify the user that data is being synchronized on the network
+ module.SynchNotificationWidget = module.PosBaseWidget.extend({
+ template: "SynchNotificationWidget",
+ init: function(parent,options) {
+ options = options || {};
+ this._super(parent,options);
+ },
+ renderElement: function() {
+ var self = this;
+ this._super();
+ this.$('.oe_pos_synch-notification-button').click(function(){
+ self.pos.flush();
+ });
+ },
+ start: function(){
+ var self = this;
+ this.pos.bind('change:nbr_pending_operations', function(){
+ self.renderElement();
+ });
+ },
+ get_nbr_pending: function(){
+ return this.pos.get('nbr_pending_operations');
+ },
+ });
+
+ // The PosWidget is the main widget that contains all other widgets in the PointOfSale.
+ // It is mainly composed of :
+ // - a header, containing the list of orders
+ // - a leftpane, containing the list of bought products (orderlines)
+ // - a rightpane, containing the screens (see pos_screens.js)
+ // - an actionbar on the bottom, containing various action buttons
+ // - popups
+ // - an onscreen keyboard
+ // a screen_selector which controls the switching between screens and the showing/closing of popups
+
+ module.PosWidget = module.PosBaseWidget.extend({
+ template: 'PosWidget',
+ init: function() {
+ this._super(arguments[0],{});
+
+ this.pos = new module.PosModel(this.session);
+ this.pos_widget = this; //So that pos_widget's childs have pos_widget set automatically
+
+ this.numpad_visible = true;
+ this.leftpane_visible = true;
+ this.leftpane_width = '440px';
+ this.cashier_controls_visible = true;
+ },
+
+ start: function() {
+ var self = this;
+ return self.pos.ready.then(function() {
+ self.build_currency_template();
+ self.renderElement();
+
+ self.$('.neworder-button').click(_.bind(self.create_new_order, self));
+
+ //when a new order is created, add an order button widget
+ self.pos.get('orders').bind('add', function(new_order){
+ var new_order_button = new module.OrderButtonWidget(null, {
+ order: new_order,
+ pos: self.pos
+ });
+ new_order_button.appendTo($('#orders'));
+ new_order_button.selectOrder();
+ }, self);
+
+ self.pos.get('orders').add(new module.Order({ pos: self.pos }));
+
+ self.build_widgets();
+
+ instance.webclient.set_content_full_screen(true);
+
+ if (!self.pos.get('pos_session')) {
+ self.screen_selector.show_popup('error', 'Sorry, we could not create a user session');
+ }else if(!self.pos.get('pos_config')){
+ self.screen_selector.show_popup('error', 'Sorry, we could not find any PoS Configuration for this session');
+ }
+
+ self.$('.loader').animate({opacity:0},3000,'swing',function(){$('.loader').hide();});
+ self.$('.loader img').hide();
+
+ },function(){ // error when loading models data from the backend
+ self.$('.loader img').hide();
+ return new instance.web.Model("ir.model.data").get_func("search_read")([['name', '=', 'action_pos_session_opening']], ['res_id'])
+ .pipe( _.bind(function(res){
+ return instance.connection.rpc('/web/action/load', {'action_id': res[0]['res_id']})
+ .pipe(_.bind(function(result){
+ var action = result.result;
+ this.do_action(action);
+ }, this));
+ }, self));
+ });
+ },
+
+ build_widgets: function() {
+
+ // -------- Screens ---------
+
+ this.product_screen = new module.ProductScreenWidget(this,{});
+ this.product_screen.appendTo($('#rightpane'));
+
+ this.receipt_screen = new module.ReceiptScreenWidget(this, {});
+ this.receipt_screen.appendTo($('#rightpane'));
+
+ this.payment_screen = new module.PaymentScreenWidget(this, {});
+ this.payment_screen.appendTo($('#rightpane'));
+
+ this.welcome_screen = new module.WelcomeScreenWidget(this,{});
+ this.welcome_screen.appendTo($('#rightpane'));
+
+ this.client_payment_screen = new module.ClientPaymentScreenWidget(this, {});
+ this.client_payment_screen.appendTo($('#rightpane'));
+
+ this.scale_invite_screen = new module.ScaleInviteScreenWidget(this, {});
+ this.scale_invite_screen.appendTo($('#rightpane'));
+
+ this.scale_screen = new module.ScaleScreenWidget(this,{});
+ this.scale_screen.appendTo($('#rightpane'));
+
+ // -------- Popups ---------
+
+ this.help_popup = new module.HelpPopupWidget(this, {});
+ this.help_popup.appendTo($('.point-of-sale'));
+
+ this.error_popup = new module.ErrorPopupWidget(this, {});
+ this.error_popup.appendTo($('.point-of-sale'));
+
+ this.error_product_popup = new module.ErrorProductNotRecognizedPopupWidget(this, {});
+ this.error_product_popup.appendTo($('.point-of-sale'));
+
+ this.error_session_popup = new module.ErrorNoSessionPopupWidget(this, {});
+ this.error_session_popup.appendTo($('.point-of-sale'));
+
+ // -------- Misc ---------
+
+ this.notification = new module.SynchNotificationWidget(this,{});
+ this.notification.replace(this.$('.placeholder-SynchNotificationWidget'));
+
+ this.username = new module.UsernameWidget(this,{});
+ this.username.replace(this.$('.placeholder-UsernameWidget'));
+
+ this.action_bar = new module.ActionBarWidget(this);
+ this.action_bar.appendTo($(".point-of-sale #content"));
+
+ this.paypad = new module.PaypadWidget(this, {});
+ this.paypad.replace($('#placeholder-PaypadWidget'));
+
+ this.numpad = new module.NumpadWidget(this);
+ this.numpad.replace($('#placeholder-NumpadWidget'));
+
+ this.order_widget = new module.OrderWidget(this, {});
+ this.order_widget.replace($('#placeholder-OrderWidget'));
+
+ this.onscreen_keyboard = new module.OnscreenKeyboardWidget(this, {
+ 'keyboard_model': 'simple'
+ });
+ this.onscreen_keyboard.appendTo($(".point-of-sale #content"));
+
+ // -------- Screen Selector ---------
+
+ this.screen_selector = new module.ScreenSelector({
+ pos: this.pos,
+ screen_set:{
+ 'products': this.product_screen,
+ 'payment' : this.payment_screen,
+ 'client_payment' : this.client_payment_screen,
+ 'scale_invite' : this.scale_invite_screen,
+ 'scale': this.scale_screen,
+ 'receipt' : this.receipt_screen,
+ 'welcome' : this.welcome_screen,
+ },
+ popup_set:{
+ 'help': this.help_popup,
+ 'error': this.error_popup,
+ 'error-product': this.error_product_popup,
+ 'error-session': this.error_session_popup,
+ },
+ default_client_screen: 'welcome',
+ default_cashier_screen: 'products',
+ default_mode: this.pos.use_selfcheckout ? 'client' : 'cashier',
+ });
+
+ this.screen_selector.set_default_screen();
+
+ this.pos.barcode_reader.connect();
+ },
+
+ //FIXME this method is probably not at the right place ...
+ scan_product: function(parsed_ean){
+ var selectedOrder = this.pos.get('selectedOrder');
+ var scannedProductModel = this.get_product_by_ean(parsed_ean);
+ if (!scannedProductModel){
+ return false;
+ } else {
+ if(parsed_ean.type === 'price'){
+ selectedOrder.addProduct(new module.Product(scannedProductModel), { price:parsed_ean.value});
+ }else if(parsed_ean.type === 'weight'){
+ selectedOrder.addProduct(new module.Product(scannedProductModel), { quantity:parsed_ean.value, merge:false});
+ }else{
+ selectedOrder.addProduct(new module.Product(scannedProductModel));
+ }
+ return true;
+ }
+ },
+
+ get_product_by_ean: function(parsed_ean) {
+ var allProducts = this.pos.get('product_list');
+ var allPackages = this.pos.get('product.packaging');
+ var scannedProductModel = undefined;
+
+ if (parsed_ean.type === 'price' || parsed_ean.type === 'weight') {
+ var itemCode = parsed_ean.id;
+ var scannedPackaging = _.detect(allPackages, function(pack) {
+ return pack.ean && pack.ean.substring(0,7) === itemCode;
+ });
+ if (scannedPackaging) {
+ scannedProductModel = _.detect(allProducts, function(pc) { return pc.id === scannedPackaging.product_id[0];});
+ }else{
+ scannedProductModel = _.detect(allProducts, function(pc) { return pc.ean13 && (pc.ean13.substring(0,7) === parsed_ean.id);});
+ }
+ } else if(parsed_ean.type === 'unit'){
+ scannedProductModel = _.detect(allProducts, function(pc) { return pc.ean13 === parsed_ean.ean;}); //TODO DOES NOT SCALE
+ }
+ return scannedProductModel;
+ },
+ // creates a new order, and add it to the list of orders.
+ create_new_order: function() {
+ var new_order;
+ new_order = new module.Order({ pos: this.pos });
+ this.pos.get('orders').add(new_order);
+ this.pos.set({ selectedOrder: new_order });
+ },
+ changed_pending_operations: function () {
+ var self = this;
+ this.synch_notification.on_change_nbr_pending(self.pos.get('nbr_pending_operations').length);
+ },
+ // shows or hide the numpad and related controls like the paypad.
+ set_numpad_visible: function(visible){
+ if(visible !== this.numpad_visible){
+ this.numpad_visible = visible;
+ if(visible){
+ this.numpad.show();
+ this.paypad.show();
+ this.order_widget.set_compact(true);
+ }else{
+ this.numpad.hide();
+ this.paypad.hide();
+ this.order_widget.set_compact(false);
+ }
+ }
+ },
+ //shows or hide the leftpane (contains the list of orderlines, the numpad, the paypad, etc.)
+ set_leftpane_visible: function(visible){
+ if(visible !== this.leftpane_visible){
+ this.leftpane_visible = visible;
+ if(visible){
+ $('#leftpane').show().animate({'width':this.leftpane_width},500,'swing');
+ $('#rightpane').animate({'left':this.leftpane_width},500,'swing');
+ }else{
+ var leftpane = $('#leftpane');
+ $('#leftpane').animate({'width':'0px'},500,'swing', function(){ leftpane.hide(); });
+ $('#rightpane').animate({'left':'0px'},500,'swing');
+ }
+ }
+ },
+ //shows or hide the controls in the PosWidget that are specific to the cashier ( Orders, close button, etc. )
+ set_cashier_controls_visible: function(visible){
+ if(visible !== this.cashier_controls_visible){
+ this.cashier_controls_visible = visible;
+ if(visible){
+ $('#loggedas').show();
+ $('#rightheader').show();
+ }else{
+ $('#loggedas').hide();
+ $('#rightheader').hide();
+ }
+ }
+ },
+ try_close: function() {
+ var self = this;
+ self.pos.flush().then(function() {
+ self.close();
+ });
+ },
+ close: function() {
+ this.pos.barcode_reader.disconnect();
+ return new instance.web.Model("ir.model.data").get_func("search_read")([['name', '=', 'action_pos_close_statement']], ['res_id']).pipe(
+ _.bind(function(res) {
+ return this.rpc('/web/action/load', {'action_id': res[0]['res_id']}).pipe(_.bind(function(result) {
+ var action = result.result;
+ action.context = _.extend(action.context || {}, {'cancel_action': {type: 'ir.actions.client', tag: 'reload'}});
+ this.do_action(action);
+ }, this));
+ }, this));
+ },
+ destroy: function() {
+ instance.webclient.set_content_full_screen(false);
+ self.pos = undefined;
+ this._super();
+ }
+ });
+}
diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml
index ee2dee7c875..29dc0fdf473 100644
--- a/addons/point_of_sale/static/src/xml/pos.xml
+++ b/addons/point_of_sale/static/src/xml/pos.xml
@@ -2,158 +2,105 @@
-
-
-
-
-
-
-
-
- Close
-
-
-
- +
-
-
-
-
-
-
-
-
-
-
-
Product
-
Price
-
Disc (%)
-
Qty
-
Total
-
-
-
-
+
+
+
+
+
+
-
- VAT
+ Tax
Quantity
@@ -236,7 +236,7 @@
Description
- VAT
+ Tax
Quantity
diff --git a/addons/sale/res_config_view.xml b/addons/sale/res_config_view.xml
index 2078c141b69..74796bac6fc 100644
--- a/addons/sale/res_config_view.xml
+++ b/addons/sale/res_config_view.xml
@@ -8,45 +8,34 @@
form
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -61,13 +50,14 @@
-
+ 0
-
-
+ 2
+
+
-
+
diff --git a/addons/sale/res_partner_view.xml b/addons/sale/res_partner_view.xml
index bb5695eac39..11ac318b7a7 100644
--- a/addons/sale/res_partner_view.xml
+++ b/addons/sale/res_partner_view.xml
@@ -1,7 +1,24 @@
-
+
+ Quotations and Sales
+ sale.order
+ form
+ tree,form,graph
+ {'search_default_partner_id': active_id}
+
+
+ This customer has no quotation or sale order.
+ Click here to create a new quotation.
+ <p>
+ The "Quotation" is the first step of the Sales flow. Manage your sales from quotation to invoice.
+ <p>
+ You will be able to sell products (manage deliveries) as well as services (create projects).
+
+
+
+
res.partner.kanban.saleorder.inheritres.partner
@@ -18,6 +35,22 @@
+
+
+ res.partner.view.buttons
+ res.partner
+ form
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale/sale_data.xml b/addons/sale/sale_data.xml
index 4622577ac3d..9768f606793 100644
--- a/addons/sale/sale_data.xml
+++ b/addons/sale/sale_data.xml
@@ -27,4 +27,28 @@
+
+
+
+ Module sale installed!
+ comment
+ text
+ res.users
+
+ Welcome to OpenERP
+
+ You can click on the top menu Sales to manage your
+ customers, your quotations and sales orders.
+
+ If you need to manage your sales pipeline (leads,
+ opportunities, phonecalls), you can install the CRM module
+ from the Settings top menu.
+
+
+
+
+
+
+
+
diff --git a/addons/sale/sale_view.xml b/addons/sale/sale_view.xml
index e7420edb910..3f7f7efa43b 100644
--- a/addons/sale/sale_view.xml
+++ b/addons/sale/sale_view.xml
@@ -1,6 +1,16 @@
+
-
+
+
+
+
+
@@ -146,10 +156,10 @@
-
+
-
-
+
+
@@ -171,10 +181,9 @@
-
+
-
@@ -197,7 +206,7 @@
@@ -263,6 +272,8 @@
+
+
@@ -297,7 +308,7 @@
-
+
@@ -569,14 +580,6 @@
-
-
form
-
+
@@ -19,8 +19,8 @@
form
-
-
+
+
diff --git a/addons/sale/wizard/sale_make_invoice.py b/addons/sale/wizard/sale_make_invoice.py
index 5265afb9bff..b4b3772a91a 100644
--- a/addons/sale/wizard/sale_make_invoice.py
+++ b/addons/sale/wizard/sale_make_invoice.py
@@ -68,4 +68,4 @@ class sale_make_invoice(osv.osv_memory):
sale_make_invoice()
-# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
\ No newline at end of file
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/sale_analytic_plans/i18n/nb.po b/addons/sale_analytic_plans/i18n/nb.po
new file mode 100644
index 00000000000..c234f234d72
--- /dev/null
+++ b/addons/sale_analytic_plans/i18n/nb.po
@@ -0,0 +1,28 @@
+# Norwegian Bokmal translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 00:37+0000\n"
+"PO-Revision-Date: 2012-07-22 21:14+0000\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: Norwegian Bokmal \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-23 05:17+0000\n"
+"X-Generator: Launchpad (build 15654)\n"
+
+#. module: sale_analytic_plans
+#: field:sale.order.line,analytics_id:0
+msgid "Analytic Distribution"
+msgstr "Analytisk Distribusjon"
+
+#. module: sale_analytic_plans
+#: model:ir.model,name:sale_analytic_plans.model_sale_order_line
+msgid "Sales Order Line"
+msgstr "Salgsordrelinje"
diff --git a/addons/sale_margin/i18n/nb.po b/addons/sale_margin/i18n/nb.po
new file mode 100644
index 00000000000..7db7e12a698
--- /dev/null
+++ b/addons/sale_margin/i18n/nb.po
@@ -0,0 +1,52 @@
+# Norwegian Bokmal translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 00:37+0000\n"
+"PO-Revision-Date: 2012-07-22 21:27+0000\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: Norwegian Bokmal \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-23 05:17+0000\n"
+"X-Generator: Launchpad (build 15654)\n"
+
+#. module: sale_margin
+#: sql_constraint:sale.order:0
+msgid "Order Reference must be unique per Company!"
+msgstr "Ordrereferanse må være unik pr. firma!"
+
+#. module: sale_margin
+#: field:sale.order.line,purchase_price:0
+msgid "Cost Price"
+msgstr "Kostpris"
+
+#. module: sale_margin
+#: model:ir.model,name:sale_margin.model_sale_order
+msgid "Sales Order"
+msgstr "Salgsordre"
+
+#. module: sale_margin
+#: help:sale.order,margin:0
+msgid ""
+"It gives profitability by calculating the difference between the Unit Price "
+"and Cost Price."
+msgstr ""
+"Fortjeneste oppnås ved å beregne differansen mellom enhetspris og kostpris"
+
+#. module: sale_margin
+#: field:sale.order,margin:0
+#: field:sale.order.line,margin:0
+msgid "Margin"
+msgstr "Margin"
+
+#. module: sale_margin
+#: model:ir.model,name:sale_margin.model_sale_order_line
+msgid "Sales Order Line"
+msgstr "Salgsordrelinje"
diff --git a/addons/sale_margin/sale_margin_view.xml b/addons/sale_margin/sale_margin_view.xml
index 4696eec6eb2..30c2a0dea14 100644
--- a/addons/sale_margin/sale_margin_view.xml
+++ b/addons/sale_margin/sale_margin_view.xml
@@ -8,8 +8,13 @@
sale.order
-
-
+
+
+
+
+
+
+
diff --git a/addons/sale_mrp/i18n/nb.po b/addons/sale_mrp/i18n/nb.po
new file mode 100644
index 00000000000..4b6589b24cc
--- /dev/null
+++ b/addons/sale_mrp/i18n/nb.po
@@ -0,0 +1,53 @@
+# Norwegian Bokmal translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 00:37+0000\n"
+"PO-Revision-Date: 2012-07-22 20:19+0000\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: Norwegian Bokmal \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-23 05:17+0000\n"
+"X-Generator: Launchpad (build 15654)\n"
+
+#. module: sale_mrp
+#: help:mrp.production,sale_ref:0
+msgid "Indicate the Customer Reference from sales order."
+msgstr "Viser kundereferansen fra salgsordren"
+
+#. module: sale_mrp
+#: field:mrp.production,sale_ref:0
+msgid "Sales Reference"
+msgstr "Salgsreferanse"
+
+#. module: sale_mrp
+#: model:ir.model,name:sale_mrp.model_mrp_production
+msgid "Manufacturing Order"
+msgstr "Produksjonsordre"
+
+#. module: sale_mrp
+#: field:mrp.production,sale_name:0
+msgid "Sales Name"
+msgstr "Salgsnavn"
+
+#. module: sale_mrp
+#: sql_constraint:mrp.production:0
+msgid "Reference must be unique per Company!"
+msgstr "Referanse må være unik pr firma!"
+
+#. module: sale_mrp
+#: constraint:mrp.production:0
+msgid "Order quantity cannot be negative or zero!"
+msgstr "Ordrekvantum kan ikke være negativt eller null!"
+
+#. module: sale_mrp
+#: help:mrp.production,sale_name:0
+msgid "Indicate the name of sales order."
+msgstr "Angi navnet på salgsordren."
diff --git a/addons/sale_mrp/sale_mrp.py b/addons/sale_mrp/sale_mrp.py
index 840c33770a7..7397edbec2b 100644
--- a/addons/sale_mrp/sale_mrp.py
+++ b/addons/sale_mrp/sale_mrp.py
@@ -70,8 +70,8 @@ class mrp_production(osv.osv):
return res
_columns = {
- 'sale_name': fields.function(_ref_calc, multi='sale_name', type='char', string='Sales Name', help='Indicate the name of sales order.'),
- 'sale_ref': fields.function(_ref_calc, multi='sale_name', type='char', string='Sales Reference', help='Indicate the Customer Reference from sales order.'),
+ 'sale_name': fields.function(_ref_calc, multi='sale_name', type='char', string='Sale Name', help='Indicate the name of sales order.'),
+ 'sale_ref': fields.function(_ref_calc, multi='sale_name', type='char', string='Sale Reference', help='Indicate the Customer Reference from sales order.'),
}
mrp_production()
diff --git a/addons/sale_mrp/sale_mrp_view.xml b/addons/sale_mrp/sale_mrp_view.xml
index fbf2e7a9327..882663680c5 100644
--- a/addons/sale_mrp/sale_mrp_view.xml
+++ b/addons/sale_mrp/sale_mrp_view.xml
@@ -8,10 +8,12 @@
form
-
-
+
+
+
+
diff --git a/addons/sale_order_dates/i18n/nb.po b/addons/sale_order_dates/i18n/nb.po
new file mode 100644
index 00000000000..9d4322c8535
--- /dev/null
+++ b/addons/sale_order_dates/i18n/nb.po
@@ -0,0 +1,58 @@
+# Norwegian Bokmal translation for openobject-addons
+# Copyright (c) 2012 Rosetta Contributors and Canonical Ltd 2012
+# This file is distributed under the same license as the openobject-addons package.
+# FIRST AUTHOR , 2012.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: openobject-addons\n"
+"Report-Msgid-Bugs-To: FULL NAME \n"
+"POT-Creation-Date: 2012-02-08 00:37+0000\n"
+"PO-Revision-Date: 2012-07-23 10:58+0000\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: Norwegian Bokmal \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Launchpad-Export-Date: 2012-07-24 04:52+0000\n"
+"X-Generator: Launchpad (build 15668)\n"
+
+#. module: sale_order_dates
+#: sql_constraint:sale.order:0
+msgid "Order Reference must be unique per Company!"
+msgstr "Ordrereferanse må være unik pr. firma!"
+
+#. module: sale_order_dates
+#: help:sale.order,requested_date:0
+msgid "Date on which customer has requested for sales."
+msgstr "Dato som kunden har ønsket for salg."
+
+#. module: sale_order_dates
+#: field:sale.order,commitment_date:0
+msgid "Commitment Date"
+msgstr "Bekreftet dato"
+
+#. module: sale_order_dates
+#: field:sale.order,effective_date:0
+msgid "Effective Date"
+msgstr "Behandlingsdato"
+
+#. module: sale_order_dates
+#: help:sale.order,effective_date:0
+msgid "Date on which picking is created."
+msgstr "Dato hvor plukkingen er gjort."
+
+#. module: sale_order_dates
+#: field:sale.order,requested_date:0
+msgid "Requested Date"
+msgstr "Ønsket dato"
+
+#. module: sale_order_dates
+#: model:ir.model,name:sale_order_dates.model_sale_order
+msgid "Sales Order"
+msgstr "Salgsordre"
+
+#. module: sale_order_dates
+#: help:sale.order,commitment_date:0
+msgid "Date on which delivery of products is to be made."
+msgstr "Dato hvor levering av varer skal skje"
diff --git a/addons/share/wizard/share_wizard.py b/addons/share/wizard/share_wizard.py
index 41871480f45..23245e12a2d 100644
--- a/addons/share/wizard/share_wizard.py
+++ b/addons/share/wizard/share_wizard.py
@@ -274,7 +274,6 @@ class share_wizard(osv.TransientModel):
'name': new_login,
'groups_id': [(6,0,[group_id])],
'share': True,
- 'menu_tips' : False,
'company_id': current_user.company_id.id
}, context)
new_line = { 'user_id': user_id,
diff --git a/addons/stock/__openerp__.py b/addons/stock/__openerp__.py
index 50bcb23df7b..2eaaa1ffb69 100644
--- a/addons/stock/__openerp__.py
+++ b/addons/stock/__openerp__.py
@@ -23,6 +23,7 @@
"name" : "Warehouse Management",
"version" : "1.1",
"author" : "OpenERP SA",
+ "summary": "Incoming Shipments, Deliveries, Inventory, Stock",
"description" : """
OpenERP Inventory Management module can manage multi-warehouses, multi and structured stock locations.
======================================================================================================
diff --git a/addons/stock/res_config_view.xml b/addons/stock/res_config_view.xml
index f6638f22002..0503ec544b1 100644
--- a/addons/stock/res_config_view.xml
+++ b/addons/stock/res_config_view.xml
@@ -13,36 +13,30 @@
or
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
-
-
-
+
-
-
-
diff --git a/addons/stock/stock_view.xml b/addons/stock/stock_view.xml
index 432b3ce365b..e90a2bb8162 100644
--- a/addons/stock/stock_view.xml
+++ b/addons/stock/stock_view.xml
@@ -2,15 +2,17 @@
-
+
+
+
+ parent="stock.menu_stock_product" sequence="0"/>
@@ -20,19 +22,19 @@
parent="stock.menu_stock_configuration" sequence="45" groups="base.group_no_one"/>
+ parent="stock.menu_product_in_config_stock" sequence="0"/>
-
-
-
+
+ stock.inventory.line.tree
@@ -116,25 +118,21 @@
-
-
+
+
-
-
-
-
+
+
-
+ -
+
-
-
-
@@ -150,19 +148,27 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
@@ -200,7 +206,7 @@
stock.inventoryform
-
+ Periodical Inventories are used to count the number of products available per location. You can use it once a year when you do the general inventory or whenever you need it, to correct the current stock level of a product.
@@ -271,9 +277,9 @@
child_ids
-
-
-
+
+
+
@@ -287,7 +293,7 @@
-
+
@@ -331,18 +337,25 @@
form
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
+
+
-
-
@@ -376,7 +389,7 @@
tree
-
+
@@ -397,7 +410,7 @@
-
+
@@ -415,7 +428,7 @@
stock.production.lotform
-
+ {}This is the list of all the production lots (serial numbers) you recorded. When you select a lot, you can get the upstream or downstream traceability of the products contained in lot. By default, the list is filtred on the serial numbers that are available in your warehouse but you can uncheck the 'Available' button to get all the lots you produced, received or delivered to customers.
@@ -433,8 +446,8 @@
move_history_ids
-
-
+
+
@@ -457,8 +470,8 @@
move_history_ids2
-
-
+
+
@@ -558,11 +571,11 @@
+ string="Internal" domain="[('usage', '=', 'internal')]" help="Internal Locations"/>
+ string="Customer" domain="[('usage', '=', 'customer')]" help="Customer Locations"/>
+ string="Supplier" domain="[('usage', '=', 'supplier')]" help="Supplier Locations"/>
@@ -726,7 +739,7 @@
-
+
@@ -734,7 +747,7 @@