17.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:05:45 +02:00
parent df627a6bba
commit d72e748793
66 changed files with 116028 additions and 0 deletions

View file

@ -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>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIByTCCAXugAwIBAgIUXOt3OuI/y68E6ogXPRKjMxM3d4kwBQYDK2VwMFExCzAJ
BgNVBAYTAkJFMRQwEgYDVQQHDAtIb3V0ZXNpcGxvdTEsMCoGA1UEAwwjSG91dGVz
aXBsb3UgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwIBcNMjQwNDIyMTUxNDU3WhgP
MzAyMzA4MjQxNTE0NTdaMFExCzAJBgNVBAYTAkJFMRQwEgYDVQQHDAtIb3V0ZXNp
cGxvdTEsMCoGA1UEAwwjSG91dGVzaXBsb3UgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
dHkwKjAFBgMrZXADIQA9E6L8dKzknznGYYkNMVRt78Rs8L5sIOLFaJHfzfpcdaNj
MGEwHQYDVR0OBBYEFOCOFRwpR4cKRG3au4pqU5tc8ItOMB8GA1UdIwQYMBaAFOCO
FRwpR4cKRG3au4pqU5tc8ItOMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD
AgGGMAUGAytlcANBAMDS7x9X9IIM1jCQdzTibtVu14mZmXoBJ8GoFMojUSvyz86y
81vpi1ZEOZoZm/HadUBlONvL4aGqbu0t6yABeA8=
-----END CERTIFICATE-----

View file

@ -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-----

View file

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEICS5j5g1kNSbzhCRT7eDN4k12P0x5n8tg6HLPcCuY7DD
-----END PRIVATE KEY-----

View file

@ -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-----

View file

@ -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-----

View file

@ -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-----

View file

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIHSVKSyvMJwmNHpoMj3BLlBy40bQ2vJJyUNknXcyPUkO
-----END PRIVATE KEY-----

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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()))

View 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)

View file

@ -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)

View file

@ -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>

View file

@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

View file

@ -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',
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 &lt;= taxable &lt; 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 &gt;= 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>

View file

@ -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>

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

File diff suppressed because it is too large Load diff

View 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)

View 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()

View 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)

View 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)

View 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)

View 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)