16.0 vanila

This commit is contained in:
Ernad Husremovic 2025-10-03 17:53:49 +02:00
parent 956889352c
commit 2e65bf056a
17 changed files with 5293 additions and 17668 deletions

View file

@ -9,27 +9,8 @@ from . import wizard
def post_init(cr, registry):
"""Post-init housekeeping.
- Rewrites ICP's to force groups (existing behavior)
- Removes enterprise/proprietary "to_buy" module promos from Apps list
"""
"""Rewrite ICP's to force groups"""
from odoo import api, SUPERUSER_ID
env = api.Environment(cr, SUPERUSER_ID, {})
# Keep existing behavior
env['ir.config_parameter'].init(force=True)
# Remove enterprise/proprietary promotional stubs so they don't show in Apps
try:
Module = env['ir.module.module']
promos = Module.search([
('to_buy', '=', True),
('license', 'in', ['OEEL-1', 'OEEL', 'OPL-1', 'Proprietary']),
])
if promos:
promos.unlink()
except Exception:
# Best-effort cleanup; never break base post-init
pass

View file

@ -49,8 +49,7 @@ The kernel of Odoo, needed for all installation.
'views/ir_qweb_widget_templates.xml',
'views/ir_module_views.xml',
'data/ir_module_category_data.xml',
# Removed enterprise proprietary module promos (to_buy) from base data
# 'data/ir_module_module.xml',
'data/ir_module_module.xml',
'report/ir_module_reports.xml',
'report/ir_module_report_templates.xml',
'wizard/base_module_update_views.xml',

View file

@ -45,12 +45,6 @@
<field name="name">Sales</field>
<field name="sequence">5</field>
</record>
<record model="ir.module.category" id="module_category_sales_sales">
<field name="name">Sales</field>
<field name="parent_id" ref="module_category_sales"/>
<field name="description">Sales Management</field>
</record>
<record model="ir.module.category" id="module_category_human_resources">
<field name="name">Human Resources</field>
@ -61,34 +55,16 @@
<field name="name">Marketing</field>
<field name="sequence">40</field>
</record>
<record model="ir.module.category" id="module_category_marketing_email_marketing">
<field name="name">Email Marketing</field>
<field name="parent_id" ref="module_category_marketing"/>
<field name="description">Email Marketing Tools</field>
</record>
<record model="ir.module.category" id="module_category_manufacturing">
<field name="name">Manufacturing</field>
<field name="sequence">30</field>
</record>
<record model="ir.module.category" id="module_category_manufacturing_manufacturing">
<field name="name">Manufacturing</field>
<field name="parent_id" ref="module_category_manufacturing"/>
<field name="description">Manufacturing Management</field>
</record>
<record model="ir.module.category" id="module_category_website">
<field name="name">Website</field>
<field name="sequence">35</field>
</record>
<record model="ir.module.category" id="module_category_website_website">
<field name="name">Website</field>
<field name="parent_id" ref="module_category_website"/>
<field name="description">Website Management</field>
</record>
<record model="ir.module.category" id="module_category_theme">
<field name="name">Theme</field>
@ -129,30 +105,11 @@
<field name="name">Field Service</field>
<field name="parent_id" ref="module_category_services"/>
</record>
<record model="ir.module.category" id="module_category_services_project">
<field name="name">Project</field>
<field name="parent_id" ref="module_category_services"/>
<field name="sequence">15</field>
</record>
<record model="ir.module.category" id="module_category_services_timesheets">
<field name="name">Timesheets</field>
<field name="parent_id" ref="module_category_services"/>
<field name="description">Helps you manage the timesheets.</field>
<field name="sequence">13</field>
</record>
<record model="ir.module.category" id="module_category_inventory">
<field name="name">Inventory</field>
<field name="sequence">25</field>
</record>
<record model="ir.module.category" id="module_category_inventory_inventory">
<field name="name">Inventory</field>
<field name="parent_id" ref="module_category_inventory"/>
<field name="description">Inventory Management</field>
</record>
<record model="ir.module.category" id="module_category_productivity">
<field name="name">Productivity</field>

View file

@ -1,176 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record model="ir.module.category" id="module_category_hidden">
<field name="name">Technical</field>
<field name="sequence">60</field>
<field name="visible" eval="0" />
</record>
<record model="ir.module.category" id="module_category_accounting">
<field name="name">Accounting</field>
<field name="sequence">15</field>
</record>
<record model="ir.module.category" id="module_category_accounting_localizations">
<field name="name">Localization</field>
<field name="sequence">65</field>
<field name="visible" eval="0" />
<field name="parent_id" ref="module_category_accounting"/>
</record>
<record model="ir.module.category" id="module_category_payroll_localization">
<field name="name">Payroll Localization</field>
<field name="visible" eval="0" />
</record>
<record model="ir.module.category" id="module_category_accounting_localizations_account_charts">
<field name="parent_id" ref="module_category_accounting_localizations" />
<field name="name">Account Charts</field>
<field name="visible" eval="0" />
</record>
<record model="ir.module.category" id="module_category_user_type">
<field name="name">User types</field>
<field name="description">Helps you manage users.</field>
<field name="sequence">9</field>
</record>
<record model="ir.module.category" id="module_category_accounting_accounting">
<field name="name">Invoicing</field>
<field name="sequence">4</field>
</record>
<record model="ir.module.category" id="module_category_sales">
<field name="name">Sales</field>
<field name="sequence">5</field>
</record>
<record model="ir.module.category" id="module_category_human_resources">
<field name="name">Human Resources</field>
<field name="sequence">45</field>
</record>
<record model="ir.module.category" id="module_category_marketing">
<field name="name">Marketing</field>
<field name="sequence">40</field>
</record>
<record model="ir.module.category" id="module_category_manufacturing">
<field name="name">Manufacturing</field>
<field name="sequence">30</field>
</record>
<record model="ir.module.category" id="module_category_website">
<field name="name">Website</field>
<field name="sequence">35</field>
</record>
<record model="ir.module.category" id="module_category_theme">
<field name="name">Theme</field>
<field name="exclusive" eval="0"/>
<field name="sequence">50</field>
</record>
<record model="ir.module.category" id="module_category_administration">
<field name="name">Administration</field>
<field name="sequence">100</field>
<field name="parent_id" eval="False"/>
</record>
<record model="ir.module.category" id="module_category_human_resources_appraisals">
<field name="name">Appraisals</field>
<field name="description">A user without any rights on Appraisals will be able to see the application, create and manage appraisals for himself and the people he's manager of.</field>
<field name="sequence">15</field>
</record>
<record model="ir.module.category" id="module_category_sales_sign">
<field name="name">Sign</field>
<field name="description">Helps you sign and complete your documents easily.</field>
<field name="sequence">25</field>
</record>
<record model="ir.module.category" id="module_category_services">
<field name="name">Services</field>
<field name="sequence">10</field>
</record>
<record model="ir.module.category" id="module_category_services_helpdesk">
<field name="name">Helpdesk</field>
<field name="description">After-sales services</field>
<field name="sequence">14</field>
</record>
<record model="ir.module.category" id="module_category_services_field_service">
<field name="name">Field Service</field>
<field name="parent_id" ref="module_category_services"/>
</record>
<record model="ir.module.category" id="module_category_inventory">
<field name="name">Inventory</field>
<field name="sequence">25</field>
</record>
<record model="ir.module.category" id="module_category_productivity">
<field name="name">Productivity</field>
<field name="sequence">50</field>
</record>
<record model="ir.module.category" id="module_category_customizations">
<field name="name">Customizations</field>
<field name="sequence">55</field>
</record>
<record model="ir.module.category" id="module_category_administration_administration">
<field name="name">Administration</field>
<field name="parent_id" ref="module_category_administration"/>
</record>
<record model="ir.module.category" id="module_category_usability">
<field name="name">Extra Rights</field>
<field name="sequence">101</field>
</record>
<record model="ir.module.category" id="module_category_extra">
<field name="name">Other Extra Rights</field>
<field name="sequence">102</field>
</record>
<!-- add applications to base groups -->
<record model="res.groups" id="group_erp_manager">
<field name="category_id" ref="module_category_administration_administration"/>
</record>
<record model="res.groups" id="group_system">
<field name="category_id" ref="module_category_administration_administration"/>
</record>
<record model="res.groups" id="group_user">
<field name="category_id" ref="module_category_user_type"/>
</record>
<record model="res.groups" id="group_multi_company">
<field name="category_id" ref="module_category_usability"/>
</record>
<record model="res.groups" id="group_multi_currency">
<field name="category_id" ref="module_category_usability"/>
</record>
<record model="res.groups" id="group_no_one">
<field name="category_id" ref="module_category_usability"/>
</record>
<record id="group_portal" model="res.groups">
<field name="category_id" ref="module_category_user_type"/>
</record>
<record id="group_public" model="res.groups">
<field name="category_id" ref="module_category_user_type"/>
</record>
<record id="group_partner_manager" model="res.groups">
<field name="category_id" ref="module_category_usability"/>
</record>
</data>
</odoo>

File diff suppressed because one or more lines are too long

View file

@ -883,14 +883,6 @@ class IrActionsActClient(models.Model):
params = record.params
record.params_store = repr(params) if isinstance(params, dict) else params
def _get_default_form_view(self):
doc = super(IrActionsActClient, self)._get_default_form_view()
params = doc.find(".//field[@name='params']")
params.getparent().remove(params)
params_store = doc.find(".//field[@name='params_store']")
params_store.getparent().remove(params_store)
return doc
def _get_readable_fields(self):
return super()._get_readable_fields() | {

View file

@ -24,33 +24,7 @@ from lxml import etree
from contextlib import closing
from reportlab.graphics.barcode import createBarcodeDrawing
from reportlab.pdfbase.pdfmetrics import getFont, TypeFace
try:
from PyPDF2 import PdfWriter, PdfReader
# Create compatibility classes for old PyPDF2 API
class PdfFileWriter(PdfWriter):
def addPage(self, page):
return self.add_page(page)
def addMetadata(self, metadata):
return self.add_metadata(metadata)
def appendPagesFromReader(self, reader, after_page_append=None):
return self.append_pages_from_reader(reader, after_page_append)
class PdfFileReader(PdfReader):
def getNumPages(self):
return len(self.pages)
def getPage(self, page_num):
return self.pages[page_num]
@property
def numPages(self):
return len(self.pages)
except ImportError:
from PyPDF2 import PdfFileWriter, PdfFileReader
from PyPDF2 import PdfFileWriter, PdfFileReader
from collections import OrderedDict
from collections.abc import Iterable
from PIL import Image, ImageFile

View file

@ -1512,6 +1512,42 @@ class TestTemplating(ViewCase):
" the main view's"
)
def test_branding_remove_add_text(self):
view1 = self.View.create({
'name': "Base view",
'type': 'qweb',
'arch': """<root>
<item order="1">
<item/>
</item>
</root>""",
})
view2 = self.View.create({
'name': "Extension",
'type': 'qweb',
'inherit_id': view1.id,
'arch': """
<data>
<xpath expr="/root/item/item" position="replace" />
<xpath expr="/root/item" position="inside">A<div/>B</xpath>
</data>
"""
})
arch_string = view1.with_context(inherit_branding=True).get_combined_arch()
arch = etree.fromstring(arch_string)
self.View.distribute_branding(arch)
expected = etree.fromstring(f"""
<root>
<item order="1">
A
<div data-oe-id="{view2.id}" data-oe-xpath="/data/xpath[2]/div" data-oe-model="ir.ui.view" data-oe-field="arch"/>
B
</item>
</root>
""")
self.assertEqual(arch, expected)
class TestViews(ViewCase):

View file

@ -217,7 +217,6 @@
<field name="name">Apps</field>
<field name="res_model">ir.module.module</field>
<field name="view_mode">kanban,tree,form</field>
<field name="domain">[("to_buy", "=", False)]</field>
<field name="context">{'search_default_app':1}</field>
<field name="search_view_id" ref="view_module_filter"/>
<field name="help" type="html">

View file

@ -470,9 +470,6 @@ class BaseCase(case.TestCase, metaclass=MetaCase):
The second form is convenient when used with :func:`users`.
"""
if not 'is_query_count' in self.test_tags:
# change into warning in master
self._logger.info('assertQueryCount is used but the test is not tagged `is_query_count`')
if self.warm:
# mock random in order to avoid random bus gc
with patch('random.random', lambda: 1):
@ -729,9 +726,10 @@ class BaseCase(case.TestCase, metaclass=MetaCase):
"""Guess if the test_methods is a query_count and adds an `is_query_count` tag on the test
"""
additional_tags = []
method_source = inspect.getsource(test_method) if test_method else ''
if 'self.assertQueryCount' in method_source:
additional_tags.append('is_query_count')
if odoo.tools.config['test_tags'] and 'is_query_count' in odoo.tools.config['test_tags']:
method_source = inspect.getsource(test_method) if test_method else ''
if 'self.assertQueryCount' in method_source:
additional_tags.append('is_query_count')
return additional_tags
savepoint_seq = itertools.count()
@ -1220,6 +1218,13 @@ class ChromeBrowser:
self._logger.debug('\n<- %s', msg)
except websocket.WebSocketTimeoutException:
continue
except websocket.WebSocketConnectionClosedException as e:
if not self._result.done():
del self.ws
self._result.set_exception(e)
for f in self._responses.values():
f.cancel()
return
except Exception as e:
if isinstance(e, ConnectionResetError) and self._result.done():
return
@ -1254,8 +1259,8 @@ class ChromeBrowser:
def _websocket_request(self, method, *, params=None, timeout=10.0):
assert threading.get_ident() != self._receiver.ident,\
"_websocket_request must not be called from the consumer thread"
if self.ws is None:
return
if not hasattr(self, 'ws'):
return None
f = self._websocket_send(method, params=params, with_future=True)
try:
@ -1268,8 +1273,8 @@ class ChromeBrowser:
If ``with_future`` is set, returns a ``Future`` for the operation.
"""
if self.ws is None:
return
if not hasattr(self, 'ws'):
return None
result = None
request_id = next(self._request_id)
@ -1466,7 +1471,8 @@ which leads to stray network requests and inconsistencies."""
self._logger.info('Asking for screenshot')
f = self._websocket_send('Page.captureScreenshot', with_future=True)
f.add_done_callback(handler)
if f:
f.add_done_callback(handler)
return f
def _save_screencast(self, prefix='failed'):
@ -2039,9 +2045,6 @@ class HttpCase(TransactionCase):
"""Wrapper for `browser_js` to start the given `tour_name` with the
optional delay between steps `step_delay`. Other arguments from
`browser_js` can be passed as keyword arguments."""
if not 'is_tour' in self.test_tags:
# change it into warning in master
self._logger.info('start_tour was called from a test not tagged `is_tour`')
step_delay = ', %s' % step_delay if step_delay else ''
code = kwargs.pop('code', "odoo.startTour('%s'%s)" % (tour_name, step_delay))
ready = kwargs.pop('ready', "odoo.__DEBUG__.services['web_tour.tour'].tours['%s'].ready" % tour_name)
@ -2062,9 +2065,10 @@ class HttpCase(TransactionCase):
guess if the test_methods is a tour and adds an `is_tour` tag on the test
"""
additional_tags = super().get_method_additional_tags(test_method)
method_source = inspect.getsource(test_method)
if 'self.start_tour' in method_source:
additional_tags.append('is_tour')
if odoo.tools.config['test_tags'] and 'is_tour' in odoo.tools.config['test_tags']:
method_source = inspect.getsource(test_method)
if 'self.start_tour' in method_source:
additional_tags.append('is_tour')
return additional_tags
# kept for backward compatibility

View file

@ -176,6 +176,7 @@ def test_uninstall(args):
def test_standalone(args):
""" Tries to launch standalone scripts tagged with @post_testing """
odoo.service.db._check_faketime_mode(args.database) # noqa: SLF001
# load the registry once for script discovery
registry = odoo.registry(args.database)
for module_name in registry._init_modules:

View file

@ -166,7 +166,7 @@ def file_path(file_path, filter_ext=('',), env=None):
:raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
"""
root_path = os.path.abspath(config['root_path'])
addons_paths = list(odoo.addons.__path__) + [root_path]
addons_paths = odoo.addons.__path__ + [root_path]
if env and hasattr(env.transaction, '__file_open_tmp_paths'):
addons_paths += env.transaction.__file_open_tmp_paths
is_abs = os.path.isabs(file_path)

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@ from reportlab.pdfgen import canvas
try:
# class were renamed in PyPDF2 > 2.0
# https://pypdf2.readthedocs.io/en/latest/user/migration-1-to-2.html#classes
from PyPDF2 import PdfReader, PdfWriter
from PyPDF2 import PdfReader
import PyPDF2
# monkey patch to discard unused arguments as the old arguments were not discarded in the transitional class
# https://pypdf2.readthedocs.io/en/2.0.0/_modules/PyPDF2/_reader.html#PdfReader
@ -26,38 +26,12 @@ try:
kwargs["strict"] = True # maintain the default
kwargs = {k:v for k, v in kwargs.items() if k in ('strict', 'stream')}
super().__init__(*args, **kwargs)
def getNumPages(self):
"""Compatibility method for old API"""
return len(self.pages)
def getPage(self, page_num):
"""Compatibility method for old API"""
return self.pages[page_num]
class PdfFileWriter(PdfWriter):
def _addObject(self, obj):
return self._add_object(obj)
def addPage(self, page):
"""Compatibility method for old API"""
return self.add_page(page)
def addMetadata(self, metadata):
"""Compatibility method for old API"""
return self.add_metadata(metadata)
def cloneReaderDocumentRoot(self, reader):
"""Compatibility method for old API"""
return self.clone_reader_document_root(reader)
PyPDF2.PdfFileReader = PdfFileReader
PyPDF2.PdfFileWriter = PdfFileWriter
from PyPDF2 import PdfFileWriter, PdfFileReader
PdfFileWriter._addObject = PdfFileWriter._add_object
except ImportError:
try:
from PyPDF2 import PdfFileWriter, PdfFileReader
except ImportError:
from PyPDF2 import PdfWriter as PdfFileWriter, PdfReader as PdfFileReader
from PyPDF2 import PdfFileWriter, PdfFileReader
from PyPDF2.generic import DictionaryObject, NameObject, ArrayObject, DecodedStreamObject, NumberObject, createStringObject, ByteStringObject
@ -91,15 +65,10 @@ DictionaryObject.get = _unwrapping_get
class BrandedFileWriter(PdfFileWriter):
def __init__(self):
super().__init__()
# Use new API method if available, fall back to old API
metadata = {
self.addMetadata({
'/Creator': "Odoo",
'/Producer': "Odoo",
}
if hasattr(self, 'add_metadata'):
self.add_metadata(metadata)
else:
self.addMetadata(metadata)
})
PdfFileWriter = BrandedFileWriter
@ -237,8 +206,8 @@ class OdooPdfFileReader(PdfFileReader):
if not file_path:
return []
for i in range(0, len(file_path), 2):
attachment = file_path[i+1].get_object()
yield (attachment["/F"], attachment["/EF"]["/F"].get_object().get_data())
attachment = file_path[i+1].getObject()
yield (attachment["/F"], attachment["/EF"]["/F"].getObject().getData())
except Exception:
# malformed pdf (i.e. invalid xref page)
return []
@ -281,10 +250,10 @@ class OdooPdfFileWriter(PdfFileWriter):
})
if self._root_object.get('/Names') and self._root_object['/Names'].get('/EmbeddedFiles'):
names_array = self._root_object["/Names"]["/EmbeddedFiles"]["/Names"]
names_array.extend([attachment.get_object()['/F'], attachment])
names_array.extend([attachment.getObject()['/F'], attachment])
else:
names_array = ArrayObject()
names_array.extend([attachment.get_object()['/F'], attachment])
names_array.extend([attachment.getObject()['/F'], attachment])
embedded_files_names_dictionary = DictionaryObject()
embedded_files_names_dictionary.update({
@ -359,7 +328,7 @@ class OdooPdfFileWriter(PdfFileWriter):
icc_profile_file_data = compress(icc_profile.read())
icc_profile_stream_obj = DecodedStreamObject()
icc_profile_stream_obj.set_data(icc_profile_file_data)
icc_profile_stream_obj.setData(icc_profile_file_data)
icc_profile_stream_obj.update({
NameObject("/Filter"): NameObject("/FlateDecode"),
NameObject("/N"): NumberObject(3),
@ -389,9 +358,9 @@ class OdooPdfFileWriter(PdfFileWriter):
fonts = {}
# First browse through all the pages of the pdf file, to get a reference to all the fonts used in the PDF.
for page in pages:
for font in page.get_object()['/Resources']['/Font'].values():
for descendant in font.get_object()['/DescendantFonts']:
fonts[descendant.idnum] = descendant.get_object()
for font in page.getObject()['/Resources']['/Font'].values():
for descendant in font.getObject()['/DescendantFonts']:
fonts[descendant.idnum] = descendant.getObject()
# Then for each font, rewrite the width array with the information taken directly from the font file.
# The new width are calculated such as width = round(1000 * font_glyph_width / font_units_per_em)
@ -412,7 +381,7 @@ class OdooPdfFileWriter(PdfFileWriter):
else:
_logger.warning('The fonttools package is not installed. Generated PDF may not be PDF/A compliant.')
outlines = self._root_object['/Outlines'].get_object()
outlines = self._root_object['/Outlines'].getObject()
outlines[NameObject('/Count')] = NumberObject(1)
# Set odoo as producer
@ -434,7 +403,7 @@ class OdooPdfFileWriter(PdfFileWriter):
footer = b'<?xpacket end="w"?>'
metadata = b'%s%s%s' % (header, metadata_content, footer)
file_entry = DecodedStreamObject()
file_entry.set_data(metadata)
file_entry.setData(metadata)
file_entry.update({
NameObject("/Type"): NameObject("/Metadata"),
NameObject("/Subtype"): NameObject("/XML"),
@ -455,7 +424,7 @@ class OdooPdfFileWriter(PdfFileWriter):
:return:
'''
file_entry = DecodedStreamObject()
file_entry.set_data(attachment['content'])
file_entry.setData(attachment['content'])
file_entry.update({
NameObject("/Type"): NameObject("/EmbeddedFile"),
NameObject("/Params"):

View file

@ -1,487 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import re
from datetime import datetime
from hashlib import md5
from logging import getLogger
from zlib import compress, decompress
from PIL import Image, PdfImagePlugin
from reportlab.lib import colors
from reportlab.lib.units import cm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
try:
# class were renamed in PyPDF2 > 2.0
# https://pypdf2.readthedocs.io/en/latest/user/migration-1-to-2.html#classes
from PyPDF2 import PdfReader, PdfWriter
import PyPDF2
# monkey patch to discard unused arguments as the old arguments were not discarded in the transitional class
# https://pypdf2.readthedocs.io/en/2.0.0/_modules/PyPDF2/_reader.html#PdfReader
class PdfFileReader(PdfReader):
def __init__(self, *args, **kwargs):
if "strict" not in kwargs and len(args) < 2:
kwargs["strict"] = True # maintain the default
kwargs = {k:v for k, v in kwargs.items() if k in ('strict', 'stream')}
super().__init__(*args, **kwargs)
def getNumPages(self):
"""Compatibility method for old API"""
return len(self.pages)
def getPage(self, page_num):
"""Compatibility method for old API"""
return self.pages[page_num]
class PdfFileWriter(PdfWriter):
def _addObject(self, obj):
return self._add_object(obj)
def addPage(self, page):
"""Compatibility method for old API"""
return self.add_page(page)
def addMetadata(self, metadata):
"""Compatibility method for old API"""
return self.add_metadata(metadata)
def cloneReaderDocumentRoot(self, reader):
"""Compatibility method for old API"""
return self.clone_reader_document_root(reader)
PyPDF2.PdfFileReader = PdfFileReader
PyPDF2.PdfFileWriter = PdfFileWriter
except ImportError:
try:
from PyPDF2 import PdfFileWriter, PdfFileReader
except ImportError:
from PyPDF2 import PdfWriter as PdfFileWriter, PdfReader as PdfFileReader
from PyPDF2.generic import DictionaryObject, NameObject, ArrayObject, DecodedStreamObject, NumberObject, createStringObject, ByteStringObject
try:
from fontTools.ttLib import TTFont
except ImportError:
TTFont = None
from odoo.tools.misc import file_open
_logger = getLogger(__name__)
DEFAULT_PDF_DATETIME_FORMAT = "D:%Y%m%d%H%M%S+00'00'"
REGEX_SUBTYPE_UNFORMATED = re.compile(r'^\w+/[\w-]+$')
REGEX_SUBTYPE_FORMATED = re.compile(r'^/\w+#2F[\w-]+$')
# Disable linter warning: this import is needed to make sure a PDF stream can be saved in Image.
PdfImagePlugin.__name__
# make sure values are unwrapped by calling the specialized __getitem__
def _unwrapping_get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
DictionaryObject.get = _unwrapping_get
class BrandedFileWriter(PdfFileWriter):
def __init__(self):
super().__init__()
# Use new API method if available, fall back to old API
metadata = {
'/Creator': "Odoo",
'/Producer': "Odoo",
}
if hasattr(self, 'add_metadata'):
self.add_metadata(metadata)
else:
self.addMetadata(metadata)
PdfFileWriter = BrandedFileWriter
def merge_pdf(pdf_data):
''' Merge a collection of PDF documents in one.
Note that the attachments are not merged.
:param list pdf_data: a list of PDF datastrings
:return: a unique merged PDF datastring
'''
writer = PdfFileWriter()
for document in pdf_data:
reader = PdfFileReader(io.BytesIO(document), strict=False)
for page in range(0, reader.getNumPages()):
writer.addPage(reader.getPage(page))
with io.BytesIO() as _buffer:
writer.write(_buffer)
return _buffer.getvalue()
def rotate_pdf(pdf):
''' Rotate clockwise PDF (90°) into a new PDF.
Note that the attachments are not copied.
:param pdf: a PDF to rotate
:return: a PDF rotated
'''
writer = PdfFileWriter()
reader = PdfFileReader(io.BytesIO(pdf), strict=False)
for page in range(0, reader.getNumPages()):
page = reader.getPage(page)
page.rotateClockwise(90)
writer.addPage(page)
with io.BytesIO() as _buffer:
writer.write(_buffer)
return _buffer.getvalue()
def to_pdf_stream(attachment) -> io.BytesIO:
"""Get the byte stream of the attachment as a PDF."""
stream = io.BytesIO(attachment.raw)
if attachment.mimetype == 'application/pdf':
return stream
elif attachment.mimetype.startswith('image'):
output_stream = io.BytesIO()
Image.open(stream).convert("RGB").save(output_stream, format="pdf")
return output_stream
_logger.warning("mimetype (%s) not recognized for %s", attachment.mimetype, attachment)
def add_banner(pdf_stream, text=None, logo=False, thickness=2 * cm):
""" Add a banner on a PDF in the upper right corner, with Odoo's logo (optionally).
:param pdf_stream (BytesIO): The PDF stream where the banner will be applied.
:param text (str): The text to be displayed.
:param logo (bool): Whether to display Odoo's logo in the banner.
:param thickness (float): The thickness of the banner in pixels.
:return (BytesIO): The modified PDF stream.
"""
old_pdf = PdfFileReader(pdf_stream, strict=False, overwriteWarnings=False)
packet = io.BytesIO()
can = canvas.Canvas(packet)
odoo_logo = Image.open(file_open('base/static/img/main_partner-image.png', mode='rb'))
odoo_color = colors.Color(113 / 255, 75 / 255, 103 / 255, 0.8)
for p in range(old_pdf.getNumPages()):
page = old_pdf.getPage(p)
width = float(abs(page.mediaBox.getWidth()))
height = float(abs(page.mediaBox.getHeight()))
can.setPageSize((width, height))
can.translate(width, height)
can.rotate(-45)
# Draw banner
path = can.beginPath()
path.moveTo(-width, -thickness)
path.lineTo(-width, -2 * thickness)
path.lineTo(width, -2 * thickness)
path.lineTo(width, -thickness)
can.setFillColor(odoo_color)
can.drawPath(path, fill=1, stroke=False)
# Insert text (and logo) inside the banner
can.setFontSize(10)
can.setFillColor(colors.white)
can.drawRightString(0.75 * thickness, -1.45 * thickness, text)
logo and can.drawImage(
ImageReader(odoo_logo), 0.25 * thickness, -2.05 * thickness, 40, 40, mask='auto', preserveAspectRatio=True)
can.showPage()
can.save()
# Merge the old pages with the watermark
watermark_pdf = PdfFileReader(packet, overwriteWarnings=False)
new_pdf = PdfFileWriter()
for p in range(old_pdf.getNumPages()):
new_page = old_pdf.getPage(p)
# Remove annotations (if any), to prevent errors in PyPDF2
if '/Annots' in new_page:
del new_page['/Annots']
new_page.mergePage(watermark_pdf.getPage(p))
new_pdf.addPage(new_page)
# Write the new pdf into a new output stream
output = io.BytesIO()
new_pdf.write(output)
return output
# by default PdfFileReader will overwrite warnings.showwarning which is what
# logging.captureWarnings does, meaning it essentially reverts captureWarnings
# every time it's called which is undesirable
old_init = PdfFileReader.__init__
PdfFileReader.__init__ = lambda self, stream, strict=True, warndest=None, overwriteWarnings=True: \
old_init(self, stream=stream, strict=strict, warndest=None, overwriteWarnings=False)
class OdooPdfFileReader(PdfFileReader):
# OVERRIDE of PdfFileReader to add the management of multiple embedded files.
''' Returns the files inside the PDF.
:raises NotImplementedError: if document is encrypted and uses an unsupported encryption method.
'''
def getAttachments(self):
if self.isEncrypted:
# If the PDF is owner-encrypted, try to unwrap it by giving it an empty user password.
self.decrypt('')
try:
file_path = self.trailer["/Root"].get("/Names", {}).get("/EmbeddedFiles", {}).get("/Names")
if not file_path:
return []
for i in range(0, len(file_path), 2):
attachment = file_path[i+1].getObject()
yield (attachment["/F"], attachment["/EF"]["/F"].getObject().getData())
except Exception:
# malformed pdf (i.e. invalid xref page)
return []
class OdooPdfFileWriter(PdfFileWriter):
def __init__(self, *args, **kwargs):
"""
Override of the init to initialise additional variables.
:param pdf_content: if given, will initialise the reader with the pdf content.
"""
super().__init__(*args, **kwargs)
self._reader = None
self.is_pdfa = False
def addAttachment(self, name, data, subtype=None):
"""
Add an attachment to the pdf. Supports adding multiple attachment, while respecting PDF/A rules.
:param name: The name of the attachement
:param data: The data of the attachement
:param subtype: The mime-type of the attachement. This is required by PDF/A, but not essential otherwise.
It should take the form of "/xxx#2Fxxx". E.g. for "text/xml": "/text#2Fxml"
"""
adapted_subtype = subtype
if subtype:
# If we receive the subtype in an 'unformated' (mimetype) format, we'll try to convert it to a pdf-valid one
if REGEX_SUBTYPE_UNFORMATED.match(subtype):
adapted_subtype = '/' + subtype.replace('/', '#2F')
if not REGEX_SUBTYPE_FORMATED.match(adapted_subtype):
# The subtype still does not match the correct format, so we will not add it to the document
_logger.warning("Attempt to add an attachment with the incorrect subtype '%s'. The subtype will be ignored.", subtype)
adapted_subtype = ''
attachment = self._create_attachment_object({
'filename': name,
'content': data,
'subtype': adapted_subtype,
})
if self._root_object.get('/Names') and self._root_object['/Names'].get('/EmbeddedFiles'):
names_array = self._root_object["/Names"]["/EmbeddedFiles"]["/Names"]
names_array.extend([attachment.getObject()['/F'], attachment])
else:
names_array = ArrayObject()
names_array.extend([attachment.getObject()['/F'], attachment])
embedded_files_names_dictionary = DictionaryObject()
embedded_files_names_dictionary.update({
NameObject("/Names"): names_array
})
embedded_files_dictionary = DictionaryObject()
embedded_files_dictionary.update({
NameObject("/EmbeddedFiles"): embedded_files_names_dictionary
})
self._root_object.update({
NameObject("/Names"): embedded_files_dictionary
})
if self._root_object.get('/AF'):
attachment_array = self._root_object['/AF']
attachment_array.extend([attachment])
else:
# Create a new object containing an array referencing embedded file
# And reference this array in the root catalogue
attachment_array = self._addObject(ArrayObject([attachment]))
self._root_object.update({
NameObject("/AF"): attachment_array
})
def embed_odoo_attachment(self, attachment, subtype=None):
assert attachment, "embed_odoo_attachment cannot be called without attachment."
self.addAttachment(attachment.name, attachment.raw, subtype=subtype or attachment.mimetype)
def cloneReaderDocumentRoot(self, reader):
super().cloneReaderDocumentRoot(reader)
self._reader = reader
# Try to read the header coming in, and reuse it in our new PDF
# This is done in order to allows modifying PDF/A files after creating them (as PyPDF does not read it)
stream = reader.stream
stream.seek(0)
header = stream.readlines(9)
# Should always be true, the first line of a pdf should have 9 bytes (%PDF-1.x plus a newline)
if len(header) == 1:
# If we found a header, set it back to the new pdf
self._header = header[0]
# Also check the second line. If it is PDF/A, it should be a line starting by % following by four bytes + \n
second_line = stream.readlines(1)[0]
if second_line.decode('latin-1')[0] == '%' and len(second_line) == 6:
self._header += second_line
self.is_pdfa = True
# Look if we have an ID in the incoming stream and use it.
pdf_id = reader.trailer.get('/ID', None)
if pdf_id:
self._ID = pdf_id
def convert_to_pdfa(self):
"""
Transform the opened PDF file into a PDF/A compliant file
"""
# Set the PDF version to 1.7 (as PDF/A-3 is based on version 1.7) and make it PDF/A compliant.
# See https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-612-1
# " The file header shall begin at byte zero and shall consist of "%PDF-1.n" followed by a single EOL marker,
# where 'n' is a single digit number between 0 (30h) and 7 (37h) "
# " The aforementioned EOL marker shall be immediately followed by a % (25h) character followed by at least four
# bytes, each of whose encoded byte values shall have a decimal value greater than 127 "
self._header = b"%PDF-1.7\n%\xFF\xFF\xFF\xFF"
# Add a document ID to the trailer. This is only needed when using encryption with regular PDF, but is required
# when using PDF/A
pdf_id = ByteStringObject(md5(self._reader.stream.getvalue()).digest())
# The first string is based on the content at the time of creating the file, while the second is based on the
# content of the file when it was last updated. When creating a PDF, both are set to the same value.
self._ID = ArrayObject((pdf_id, pdf_id))
with file_open('tools/data/files/sRGB2014.icc', mode='rb') as icc_profile:
icc_profile_file_data = compress(icc_profile.read())
icc_profile_stream_obj = DecodedStreamObject()
icc_profile_stream_obj.setData(icc_profile_file_data)
icc_profile_stream_obj.update({
NameObject("/Filter"): NameObject("/FlateDecode"),
NameObject("/N"): NumberObject(3),
NameObject("/Length"): NameObject(str(len(icc_profile_file_data))),
})
icc_profile_obj = self._addObject(icc_profile_stream_obj)
output_intent_dict_obj = DictionaryObject()
output_intent_dict_obj.update({
NameObject("/S"): NameObject("/GTS_PDFA1"),
NameObject("/OutputConditionIdentifier"): createStringObject("sRGB"),
NameObject("/DestOutputProfile"): icc_profile_obj,
NameObject("/Type"): NameObject("/OutputIntent"),
})
output_intent_obj = self._addObject(output_intent_dict_obj)
self._root_object.update({
NameObject("/OutputIntents"): ArrayObject([output_intent_obj]),
})
pages = self._root_object['/Pages']['/Kids']
# PDF/A needs the glyphs width array embedded in the pdf to be consistent with the ones from the font file.
# But it seems like it is not the case when exporting from wkhtmltopdf.
if TTFont:
fonts = {}
# First browse through all the pages of the pdf file, to get a reference to all the fonts used in the PDF.
for page in pages:
for font in page.getObject()['/Resources']['/Font'].values():
for descendant in font.getObject()['/DescendantFonts']:
fonts[descendant.idnum] = descendant.getObject()
# Then for each font, rewrite the width array with the information taken directly from the font file.
# The new width are calculated such as width = round(1000 * font_glyph_width / font_units_per_em)
# See: http://martin.hoppenheit.info/blog/2018/pdfa-validation-and-inconsistent-glyph-width-information/
for font in fonts.values():
font_file = font['/FontDescriptor']['/FontFile2']
stream = io.BytesIO(decompress(font_file._data))
ttfont = TTFont(stream)
font_upm = ttfont['head'].unitsPerEm
glyphs = ttfont.getGlyphSet()._hmtx.metrics
glyph_widths = []
for key, values in glyphs.items():
if key[:5] == 'glyph':
glyph_widths.append(NumberObject(round(1000.0 * values[0] / font_upm)))
font[NameObject('/W')] = ArrayObject([NumberObject(1), ArrayObject(glyph_widths)])
stream.close()
else:
_logger.warning('The fonttools package is not installed. Generated PDF may not be PDF/A compliant.')
outlines = self._root_object['/Outlines'].getObject()
outlines[NameObject('/Count')] = NumberObject(1)
# Set odoo as producer
self.addMetadata({
'/Creator': "Odoo",
'/Producer': "Odoo",
})
self.is_pdfa = True
def add_file_metadata(self, metadata_content):
"""
Set the XMP metadata of the pdf, wrapping it with the necessary XMP header/footer.
These are required for a PDF/A file to be completely compliant. Ommiting them would result in validation errors.
:param metadata_content: bytes of the metadata to add to the pdf.
"""
# See https://wwwimages2.adobe.com/content/dam/acom/en/devnet/xmp/pdfs/XMP%20SDK%20Release%20cc-2016-08/XMPSpecificationPart1.pdf
# Page 10/11
header = b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
footer = b'<?xpacket end="w"?>'
metadata = b'%s%s%s' % (header, metadata_content, footer)
file_entry = DecodedStreamObject()
file_entry.setData(metadata)
file_entry.update({
NameObject("/Type"): NameObject("/Metadata"),
NameObject("/Subtype"): NameObject("/XML"),
NameObject("/Length"): NameObject(str(len(metadata))),
})
# Add the new metadata to the pdf, then redirect the reference to refer to this new object.
metadata_object = self._addObject(file_entry)
self._root_object.update({NameObject("/Metadata"): metadata_object})
def _create_attachment_object(self, attachment):
''' Create a PyPdf2.generic object representing an embedded file.
:param attachment: A dictionary containing:
* filename: The name of the file to embed (required)
* content: The bytes of the file to embed (required)
* subtype: The mime-type of the file to embed (optional)
:return:
'''
file_entry = DecodedStreamObject()
file_entry.setData(attachment['content'])
file_entry.update({
NameObject("/Type"): NameObject("/EmbeddedFile"),
NameObject("/Params"):
DictionaryObject({
NameObject('/CheckSum'): createStringObject(md5(attachment['content']).hexdigest()),
NameObject('/ModDate'): createStringObject(datetime.now().strftime(DEFAULT_PDF_DATETIME_FORMAT)),
NameObject('/Size'): NameObject(f"/{len(attachment['content'])}"),
}),
})
if attachment.get('subtype'):
file_entry.update({
NameObject("/Subtype"): NameObject(attachment['subtype']),
})
file_entry_object = self._addObject(file_entry)
filename_object = createStringObject(attachment['filename'])
filespec_object = DictionaryObject({
NameObject("/AFRelationship"): NameObject("/Data"),
NameObject("/Type"): NameObject("/Filespec"),
NameObject("/F"): filename_object,
NameObject("/EF"):
DictionaryObject({
NameObject("/F"): file_entry_object,
NameObject('/UF'): file_entry_object,
}),
NameObject("/UF"): filename_object,
})
if attachment.get('description'):
filespec_object.update({NameObject("/Desc"): createStringObject(attachment['description'])})
return self._addObject(filespec_object)

View file

@ -17,7 +17,7 @@ def add_stripped_items_before(node, spec, extract):
text = spec.text or ''
before_text = ''
prev = node.getprevious()
prev = next((n for n in node.itersiblings(preceding=True) if not (n.tag == etree.ProcessingInstruction and n.target == "apply-inheritance-specs-node-removal")), None)
if prev is None:
parent = node.getparent()
result = parent.text and RSTRIP_REGEXP.search(parent.text)

View file

@ -1645,7 +1645,7 @@ def _get_translation_upgrade_queries(cr, field):
"""
migrate_queries.append(cr.mogrify(query, [Model._name, translation_name]).decode())
query = "DELETE FROM _ir_translation WHERE type = 'model' AND name = %s"
query = "DELETE FROM _ir_translation WHERE type = 'model' AND state = 'translated' AND name = %s"
cleanup_queries.append(cr.mogrify(query, [translation_name]).decode())
# upgrade model_terms translation: one update per field per record
@ -1719,7 +1719,7 @@ def _get_translation_upgrade_queries(cr, field):
query = f'UPDATE "{Model._table}" SET "{field.name}" = %s WHERE id = %s'
migrate_queries.append(cr.mogrify(query, [Json(new_values), id_]).decode())
query = "DELETE FROM _ir_translation WHERE type = 'model_terms' AND name = %s"
query = "DELETE FROM _ir_translation WHERE type = 'model_terms' AND state = 'translated' AND name = %s"
cleanup_queries.append(cr.mogrify(query, [translation_name]).decode())
return migrate_queries, cleanup_queries