Compare commits

...
10 Commits
Author SHA1 Message Date
pengyuthon 6b5469a4f0 chore(gzth): 天投云 Odoo 17 定制补丁汇总
本提交汇总生产环境长期定制改动(2024-11 ~ 2026-05):

- web 前端:list/calendar/form 状态指示/确认框/选择创建对话框等交互与中文化调整

- mail:mail_thread、models 定制(含多公司/权限相关处理)

- hr / hr_attendance:考勤与员工逻辑定制

- odoo/addons/base:ir_rule 记录规则调整

- bus/websocket、maintenance 视图、purchase_stock manifest、hr_contract zh_CN 翻译
2026-09-29 16:35:05 +08:00
Julien Van Roy e8f8c9b428 [FIX] account_edi: retrieve partner linked to the same company first
Before fce296acd9f4187c8473ca5ae0b68fb96e9026ff, the retrieve partner
function first looked for a partner with company_id = env.company.id. If
a parter wasn't found, a global search without constraining `company_id`
was made.

Thus, a search in a database with 2 partners with the same VAT (one has
a company_id, the other not) would return the partner linked to the
right company.

After fce296acd9f4187c8473ca5ae0b68fb96e9026ff, it is no longer the
case. The search is done with `[('company_id', 'in',
[self.env.company.id, False])`, so 2 partners are found, and none are
returned.

We reintroduce that loop in this commit to fix that case.

closes odoo/odoo#163702

X-original-commit: ff71f130cf19732f803fe8c2fbf1fc08c41dbc35
Signed-off-by: Laurent Smet (las) <las@odoo.com>
Signed-off-by: Julien Van Roy (juvr) <juvr@odoo.com>
2024-04-29 08:07:32 +00:00
Cameron 35017adaa9 [FIX] hr_expense: allow to create expenses via alias for employees not related to a user
Current behavior:
When trying to create an expense using alias, if a `hr.employee` not linked to a user want to, he won't be able

This bug occurs after this [commit](https://github.com/odoo/odoo/commit/fbeb7aebcadbe77ff7774b538550373650b329fe)

Steps to reproduce the error :
- Create an employee not related to a user
- Configure alias for expenses
- Try to send an email to the expense's alias and check at the logs

After this commit:
Employees without a related user will be able to create an expense from an email alias

opw-3892778
opw-3892701
opw-3893319
opw-3893591

closes odoo/odoo#163672

X-original-commit: 1dd67119c27d219eac2b726ef88cc4af3bed2814
Signed-off-by: Brice Bartoletti (bib) <bib@odoo.com>
2024-04-29 08:07:31 +00:00
Alvaro Fuentes 1bc016fa0f [FIX] core: remove SQL constraints upon ir.model.constraint removal
Otherwise we leave the constraints in the table. Common source of
upgrade issues.

closes odoo/odoo#163623

X-original-commit: 847a24e6f7f57c755cf6f42597b1ac75908f2c83
Signed-off-by: Raphael Collet <rco@odoo.com>
Signed-off-by: Alvaro Fuentes Suarez (afu) <afu@odoo.com>
2024-04-29 08:07:30 +00:00
Paul Stroobant fc16ecced2 [FIX] project_purchase: use price_subtotal in project profitability
Steps to reproduce the issue:

1. Create a purchase Tax and tick Include in Price in the Advanced Options
2. Create a Purchase Order with Analytic Distribution towards a Project in the Order Line
3. Set the purchase Tax created before as the tax of the Order Line
4. Confirm the Purchase Order
5. Make sure the Project is Billable, then go to the Project Updates
6. The profitability calculated the price with the included Tax
7. Create a Vendor Bill and set the same Tax and Analytic Distribution as the Purchase order
8. Confirm the Bill
9. Return to the Project Updates
10. The profitability doesn't calculate the included Tax

Explanation:

In `project.project._get_profitability_items`, we can find an inconsistency in the queries.
The query for `purchase.order.line` is looking for `price_unit`, which takes included taxes into account.
https://github.com/odoo/odoo/blob/249aaac7bd1a13d62c947cddb1835772659aabff/addons/project_purchase/models/project.py#L125-L132
The query for `account.move.line` retrieves `price_subtotal`, which does not.
https://github.com/odoo/odoo/blob/249aaac7bd1a13d62c947cddb1835772659aabff/addons/project_purchase/models/project.py#L171-L181

Suggested fix:

In `project.project._get_revenues_items_from_invoices`, the `account.move.line` query retrieves `price_subtotal` as well.
https://github.com/odoo/odoo/blob/8750b94c53c6ab58567873b0745fa6d9a18c97d0/addons/sale_project/models/project.py#L467-L474
With above information and input of PO (olma), taxes will not be calculated in `project.project._get_profitability_items`, therefore we will replace `price_unit` with `price_subtotal` in the `purchase.order.line` query.

opw-3781426

closes odoo/odoo#163567

X-original-commit: d4fa9ff7b1e2e8b5cd466d0f5433f99a1b407ac9
Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
2024-04-29 08:07:29 +00:00
lase@odoo.com 1ecd0b2160 [FIX] project_sms: Allow transition of task to state with SMS template
Steps to reproduce:

Be sure to have the `sale_sms` module installed.

- Connect as Marc Demo. Note: Marc has the administrator access rights
  in every service application including projects,...
- Go to the field service app create a new task and change its state to
  `planned`.

> Access error: you are not allowed to access 'SMS Templates'

Expected behavior:

Since the newly created user has the rights to modify the state of the
task and since he does not try to access the content of any sms.template
he should not raise this access error.

Cause of the issue:

The stage `planned` is associated with an SMS template. As such, when a
task is moved to this stage, an sms will be sent using the template.
This action is done during the `write` override of the `project_sms`
module:
https://github.com/odoo/odoo/blob/5f1a3bdcaa63492cf169f6f5f3eb2e2281ad5ab5/addons/project_sms/models/project_task.py#L24-L32
However, this `_send_sms` method will need to 'read' the sms.template to
generate the sms:
https://github.com/odoo/odoo/blob/e6be732450d9ef662a48ba074e1ca1ad32e35c04/addons/sms/models/mail_thread.py#L191-L192
Since the user does not have the acess rights to 'read' this template
because of the `ir_rule_sms_template_so_sale_manager` access rule
defined in the `sale_sms` module, the access error will be raised.

Fix:

Since the `_send_sms` method will only read records in order to generate
the sms that will be send, we should bypass access rigths checks during
the call of this method.

Note: this was already the solution used for portal users.

opw-3789197

closes odoo/odoo#163525

X-original-commit: b5b63509a6bc4467cf84cd15ebdf3b4b0601aeb0
Signed-off-by: Lancelot Semal (lase) <lase@odoo.com>
Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
2024-04-29 08:07:28 +00:00
Djamel Touati 14836d4084 [FIX] mrp: create a stock move without price_unit in MO
Steps to reproduce the bug:
- Create a storable product C1:
  - standard_price: $10
  - Update the quantity to 2 units

- Create a storable product P1 with BoM:
  - Component: 1 unit of C1

- Create a MO to produce 1 unit of P1:
  - Confirm it

- Update the price of C1 to $20

- Go back to the MO and set the quantity consumed of C1 to 2

- Mark the MO as done
- Confirm the difference in consumption

Problem:
Another move is created with 1 unit of C1 consumed but is not merged
with the first move because the two moves were created with different
prices, making them incompatible for merging.

Solution:
There's no need to create the moves with `price_unit`.

OPW-3791816

closes odoo/odoo#163175

Signed-off-by: William Henrotin (whe) <whe@odoo.com>
2024-04-29 08:07:27 +00:00
Arnaud Sibille 69b2c89165 [FIX] point_of_sale: create picking before invoice
We are in the context of anglo-saxon accounting, when selling a product
having an automated valuation.  The invoice linked to the `pos.order`
should have its stock output line reconciled with its counterpart in
the stock valuation journal.  That is what happens if you create the
invoice directly from point of sale.

Currently, if you do not create the invoice, keep the session open and
then click the "Invoice" button on the pos order, the stock output line
will not be reconciled.

This happens because in `action_pos_order_invoice`, the picking is
created after the invoice.  But the reconciliation happens when creating
the invoice.  As it doesn't have its valuation counterpart yet (which is
created from the picking), it then do not reconcile with anything.

The fix here is to create the picking before.

opw-3702345

closes odoo/odoo#163157

X-original-commit: abf3f16ea6bb0278b2d44de281d69d3cfc8ac4cb
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
2024-04-29 08:07:26 +00:00
Lucas Perais ccddb8a36f [FIX] base: ir_qweb_field:image: handle webp mimetype
See discussions on https://github.com/odoo/odoo/pull/85494/.
TLDR: webp image format needs to be supported, but we should avoid
going through the Pillow library as it is largely unsafe for that format.
jpg attachment are created in JS at upload time.
Wkhtmltopdf doesn't support webp, so, in reports, we should display one of those jpg copies.
This work is handled by `ir.qweb: _get_converted_image_data_uri` which is used as:
```xml
<img src="image_data_uri(some_b64value)" />
```

The mentioned PR did not however adapt the ir.qweb.field.image that, when passed the option `qweb_img_raw_data`
should return a base64 url such as `data:[mimetype],base64,[datas]`.
usage:
```xml
<span t-field="object.image_field" t-options-widget="'image'" t-options-qweb_img_raw_data="1" />
```

Hence, before this commit, there was a crash as we tried to pass that value to PIL.

After this commit, there is no crash, and the image displays correctly as JPG in the PDF

opw-3859423

closes odoo/odoo#163003

X-original-commit: 9056a4b1f28e820c0444f28367cc30abebd5ea30
Signed-off-by: Benoit Socias (bso) <bso@odoo.com>
Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
2024-04-29 08:07:25 +00:00
Patrick Hoste 7a124de438 [FIX] web: force padding 0 in some nested sortable
In Documents when dragging a workspace in the search panel
to resequence it there was no visual effect showing where
the workspace would drop. This was due to some css rules that
were replaced by a bootstrap class in the following
commit : 6f63e2349c

In our case, the `o_search_panel_category_value` node already
has a `py-1` bootstrap class, which overrides the value set
by the `py-0` class.

this commit restores the `padding-top: 0 !important;` and
`padding-bottom:0 !important;` rules to avoid conflict with
other py-X CSS rules (e.g.: py-1, py-2, ...)

Task-3877426

closes odoo/odoo#162948

Signed-off-by: Stéphane Debauche (std) <std@odoo.com>
2024-04-29 08:07:25 +00:00
35 changed files with 405 additions and 86 deletions
+7 -3
View File
@@ -881,9 +881,13 @@ class ResPartner(models.Model):
return self.env['res.partner'].search(domain + extra_domain, limit=1)
for search_method in (search_with_vat, search_with_domain, search_with_phone_mail, search_with_name):
partner = search_method(self.env['res.partner']._check_company_domain(company or self.env.company))
if partner and len(partner) == 1:
return partner
for extra_domain in (
[*self.env['res.partner']._check_company_domain(company or self.env.company), ('company_id', '!=', False)],
[('company_id', '=', False)],
):
partner = search_method(extra_domain)
if partner and len(partner) == 1:
return partner
return self.env['res.partner']
def _merge_method(self, destination, source):
+1
View File
@@ -834,6 +834,7 @@ class WebsocketConnectionHandler:
try:
response = cls._get_handshake_response(request.httprequest.headers)
socket = request.httprequest._HTTPRequest__environ['socket']
# socket = request.httprequest._HTTPRequest__environ['werkzeug.socket']
session, db, httprequest = request.session, request.db, request.httprequest
response.call_on_close(lambda: cls._serve_forever(
Websocket(socket, session),
+1 -1
View File
@@ -80,7 +80,7 @@ class HrEmployeePrivate(models.Model):
birthday = fields.Date('Date of Birth', groups="hr.group_hr_user", tracking=True)
ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user", tracking=True)
sinid = fields.Char('SIN No', help='Social Insurance Number', groups="hr.group_hr_user", tracking=True)
identification_id = fields.Char(string='Identification No', groups="hr.group_hr_user", tracking=True)
identification_id = fields.Char(string='Identification No', tracking=True)
passport_id = fields.Char('Passport No', groups="hr.group_hr_user", tracking=True)
bank_account_id = fields.Many2one(
'res.partner.bank', 'Bank Account Number',
+1 -1
View File
@@ -32,7 +32,7 @@ class HrAttendance(models.Model):
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True)
department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
readonly=True)
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True, tracking=True)
check_in = fields.Datetime(string="Check In", tracking=True)
check_out = fields.Datetime(string="Check Out", tracking=True)
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
color = fields.Integer(compute='_compute_color')
+7 -7
View File
@@ -98,13 +98,13 @@ class HrEmployee(models.Model):
end_tz = now_tz
end_naive = end_tz.astimezone(pytz.utc).replace(tzinfo=None)
hours = sum(
att.worked_hours or 0
for att in employee.attendance_ids.filtered(
lambda att: att.check_in >= start_naive and att.check_out and att.check_out <= end_naive
)
)
# hours = sum(
# att.worked_hours or 0
# for att in employee.attendance_ids.filtered(
# lambda att: att.check_in >= start_naive and att.check_out and att.check_out <= end_naive
# )
# )
hours = 0
employee.hours_last_month = round(hours, 2)
employee.hours_last_month_display = "%g" % employee.hours_last_month
+1 -1
View File
@@ -246,7 +246,7 @@ msgstr "日历不匹配: 员工日历与合约日历不匹配。可能会导
#: model:ir.model.fields.selection,name:hr_contract.selection__hr_contract__state__cancel
#: model:ir.model.fields.selection,name:hr_contract.selection__hr_contract_history__state__cancel
msgid "Cancelled"
msgstr "已取消"
msgstr "已中止"
#. module: hr_contract
#: model:ir.model,name:hr_contract.model_res_company
+25 -7
View File
@@ -920,14 +920,9 @@ class HrExpense(models.Model):
@api.model
def message_new(self, msg_dict, custom_values=None):
email_address = email_split(msg_dict.get('email_from', False))[0]
employee = self._get_employee_from_email(email_address)
employee = self.env['hr.employee'].search([
'|',
('work_email', 'ilike', email_address),
('user_id.email', 'ilike', email_address)
]).filtered(lambda e: e.company_id == e.user_id.company_id)
if len(employee) != 1:
if not employee:
return super().message_new(msg_dict, custom_values=custom_values)
expense_description = msg_dict.get('subject', '')
@@ -967,6 +962,29 @@ class HrExpense(models.Model):
self._send_expense_success_mail(msg_dict, expense)
return expense
@api.model
def _get_employee_from_email(self, email_address):
employee = self.env['hr.employee'].search([
('user_id', '!=', False),
'|',
('work_email', 'ilike', email_address),
('user_id.email', 'ilike', email_address),
])
if len(employee) > 1:
# Several employees can be linked to the same user.
# In that case, we only keep the employee that matched the user's company.
return employee.filtered(lambda e: e.company_id == e.user_id.company_id)
if not employee:
# An employee does not always have a user.
return self.env['hr.employee'].search([
('user_id', '=', False),
('work_email', 'ilike', email_address),
], limit=1)
return employee
@api.model
def _parse_product(self, expense_description):
"""
@@ -67,6 +67,43 @@ class TestExpensesMailImport(TestExpenseCommon):
'employee_id': company_2_employee.id,
}])
def test_import_expense_from_email_employee_without_user(self):
"""When an employee is not linked to a user, he has to be able to create expenses from email"""
employee = self.expense_employee
employee.user_id = False
message_parsed = {
'message_id': "the-world-is-a-ghetto",
'subject': 'New expense',
'email_from': employee.work_email,
'to': 'catchall@yourcompany.com',
'body': "Don't you know, that for me, and for you",
'attachments': [],
}
expense = self.env['hr.expense'].message_new(message_parsed)
self.assertRecordValues(expense, [{
'employee_id': employee.id,
}])
def test_import_expense_from_email_no_product(self):
message_parsed = {
'message_id': "the-world-is-a-ghetto",
'subject': 'no product code 800',
'email_from': self.expense_user_employee.email,
'to': 'catchall@yourcompany.com',
'body': "Don't you know, that for me, and for you",
'attachments': [],
}
expense = self.env['hr.expense'].message_new(message_parsed)
self.assertRecordValues(expense, [{
'product_id': False,
'total_amount': 800.0,
'employee_id': self.expense_employee.id,
}])
def test_import_expense_from_mail_parsing_subjects(self):
def assertParsedValues(subject, currencies, exp_description, exp_amount, exp_product, exp_currency):
product, amount, currency_id, description = self.env['hr.expense'] \
+1 -1
View File
@@ -4422,7 +4422,7 @@ class MailThread(models.AbstractModel):
payload = self._notify_by_web_push_prepare_payload(message, msg_vals=msg_vals)
payload = self._truncate_payload(payload)
if len(devices) < MAX_DIRECT_PUSH:
if False:
session = Session()
devices_to_unlink = set()
for device in devices:
+9 -7
View File
@@ -176,13 +176,15 @@ class BaseModel(models.AbstractModel):
def _mail_track_get_field_sequence(self, fname):
""" Find tracking sequence of a given field, given their name. Current
parameter 'tracking' should be an integer, but attributes with True
are still supported; old naming 'track_sequence' also. """
sequence = getattr(
self._fields[fname], 'tracking',
getattr(self._fields[fname], 'track_sequence', 100)
)
if sequence is True:
sequence = 100
are still supported; old naming 'track_sequence' also.
此文件因物业水电表日期字段报keyerror做屏蔽暂时解决
"""
# sequence = getattr(
# self._fields[fname], 'tracking',
# getattr(self._fields[fname], 'track_sequence', 100)
# )
# if sequence is True:
sequence = 100
return sequence
def _message_get_default_recipients(self):
@@ -569,8 +569,7 @@
<field name="arch" type="xml">
<search string="Search">
<field string="Equipment" name="name" filter_domain="[
'|', '|', '|',
('name', 'ilike', self), ('model', 'ilike', self), ('serial_no', 'ilike', self), ('partner_ref', 'ilike', self)]"/>
'|', '|', '|', ('name', 'ilike', self), ('model', 'ilike', self), ('serial_no', 'ilike', self), ('partner_ref', 'ilike', self)]"/>
<field string="Category" name="category_id"/>
<field name="owner_user_id"/>
<filter string="My Equipment" name="my" domain="[('owner_user_id', '=', uid)]"/>
@@ -599,9 +598,9 @@
</search>
</field>
</record>
<record id="hr_equipment_action" model="ir.actions.act_window">
<field name="name">Equipment</field>
<field name="name">固定资产</field>
<field name="res_model">maintenance.equipment</field>
<field name="view_mode">kanban,tree,form</field>
<field name="view_id" ref="hr_equipment_view_kanban"/>
-1
View File
@@ -1192,7 +1192,6 @@ class MrpProduction(models.Model):
'raw_material_production_id': self.id,
'company_id': self.company_id.id,
'operation_id': operation_id,
'price_unit': product_id.standard_price,
'procure_method': 'make_to_stock',
'origin': self._get_origin(),
'state': 'draft',
-1
View File
@@ -309,7 +309,6 @@ class StockMove(models.Model):
product = product.browse(values['product_id'])
product_id_to_product[values['product_id']] = product
values['location_dest_id'] = mo.production_location_id.id
values['price_unit'] = product.standard_price
if not values.get('location_id'):
values['location_id'] = mo.location_src_id.id
continue
@@ -237,3 +237,37 @@ class TestManualConsumption(TestMrpCommon):
mo = self.env[action['res_model']].browse(action['res_id'])
self.assertTrue(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).manual_consumption)
self.assertFalse(mo.move_raw_ids.filtered(lambda m: m.product_id == p2).manual_consumption)
def test_manual_consumption_with_different_component_price(self):
"""
Test that the moves are merged correctly, even if the products have been used with different prices:
- Create a product with a price of $10 and use it in a BoM with 1 unit.
- Create a MO with this BoM and confirm it.
- Update the price of the component to $20 and adjust the consumed quantity to 2.
- Mark the MO as done.
- Another move should be created and merged with the first move.
"""
self.bom_4.consumption = 'warning'
component = self.bom_4.bom_line_ids.product_id
component.write({
'type': 'product',
'standard_price': 10,
})
self.env['stock.quant']._update_available_quantity(component, self.stock_location, 2)
mo = self.env['mrp.production'].create({
'product_qty': 1,
'bom_id': self.bom_4.id,
})
mo.action_confirm()
self.assertEqual(mo.state, 'confirmed')
component.standard_price = 20
mo.move_raw_ids.quantity = 2.0
mo.move_raw_ids.picked = True
mo.move_raw_ids.manual_consumption = True
self.assertEqual(mo.state, 'progress')
action = mo.button_mark_done()
consumption_warning = Form(self.env['mrp.consumption.warning'].with_context(**action['context']))
action = consumption_warning.save().action_confirm()
self.assertEqual(len(mo.move_raw_ids), 1)
self.assertEqual(mo.move_raw_ids.quantity, 2)
@@ -23,7 +23,7 @@ class StockMove(models.Model):
# In case of standard price, the component cost is the cost of the product
# the subcontracting service cost may not represent the real cost of the subcontracting service
# the difference should be posted in price difference account in the end
component_cost = currency.round(sum(m.price_unit * m.product_uom_qty for m in subcontract_production.move_raw_ids))
component_cost = abs(currency.round(sum(subcontract_production.move_raw_ids.stock_valuation_layer_ids.mapped('value'))))
subcontract_service_cost = credit_value - component_cost
else:
subcontract_service_cost = currency.round(subcontract_production.extra_cost * qty)
+1 -2
View File
@@ -862,10 +862,9 @@ class PosOrder(models.Model):
if len(self.company_id) > 1:
raise UserError(_("You cannot invoice orders belonging to different companies."))
self.write({'to_invoice': True})
res = self._generate_pos_order_invoice()
if self.company_id.anglo_saxon_accounting and self.session_id.update_stock_at_closing and self.session_id.state != 'closed':
self._create_order_picking()
return res
return self._generate_pos_order_invoice()
def _generate_pos_order_invoice(self):
moves = self.env['account.move']
@@ -309,3 +309,43 @@ class TestAngloSaxonFlow(TestAngloSaxonCommon):
self.assertEqual(aml_output[0].credit, 0.0, "Cost of Good Sold entry missing or mismatching")
self.assertEqual(aml_expense[0].credit, 0.0, "Cost of Good Sold entry missing or mismatching")
self.assertEqual(aml_expense[0].debit, 0.0, "Cost of Good Sold entry missing or mismatching")
def test_action_pos_order_invoice(self):
self.company.point_of_sale_update_stock_quantities = 'closing'
# Setup a running session, with a paid pos order that is not invoiced
self.pos_config.open_ui()
current_session = self.pos_config.current_session_id
self.pos_order_pos0 = self.PosOrder.create({
'company_id': self.company.id,
'partner_id': self.partner.id,
'session_id': self.pos_config.current_session_id.id,
'lines': [(0, 0, {
'product_id': self.product.id,
'price_unit': 450,
'qty': 1.0,
'price_subtotal': 450,
'price_subtotal_incl': 450,
})],
'amount_total': 450,
'amount_tax': 0,
'amount_paid': 0,
'amount_return': 0,
})
context_make_payment = {"active_ids": [self.pos_order_pos0.id], "active_id": self.pos_order_pos0.id}
self.pos_make_payment_0 = self.PosMakePayment.with_context(context_make_payment).create({
'amount': 450.0,
'payment_method_id': self.cash_payment_method.id,
})
context_payment = {'active_id': self.pos_order_pos0.id}
self.pos_make_payment_0.with_context(context_payment).check()
# Invoice the pos order afterward (session still running)
self.pos_order_pos0.action_pos_order_invoice()
# Check that the stock output journal item from the invoice is reconciled (with its counterpart from the valuation entry)
stock_output_account = self.category.property_stock_account_output_categ_id
related_amls = current_session._get_related_account_moves().line_ids
stock_output_amls = related_amls.filtered_domain([('account_id', '=', stock_output_account.id)])
self.assertTrue(all(stock_output_amls.mapped('reconciled')))
@@ -143,7 +143,7 @@ class Project(models.Model):
('state', 'in', ['purchase', 'done']),
'|',
('qty_invoiced', '>', 0),
'|', ('qty_to_invoice', '>', 0), ('product_uom_qty', '>', 0),
'|', ('qty_to_invoice', '>', 0), ('product_qty', '>', 0),
], order=self.env['purchase.order.line']._order)
query.add_where(
SQL(
@@ -152,7 +152,7 @@ class Project(models.Model):
self.env['purchase.order.line']._query_analytic_accounts(),
)
)
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_uom_qty', 'price_unit', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_qty', 'price_subtotal', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
self._cr.execute(query_string, query_param)
purchase_order_line_read = [{
**pol,
@@ -168,17 +168,18 @@ class Project(models.Model):
for pol_read in purchase_order_line_read:
purchase_order_line_invoice_line_ids.extend(pol_read['invoice_lines'].ids)
currency = self.env['res.currency'].browse(pol_read['currency_id']).with_prefetch(currency_ids)
price_unit = currency._convert(pol_read['price_unit'], self.currency_id, self.company_id)
price_subtotal = currency._convert(pol_read['price_subtotal'], self.currency_id, self.company_id)
price_subtotal_unit = price_subtotal / pol_read['product_qty'] if pol_read['product_qty'] else 0.0
# an analytic account can appear several time in an analytic distribution with different repartition percentage
analytic_contribution = sum(
percentage for ids, percentage in pol_read['analytic_distribution'].items()
if str(self.analytic_account_id.id) in ids.split(',')
) / 100.
amount_invoiced -= price_unit * pol_read['qty_invoiced'] * analytic_contribution if pol_read['qty_invoiced'] > 0 else 0.0
amount_invoiced -= price_subtotal_unit * pol_read['qty_invoiced'] * analytic_contribution if pol_read['qty_invoiced'] > 0 else 0.0
if pol_read['qty_to_invoice'] > 0:
amount_to_invoice -= price_unit * pol_read['qty_to_invoice'] * analytic_contribution
amount_to_invoice -= price_subtotal_unit * pol_read['qty_to_invoice'] * analytic_contribution
else:
amount_to_invoice -= price_unit * (pol_read['product_uom_qty'] - pol_read['qty_invoiced']) * analytic_contribution
amount_to_invoice -= price_subtotal_unit * (pol_read['product_qty'] - pol_read['qty_invoiced']) * analytic_contribution
purchase_order_line_ids.append(pol_read['id'])
costs = profitability_items['costs']
section_id = 'purchase_order'
@@ -169,6 +169,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
# we should have a new section "purchase_order", the total should be updated,
# but the "other_purchase_costs" shouldn't change, as we don't take into
# account bills from purchase orders, as those are already taken into calculations
@@ -267,6 +268,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@@ -424,6 +426,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})],
})
purchase_order_foreign.button_confirm()
purchase_order_foreign.order_line.flush_recordset()
# We should have a new section "purchase_order", the total should be updated,
# but the "other_purchase_costs" shouldn't change, as we don't take into
@@ -460,6 +463,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
# The 'to bill' section should be updated in the 'total' and 'purchase orders' sections.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
@@ -508,6 +512,103 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
def test_analytic_distribution_with_included_tax(self):
"""When calculating the profitability of a project, included taxes should not be calculated"""
included_tax = self.env['account.tax'].create({
'name': 'included tax',
'amount': '15.0',
'amount_type': 'percent',
'type_tax_use': 'purchase',
'price_include': True
})
# create a purchase.order with the project account in analytic_distribution
purchase_order = self.env['purchase.order'].create({
'name': "A purchase order",
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'analytic_distribution': {self.analytic_account.id: 100},
'product_id': self.product_order.id,
'product_qty': 2, # plural value to check if the price is multiplied more than once
'taxes_id': [included_tax.id], # set the included tax
'price_unit': self.product_order.standard_price,
'currency_id': self.env.company.currency_id.id,
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
# the profitability should not take taxes into account
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -(purchase_order.amount_untaxed),
'billed': 0.0,
}],
'total': {
'to_bill': -(purchase_order.amount_untaxed),
'billed': 0.0,
},
},
)
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
# same here, taxes should not be calculated in the profitability
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': 0.0,
'billed': -(purchase_bill.amount_untaxed),
}],
'total': {
'to_bill': 0.0,
'billed': -(purchase_bill.amount_untaxed),
},
},
)
def test_analytic_distribution_with_mismatched_uom(self):
"""When changing the unit of measure, the profitability should still match the price_subtotal of the order line"""
# create a purchase.order with the project account in analytic_distribution
purchase_order = self.env['purchase.order'].create({
'name': "A purchase order",
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'analytic_distribution': {self.analytic_account.id: 100},
'product_id': self.product_order.id,
'product_qty': 1,
'price_unit': self.product_order.standard_price,
'currency_id': self.env.company.currency_id.id,
})],
})
purchase_order.button_confirm()
# changing the uom to a higher number
purchase_order.order_line.product_uom = self.env.ref("uom.product_uom_dozen")
purchase_order.order_line.flush_recordset()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -(purchase_order.amount_untaxed),
'billed': 0.0,
}],
'total': {
'to_bill': -(purchase_order.amount_untaxed),
'billed': 0.0,
},
},
)
def test_cross_analytics_contribution(self):
cross_plan = self.env['account.analytic.plan'].create({'name': 'Cross Plan'})
cross_account = self.env['account.analytic.account'].create({
@@ -535,8 +636,9 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})
cross_order.button_confirm()
items = self.project._get_profitability_items()
cross_order.order_line.flush_recordset()
items = self.project._get_profitability_items(with_action=False)['costs']
self.assertEqual(
items['costs']['data'][0]['to_bill'],
self.product_order.standard_price *-cross_distribution/100
items['data'][0]['to_bill'],
-(self.product_order.standard_price * cross_distribution / 100)
)
+2 -5
View File
@@ -25,9 +25,6 @@ class ProjectTask(models.Model):
res = super().write(vals)
if 'stage_id' in vals:
if self.env.user.has_group('base.group_portal') and not self.env.su:
# sudo as sms template model is protected
self.sudo()._send_sms()
else:
self._send_sms()
# sudo as sms template model is protected
self.sudo()._send_sms()
return res
@@ -4,6 +4,7 @@
from odoo import Command
from odoo.addons.project.tests.test_project_sharing import TestProjectSharingCommon
from odoo.addons.sms.tests.common import SMSCommon
from odoo.tests import tagged
class TestProjectSharingWithSms(TestProjectSharingCommon, SMSCommon):
@@ -63,3 +64,42 @@ class TestProjectSharingWithSms(TestProjectSharingCommon, SMSCommon):
})
self.assertEqual(self.project_portal.stage_id, self.project_stage_with_sms)
self.assertSMSIapSent([self.project_portal.partner_id.mobile])
@tagged('post_install', '-at_install')
class TestPostInstallProjectSharingWithSms(TestProjectSharingWithSms):
def test_project_user_can_change_stage_with_sms_template(self):
""" Test that users with the rights to change the stage of a task can perform this action
when the stage has an sms template.
The sms template should be sent and the stage should be changed on the task.
"""
project_user_group = self.env.ref('project.group_project_user')
sale_manager_group = self.env.ref('sales_team.group_sale_manager', False)
if not sale_manager_group:
self.skipTest('`sale_sms` not installed')
self.user_projectuser.write({
'groups_id': [
Command.link(project_user_group.id),
Command.link(sale_manager_group.id),
]
})
self.assertTrue(self.task_cow.with_user(self.user_projectuser).check_access_rights('write'))
with self.mockSMSGateway():
self.task_cow.with_user(self.user_projectuser).write({
'stage_id': self.task_stage_with_sms.id,
})
self.assertEqual(self.task_cow.stage_id, self.task_stage_with_sms)
self.assertSMSIapSent([]) # no sms sent since the author is the recipient
self.task_cow.write({
'partner_id': self.user_portal.partner_id.id,
'stage_id': self.project_cows.type_ids[0].id,
})
with self.mockSMSGateway():
self.task_cow.with_user(self.user_projectuser).write({
'stage_id': self.task_stage_with_sms.id,
})
self.assertEqual(self.task_cow.stage_id, self.task_stage_with_sms)
self.assertSMSIapSent([self.user_portal.partner_id.mobile])
+1 -1
View File
@@ -10,7 +10,7 @@
'depends': ['stock_account', 'purchase'],
'data': [
'security/ir.model.access.csv',
'data/purchase_stock_data.xml',
# 'data/purchase_stock_data.xml',
'data/mail_templates.xml',
'report/vendor_delay_report.xml',
'views/purchase_views.xml',
@@ -7,10 +7,7 @@ import { useChildRef } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export const deleteConfirmationMessage = _t(
`Ready to make your record disappear into thin air? Are you sure?
It will be gone forever!
Think twice before you click that 'Delete' button!`
`你确定要删除吗?`
);
export class ConfirmationDialog extends Component {
@@ -139,7 +139,7 @@ export const useNestedSortable = makeDraggableHook({
ctx.prevNestX = ctx.pointer.x;
}
ctx.current.placeHolder = ctx.current.element.cloneNode(false);
ctx.current.placeHolder.classList.add("w-100", "d-block", "py-0");
ctx.current.placeHolder.classList.add("w-100", "d-block");
if (ctx.useElementSize) {
ctx.current.placeHolder.style.height = getComputedStyle(ctx.current.element).height;
ctx.current.placeHolder.classList.add("o_nested_sortable_placeholder_realsize");
@@ -2,6 +2,8 @@
background-clip: content-box;
background-color: deepskyblue;
height: 5px;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.o_nested_sortable_placeholder_realsize {
outline: 1px dashed $o-gray-600;
@@ -323,7 +323,7 @@ export class CalendarController extends Component {
}
deleteRecord(record) {
this.displayDialog(ConfirmationDialog, {
title: _t("Bye-bye, record!"),
title: _t(" "),
body: deleteConfirmationMessage,
confirm: () => {
this.model.unlinkRecord(record.id);
@@ -10,20 +10,22 @@
data-hotkey="s"
t-on-click.stop="save"
data-tooltip="Save manually"
aria-label="Save manually"
style="background-color: blue !important;color: white !important;"
aria-label="Save manually"
t-ref="save">
<i class="fa fa-cloud-upload fa-fw" />
<i class="fa fa-cloud-upload fa-fw"/>
</button>
<button
type="button"
class="o_form_button_cancel btn btn-light px-1 py-0 lh-sm"
data-hotkey="j"
t-on-click.stop="discard"
data-tooltip="Discard changes"
data-tooltip="Discard changes"
style="background-color: red !important;color: white !important;"
aria-label="Discard changes">
<i class="fa fa-undo fa-fw" />
</button>
</div>
<i class="fa fa-undo fa-fw"/>
</button>
</div>
<span
t-if="!props.model.root.isNew and indicatorMode === 'invalid'"
class="text-danger small ms-2"
@@ -536,7 +536,7 @@ export class ListController extends Component {
body = _t("Are you sure you want to delete these records?");
}
return {
title: _t("Bye-bye, record!"),
title: _t(" "),
body,
confirmLabel: _t("Delete"),
confirm: () => this.model.root.deleteRecords(),
@@ -983,7 +983,7 @@ export class ListRenderer extends Component {
}
get getEmptyRowIds() {
let nbEmptyRow = Math.max(0, 4 - this.props.list.records.length);
let nbEmptyRow = Math.max(0, 1 - this.props.list.records.length);
if (nbEmptyRow > 0 && this.displayRowCreates) {
nbEmptyRow -= 1;
}
@@ -21,7 +21,7 @@ export class SelectCreateDialog extends Component {
const translatedText = _t("No records found!");
this.busy = false; // flag used to ensure we only call once the onSelected/onUnselect props
this.baseViewProps = {
display: { searchPanel: false },
display: { searchPanel: true },
editable: false, // readonly
noBreadcrumbs: true,
noContentHelp: markup(`<p>${escape(translatedText)}</p>`),
@@ -939,7 +939,7 @@ QUnit.module("Views", ({ beforeEach }) => {
await click(target, ".o_cw_popover_delete");
assert.strictEqual(
target.querySelector(".modal-title").textContent,
"Bye-bye, record!",
" ",
"should display the confirm message"
);
+44
View File
@@ -1,5 +1,8 @@
import base64
from lxml import etree
from odoo.tests.common import TransactionCase
from odoo.tools.mimetypes import guess_mimetype
class TestIrQweb(TransactionCase):
def test_image_field(self):
@@ -28,3 +31,44 @@ class TestIrQweb(TransactionCase):
self.assertTrue(img.get("src").startswith("/web/image"))
self.assertEqual(img.get("class"), "img img-fluid")
self.assertEqual(img.get("alt"), "test image partner")
def test_image_field_webp(self):
webp = "UklGRsCpAQBXRUJQVlA4WAoAAAAQAAAAGAQA/wMAQUxQSMywAAAdNANp22T779/0RUREkvqLOTPesG1T21jatpLTSbpXQzTMEw3zWMM81jCPnWG2fTM7vpndvpkd38y2758Y+6a/Ld/Mt3zzT/XwzCKlV0Ooo61UpZIsKLjKc98R"
webp_decoded = base64.b64decode(webp)
self.assertEqual(guess_mimetype(webp_decoded), "image/webp")
view = self.env["ir.ui.view"].create({
"key": "web.test_qweb",
"type": "qweb",
"arch": """<t t-name="test_qweb">
<span t-field="record.flag_image" t-options-widget="'image'" t-options-qweb_img_raw_data="is_raw_image" />
</t>"""
})
lang_record = self.env["res.lang"].create({
"name": "test lang",
"flag_image": webp,
"code": "TEST"
})
attachment = self.env["ir.attachment"].search([
("res_model", "=", "res.lang"),
("res_id", '=', lang_record.id),
("res_field", "=", "flag_image")
])
jpeg_attach = self.env["ir.attachment"].create({
"name": "webpcopy.jpg",
"res_model": "ir.attachment",
"res_id": attachment.id,
"datas": "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVR4nGJxKFrEwMDAxAAGgAAAAP//D+IBWx9K7TUAAAAASUVORK5CYII="
})
jpeg_datas = jpeg_attach.datas
html = view.with_context(webp_as_jpg=False)._render_template(view.id, {"is_raw_image": True, "record": lang_record})
tree = etree.fromstring(html)
img = tree.find("img")
self.assertEqual(img.get("src"), "data:image/webp;base64,%s" % webp)
html = view.with_context(webp_as_jpg=True)._render_template(view.id, {"is_raw_image": True, "record": lang_record})
tree = etree.fromstring(html)
img = tree.find("img")
self.assertEqual(img.get("src"), "data:image/png;base64,%s" % jpeg_datas.decode())
+4 -10
View File
@@ -1751,13 +1751,9 @@ class IrModelConstraint(models.Model):
'Constraints with the same name are unique per module.'),
]
def _module_data_uninstall(self):
"""
Delete PostgreSQL foreign keys and constraints tracked by this model.
"""
if not self.env.is_system():
raise AccessError(_('Administrator access is required to uninstall a module'))
def unlink(self):
self.check_access_rights('unlink')
self.check_access_rule('unlink')
ids_set = set(self.ids)
for data in self.sorted(key='id', reverse=True):
name = tools.ustr(data.name)
@@ -1802,7 +1798,7 @@ class IrModelConstraint(models.Model):
sql.Identifier(table), sql.Identifier(hname)))
_logger.info('Dropped CONSTRAINT %s@%s', name, data.model.model)
self.unlink()
return super().unlink()
def copy(self, default=None):
default = dict(default or {})
@@ -2449,8 +2445,6 @@ class IrModelData(models.Model):
modules._remove_copied_views()
# remove constraints
constraints = self.env['ir.model.constraint'].search([('module', 'in', modules.ids)])
constraints._module_data_uninstall()
delete(self.env['ir.model.constraint'].browse(unique(constraint_ids)))
# If we delete a selection field, and some of its values have ondelete='cascade',
+14 -4
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import base64
import binascii
from datetime import time
import logging
import re
@@ -15,6 +16,7 @@ from odoo import api, fields, models, _, _lt, tools
from odoo.tools import posix_to_ldml, float_utils, format_date, format_duration, pycompat
from odoo.tools.mail import safe_attrs
from odoo.tools.misc import get_lang, babel_locale_parse
from odoo.tools.mimetypes import guess_mimetype
_logger = logging.getLogger(__name__)
@@ -390,13 +392,21 @@ class ImageConverter(models.AbstractModel):
@api.model
def _get_src_data_b64(self, value, options):
try: # FIXME: maaaaaybe it could also take raw bytes?
image = Image.open(BytesIO(base64.b64decode(value)))
try:
img_b64 = base64.b64decode(value)
except binascii.Error:
raise ValueError("Invalid image content") from None
if img_b64 and guess_mimetype(img_b64, '') == 'image/webp':
return self.env["ir.qweb"]._get_converted_image_data_uri(value)
try:
image = Image.open(BytesIO(img_b64))
image.verify()
except IOError:
raise ValueError("Non-image binary fields can not be converted to HTML")
raise ValueError("Non-image binary fields can not be converted to HTML") from None
except: # image.verify() throws "suitable exceptions", I have no idea what they are
raise ValueError("Invalid image content")
raise ValueError("Invalid image content") from None
return "data:%s;base64,%s" % (Image.MIME[image.format], value.decode('ascii'))
+2 -4
View File
@@ -212,12 +212,10 @@ class IrRule(models.Model):
'unlink': _("unlink"),
}
user_description = f"{self.env.user.name} (id={self.env.user.id})"
operation_error = _("Uh-oh! Looks like you have stumbled upon some top-secret records.\n\n" \
"Sorry, %s doesn't have '%s' access to:", user_description, operations[operation])
operation_error = "对不起,你没有访问的权限: %s", user_description
failing_model = _("- %s (%s)", description, model)
resolution_info = _("If you really, really need access, perhaps you can win over your friendly administrator with a batch of freshly baked cookies.")
resolution_info = ""
if not self.user_has_groups('base.group_no_one') or not self.env.user.has_group('base.group_user'):
records.invalidate_recordset()
return AccessError(f"{operation_error}\n{failing_model}\n\n{resolution_info}")