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>