17.0 vanilla
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!--
|
||||
In demo only, enable the USD currency.
|
||||
The rationale is that, without demo, you shouldn't enable any currency by default,
|
||||
so you do not land in a multi-currency database by default.
|
||||
e.g. if USD was enabled by default even without demo, and you install the accounting for Belgium,
|
||||
installing the Belgian Chart Of Account, it would enable the EUR currency,
|
||||
therefore leaving you with the USD and EUR currency enabled,
|
||||
and therefore in a multi-currency environment.
|
||||
But, with demo, you need to enable the currency used by the company by default,
|
||||
so the monetary fields display the currency symbol even when invoicing (account) is not installed.
|
||||
e.g. install a demo database with just CRM, you want the $ to appear next to the expected revenue field.
|
||||
-->
|
||||
<record id="USD" model="res.currency">
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
42400
odoo-bringout-oca-ocb-base/odoo/addons/base/i18n/es_419.po
Normal file
35021
odoo-bringout-oca-ocb-base/odoo/addons/base/i18n/ku.po
Normal file
35025
odoo-bringout-oca-ocb-base/odoo/addons/base/i18n/my.po
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResUsersSettings(models.Model):
|
||||
_name = 'res.users.settings'
|
||||
_description = 'User Settings'
|
||||
_rec_name = 'user_id'
|
||||
|
||||
user_id = fields.Many2one("res.users", string="User", required=True, ondelete="cascade", domain=[("res_users_settings_id", "=", False)])
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_user_id', 'UNIQUE(user_id)', 'One user should only have one user settings.')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _find_or_create_for_user(self, user):
|
||||
settings = user.sudo().res_users_settings_ids
|
||||
if not settings:
|
||||
settings = self.sudo().create({'user_id': user.id})
|
||||
return settings
|
||||
|
||||
def _res_users_settings_format(self, fields_to_format=None):
|
||||
self.ensure_one()
|
||||
if not fields_to_format:
|
||||
fields_to_format = [name for name, field in self._fields.items() if name == 'id' or not field.automatic]
|
||||
res = self._format_settings(fields_to_format)
|
||||
return res
|
||||
|
||||
def _format_settings(self, fields_to_format):
|
||||
res = self._read_format(fnames=fields_to_format)[0]
|
||||
if 'user_id' in fields_to_format:
|
||||
res = self._read_format(fnames=fields_to_format)[0]
|
||||
res['user_id'] = {'id': self.user_id.id}
|
||||
return res
|
||||
|
||||
def set_res_users_settings(self, new_settings):
|
||||
self.ensure_one()
|
||||
changed_settings = {}
|
||||
for setting in new_settings.keys():
|
||||
if setting in self._fields and new_settings[setting] != self[setting]:
|
||||
changed_settings[setting] = new_settings[setting]
|
||||
self.write(changed_settings)
|
||||
formated = self._res_users_settings_format([*changed_settings.keys(), 'id'])
|
||||
return formated
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
|
@ -0,0 +1,18 @@
|
|||
<root>
|
||||
<table id="cool-table" class="fancy-style">
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table id="cool-table" class="fancy-style">
|
||||
<tr>
|
||||
<td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<root>
|
||||
<table id="cool-table" class="fancy-style">
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<table>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>7</td>
|
||||
<td>7</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<table>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>7</td>
|
||||
<td>7</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>7</td>
|
||||
<td>7</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
<td>4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
<td>5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
<td>6</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>7</td>
|
||||
<td>7</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<root>
|
||||
<table>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
</table>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIByTCCAXugAwIBAgIUXOt3OuI/y68E6ogXPRKjMxM3d4kwBQYDK2VwMFExCzAJ
|
||||
BgNVBAYTAkJFMRQwEgYDVQQHDAtIb3V0ZXNpcGxvdTEsMCoGA1UEAwwjSG91dGVz
|
||||
aXBsb3UgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwIBcNMjQwNDIyMTUxNDU3WhgP
|
||||
MzAyMzA4MjQxNTE0NTdaMFExCzAJBgNVBAYTAkJFMRQwEgYDVQQHDAtIb3V0ZXNp
|
||||
cGxvdTEsMCoGA1UEAwwjSG91dGVzaXBsb3UgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
|
||||
dHkwKjAFBgMrZXADIQA9E6L8dKzknznGYYkNMVRt78Rs8L5sIOLFaJHfzfpcdaNj
|
||||
MGEwHQYDVR0OBBYEFOCOFRwpR4cKRG3au4pqU5tc8ItOMB8GA1UdIwQYMBaAFOCO
|
||||
FRwpR4cKRG3au4pqU5tc8ItOMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD
|
||||
AgGGMAUGAytlcANBAMDS7x9X9IIM1jCQdzTibtVu14mZmXoBJ8GoFMojUSvyz86y
|
||||
81vpi1ZEOZoZm/HadUBlONvL4aGqbu0t6yABeA8=
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIICCTCCAbugAwIBAgICEAAwBQYDK2VwMFExCzAJBgNVBAYTAkJFMRQwEgYDVQQH
|
||||
DAtIb3V0ZXNpcGxvdTEsMCoGA1UEAwwjSG91dGVzaXBsb3UgQ2VydGlmaWNhdGlv
|
||||
biBBdXRob3JpdHkwIBcNMjQwNDIyMTUxNDU3WhgPMzAyMzA4MjQxNTE0NTdaMEAx
|
||||
CzAJBgNVBAYTAkJFMRQwEgYDVQQHDAtIb3V0ZXNpcGxvdTEbMBkGA1UEAwwSSG91
|
||||
dGVzaXBsb3UgQ2xpZW50MCowBQYDK2VwAyEAFkZcOJ5bNr2NZcwZuXkdC5PfX+fu
|
||||
M+YWyh5eeMtU7mujgcUwgcIwCQYDVR0TBAIwADARBglghkgBhvhCAQEEBAMCBaAw
|
||||
MwYJYIZIAYb4QgENBCYWJE9wZW5TU0wgR2VuZXJhdGVkIENsaWVudCBDZXJ0aWZp
|
||||
Y2F0ZTAdBgNVHQ4EFgQUxag2EgTttBUfjSg/MHMs+4h/16cwHwYDVR0jBBgwFoAU
|
||||
4I4VHClHhwpEbdq7impTm1zwi04wDgYDVR0PAQH/BAQDAgXgMB0GA1UdJQQWMBQG
|
||||
CCsGAQUFBwMCBggrBgEFBQcDBDAFBgMrZXADQQAezi5mtJ3Y5A8DLuxL7g9rPff7
|
||||
qUQqwg3RlGQ7QAkwiuUa6nQ1kfcIGSTFe6sOBL5ACMLwxP1ibgAENxBamvAC
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEICS5j5g1kNSbzhCRT7eDN4k12P0x5n8tg6HLPcCuY7DD
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFzCCAf+gAwIBAgIUfnEIpcavhz3Pn9P5YMIZzku+OJUwDQYJKoZIhvcNAQEL
|
||||
BQAwGjEYMBYGA1UEAwwPU2VsZlNpZ25lZCBMbXRkMCAXDTI0MDQyMjE1MTQ1N1oY
|
||||
DzMwMjMwODI0MTUxNDU3WjAaMRgwFgYDVQQDDA9TZWxmU2lnbmVkIExtdGQwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIfgXKzK/FiQzqqAtOCqbTSCtp
|
||||
rFr5hpTQJWscfQXfkQiJC0nLLojj4sLSNf/PnP8eglju+AIO6st2TF3v+qugog0o
|
||||
D9cMvjhzqwLfpJzSr7EN5n4X3TAoyucPg/aF3+ACXwVXuTfxTCbJbEeBrWSk7b1k
|
||||
R1bORVQvH895V08rVs2MZ5v3sutF0EWeiyh4O/EzpSiKywLUE5eLoZiJtN9j3yGm
|
||||
bJii/fCcON4sQTxpL2/i2jbPo2dH2EKKS1xO0LBt4IoiOidRTQiOsjL2VANZiCer
|
||||
UuWUlsG5albCcExvVzQnINRb7+6F6NJqJdC+rRqQG6YeXpUeXBe8GMZjpqLdAgMB
|
||||
AAGjUzBRMB0GA1UdDgQWBBSRC2v9uCKuJ6lpWVbe2nO6gI6bJTAfBgNVHSMEGDAW
|
||||
gBSRC2v9uCKuJ6lpWVbe2nO6gI6bJTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQDADAKCY4WDhVJnReyP2fc/E/N2Wa29BCdR9LZxS2sNd5gyQnZJ
|
||||
FHLqu1R+tMACtXG4q1AEuN0yn7hsqCfy3hQH5mFLqQzNqoWF6HDQr2b7kfw/E1S3
|
||||
bH/K+6Kbu5fEYWRZNo5Ti62mEHhSS2tBw0qmN9pceT/ZFhYpe44RQzuxEckOXBpx
|
||||
g1J8NAqvknfvl9nu31C8Ye98XlsHDGz/qUvmIPqXCmmg26NcT/NE26A76tgegUDo
|
||||
U/K4WFOr5cyHcIfaYWFg2/SKukudQldwU92ZvJOSwYvn3MKixg/YZGK+0MwzMm4c
|
||||
+ZKttL1Pwub4Gt9ZaFrzrM1t2NRTgMiKU2lQ
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDIfgXKzK/FiQzq
|
||||
qAtOCqbTSCtprFr5hpTQJWscfQXfkQiJC0nLLojj4sLSNf/PnP8eglju+AIO6st2
|
||||
TF3v+qugog0oD9cMvjhzqwLfpJzSr7EN5n4X3TAoyucPg/aF3+ACXwVXuTfxTCbJ
|
||||
bEeBrWSk7b1kR1bORVQvH895V08rVs2MZ5v3sutF0EWeiyh4O/EzpSiKywLUE5eL
|
||||
oZiJtN9j3yGmbJii/fCcON4sQTxpL2/i2jbPo2dH2EKKS1xO0LBt4IoiOidRTQiO
|
||||
sjL2VANZiCerUuWUlsG5albCcExvVzQnINRb7+6F6NJqJdC+rRqQG6YeXpUeXBe8
|
||||
GMZjpqLdAgMBAAECggEAF197B7eRXZB1dTJjpan1OoKv2PETEVg1/LFYCX6uaBqi
|
||||
1EMrdlM1pId42dkaD5czC1imddlreAIO99AS+sPjgpBRw1QGvda72F7dMLr33fyb
|
||||
F06XGutveVn00j4P8m8f0MxItaot549Oou22GlvfSfY96DUEMtDUmG0iGqMUji3V
|
||||
SeHvAeginTCaoux5OPdB94jcRpsSf+B0pRS0JMpW/87+mg3bzT8ouPYkbfRZ92Wo
|
||||
4x3b1kcBbq7xvtmSsN0PxqZGcuuDn3zjO1XI6HCwWWP+ICUIC6+Tw8szWIvJ9Cgp
|
||||
te2nvcAYstnrW15smJTjRNhikMpu3EsBajsgfsScywKBgQDyoAGlucEfREVmVuba
|
||||
hbLfhGTmKvvsgS2e7p5ktJPkdCyvImU6l9Crzt8qD+9ic2LEgvRBtqaaHkC8DgLc
|
||||
LiEL/QiIbNdGhfxm9+bWMW0wx6HWRwC1b7t1BT3Dh4y9I4YYkLR2NDqh1R8D4X0D
|
||||
mEk9pfR2nFEDjkGyDp9gyCB68wKBgQDTi21DuKu14WQCfrInn3SGoDTeHGRhV6b6
|
||||
HrOJj4ppbqffjUcvvpSzYJAZtk8eHdHMM8tut+OejxPzabFdgXrO4EaMiH5XYaTx
|
||||
zDFpTe/cGZvb5m9jg6fEb0YCB6KfefDpFABXOlaILyl8JwnC0BT1bEL7k8c/s/it
|
||||
LIVIy38+7wKBgQDG0lonPZ5FigO5BpOtFQzs36hzeVvyhjUlXXNNITFkb9NCPVRo
|
||||
/ImSkTcNV/uaWOXiFVImG5BREWOI945ecirAkT0R1udesmOQ2as/cUeCRsWXO54Y
|
||||
EJS0I3Rmq8ioIdk8fjB0AP7fKS9+VaTFcmDqdPlszVISMNwjFpqCi90aAwKBgQCT
|
||||
RkxJi3Wv6DyyJ/Zr820ylLJ5t5aC1n0fQOSJbm9UO3+P+VGIAcyQnTd1TyEBvIzk
|
||||
92I3sLo9FysymXCrwor3H9i92gDrYMVuuVPlFidZOlLx4xnFVFEmRrmcjChBkqmP
|
||||
+ybJk4nOwdbF4n+/KxKMUlTHxPhAd1E3bvlT1qi97QKBgAHUTniLWiqxH4wyspIW
|
||||
SV7EW87Suat+p6TdR81FonNAgLMGjtTqRcHkoCfQfExpPycuybpqtJK7IeiVqNc5
|
||||
lCe48Hl6hpASfwe9T2K3q68UACU5pk5i/PTydFCI+zwVbaiBGip71skzGUVzcPY9
|
||||
JBbw8hEOjPwcK4SAgi+Yprmc
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIChzCCAjmgAwIBAgICEAEwBQYDK2VwMFExCzAJBgNVBAYTAkJFMRQwEgYDVQQH
|
||||
DAtIb3V0ZXNpcGxvdTEsMCoGA1UEAwwjSG91dGVzaXBsb3UgQ2VydGlmaWNhdGlv
|
||||
biBBdXRob3JpdHkwIBcNMjQwNDIyMTUxNDU3WhgPMzAyMzA4MjQxNTE0NTdaMEAx
|
||||
CzAJBgNVBAYTAkJFMRQwEgYDVQQHDAtIb3V0ZXNpcGxvdTEbMBkGA1UEAwwSSG91
|
||||
dGVzaXBsb3UgU2VydmVyMCowBQYDK2VwAyEACb6GUUvOz8O1IlrH9/4sxaRGAemi
|
||||
c/eMlQ7xjAjUbLOjggFCMIIBPjAJBgNVHRMEAjAAMBEGCWCGSAGG+EIBAQQEAwIG
|
||||
QDAzBglghkgBhvhCAQ0EJhYkT3BlblNTTCBHZW5lcmF0ZWQgU2VydmVyIENlcnRp
|
||||
ZmljYXRlMB0GA1UdDgQWBBRkIUgQms0wKozvwQp1b1XU53E3kjCBjgYDVR0jBIGG
|
||||
MIGDgBTgjhUcKUeHCkRt2ruKalObXPCLTqFVpFMwUTELMAkGA1UEBhMCQkUxFDAS
|
||||
BgNVBAcMC0hvdXRlc2lwbG91MSwwKgYDVQQDDCNIb3V0ZXNpcGxvdSBDZXJ0aWZp
|
||||
Y2F0aW9uIEF1dGhvcml0eYIUXOt3OuI/y68E6ogXPRKjMxM3d4kwDgYDVR0PAQH/
|
||||
BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBQGA1UdEQQNMAuCCWxvY2FsaG9z
|
||||
dDAFBgMrZXADQQDidpmoltxDR1Qh5ZDluVC1DENZpXk+My23SDzATvxovbmkHEK2
|
||||
/LdWbqrTPJZIHGhs9W/7vWSbLzyHYTKH2fAD
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIHSVKSyvMJwmNHpoMj3BLlBy40bQ2vJJyUNknXcyPUkO
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import contextlib
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
IGNORE_MODEL_NAMES = {
|
||||
'ir.attachment',
|
||||
'test_new_api.attachment',
|
||||
'payment.link.wizard',
|
||||
'account.multicurrency.revaluation.wizard',
|
||||
'account_followup.manual_reminder',
|
||||
}
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestEveryModel(TransactionCase):
|
||||
|
||||
def test_display_name_new_record(self):
|
||||
for model_name in self.registry:
|
||||
model = self.env[model_name]
|
||||
if model._abstract or not model._auto or model_name in IGNORE_MODEL_NAMES:
|
||||
continue
|
||||
|
||||
with self.subTest(
|
||||
msg="`_compute_display_name` doesn't work with new record (first onchange call).",
|
||||
model=model_name,
|
||||
):
|
||||
# Check that the first onchange with display_name works on every models
|
||||
# OR it will fail anyway when people will use click on New
|
||||
fields_used = model._fields['display_name'].get_depends(model)[0]
|
||||
fields_used = [f.split('.', 1)[0] for f in fields_used]
|
||||
fields_spec = dict.fromkeys(fields_used + ['display_name'], {})
|
||||
with contextlib.suppress(UserError):
|
||||
model.onchange({}, [], fields_spec)
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
import contextlib
|
||||
import logging
|
||||
import shutil
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import unittest
|
||||
import warnings
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from socket import getaddrinfo # keep a reference on the non-patched function
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import file_path, mute_logger
|
||||
from .common import TransactionCaseWithUserDemo
|
||||
|
||||
try:
|
||||
import aiosmtpd
|
||||
import aiosmtpd.controller
|
||||
import aiosmtpd.smtp
|
||||
import aiosmtpd.handlers
|
||||
except ImportError:
|
||||
aiosmtpd = None
|
||||
|
||||
|
||||
PASSWORD = 'secretpassword'
|
||||
_openssl = shutil.which('openssl')
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_free_local_address():
|
||||
""" Get a triple (family, address, port) on which it possible to bind
|
||||
a local tcp service. """
|
||||
addr = aiosmtpd.controller.get_localhost() # it returns 127.0.0.1 or ::1
|
||||
family = socket.AF_INET if addr == '127.0.0.1' else socket.AF_INET6
|
||||
with socket.socket(family, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((addr, 0))
|
||||
port = sock.getsockname()[1]
|
||||
return family, addr, port
|
||||
|
||||
|
||||
def _smtp_authenticate(server, session, enveloppe, mechanism, data):
|
||||
""" Callback method used by aiosmtpd to validate a login/password pair. """
|
||||
result = aiosmtpd.smtp.AuthResult(success=data.password == PASSWORD.encode())
|
||||
_logger.debug("AUTH %s", "successfull" if result.success else "failed")
|
||||
return result
|
||||
|
||||
|
||||
class Certificate:
|
||||
def __init__(self, key, cert):
|
||||
self.key = key and Path(file_path(key, filter_ext='.pem'))
|
||||
self.cert = Path(file_path(cert, filter_ext='.pem'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"Certificate({self.key=}, {self.cert=})"
|
||||
|
||||
|
||||
# skip when optional dependencies are not found
|
||||
@unittest.skipUnless(aiosmtpd, "aiosmtpd couldn't be imported")
|
||||
@unittest.skipUnless(_openssl, "openssl not found in path")
|
||||
# fail fast for timeout errors
|
||||
@patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', .1)
|
||||
# prevent the CLI from interfering with the tests
|
||||
@patch.dict('odoo.tools.config.options', {'smtp_server': ''})
|
||||
class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# aiosmtpd emits deprecation warnings because it uses its own
|
||||
# deprecated features, mute those logs.
|
||||
# https://github.com/aio-libs/aiosmtpd/issues/347
|
||||
class Session(aiosmtpd.smtp.Session):
|
||||
@property
|
||||
def login_data(self):
|
||||
return self._login_data
|
||||
@login_data.setter
|
||||
def login_data(self, value):
|
||||
self._login_data = value
|
||||
patcher = patch('aiosmtpd.smtp.Session', Session)
|
||||
patcher.start()
|
||||
cls.addClassCleanup(patcher.stop)
|
||||
|
||||
# aiosmtpd emits warnings for some unusual configuration, like
|
||||
# requiring AUTH on a clear-text transport. Mute those logs
|
||||
# since we also test those unusual configurations.
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
"Requiring AUTH while not requiring TLS can lead to security vulnerabilities!",
|
||||
category=UserWarning
|
||||
)
|
||||
class CustomFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
if record.msg == "auth_required == True but auth_require_tls == False":
|
||||
return False
|
||||
if record.msg == "tls_context.verify_mode not in {CERT_NONE, CERT_OPTIONAL}; this might cause client connection problems":
|
||||
return False
|
||||
return True
|
||||
logging.getLogger('mail.log').addFilter(CustomFilter())
|
||||
|
||||
# decrease aiosmtpd verbosity, odoo INFO = aiosmtpd WARNING
|
||||
logging.getLogger('mail.log').setLevel(_logger.getEffectiveLevel() + 10)
|
||||
|
||||
# Get various TLS keys and certificates. CA was used to sign
|
||||
# both client and server. self_signed is... self signed.
|
||||
cls.ssl_ca, cls.ssl_client, cls.ssl_server, cls.ssl_self_signed = [
|
||||
Certificate(None, 'base/tests/ssl/ca.cert.pem'),
|
||||
Certificate('base/tests/ssl/client.key.pem',
|
||||
'base/tests/ssl/client.cert.pem'),
|
||||
Certificate('base/tests/ssl/server.key.pem',
|
||||
'base/tests/ssl/server.cert.pem'),
|
||||
Certificate('base/tests/ssl/self_signed.key.pem',
|
||||
'base/tests/ssl/self_signed.cert.pem'),
|
||||
]
|
||||
|
||||
# Patch the two SMTP client classes into trusting the above CA
|
||||
class TEST_SMTP(smtplib.SMTP):
|
||||
def starttls(self, *, context):
|
||||
if context is None:
|
||||
context = ssl._create_stdlib_context() # what SMTP_SSL does
|
||||
# context = ssl.create_default_context() # what it should do
|
||||
context.load_verify_locations(cafile=str(cls.ssl_ca.cert))
|
||||
super().starttls(context=context)
|
||||
class TEST_SMTP_SSL(smtplib.SMTP_SSL):
|
||||
def _get_socket(self, *args, **kwargs):
|
||||
# self.context = ssl.create_default_context() # what it should do
|
||||
self.context.load_verify_locations(cafile=str(cls.ssl_ca.cert))
|
||||
return super()._get_socket(*args, **kwargs)
|
||||
patcher = patch('smtplib.SMTP', TEST_SMTP)
|
||||
patcher.start()
|
||||
cls.addClassCleanup(patcher.stop)
|
||||
patcher = patch('smtplib.SMTP_SSL', TEST_SMTP_SSL)
|
||||
patcher.start()
|
||||
cls.addClassCleanup(patcher.stop)
|
||||
|
||||
# reactivate sending emails during this test suite, make sure
|
||||
# NOT TO send emails using another ir.mail_server than the one
|
||||
# created in setUp!
|
||||
patcher = patch.object(cls.registry['ir.mail_server'], '_is_test_mode')
|
||||
mock = patcher.start()
|
||||
mock.return_value = False
|
||||
cls.addClassCleanup(patcher.stop)
|
||||
|
||||
# fix runbot, docker uses a single ipv4 stack but it gives ::1
|
||||
# when resolving "localhost" (so stupid), use the following to
|
||||
# force aiosmtpd/odoo to bind/connect to a fixed ipv4 OR ipv6
|
||||
# address.
|
||||
family, _, cls.port = _find_free_local_address()
|
||||
cls.localhost = getaddrinfo('localhost', cls.port, family)
|
||||
cls.startClassPatcher(patch('socket.getaddrinfo', cls.getaddrinfo))
|
||||
|
||||
@classmethod
|
||||
def getaddrinfo(cls, host, port, *args, **kwargs):
|
||||
"""
|
||||
Resolve both "localhost" and "notlocalhost" on the ip address
|
||||
bound by aiosmtpd inside `start_smtpd`.
|
||||
"""
|
||||
if host in ('localhost', 'notlocalhost') and port == cls.port:
|
||||
return cls.localhost
|
||||
return getaddrinfo(host, port, family=0, type=0, proto=0, flags=0)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def start_smtpd(
|
||||
self, encryption, ssl_context=None, auth_required=True, stop_on_cleanup=True
|
||||
):
|
||||
"""
|
||||
Start a smtp daemon in a background thread, stop it upon exiting
|
||||
the context manager.
|
||||
|
||||
:param encryption: 'none', 'ssl' or 'starttls', the kind of
|
||||
server to start.
|
||||
:param ssl_context: the ``ssl.SSLContext`` object to use with
|
||||
'ssl' or 'starttls'.
|
||||
:param auth_required: whether the server enforces password
|
||||
authentication or not.
|
||||
"""
|
||||
assert encryption in ('none', 'ssl', 'starttls')
|
||||
assert encryption == 'none' or ssl_context
|
||||
|
||||
kwargs = {}
|
||||
if encryption == 'starttls':
|
||||
# for aiosmtpd.smtp.SMTP
|
||||
kwargs.update({
|
||||
'require_starttls': True,
|
||||
'tls_context': ssl_context,
|
||||
})
|
||||
elif encryption == 'ssl':
|
||||
# for aiosmtpd.controller.InetMixin
|
||||
kwargs['ssl_context'] = ssl_context
|
||||
if auth_required:
|
||||
kwargs['authenticator'] = _smtp_authenticate
|
||||
|
||||
smtpd_thread = aiosmtpd.controller.Controller(
|
||||
aiosmtpd.handlers.Debugging(),
|
||||
hostname=aiosmtpd.controller.get_localhost(),
|
||||
server_hostname='localhost',
|
||||
port=self.port,
|
||||
auth_required=auth_required,
|
||||
auth_require_tls=False,
|
||||
enable_SMTPUTF8=True,
|
||||
**kwargs,
|
||||
)
|
||||
try:
|
||||
smtpd_thread.start()
|
||||
yield smtpd_thread
|
||||
finally:
|
||||
smtpd_thread.stop()
|
||||
|
||||
@mute_logger('mail.log')
|
||||
def test_authentication_certificate_matrix(self):
|
||||
"""
|
||||
Connect to a server that is authenticating users via a TLS
|
||||
certificate. Test the various possible configurations (missing
|
||||
cert, invalid cert and valid cert) against both a STARTTLS and
|
||||
a SSL/TLS SMTP server.
|
||||
"""
|
||||
mail_server = self.env['ir.mail_server'].create({
|
||||
'name': 'test smtpd',
|
||||
'from_filter': 'localhost',
|
||||
'smtp_host': 'localhost',
|
||||
'smtp_port': self.port,
|
||||
'smtp_authentication': 'login',
|
||||
'smtp_user': '',
|
||||
'smtp_pass': '',
|
||||
})
|
||||
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(self.ssl_server.cert, self.ssl_server.key)
|
||||
ssl_context.load_verify_locations(cafile=self.ssl_ca.cert)
|
||||
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||
|
||||
self_signed_key = b64encode(self.ssl_self_signed.key.read_bytes())
|
||||
self_signed_cert = b64encode(self.ssl_self_signed.cert.read_bytes())
|
||||
client_key = b64encode(self.ssl_client.key.read_bytes())
|
||||
client_cert = b64encode(self.ssl_client.cert.read_bytes())
|
||||
matrix = [
|
||||
# authentication, name, certificate, private key, error pattern
|
||||
('login', "missing", '', '',
|
||||
r"The server has closed the connection unexpectedly\. "
|
||||
r"Check configuration served on this port number\.\n "
|
||||
r"Connection unexpectedly closed"),
|
||||
('certificate', "self signed", self_signed_cert, self_signed_key,
|
||||
r"The server has closed the connection unexpectedly\. "
|
||||
r"Check configuration served on this port number\.\n "
|
||||
r"Connection unexpectedly closed"),
|
||||
('certificate', "valid client", client_cert, client_key, None),
|
||||
]
|
||||
|
||||
for encryption in ('starttls', 'ssl'):
|
||||
mail_server.smtp_encryption = encryption
|
||||
with self.start_smtpd(encryption, ssl_context, auth_required=False):
|
||||
for authentication, name, certificate, private_key, error_pattern in matrix:
|
||||
with self.subTest(encryption=encryption, certificate=name):
|
||||
mail_server.write({
|
||||
'smtp_authentication': authentication,
|
||||
'smtp_ssl_certificate': certificate,
|
||||
'smtp_ssl_private_key': private_key,
|
||||
})
|
||||
if error_pattern:
|
||||
with self.assertRaises(UserError) as error_capture:
|
||||
mail_server.test_smtp_connection()
|
||||
self.assertRegex(error_capture.exception.args[0], error_pattern)
|
||||
else:
|
||||
mail_server.test_smtp_connection()
|
||||
|
||||
|
||||
def test_authentication_login_matrix(self):
|
||||
"""
|
||||
Connect to a server that is authenticating users via a login/pwd
|
||||
pair. Test the various possible configurations (missing pair,
|
||||
invalid pair and valid pair) against both a SMTP server without
|
||||
encryption, a STARTTLS and a SSL/TLS SMTP server.
|
||||
"""
|
||||
mail_server = self.env['ir.mail_server'].create({
|
||||
'name': 'test smtpd',
|
||||
'from_filter': 'localhost',
|
||||
'smtp_host': 'localhost',
|
||||
'smtp_port': self.port,
|
||||
'smtp_authentication': 'login',
|
||||
'smtp_user': '',
|
||||
'smtp_pass': '',
|
||||
})
|
||||
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(self.ssl_server.cert, self.ssl_server.key)
|
||||
|
||||
MISSING = ''
|
||||
INVALID = 'bad password'
|
||||
matrix = [
|
||||
# auth_required, password, error_pattern
|
||||
(False, MISSING, None),
|
||||
(True, MISSING,
|
||||
r"The server refused the sender address \(noreply@localhost\) "
|
||||
r"with error b'5\.7\.0 Authentication required'"),
|
||||
(True, INVALID,
|
||||
r"The server has closed the connection unexpectedly\. "
|
||||
r"Check configuration served on this port number\.\n "
|
||||
r"Connection unexpectedly closed:.* timed out"),
|
||||
(True, PASSWORD, None),
|
||||
]
|
||||
|
||||
for encryption in ('none', 'starttls', 'ssl'):
|
||||
mail_server.smtp_encryption = encryption
|
||||
for auth_required, password, error_pattern in matrix:
|
||||
mail_server.smtp_user = password and self.user_demo.email
|
||||
mail_server.smtp_pass = password
|
||||
with self.subTest(encryption=encryption,
|
||||
auth_required=auth_required,
|
||||
password=password):
|
||||
with self.start_smtpd(encryption, ssl_context, auth_required):
|
||||
if error_pattern:
|
||||
with self.assertRaises(UserError) as capture:
|
||||
mail_server.test_smtp_connection()
|
||||
self.assertRegex(capture.exception.args[0], error_pattern)
|
||||
else:
|
||||
mail_server.test_smtp_connection()
|
||||
|
||||
@mute_logger('mail.log')
|
||||
def test_encryption_matrix(self):
|
||||
"""
|
||||
Connect to a server on a different encryption configuration than
|
||||
the server is configured. Verify that it crashes with a good
|
||||
error message.
|
||||
"""
|
||||
mail_server = self.env['ir.mail_server'].create({
|
||||
'name': 'test smtpd',
|
||||
'from_filter': 'localhost',
|
||||
'smtp_host': 'localhost',
|
||||
'smtp_port': self.port,
|
||||
'smtp_authentication': 'login',
|
||||
'smtp_user': '',
|
||||
'smtp_pass': '',
|
||||
})
|
||||
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(self.ssl_server.cert, self.ssl_server.key)
|
||||
|
||||
matrix = [
|
||||
# client, server, error_pattern
|
||||
('none', 'ssl',
|
||||
r"The server has closed the connection unexpectedly\. "
|
||||
r"Check configuration served on this port number\.\n "
|
||||
r"Connection unexpectedly closed: timed out"),
|
||||
('none', 'starttls',
|
||||
r"The server refused the sender address \(noreply@localhost\) with error "
|
||||
r"b'Must issue a STARTTLS command first'"),
|
||||
('starttls', 'none',
|
||||
r"An option is not supported by the server:\n "
|
||||
r"STARTTLS extension not supported by server\."),
|
||||
('starttls', 'ssl',
|
||||
r"The server has closed the connection unexpectedly\. "
|
||||
r"Check configuration served on this port number\.\n "
|
||||
r"Connection unexpectedly closed: timed out"),
|
||||
('ssl', 'none',
|
||||
r"An SSL exception occurred\. "
|
||||
r"Check connection security type\.\n "
|
||||
r".*?wrong version number"),
|
||||
('ssl', 'starttls',
|
||||
r"An SSL exception occurred\. "
|
||||
r"Check connection security type\.\n "
|
||||
r".*?wrong version number"),
|
||||
]
|
||||
|
||||
for client_encryption, server_encryption, error_pattern in matrix:
|
||||
with self.subTest(server_encryption=server_encryption,
|
||||
client_encryption=client_encryption):
|
||||
mail_server.smtp_encryption = client_encryption
|
||||
with self.start_smtpd(server_encryption, ssl_context, auth_required=False):
|
||||
with self.assertRaises(UserError) as capture:
|
||||
mail_server.test_smtp_connection()
|
||||
self.assertRegex(capture.exception.args[0], error_pattern)
|
||||
|
||||
def test_man_in_the_middle_matrix(self):
|
||||
"""
|
||||
Simulate that a pirate was successful at intercepting the live
|
||||
traffic in between the Odoo server and the legitimate SMTP
|
||||
server.
|
||||
"""
|
||||
mail_server = self.env['ir.mail_server'].create({
|
||||
'name': 'test smtpd',
|
||||
'from_filter': 'localhost',
|
||||
'smtp_host': 'localhost',
|
||||
'smtp_port': self.port,
|
||||
'smtp_authentication': 'login',
|
||||
'smtp_user': self.user_demo.email,
|
||||
'smtp_pass': PASSWORD,
|
||||
'smtp_ssl_certificate': b64encode(self.ssl_client.cert.read_bytes()),
|
||||
'smtp_ssl_private_key': b64encode(self.ssl_client.key.read_bytes()),
|
||||
})
|
||||
|
||||
cert_good = self.ssl_server
|
||||
cert_bad = self.ssl_self_signed
|
||||
host_good = 'localhost'
|
||||
host_bad = 'notlocalhost'
|
||||
|
||||
# for now it doesn't raise any error for bad cert/host
|
||||
matrix = [
|
||||
# authentication, certificate, hostname, error_pattern
|
||||
('login', cert_bad, host_good, None),
|
||||
('login', cert_good, host_bad, None),
|
||||
('certificate', cert_bad, host_good, None),
|
||||
('certificate', cert_good, host_bad, None),
|
||||
]
|
||||
|
||||
for encryption in ('starttls', 'ssl'):
|
||||
for authentication, certificate, hostname, error_pattern in matrix:
|
||||
mail_server.smtp_host = hostname
|
||||
mail_server.smtp_authentication = authentication
|
||||
mail_server.smtp_encryption = encryption
|
||||
with self.subTest(
|
||||
encryption=encryption,
|
||||
authentication=authentication,
|
||||
cert_good=certificate == cert_good,
|
||||
host_good=hostname == host_good,
|
||||
):
|
||||
mitm_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
mitm_context.load_cert_chain(certificate.cert, certificate.key)
|
||||
auth_required = authentication == 'login'
|
||||
with self.start_smtpd(encryption, mitm_context, auth_required):
|
||||
if error_pattern:
|
||||
with self.assertRaises(UserError) as capture:
|
||||
mail_server.test_smtp_connection()
|
||||
self.assertRegex(capture.exception.args[0], error_pattern)
|
||||
else:
|
||||
mail_server.test_smtp_connection()
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestOverrides(TransactionCase):
|
||||
|
||||
# Ensure all main ORM methods behavior works fine even on empty recordset
|
||||
# and that their returned value(s) follow the expected format.
|
||||
|
||||
def test_creates(self):
|
||||
for model_env in self.env.values():
|
||||
if model_env._abstract:
|
||||
continue
|
||||
# with self.assertQueryCount(0):
|
||||
self.assertEqual(
|
||||
model_env.create([]), model_env.browse(),
|
||||
"Invalid create return value for model %s" % model_env._name)
|
||||
|
||||
def test_writes(self):
|
||||
for model_env in self.env.values():
|
||||
if model_env._abstract:
|
||||
continue
|
||||
try:
|
||||
# with self.assertQueryCount(0):
|
||||
self.assertEqual(
|
||||
model_env.browse().write({}), True,
|
||||
"Invalid write return value for model %s" % model_env._name)
|
||||
except UserError:
|
||||
# skip models that should never be modified
|
||||
continue
|
||||
|
||||
def test_default_get(self):
|
||||
for model_env in self.env.values():
|
||||
if model_env._transient:
|
||||
continue
|
||||
try:
|
||||
# with self.assertQueryCount(1): # allow one query for the call to get_model_defaults.
|
||||
self.assertEqual(
|
||||
model_env.browse().default_get([]), {},
|
||||
"Invalid default_get return value for model %s" % model_env._name)
|
||||
except UserError:
|
||||
# skip "You must be logged in a Belgian company to use this feature" errors
|
||||
continue
|
||||
|
||||
def test_unlink(self):
|
||||
for model_env in self.env.values():
|
||||
if model_env._abstract:
|
||||
continue
|
||||
# with self.assertQueryCount(0):
|
||||
self.assertEqual(
|
||||
model_env.browse().unlink(), True,
|
||||
"Invalid unlink return value for model %s" % model_env._name)
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tools import file_open
|
||||
from odoo.addons.base.models.ir_actions_report import _split_table
|
||||
from lxml import etree
|
||||
|
||||
def cleanup_string(s):
|
||||
return ''.join(s.split())
|
||||
|
||||
class TestSplitTable(TransactionCase):
|
||||
def test_split_table(self):
|
||||
# NOTE: All the tests's xml are in split_table/ relative to this file
|
||||
CASES = (
|
||||
("Table's len is equal to max_rows and should not be split", "simple", "simple", 3),
|
||||
("Table's len is greater to max_rows and should not be split", "simple", "simple", 4),
|
||||
("max_rows is 1 and every table should be split", "simple", "simple.split1", 1),
|
||||
("max_row is 2 and the table should be split", "simple", "simple.split2", 2),
|
||||
("Nested tables should be split", "nested", "nested.split2", 2),
|
||||
("Nested tables at the start should be split", "first_nested", "first_nested.split2", 2),
|
||||
("Attributes should be copied", "copy_attributes", "copy_attributes.split1", 1),
|
||||
)
|
||||
|
||||
for description, actual, expected, max_rows in CASES:
|
||||
with self.subTest(description), \
|
||||
file_open(f"base/tests/split_table/{actual}.xml") as actual, \
|
||||
file_open(f"base/tests/split_table/{expected}.xml") as expected:
|
||||
|
||||
tree = etree.fromstring(actual.read())
|
||||
_split_table(tree, max_rows)
|
||||
processed = etree.tostring(tree, encoding='unicode')
|
||||
self.assertEqual(cleanup_string(processed), cleanup_string(expected.read()))
|
||||
177
odoo-bringout-oca-ocb-base/odoo/addons/base/tests/test_sql.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from psycopg2.errors import CheckViolation
|
||||
|
||||
from odoo.tests.common import BaseCase, TransactionCase
|
||||
from odoo.tools import SQL, mute_logger, sql
|
||||
|
||||
|
||||
class TestSQL(BaseCase):
|
||||
|
||||
def test_sql_empty(self):
|
||||
sql = SQL()
|
||||
self.assertEqual(sql.code, "")
|
||||
self.assertEqual(sql.params, [])
|
||||
|
||||
def test_sql_bool(self):
|
||||
self.assertFalse(SQL())
|
||||
self.assertFalse(SQL(""))
|
||||
self.assertTrue(SQL("anything"))
|
||||
self.assertTrue(SQL("%s", 42))
|
||||
|
||||
def test_sql_with_no_parameter(self):
|
||||
sql = SQL("SELECT id FROM table WHERE foo=bar")
|
||||
self.assertEqual(sql.code, "SELECT id FROM table WHERE foo=bar")
|
||||
self.assertEqual(sql.params, [])
|
||||
|
||||
def test_sql_with_literal_parameters(self):
|
||||
sql = SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 42, 'baz')
|
||||
self.assertEqual(sql.code, "SELECT id FROM table WHERE foo=%s AND bar=%s")
|
||||
self.assertEqual(sql.params, [42, 'baz'])
|
||||
|
||||
def test_sql_with_wrong_pattern(self):
|
||||
with self.assertRaises(TypeError):
|
||||
SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 42)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 1, 2, 3)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
SQL("SELECT id FROM table WHERE foo=%s AND bar=%(two)s", 1, two=2)
|
||||
|
||||
with self.assertRaises(KeyError):
|
||||
SQL("SELECT id FROM table WHERE foo=%(one)s AND bar=%(two)s", one=1, to=2)
|
||||
|
||||
def test_sql_equality(self):
|
||||
sql1 = SQL("SELECT id FROM table WHERE foo=%s", 42)
|
||||
sql2 = SQL("SELECT id FROM table WHERE foo=%s", 42)
|
||||
self.assertEqual(sql1, sql2)
|
||||
|
||||
sql1 = SQL("SELECT id FROM table WHERE foo=%s", 42)
|
||||
sql2 = SQL("SELECT id FROM table WHERE bar=%s", 42)
|
||||
self.assertNotEqual(sql1, sql2)
|
||||
|
||||
sql1 = SQL("SELECT id FROM table WHERE foo=%s", 42)
|
||||
sql2 = SQL("SELECT id FROM table WHERE foo=%s", 421)
|
||||
self.assertNotEqual(sql1, sql2)
|
||||
|
||||
def test_sql_idempotence(self):
|
||||
sql1 = SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 42, 'baz')
|
||||
sql2 = SQL(sql1)
|
||||
self.assertIs(sql1, sql2)
|
||||
|
||||
def test_sql_unpacking(self):
|
||||
sql = SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 42, 'baz')
|
||||
string, params = sql
|
||||
self.assertEqual(string, "SELECT id FROM table WHERE foo=%s AND bar=%s")
|
||||
self.assertEqual(params, [42, 'baz'])
|
||||
|
||||
def test_sql_join(self):
|
||||
sql = SQL(" AND ").join([])
|
||||
self.assertEqual(sql.code, "")
|
||||
self.assertEqual(sql.params, [])
|
||||
self.assertEqual(sql, SQL(""))
|
||||
|
||||
sql = SQL(" AND ").join([SQL("foo=%s", 1)])
|
||||
self.assertEqual(sql.code, "foo=%s")
|
||||
self.assertEqual(sql.params, [1])
|
||||
|
||||
sql = SQL(" AND ").join([
|
||||
SQL("foo=%s", 1),
|
||||
SQL("bar=%s", 2),
|
||||
SQL("baz=%s", 3),
|
||||
])
|
||||
self.assertEqual(sql.code, "foo=%s AND bar=%s AND baz=%s")
|
||||
self.assertEqual(sql.params, [1, 2, 3])
|
||||
|
||||
sql = SQL(", ").join([1, 2, 3])
|
||||
self.assertEqual(sql.code, "%s, %s, %s")
|
||||
self.assertEqual(sql.params, [1, 2, 3])
|
||||
|
||||
def test_sql_identifier(self):
|
||||
sql = SQL.identifier('foo')
|
||||
self.assertEqual(sql.code, '"foo"')
|
||||
self.assertEqual(sql.params, [])
|
||||
|
||||
sql = SQL.identifier('année')
|
||||
self.assertEqual(sql.code, '"année"')
|
||||
self.assertEqual(sql.params, [])
|
||||
|
||||
sql = SQL.identifier('foo', 'bar')
|
||||
self.assertEqual(sql.code, '"foo"."bar"')
|
||||
self.assertEqual(sql.params, [])
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
sql = SQL.identifier('foo"')
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
sql = SQL.identifier('(SELECT 42)')
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
sql = SQL.identifier('foo', 'ba"r')
|
||||
|
||||
def test_sql_with_sql_parameters(self):
|
||||
sql = SQL("SELECT id FROM table WHERE foo=%s AND %s", 1, SQL("bar=%s", 2))
|
||||
self.assertEqual(sql.code, "SELECT id FROM table WHERE foo=%s AND bar=%s")
|
||||
self.assertEqual(sql.params, [1, 2])
|
||||
self.assertEqual(sql, SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 1, 2))
|
||||
|
||||
sql = SQL("SELECT id FROM table WHERE %s AND bar=%s", SQL("foo=%s", 1), 2)
|
||||
self.assertEqual(sql.code, "SELECT id FROM table WHERE foo=%s AND bar=%s")
|
||||
self.assertEqual(sql.params, [1, 2])
|
||||
self.assertEqual(sql, SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 1, 2))
|
||||
|
||||
sql = SQL("SELECT id FROM table WHERE %s AND %s", SQL("foo=%s", 1), SQL("bar=%s", 2))
|
||||
self.assertEqual(sql.code, "SELECT id FROM table WHERE foo=%s AND bar=%s")
|
||||
self.assertEqual(sql.params, [1, 2])
|
||||
self.assertEqual(sql, SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 1, 2))
|
||||
|
||||
def test_sql_with_named_parameters(self):
|
||||
sql = SQL("SELECT id FROM table WHERE %(one)s AND bar=%(two)s", one=SQL("foo=%s", 1), two=2)
|
||||
self.assertEqual(sql.code, "SELECT id FROM table WHERE foo=%s AND bar=%s")
|
||||
self.assertEqual(sql.params, [1, 2])
|
||||
self.assertEqual(sql, SQL("SELECT id FROM table WHERE foo=%s AND bar=%s", 1, 2))
|
||||
|
||||
# the parameters are bound locally
|
||||
sql = SQL(
|
||||
"%s AND %s",
|
||||
SQL("foo=%(value)s", value=1),
|
||||
SQL("bar=%(value)s", value=2),
|
||||
)
|
||||
self.assertEqual(sql.code, "foo=%s AND bar=%s")
|
||||
self.assertEqual(sql.params, [1, 2])
|
||||
self.assertEqual(sql, SQL("foo=%s AND bar=%s", 1, 2))
|
||||
|
||||
def test_complex_sql(self):
|
||||
sql = SQL(
|
||||
"SELECT %s FROM %s WHERE %s",
|
||||
SQL.identifier('id'),
|
||||
SQL.identifier('table'),
|
||||
SQL(" AND ").join([
|
||||
SQL("%s=%s", SQL.identifier('table', 'foo'), 1),
|
||||
SQL("%s=%s", SQL.identifier('table', 'bar'), 2),
|
||||
]),
|
||||
)
|
||||
self.assertEqual(sql.code, 'SELECT "id" FROM "table" WHERE "table"."foo"=%s AND "table"."bar"=%s')
|
||||
self.assertEqual(sql.params, [1, 2])
|
||||
self.assertEqual(sql, SQL('SELECT "id" FROM "table" WHERE "table"."foo"=%s AND "table"."bar"=%s', 1, 2))
|
||||
self.assertEqual(
|
||||
repr(sql),
|
||||
"""SQL('SELECT "id" FROM "table" WHERE "table"."foo"=%s AND "table"."bar"=%s', 1, 2)"""
|
||||
)
|
||||
|
||||
class TestSqlTools(TransactionCase):
|
||||
|
||||
def test_add_constraint(self):
|
||||
definition = "CHECK (name !~ '%')"
|
||||
sql.add_constraint(self.env.cr, 'res_bank', 'test_constraint_dummy', definition)
|
||||
|
||||
# ensure the constraint with % works and it's in the DB
|
||||
with self.assertRaises(CheckViolation), mute_logger('odoo.sql_db'):
|
||||
self.env['res.bank'].create({'name': '10% bank'})
|
||||
|
||||
# ensure the definitions match
|
||||
db_definition = sql.constraint_definition(self.env.cr, 'res_bank', 'test_constraint_dummy')
|
||||
self.assertEqual(definition, db_definition)
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestTransactionEnvs(TransactionCase):
|
||||
def test_transation_envs_weakrefs(self):
|
||||
transaction = self.env.transaction
|
||||
starting_envs = set(transaction.envs)
|
||||
base_x = self.env['base'].with_context(test_stuff=False)
|
||||
self.assertIn(base_x.env, transaction.envs)
|
||||
del base_x
|
||||
self.assertEqual(set(transaction.envs), starting_envs)
|
||||
|
||||
def do_stuff_with_env(self):
|
||||
base_test = self.env['base'].with_context(test_stuff=False)
|
||||
base_test |= self.env['base'].with_context(test_stuff=1)
|
||||
base_test |= self.env['base'].with_context(test_stuff=2)
|
||||
return base_test
|
||||
|
||||
def test_transation_envs_weakrefs_call(self):
|
||||
transaction = self.env.transaction
|
||||
starting_envs = set(transaction.envs)
|
||||
self.do_stuff_with_env()
|
||||
self.assertEqual(set(transaction.envs), starting_envs)
|
||||
|
||||
def test_transation_envs_weakrefs_return(self):
|
||||
transaction = self.env.transaction
|
||||
starting_envs = set(transaction.envs)
|
||||
base_test = self.do_stuff_with_env()
|
||||
self.assertEqual(set(transaction.envs), starting_envs | {base_test.env})
|
||||
|
||||
def test_transation_envs_ordered(self):
|
||||
transaction = self.env.transaction
|
||||
starting_envs = set(transaction.envs)
|
||||
# create environments in a certain order, not sorted on item
|
||||
items = [3, 8, 1, 5, 2, 7, 6, 9, 0, 4]
|
||||
envs = [self.env(context={'item': item}) for item in items]
|
||||
# check that those environments appear in order in transaction.envs
|
||||
env_items = [env.context['item'] for env in transaction.envs if env not in starting_envs]
|
||||
self.assertEqual(env_items, items)
|
||||
del envs
|
||||
self.assertEqual(set(transaction.envs), starting_envs)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="res_users_identitycheck_view_form" model="ir.ui.view">
|
||||
<field name="model">res.users.identitycheck</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Password Confirmation">
|
||||
<sheet>
|
||||
<h3><strong>Please enter your password to confirm you own this account</strong></h3>
|
||||
<div>
|
||||
<field class="o_field_highlight col-10 col-md-6 px-0" name="password" autocomplete="current-password"
|
||||
required="True" password="True" placeholder="************"/>
|
||||
</div>
|
||||
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string="Confirm Password" type="object" name="run_check" class="btn btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" special="cancel" data-hotkey="z" class="btn btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="res_users_identitycheck_view_form_revokedevices" model="ir.ui.view">
|
||||
<field name="name">Revoke All Devices</field>
|
||||
<field name="model">res.users.identitycheck</field>
|
||||
<field name="priority">40</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Log out from all devices">
|
||||
<div>
|
||||
You are about to log out from all devices that currently have access to your account.<br/><br/>
|
||||
<strong>Type in your password to confirm :</strong>
|
||||
<field class="oe_inline o_field_highlight" name="password" password="True" required="True"/>
|
||||
</div>
|
||||
<footer>
|
||||
<button string="Log out from all devices" name="revoke_all_devices" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
{%- set codeUpper = code|upper -%}
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': '{{countryPascal}} - Payroll',
|
||||
'countries': ['{{code}}'],
|
||||
'category': 'Human Resources/Payroll',
|
||||
'depends': ['hr_payroll'],
|
||||
'version': '1.0',
|
||||
'description': """
|
||||
''Country-ish'' Payroll Rules.
|
||||
==============================
|
||||
|
||||
* Employee Details
|
||||
* Employee Contracts
|
||||
* Passport based Contract
|
||||
* Allowances/Deductions
|
||||
* Allow to configure Basic/Gross/Net Salary
|
||||
* Employee Payslip
|
||||
* Integrated with Leaves Management
|
||||
""",
|
||||
'data': [
|
||||
'data/hr_salary_rule_category_data.xml',
|
||||
'data/hr_payroll_structure_type_data.xml',
|
||||
'views/hr_payroll_report.xml',
|
||||
'data/hr_payroll_structure_data.xml',
|
||||
'data/hr_rule_parameters_data.xml',
|
||||
'data/hr_salary_rule_data.xml',
|
||||
'views/report_payslip_templates.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/l10n_{{code}}_hr_payroll_demo.xml',
|
||||
],
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
{%- set codeUpper = code|upper -%}
|
||||
<odoo>
|
||||
<record id="l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary" model="hr.payroll.structure">
|
||||
<field name="name">{{countryPascal}}: Regular Pay</field>
|
||||
<field name="code">{{codeUpper}}MONTHLY</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
<field name="type_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_structure_type_employee_{{code}}"/>
|
||||
<field name="report_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_action_report_payslip_{{code}}"/>
|
||||
</record>
|
||||
<record id="l10n_{{code}}_hr_payroll.l10n_{{code}}_structure_type_employee_{{code}}" model="hr.payroll.structure.type">
|
||||
<field name="default_struct_id" ref="l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
<odoo>
|
||||
<record id="l10n_{{code}}_structure_type_employee_{{code}}" model="hr.payroll.structure.type">
|
||||
<field name="name">{{countryPascal}}: Employee</field>
|
||||
<field name="default_resource_calendar_id" ref="resource.resource_calendar_std"/>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
{%- set codeUpper = code|upper -%}
|
||||
<odoo>
|
||||
<record id="l10n_{{code}}_rule_parameter_social_employee_rate" model="hr.rule.parameter">
|
||||
<field name="name">{{countryPascal}}: Pension Contribution Employee Rate</field>
|
||||
<field name="code">l10n_{{code}}_social_employee_rate</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
</record>
|
||||
<record id="l10n_{{code}}_rule_parameter_social_employee_rate_2023" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">-9.76</field>
|
||||
<field name="rule_parameter_id" ref="l10n_{{code}}_rule_parameter_social_employee_rate"/>
|
||||
<field name="date_from" eval="datetime(2017, 1, 1).date()"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_rule_parameter_social_employer_rate" model="hr.rule.parameter">
|
||||
<field name="name">{{countryPascal}}: Pension Contribution Employer Rate</field>
|
||||
<field name="code">l10n_{{code}}_social_employer_rate</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
</record>
|
||||
<record id="l10n_{{code}}_rule_parameter_social_employer_rate_2023" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">9.76</field>
|
||||
<field name="rule_parameter_id" ref="l10n_{{code}}_rule_parameter_social_employer_rate"/>
|
||||
<field name="date_from" eval="datetime(2017, 1, 1).date()"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_rule_parameter_tax_brackets" model="hr.rule.parameter">
|
||||
<field name="name">{{countryPascal}}: Tax Brackets</field>
|
||||
<field name="code">l10n_{{code}}_tax_brackets</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
</record>
|
||||
<record id="l10n_{{code}}_rule_parameter_tax_brackets_2023" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">[
|
||||
( 0, 10000, 0, 5),
|
||||
(10000, 30000, 500, 10),
|
||||
(30000, 'inf', 2000, 5),
|
||||
]</field>
|
||||
<field name="rule_parameter_id" ref="l10n_{{code}}_rule_parameter_tax_brackets"/>
|
||||
<field name="date_from" eval="datetime(2017, 1, 1).date()"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_rule_parameter_childcare_relief" model="hr.rule.parameter">
|
||||
<field name="name">{{countryPascal}}: Chilcare Relief</field>
|
||||
<field name="code">l10n_{{code}}_childcare_relief</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
</record>
|
||||
<record id="l10n_{{code}}_rule_parameter_childcare_relief_2023" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">[92.67, 92.67, 166.67, 225]</field>
|
||||
<field name="rule_parameter_id" ref="l10n_{{code}}_rule_parameter_childcare_relief"/>
|
||||
<field name="date_from" eval="datetime(2017, 1, 1).date()"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
{%- set codeUpper = code|upper -%}
|
||||
<odoo>
|
||||
<record id="l10n_{{code}}_category_social_security_employee" model="hr.salary.rule.category">
|
||||
<field name="name">{{countryPascal}}: Social Security Employee</field>
|
||||
<field name="code">SOCIAL.EMPLOYEE</field>
|
||||
<field name="parent_id" ref="hr_payroll.ALW"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_category_social_security_employer" model="hr.salary.rule.category">
|
||||
<field name="name">{{countryPascal}}: Social Security Employer</field>
|
||||
<field name="code">SOCIAL.EMPLOYER</field>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_category_social_security_employee_total" model="hr.salary.rule.category">
|
||||
<field name="name">{{countryPascal}}: Social Security Employee (TOTAL)</field>
|
||||
<field name="code">SOCIALTOTAL.EMPLOYEE</field>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_category_social_security_employer_total" model="hr.salary.rule.category">
|
||||
<field name="name">{{countryPascal}}: Social Security Employer (TOTAL)</field>
|
||||
<field name="code">SOCIALTOTAL.EMPLOYER</field>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_category_taxable_amount" model="hr.salary.rule.category">
|
||||
<field name="name">{{countryPascal}}: Taxable Amount</field>
|
||||
<field name="code">TAXABLE</field>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_category_withholding_taxes" model="hr.salary.rule.category">
|
||||
<field name="name">{{countryPascal}}: Withholding Taxes</field>
|
||||
<field name="code">TAX</field>
|
||||
<field name="parent_id" ref="hr_payroll.DED"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
<odoo>
|
||||
<!--
|
||||
Sources:
|
||||
government website, pwc, papayaglobal
|
||||
-->
|
||||
<record id="l10n_{{code}}_social_contribution" model="hr.salary.rule">
|
||||
<field name="category_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_category_social_security_employee"/>
|
||||
<field name="name">{{countryPascal}}: Social Contribution</field>
|
||||
<field name="code">SOCIAL.EMPLOYEE</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
result_rate = payslip.rule_parameter('l10n_{{code}}_social_employee_rate')
|
||||
result = categories.BASIC
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_social_contribution_employer" model="hr.salary.rule">
|
||||
<field name="category_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_category_social_security_employer"/>
|
||||
<field name="name">{{countryPascal}}: Social Contribution (Employer)</field>
|
||||
<field name="code">SOCIAL.EMPLOYER</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
result_rate = payslip.rule_parameter('l10n_{{code}}_social_employer_rate')
|
||||
result = categories.BASIC
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="appears_on_payslip" eval="False"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_social_employee_total" model="hr.salary.rule">
|
||||
<field name="category_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_category_social_security_employee_total"/>
|
||||
<field name="name">{{countryPascal}}: Social Security Total (Employee)</field>
|
||||
<field name="code">SOCIAL.EMPLOYEE.TOTAL</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
result = categories['SOCIAL.EMPLOYEE']
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_social_employer_total" model="hr.salary.rule">
|
||||
<field name="category_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_category_social_security_employer_total"/>
|
||||
<field name="name">{{countryPascal}}: Social Security Total (Employer)</field>
|
||||
<field name="code">SOCIAL.EMPLOYER.TOTAL</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
result = categories['SOCIAL.EMPLOYER']
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="appears_on_payslip" eval="False"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_taxable_amount" model="hr.salary.rule">
|
||||
<field name="category_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_category_taxable_amount"/>
|
||||
<field name="name">{{countryPascal}}: Taxable Amount</field>
|
||||
<field name="code">TAXABLE</field>
|
||||
<field name="sequence">115</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
result = categories.GROSS
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_withholding_taxes" model="hr.salary.rule">
|
||||
<field name="category_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_category_withholding_taxes"/>
|
||||
<field name="name">{{countryPascal}}: Withholding Taxes</field>
|
||||
<field name="code">TAX</field>
|
||||
<field name="sequence">150</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
taxable = categories.TAXABLE
|
||||
brackets = payslip.rule_parameter('l10n_{{code}}_tax_brackets')
|
||||
for low, high, prev, rate in brackets:
|
||||
if high == 'inf':
|
||||
high = float('inf')
|
||||
else:
|
||||
if low <= taxable < high:
|
||||
result = -(prev + (taxable - low) * rate / 100)
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_childcare_relief" model="hr.salary.rule">
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="name">{{countryPascal}}: Childcare relief</field>
|
||||
<field name="code">CHILD</field>
|
||||
<field name="sequence">165</field>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
children = payslip.dict.employee_id.children
|
||||
amounts = payslip.rule_parameter('l10n_{{code}}_childcare_relief')
|
||||
if not children:
|
||||
result = 0
|
||||
elif children == 1:
|
||||
result = amounts[0]
|
||||
elif children == 2:
|
||||
result = amounts[0] + amounts[1]
|
||||
elif children == 3:
|
||||
result = amounts[0] + amounts[1] + amounts[2]
|
||||
elif children >= 4:
|
||||
result = amounts[0] + amounts[1] + amounts[2] + (children - 3) * amounts[3]
|
||||
</field>
|
||||
<field name="appears_on_payroll_report" eval="True"/>
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="struct_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_hr_payroll_structure_{{code}}_employee_salary"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
{%- set codeUpper = code|upper -%}
|
||||
<odoo>
|
||||
<record id="l10n_{{code}}_res_company_{{code}}" model="res.company">
|
||||
<field name="name">My ''country-ish'' Company</field>
|
||||
<field name="currency_id" ref="base.EUR"/>
|
||||
<field name="street">34A, Vilijos g.</field>
|
||||
<field name="zip">02243</field>
|
||||
<field name="city">Vilnius</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
</record>
|
||||
|
||||
<record id="base.user_admin" model="res.users">
|
||||
<field name="company_ids" eval="[(4, ref('l10n_{{code}}_hr_payroll.res_company_{{code}}'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="base.user_demo" model="res.users">
|
||||
<field name="company_ids" eval="[(4, ref('l10n_{{code}}_hr_payroll.res_company_{{code}}'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_hr_department_rd{{code}}" model="hr.department">
|
||||
<field name="name">RD {{codeUpper}}</field>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_job_developer_{{name}}" model="hr.job">
|
||||
<field name="name">Experienced Developer {{codeUpper}}</field>
|
||||
<field name="department_id" ref="hr_department_rd{{code}}"/>
|
||||
<field name="no_of_recruitment">5</field>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_hr_employee_blazej" model="hr.employee">
|
||||
<field name="name">Błażej Czarnecki</field>
|
||||
<field name="job_id" ref="job_developer_{{name}}"/>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
<field name="gender">male</field>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_res_partner_antonina" model="res.partner">
|
||||
<field name="name">Antonina Kaczmarczyk</field>
|
||||
<field name="street">Wczasowa 84A/39</field>
|
||||
<field name="city">Magdalenka</field>
|
||||
<field name="zip">15584</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
<field name="phone">0048 025 446 527</field>
|
||||
<field name="email">antonina.kaczmarczyk@example.com</field>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_user_antonina" model="res.users">
|
||||
<field name="partner_id" ref="l10n_{{code}}_hr_payroll.res_partner_antonina"/>
|
||||
<field name="login">antoninakaczmarczyk@example.com</field>
|
||||
<field name="password">antoninakaczmarczyk</field>
|
||||
<field name="signature" type="html"><span>--<br/>+A. Kaczmarczyk</span></field>
|
||||
<field name="company_ids" eval="[(4, ref('l10n_{{code}}_hr_payroll.res_company_{{code}}'))]"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
<field name="groups_id" eval="[(6,0,[ref('base.group_user')])]"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_res_partner_antonina_work_address" model="res.partner">
|
||||
<field name="name">{{codeUpper}} Offices</field>
|
||||
<field name="street">Bogusławskiego Wojciecha, 61/70</field>
|
||||
<field name="city">Zgorzelec</field>
|
||||
<field name="zip">26200</field>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_res_partner_bank_account_norberta" model="res.partner.bank">
|
||||
<field name="acc_number">{{codeUpper}}20002904010518489266398616</field>
|
||||
<field name="bank_id" ref="base.bank_ing"/>
|
||||
<field name="partner_id" ref="l10n_{{code}}_hr_payroll.res_partner_antonina_work_address"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_hr_employee_antonina" model="hr.employee">
|
||||
<field name="name">Antonina Kaczmarczyk (fpo)</field>
|
||||
<field name="gender">female</field>
|
||||
<field name="marital">single</field>
|
||||
<field name="job_title">Software Developer</field>
|
||||
<field name="address_id" ref="l10n_{{code}}_hr_payroll.res_partner_antonina_work_address"/>
|
||||
<field name="private_street">Księżycowa 14/69</field>
|
||||
<field name="private_city">Kamienica Królewska</field>
|
||||
<field name="private_zip">55616</field>
|
||||
<field name="private_country_id" ref="base.{{code}}"/>
|
||||
<field name="private_phone">0048 391 443 088</field>
|
||||
<field name="private_email">antonina.kaczmarczyk@example.com</field>
|
||||
<field name="emergency_contact">Mariusz Kaczmarczyk</field>
|
||||
<field name="emergency_phone">0048(07)4975784</field>
|
||||
<field name="birthday">1991-07-28</field>
|
||||
<field name="km_home_work">25</field>
|
||||
<field name="place_of_birth">{{countryPascal}}</field>
|
||||
<field name="country_of_birth" ref="base.{{code}}"/>
|
||||
<field name="certificate">master</field>
|
||||
<field name="study_field">Civil Engineering</field>
|
||||
<field name="study_school">Université Catholique de Louvain-la-Neuve</field>
|
||||
<field name="parent_id" ref="l10n_{{code}}_hr_payroll.hr_employee_blazej"/>
|
||||
<field name="country_id" ref="base.{{code}}"/>
|
||||
<field name="resource_calendar_id" ref="resource.resource_calendar_std"/>
|
||||
<field name="identification_id">8752027365496</field>
|
||||
<field name="bank_account_id" ref="l10n_{{code}}_hr_payroll.res_partner_bank_account_norberta"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
<field name="user_id" ref="l10n_{{code}}_hr_payroll.user_antonina"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_hr_contract_cdi_antonina_previous" model="hr.contract">
|
||||
<field name="name">CDI - Antonina Kaczmarczyk - Experienced Developer</field>
|
||||
<field name="department_id" ref="hr_department_rd{{code}}"/>
|
||||
<field name="employee_id" ref="hr_employee_antonina"/>
|
||||
<field name="job_id" ref="l10n_{{code}}_hr_payroll.job_developer_{{name}}"/>
|
||||
<field name="structure_type_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_structure_type_employee_{{code}}"/>
|
||||
<field name="wage">3000</field>
|
||||
<field name="state">close</field>
|
||||
<field name="hr_responsible_id" ref="base.user_demo"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
<field name="date_start" eval="(DateTime.today() + relativedelta(years=-2, month=1, day=1))"/>
|
||||
<field name="date_end" eval="(DateTime.today() + relativedelta(years=-1, month=1, day=1, days=-2))"/>
|
||||
<field name="resource_calendar_id" model="resource.calendar" eval="obj().search([('company_id', '=', obj().env.ref('l10n_{{code}}_hr_payroll.res_company_{{code}}').id)], limit=1)"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_hr_contract_cdi_antonina" model="hr.contract">
|
||||
<field name="name">CDI - Antonina Kaczmarczyk - Experienced Developer</field>
|
||||
<field name="department_id" ref="hr_department_rd{{code}}"/>
|
||||
<field name="employee_id" ref="hr_employee_antonina"/>
|
||||
<field name="job_id" ref="l10n_{{code}}_hr_payroll.job_developer_{{name}}"/>
|
||||
<field name="structure_type_id" ref="l10n_{{code}}_hr_payroll.l10n_{{code}}_structure_type_employee_{{code}}"/>
|
||||
<field name="wage">3600</field>
|
||||
<field name="state">open</field>
|
||||
<field name="hr_responsible_id" ref="base.user_demo"/>
|
||||
<field name="company_id" ref="l10n_{{code}}_hr_payroll.res_company_{{code}}"/>
|
||||
<field name="date_start" eval="(DateTime.today() + relativedelta(years=-1, month=1, day=1, days=-1))"/>
|
||||
<field name="resource_calendar_id" model="resource.calendar" eval="obj().search([('company_id', '=', obj().env.ref('l10n_{{code}}_hr_payroll.res_company_{{code}}').id)], limit=1)"/>
|
||||
</record>
|
||||
|
||||
<record id="l10n_{{code}}_hr_employee_antonina" model="hr.employee">
|
||||
<field name="contract_id" ref="l10n_{{code}}_hr_payroll.hr_contract_cdi_antonina"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_payslip
|
||||
from . import hr_contract
|
||||
from . import hr_payslip_worked_days
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrContract(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
l10n_{{code}}_field = fields.Char()
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
def _get_data_files_to_update(self):
|
||||
# Note: file order should be maintained
|
||||
return super()._get_data_files_to_update() + [(
|
||||
'l10n_{{code}}_hr_payroll', [
|
||||
'data/hr_salary_rule_category_data.xml',
|
||||
'data/hr_payroll_structure_type_data.xml',
|
||||
'data/hr_payroll_structure_data.xml',
|
||||
'data/hr_rule_parameters_data.xml',
|
||||
'data/hr_salary_rule_data.xml',
|
||||
])]
|
||||
|
||||
def _get_base_local_dict(self):
|
||||
res = super()._get_base_local_dict()
|
||||
res.update({
|
||||
'compute_some_property': compute_some_property,
|
||||
})
|
||||
return res
|
||||
|
||||
def compute_some_property(payslip, parameter):
|
||||
gross = payslip.result_rules.GROSS
|
||||
return gross * parameter
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{%- set codeUpper = code|upper -%}
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class HrPayslipWorkedDays(models.Model):
|
||||
_inherit = 'hr.payslip.worked_days'
|
||||
|
||||
def _compute_amount(self):
|
||||
super()._compute_amount()
|
||||
for worked_day in self:
|
||||
if worked_day.payslip_id.state in ['draft', 'verify'] \
|
||||
and not worked_day.payslip_id.edited \
|
||||
and worked_day.payslip_id.wage_type == "monthly" \
|
||||
and worked_day.payslip_id.struct_id.country_id.code == "{{codeUpper}}" \
|
||||
and worked_day.is_paid \
|
||||
and worked_day.work_entry_type_id.code == "LEAVE110":
|
||||
worked_day.amount *= 0.80
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{%- set countryPascal = name|pascal -%}
|
||||
<odoo>
|
||||
<record id="l10n_{{code}}_action_report_payslip_{{code}}" model="ir.actions.report">
|
||||
<field name="name">{{countryPascal}}: Payslip</field>
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">l10n_{{code}}_hr_payroll.report_payslip_{{code}}_lang</field>
|
||||
<field name="report_file">l10n_{{code}}_hr_payroll.report_payslip_{{code}}_lang</field>
|
||||
<field name="print_report_name">'Payslip - %s' % (object.employee_id.name)</field>
|
||||
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip"/>
|
||||
<field name="binding_type">report</field>
|
||||
<!-- Erase fields to avoid double PDF posting -->
|
||||
<field name="attachment"></field>
|
||||
<field name="attachment_use" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<odoo>
|
||||
<template id="l10n_{{code}}_report_payslip_{{code}}" inherit_id="hr_payroll.report_payslip" primary="True">
|
||||
|
||||
</template>
|
||||
|
||||
<template id="l10n_{{code}}_report_payslip_{{code}}_lang">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="o">
|
||||
<t t-set="o" t-value="o.with_context(lang=o.employee_id.sudo().lang or o.env.lang)"/>
|
||||
<t t-call="l10n_{{code}}_hr_payroll.report_payslip_{{code}}" t-lang="o.env.lang"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
1010
odoo-bringout-oca-ocb-base/odoo/tests/form.py
Normal file
98
odoo-bringout-oca-ocb-base/odoo/tests/shell.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
__all__ = ['run_tests']
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from psycopg2.extensions import STATUS_READY
|
||||
|
||||
import odoo
|
||||
from odoo.modules.registry import Registry
|
||||
|
||||
from .loader import make_suite, run_suite
|
||||
from .result import OdooTestResult
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
TEST_MODULE_NAME_PATTERN = re.compile(r'^odoo\.addons\.\w+\.tests')
|
||||
|
||||
|
||||
def run_tests(env, test_tags, modules=None, reload_tests=False):
|
||||
"""Run tests for the given modules and test tags."""
|
||||
|
||||
if odoo.cli.COMMAND != 'shell':
|
||||
_logger.error('run_tests should be used only in odoo shell')
|
||||
return
|
||||
|
||||
if odoo.tools.config['workers'] != 0:
|
||||
_logger.error('run_tests should be used only in threaded mode')
|
||||
return
|
||||
|
||||
from odoo.service.server import server # noqa: PLC0415
|
||||
if not server.httpd:
|
||||
# some tests need the http daemon to be available...
|
||||
server.http_spawn()
|
||||
|
||||
if env.cr._cnx.status != STATUS_READY:
|
||||
# rollback the cr in case it holds a database lock which may cause deadlock while running tests
|
||||
_logger.warning("Rolling backin the transaction before testing")
|
||||
env.cr.rollback()
|
||||
|
||||
if not modules:
|
||||
modules = sorted(env.registry._init_modules)
|
||||
|
||||
if reload_tests:
|
||||
_clear_loaded_test_modules()
|
||||
|
||||
odoo.tools.config['test_tags'] = test_tags
|
||||
odoo.tools.config['test_enable'] = True
|
||||
report = _run_tests(env.cr.dbname, modules)
|
||||
odoo.tools.config['test_enable'] = None
|
||||
odoo.tools.config['test_tags'] = None
|
||||
|
||||
_log_test_report(report)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _run_tests(db_name, modules):
|
||||
report = OdooTestResult()
|
||||
|
||||
# Run at_install tests
|
||||
with Registry._lock:
|
||||
registry = Registry(db_name)
|
||||
try:
|
||||
# best effort to restore the test environment
|
||||
registry.loaded = False
|
||||
registry.ready = False
|
||||
at_install_suite = make_suite(modules, 'at_install')
|
||||
if at_install_suite.countTestCases():
|
||||
_logger.info("Starting at_install tests")
|
||||
report.update(run_suite(at_install_suite, report))
|
||||
finally:
|
||||
registry.loaded = True
|
||||
registry.ready = True
|
||||
|
||||
# Run post_install tests
|
||||
post_install_suite = make_suite(modules, 'post_install')
|
||||
if post_install_suite.countTestCases():
|
||||
_logger.info("Starting post_install tests")
|
||||
report.update(run_suite(post_install_suite, report))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _clear_loaded_test_modules():
|
||||
"""Clear loaded test modules that may have been modified."""
|
||||
for module_key in list(sys.modules):
|
||||
if TEST_MODULE_NAME_PATTERN.match(module_key):
|
||||
_logger.debug("Removing module from sys.modules for reload: %s", module_key)
|
||||
del sys.modules[module_key]
|
||||
|
||||
|
||||
def _log_test_report(report):
|
||||
if not report.wasSuccessful():
|
||||
_logger.error('Tests failed: %s', report)
|
||||
elif not report.testsRun:
|
||||
_logger.warning('No tests executed: %s', report)
|
||||
else:
|
||||
_logger.info('Tests passed: %s', report)
|
||||
10
odoo-bringout-oca-ocb-base/odoo/tools/constants.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
SCRIPT_EXTENSIONS = ('js',)
|
||||
STYLE_EXTENSIONS = ('css', 'scss', 'sass', 'less')
|
||||
TEMPLATE_EXTENSIONS = ('xml',)
|
||||
ASSET_EXTENSIONS = SCRIPT_EXTENSIONS + STYLE_EXTENSIONS + TEMPLATE_EXTENSIONS
|
||||
|
||||
SUPPORTED_DEBUGGER = {'pdb', 'ipdb', 'wdb', 'pudb'}
|
||||
EXTERNAL_ASSET = object()
|
||||
584
odoo-bringout-oca-ocb-base/odoo/tools/pdf/__init__.py
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import importlib
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from hashlib import md5
|
||||
from logging import getLogger
|
||||
from zlib import compress, decompress, decompressobj
|
||||
|
||||
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
|
||||
|
||||
from odoo.tools.parse_version import parse_version
|
||||
from odoo.tools.misc import file_open
|
||||
|
||||
try:
|
||||
import fontTools
|
||||
from fontTools.ttLib import TTFont
|
||||
except ImportError:
|
||||
TTFont = None
|
||||
|
||||
|
||||
# might be a good case for exception groups
|
||||
error = None
|
||||
# keep pypdf2 2.x first so noble uses that rather than pypdf 4.0
|
||||
for SUBMOD in ['._pypdf2_2', '._pypdf', '._pypdf2_1']:
|
||||
try:
|
||||
pypdf = importlib.import_module(SUBMOD, __spec__.name)
|
||||
break
|
||||
except ImportError as e:
|
||||
if error is None:
|
||||
error = e
|
||||
else:
|
||||
raise ImportError("pypdf implementation not found") from error
|
||||
del error
|
||||
|
||||
PdfReaderBase, PdfWriter, filters, generic, errors, create_string_object =\
|
||||
pypdf.PdfReader, pypdf.PdfWriter, pypdf.filters, pypdf.generic, pypdf.errors, pypdf.create_string_object
|
||||
# because they got re-exported
|
||||
ArrayObject, BooleanObject, ByteStringObject, DecodedStreamObject, DictionaryObject, IndirectObject, NameObject, NumberObject =\
|
||||
generic.ArrayObject, generic.BooleanObject, generic.ByteStringObject, generic.DecodedStreamObject, generic.DictionaryObject, generic.IndirectObject, generic.NameObject, generic.NumberObject
|
||||
|
||||
# compatibility aliases
|
||||
PdfReadError = errors.PdfReadError # moved in 2.0
|
||||
PdfStreamError = errors.PdfStreamError # moved in 2.0
|
||||
createStringObject = create_string_object # deprecated in 2.0, removed in 5.0
|
||||
try:
|
||||
DependencyError = errors.DependencyError
|
||||
except AttributeError:
|
||||
DependencyError = NotImplementedError
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# PyPDF2 hack
|
||||
# ensure that zlib does not throw error -5 when decompressing
|
||||
# because some pdf won't fit into allocated memory
|
||||
# https://docs.python.org/3/library/zlib.html#zlib.decompressobj
|
||||
# ----------------------------------------------------------
|
||||
pypdf.filters.decompress = lambda data: decompressobj().decompress(data)
|
||||
|
||||
|
||||
# monkey patch to discard unused arguments as the old arguments were not discarded in the transitional class
|
||||
# This keep the old default value of the `strict` argument
|
||||
# https://github.com/py-pdf/pypdf/blob/1.26.0/PyPDF2/pdf.py#L1061
|
||||
# https://pypdf2.readthedocs.io/en/2.0.0/_modules/PyPDF2/_reader.html#PdfReader
|
||||
class PdfReader(PdfReaderBase):
|
||||
def __init__(self, stream, strict=True, *args, **kwargs):
|
||||
super().__init__(stream, strict)
|
||||
|
||||
|
||||
# Ensure that PdfFileReader and PdfFileWriter are available in case it's still used somewhere
|
||||
PdfFileReader = pypdf.PdfFileReader = PdfReader
|
||||
pypdf.PdfFileWriter = PdfWriter
|
||||
|
||||
_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
|
||||
|
||||
|
||||
if hasattr(PdfWriter, 'write_stream'):
|
||||
# >= 2.x has a utility `write` which can open a path, so `write_stream` could be called directly
|
||||
class BrandedFileWriter(PdfWriter):
|
||||
def write_stream(self, *args, **kwargs):
|
||||
self.add_metadata({
|
||||
'/Creator': "Odoo",
|
||||
'/Producer': "Odoo",
|
||||
})
|
||||
super().write_stream(*args, **kwargs)
|
||||
else:
|
||||
# 1.x has a monolithic write method
|
||||
class BrandedFileWriter(PdfWriter):
|
||||
def write(self, *args, **kwargs):
|
||||
self.addMetadata({
|
||||
'/Creator': "Odoo",
|
||||
'/Producer': "Odoo",
|
||||
})
|
||||
super().write(*args, **kwargs)
|
||||
|
||||
|
||||
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 fill_form_fields_pdf(writer, form_fields):
|
||||
''' Fill in the form fields of a PDF
|
||||
:param writer: a PdfFileWriter object
|
||||
:param dict form_fields: a dictionary of form fields to update in the PDF
|
||||
:return: a filled PDF datastring
|
||||
'''
|
||||
|
||||
# This solves a known problem with PyPDF2, where with some pdf software, forms fields aren't
|
||||
# correctly filled until the user click on it, see: https://github.com/py-pdf/pypdf/issues/355
|
||||
if hasattr(writer, 'set_need_appearances_writer'):
|
||||
writer.set_need_appearances_writer()
|
||||
is_upper_version_pypdf2 = True
|
||||
else: # This method was renamed in PyPDF2 2.0
|
||||
is_upper_version_pypdf2 = False
|
||||
catalog = writer._root_object
|
||||
# get the AcroForm tree
|
||||
if "/AcroForm" not in catalog:
|
||||
writer._root_object.update({
|
||||
NameObject("/AcroForm"): IndirectObject(len(writer._objects), 0, writer)
|
||||
})
|
||||
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(True)
|
||||
|
||||
nbr_pages = len(writer.pages) if is_upper_version_pypdf2 else writer.getNumPages()
|
||||
|
||||
for page_id in range(0, nbr_pages):
|
||||
page = writer.getPage(page_id)
|
||||
|
||||
if is_upper_version_pypdf2:
|
||||
writer.update_page_form_field_values(page, form_fields)
|
||||
else:
|
||||
# Known bug on previous versions of PyPDF2, fixed in 2.11
|
||||
if not page.get('/Annots'):
|
||||
_logger.info("No fields to update in this page")
|
||||
else:
|
||||
try:
|
||||
writer.updatePageFormFieldValues(page, form_fields)
|
||||
except ValueError:
|
||||
# Known bug on previous versions of PyPDF2 for some PDFs, fixed in 2.4.2
|
||||
_logger.info("Fields couldn't be filled in this page.")
|
||||
continue
|
||||
|
||||
for raw_annot in page.get('/Annots', []):
|
||||
annot = raw_annot.getObject()
|
||||
for field in form_fields:
|
||||
# Modifying the form flags to force all text fields read-only
|
||||
if annot.get('/T') == field:
|
||||
form_flags = annot.get('/Ff', 0)
|
||||
readonly_flag = 1 # 1st bit sets readonly
|
||||
new_flags = form_flags | readonly_flag
|
||||
annot.update({NameObject("/Ff"): NumberObject(new_flags)})
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 p in file_path[1::2]:
|
||||
attachment = p.getObject()
|
||||
yield (attachment["/F"], attachment["/EF"]["/F"].getObject().getData())
|
||||
except Exception: # noqa: BLE001
|
||||
# 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 format_subtype(self, subtype):
|
||||
"""
|
||||
Apply the correct format to the subtype.
|
||||
It should take the form of "/xxx#2Fxxx". E.g. for "text/xml": "/text#2Fxml"
|
||||
:param subtype: The mime-type of the attachement.
|
||||
"""
|
||||
if not subtype:
|
||||
return subtype
|
||||
|
||||
adapted_subtype = subtype
|
||||
if REGEX_SUBTYPE_UNFORMATED.match(subtype):
|
||||
# _pypdf2_2 does the formating when creating a NameObject
|
||||
if SUBMOD == '._pypdf2_2':
|
||||
return '/' + 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 = ''
|
||||
return adapted_subtype
|
||||
|
||||
def add_attachment(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.
|
||||
"""
|
||||
adapted_subtype = self.format_subtype(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
|
||||
})
|
||||
addAttachment = add_attachment
|
||||
|
||||
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.is_pdfa = True
|
||||
# This is broken in pypdf 3+ and pypdf2 has been automatically
|
||||
# writing a binary comment since 1.27
|
||||
# py-pdf/pypdf@036789a4664e3f572292bc7dceec10f08b7dbf62 so we
|
||||
# only need this if running on 1.x
|
||||
#
|
||||
# incidentally that means the heuristic above is completely broken
|
||||
if SUBMOD == '._pypdf2_1':
|
||||
self._header += second_line
|
||||
# clone_reader_document_root clones reader._ID since 3.2 (py-pdf/pypdf#1520)
|
||||
if not hasattr(self, '_ID'):
|
||||
# Look if we have an ID in the incoming stream and use it.
|
||||
self._set_id(reader.trailer.get('/ID', None))
|
||||
|
||||
def _set_id(self, pdf_id):
|
||||
if not pdf_id:
|
||||
return
|
||||
|
||||
# property in pypdf
|
||||
if hasattr(type(self), '_ID'):
|
||||
self.trailers['/ID'] = pdf_id
|
||||
else:
|
||||
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"
|
||||
if SUBMOD != '._pypdf2_2':
|
||||
self._header += b"\n"
|
||||
if SUBMOD == '._pypdf2_1':
|
||||
self._header += b"%\xDE\xAD\xBE\xEF"
|
||||
|
||||
# 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._set_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
|
||||
if parse_version(fontTools.__version__) < parse_version('4.37.2'):
|
||||
glyphs = ttfont.getGlyphSet()._hmtx.metrics
|
||||
else:
|
||||
glyphs = ttfont.getGlyphSet().hMetrics
|
||||
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)
|
||||
80
odoo-bringout-oca-ocb-base/odoo/tools/pdf/_pypdf.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from typing import Dict, Any
|
||||
|
||||
import pypdf
|
||||
from pypdf import errors, filters, generic, PdfReader as _Reader, PdfWriter as _Writer
|
||||
from pypdf.generic import create_string_object
|
||||
|
||||
__all__ = [
|
||||
"PdfReader",
|
||||
"PdfWriter",
|
||||
"create_string_object",
|
||||
"errors",
|
||||
"filters",
|
||||
"generic",
|
||||
]
|
||||
|
||||
|
||||
pypdf.PageObject.mergePage = lambda self, page2: self.merge_page(page2)
|
||||
pypdf.PageObject.mediaBox = property(lambda self: self.mediabox)
|
||||
# use lambdas (rather than copying) to allow overrides of the base method
|
||||
generic.PdfObject.getObject = lambda self: self.get_object()
|
||||
generic.StreamObject.getData = lambda self: self.get_data()
|
||||
generic.StreamObject.setData = lambda self, data: self.set_data(data)
|
||||
generic.RectangleObject.getWidth = lambda self: self.width
|
||||
generic.RectangleObject.getHeight = lambda self: self.height
|
||||
|
||||
|
||||
class PdfReader(_Reader):
|
||||
@property
|
||||
def isEncrypted(self):
|
||||
return self.is_encrypted
|
||||
|
||||
def getPage(self, pageNumber):
|
||||
return self.pages[pageNumber]
|
||||
|
||||
def getNumPages(self):
|
||||
return len(self.pages)
|
||||
|
||||
@property
|
||||
def numPages(self):
|
||||
return len(self.pages)
|
||||
|
||||
def getDocumentInfo(self):
|
||||
return self.metadata
|
||||
|
||||
|
||||
class PdfWriter(_Writer):
|
||||
def add_metadata(self, infos: Dict[str, Any]) -> None:
|
||||
if hasattr(self, '_info') and self._info is None:
|
||||
self._info = generic.DictionaryObject()
|
||||
super().add_metadata(infos)
|
||||
|
||||
def getPage(self, pageNumber):
|
||||
return self.pages[pageNumber]
|
||||
|
||||
def getNumPages(self):
|
||||
return len(self.pages)
|
||||
|
||||
def addPage(self, page):
|
||||
return self.add_page(page)
|
||||
|
||||
def appendPagesFromReader(self, reader):
|
||||
return self.append_pages_from_reader(reader)
|
||||
|
||||
def addBlankPage(self, width=None, height=None):
|
||||
return self.add_blank_page(width=width, height=height)
|
||||
|
||||
def addAttachment(self, fname, data):
|
||||
return self.add_attachment(fname, data)
|
||||
|
||||
def addMetadata(self, infos):
|
||||
return self.add_metadata(infos)
|
||||
|
||||
def cloneReaderDocumentRoot(self, reader):
|
||||
return self.clone_reader_document_root(reader)
|
||||
|
||||
def getFields(self, *args, **kwargs):
|
||||
return self.get_fields(*args, **kwargs)
|
||||
|
||||
def _addObject(self, *args, **kwargs):
|
||||
return self._add_object(*args, **kwargs)
|
||||
26
odoo-bringout-oca-ocb-base/odoo/tools/pdf/_pypdf2_1.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from PyPDF2 import filters, generic, utils as errors, PdfFileReader, PdfFileWriter
|
||||
from PyPDF2.generic import createStringObject as create_string_object
|
||||
|
||||
__all__ = [
|
||||
"PdfReader",
|
||||
"PdfWriter",
|
||||
"create_string_object",
|
||||
"errors",
|
||||
"filters",
|
||||
"generic",
|
||||
]
|
||||
|
||||
# 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
|
||||
class PdfReader(PdfFileReader):
|
||||
def __init__(self, stream, strict=True, warndest=None, overwriteWarnings=True):
|
||||
super().__init__(stream, strict=strict, warndest=warndest, overwriteWarnings=False)
|
||||
|
||||
|
||||
class PdfWriter(PdfFileWriter):
|
||||
def get_fields(self, *args, **kwargs):
|
||||
return self.getFields(*args, **kwargs)
|
||||
|
||||
def _add_object(self, *args, **kwargs):
|
||||
return self._addObject(*args, **kwargs)
|
||||
19
odoo-bringout-oca-ocb-base/odoo/tools/pdf/_pypdf2_2.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from PyPDF2 import errors, filters, generic, PdfReader, PdfWriter as _Writer
|
||||
from PyPDF2.generic import create_string_object
|
||||
|
||||
__all__ = [
|
||||
"PdfReader",
|
||||
"PdfWriter",
|
||||
"create_string_object",
|
||||
"errors",
|
||||
"filters",
|
||||
"generic",
|
||||
]
|
||||
|
||||
|
||||
class PdfWriter(_Writer):
|
||||
def getFields(self, *args, **kwargs):
|
||||
return self.get_fields(*args, **kwargs)
|
||||
|
||||
def _addObject(self, *args, **kwargs):
|
||||
return self._add_object(*args, **kwargs)
|
||||