Public release from ruodoo-project: 19.0 - 2026-05-31 21:19:12 UTC
This commit is contained in:
30
access_apps/README.rst
Normal file
30
access_apps/README.rst
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
.. image:: https://itpp.dev/images/infinity-readme.png
|
||||||
|
:alt: Tested and maintained by IT Projects Labs
|
||||||
|
:target: https://itpp.dev
|
||||||
|
|
||||||
|
========================
|
||||||
|
Control access to Apps
|
||||||
|
========================
|
||||||
|
|
||||||
|
Allows to configure administrators which don't have access to Apps.
|
||||||
|
|
||||||
|
Adds **Apps access** selection in user's access rights tab. Two options are available:
|
||||||
|
|
||||||
|
* *Allow installing apps*
|
||||||
|
* *Allow installing apps only from settings*
|
||||||
|
|
||||||
|
Questions?
|
||||||
|
==========
|
||||||
|
|
||||||
|
To get an assistance on this module contact us by email :arrow_right: help@itpp.dev
|
||||||
|
|
||||||
|
Contributors
|
||||||
|
============
|
||||||
|
* `Ivan Yelizariev <https://it-projects.info/team/yelizariev>`__
|
||||||
|
* `Ildar Nasyrov <https://it-projects.info/team/iledarn>`__
|
||||||
|
|
||||||
|
|
||||||
|
Further information
|
||||||
|
===================
|
||||||
|
|
||||||
|
Tested on `Odoo 17.0 <https://github.com/odoo/odoo/commit/40b19d89846303016098840f4958fe7cc105067c>`_
|
||||||
2
access_apps/__init__.py
Normal file
2
access_apps/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from .hooks import uninstall_hook
|
||||||
29
access_apps/__manifest__.py
Normal file
29
access_apps/__manifest__.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Copyright 2018,2021 Ivan Yelizariev <https://it-projects.info/team/yelizariev>
|
||||||
|
# Copyright 2018 Ildar Nasyrov <https://it-projects.info/team/iledarn>
|
||||||
|
# License MIT (https://opensource.org/licenses/MIT).
|
||||||
|
{
|
||||||
|
"name": """Control access to Apps""",
|
||||||
|
"summary": """You can configure administrators which don't have access to Apps""",
|
||||||
|
"category": "Extra Tools",
|
||||||
|
# "live_test_url": "",
|
||||||
|
"images": ["images/banner.png"],
|
||||||
|
"version": "19.0.2.0.0",
|
||||||
|
"application": False,
|
||||||
|
"author": "IT-Projects LLC, Ivan Yelizariev",
|
||||||
|
"support": "apps@itpp.dev",
|
||||||
|
"website": "https://twitter.com/OdooFree",
|
||||||
|
"license": "Other OSI approved licence", # MIT
|
||||||
|
# "price": 10.00,
|
||||||
|
"currency": "EUR",
|
||||||
|
"depends": ["access_restricted"],
|
||||||
|
"external_dependencies": {"python": [], "bin": []},
|
||||||
|
"data": ["security/access_apps_security.xml", "security/ir.model.access.csv"],
|
||||||
|
"demo": [],
|
||||||
|
"qweb": [],
|
||||||
|
"post_load": None,
|
||||||
|
"pre_init_hook": None,
|
||||||
|
"post_init_hook": None,
|
||||||
|
"uninstall_hook": "uninstall_hook",
|
||||||
|
"auto_install": False,
|
||||||
|
"installable": True,
|
||||||
|
}
|
||||||
37
access_apps/doc/changelog.rst
Normal file
37
access_apps/doc/changelog.rst
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
`2.0.0`
|
||||||
|
-------
|
||||||
|
- **Fix:** restore original access rights on uninstallation
|
||||||
|
- **Fix:** error in Settings menu when some modules are installed
|
||||||
|
|
||||||
|
`1.3.3`
|
||||||
|
-------
|
||||||
|
- **Fix:** Grant `Allow installing apps` to Admin and System users (it was only System)
|
||||||
|
|
||||||
|
`1.3.2`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** allow users in group `Allow install apps only from settings` also to uninstall from settings
|
||||||
|
- **Fix:** `Allow install apps only from settings` security group should imply `Administration: Settings` - no access to settings otherwise
|
||||||
|
|
||||||
|
`1.3.1`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** `Access Error` when administrators without access to apps trying to open ``[[ Website Admin ]] >> Configuratiion >> Settings``
|
||||||
|
|
||||||
|
`1.3.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** the "Allow install apps" group is now implies "Administration: Settings" ("base.group_system") since in Odoo 11.0 only group_system users can install apps
|
||||||
|
|
||||||
|
`1.2.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Improvement:** rename "Show Apps Menu" to "Allow install apps"
|
||||||
|
- **New:** "Allow install apps only from settings"
|
||||||
|
- **Improvement:** group "Show Apps Menu" and "Allow install apps only from settings" under "Apps access" security category
|
||||||
|
|
||||||
|
`1.0.1`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** updates for recent odoo 9.0
|
||||||
|
- **Improvement:** apps dashboard can be showed if user has access 'Show Apps Menu'
|
||||||
32
access_apps/doc/index.rst
Normal file
32
access_apps/doc/index.rst
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
========================
|
||||||
|
Control access to Apps
|
||||||
|
========================
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
* `Install <https://odoo-development.readthedocs.io/en/latest/odoo/usage/install-module.html>`__ this module in a usual way
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
After the installation of this module by default only *default admin* and *superuser* have access to installing modules.
|
||||||
|
To update the permission follow for other users follow the steps below.
|
||||||
|
|
||||||
|
|
||||||
|
* Open menu ``[[ Settings ]] >> Users & Companies >> Users``, select the user you want to grant the access to
|
||||||
|
* On ``Access Rights`` tab, ``Other`` settings group there is an ``Apps access`` security category
|
||||||
|
|
||||||
|
* Select ``Allow installing apps`` - to allow apps installation from everywhere
|
||||||
|
* Select ``Allow installing apps only from settings`` - allow apps installation only from other module's ``Configuration >> Settings`` menu. More precisely, it just hides ``[[ Apps ]]`` menu
|
||||||
|
* Select blank line - to restrict application installation
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
* Be sure that you don't test the functionality under the ``Administrator`` (superuser with id=1) user - he is immune to any access restriction except of hiding menus or buttons in UI
|
||||||
|
* If you don't select anything in ``Apps access``: there is no ``[[ Apps ]]`` menu - even if your user is in ``Administration: Settings`` security group. Also note that you
|
||||||
|
have no ability to include yourself in groups allowing to installing apps - this is what ``access_restricted`` module does (``access_apps`` depends on it)
|
||||||
|
* If you have ``Allow installing apps`` selected: there is ``[[ Apps ]]`` menu
|
||||||
|
* If you have ``Allow installing apps only from settings``: from other module's ``Configuration >> Settings`` menu, e.g. from ``[[ Website ]] >> Configuration >> Settings`` see that
|
||||||
|
you have the ability to check the ``Digital Content`` checkbox that actually installs the ``website_sale_digital`` module after clicking on ``[Apply]`` button.
|
||||||
7
access_apps/hooks.py
Normal file
7
access_apps/hooks.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Copyright 2021,2023 Ivan Yelizariev <https://it-projects.info/team/yelizariev>
|
||||||
|
# License MIT (https://opensource.org/licenses/MIT).
|
||||||
|
def uninstall_hook(env):
|
||||||
|
access = env.ref(
|
||||||
|
"base.access_ir_module_module_group_user", raise_if_not_found=False
|
||||||
|
)
|
||||||
|
access.write({"active": True})
|
||||||
35
access_apps/i18n/access_apps.pot
Normal file
35
access_apps/i18n/access_apps.pot
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_apps
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 12.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"Last-Translator: <>\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps
|
||||||
|
msgid "Allow installing apps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps_only_from_settings
|
||||||
|
msgid "Allow installing apps only from settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.module.category,name:access_apps.module_category_access_apps
|
||||||
|
msgid "Apps access"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.model,name:access_apps.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
42
access_apps/i18n/de.po
Normal file
42
access_apps/i18n/de.po
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_apps
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Sergej Briesin <20bs18@gmail.com>, 2018
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 11.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"Last-Translator: Sergej Briesin <20bs18@gmail.com>, 2018\n"
|
||||||
|
"Language-Team: German (https://www.transifex.com/it-projects-llc/teams/76080/"
|
||||||
|
"de/)\n"
|
||||||
|
"Language: de\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Allow installing apps"
|
||||||
|
msgstr "Erlaube Apps zu installieren"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps_only_from_settings
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Allow installing apps only from settings"
|
||||||
|
msgstr "Erlaube Apps nur vom Einstellungen zu installieren"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.module.category,name:access_apps.module_category_access_apps
|
||||||
|
msgid "Apps access"
|
||||||
|
msgstr "Apps Zugriff"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.model,name:access_apps.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
45
access_apps/i18n/es.po
Normal file
45
access_apps/i18n/es.po
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_apps
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# charles paul requena palomino <rcharles84@gmail.com>, 2018
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 10.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-26 19:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2018-04-26 19:17+0000\n"
|
||||||
|
"Last-Translator: charles paul requena palomino <rcharles84@gmail.com>, 2018\n"
|
||||||
|
"Language-Team: Spanish (https://www.transifex.com/it-projects-llc/"
|
||||||
|
"teams/76080/es/)\n"
|
||||||
|
"Language: es\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Allow installing apps"
|
||||||
|
msgstr "Permitir instalar aplicaciones solo desde la configuración"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps_only_from_settings
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Allow installing apps only from settings"
|
||||||
|
msgstr "Permitir instalar aplicaciones solo desde la configuración"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.module.category,name:access_apps.module_category_access_apps
|
||||||
|
msgid "Apps access"
|
||||||
|
msgstr "Acceso a aplicaciones"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.model,name:access_apps.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#~ msgid "Access to Modules"
|
||||||
|
#~ msgstr "Acceso a los módulos"
|
||||||
43
access_apps/i18n/es_CR.po
Normal file
43
access_apps/i18n/es_CR.po
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_apps
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Randall <randall_castro@me.com>, 2018
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 11.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2017-11-28 13:02+0000\n"
|
||||||
|
"Last-Translator: Randall <randall_castro@me.com>, 2018\n"
|
||||||
|
"Language-Team: Spanish (Costa Rica) (https://www.transifex.com/it-projects-"
|
||||||
|
"llc/teams/76080/es_CR/)\n"
|
||||||
|
"Language: es_CR\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Allow installing apps"
|
||||||
|
msgstr "Permitir instalar aplicaciones"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:res.groups,name:access_apps.group_allow_apps_only_from_settings
|
||||||
|
msgid "Allow installing apps only from settings"
|
||||||
|
msgstr "Permitir instalar aplicaciones sólo desde ajustes"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.module.category,name:access_apps.module_category_access_apps
|
||||||
|
msgid "Apps access"
|
||||||
|
msgstr "Acceso aplicaciones"
|
||||||
|
|
||||||
|
#. module: access_apps
|
||||||
|
#: model:ir.model,name:access_apps.model_res_config_settings
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr "res.config.settings"
|
||||||
BIN
access_apps/images/banner.png
Normal file
BIN
access_apps/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
1
access_apps/models/__init__.py
Normal file
1
access_apps/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import res_config
|
||||||
70
access_apps/models/res_config.py
Normal file
70
access_apps/models/res_config.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Copyright 2018 Ivan Yelizariev <https://it-projects.info/team/yelizariev>
|
||||||
|
# Copyright 2018 Ildar Nasyrov <https://it-projects.info/team/iledarn>
|
||||||
|
# License MIT (https://opensource.org/licenses/MIT).
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = "res.config.settings"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _install_modules(self, modules):
|
||||||
|
if self.env.user.has_group(
|
||||||
|
"access_apps.group_allow_apps_only_from_settings"
|
||||||
|
):
|
||||||
|
self = self.sudo()
|
||||||
|
|
||||||
|
return super(ResConfigSettings, self)._install_modules(modules)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields):
|
||||||
|
# We restricted any access to apps by default (`ir.module.module`) but in `website_sale` module configuration
|
||||||
|
# there is a field that gets its default value by searching in apps.
|
||||||
|
# Without this there is a possibility to encounter the `Access Error` when trying to open settings
|
||||||
|
# - e.g. when administrators without access to apps open ``[[ Website Admin ]] >> Configuration >> Settings``
|
||||||
|
|
||||||
|
# TODO: this solution may lead to unexpected result
|
||||||
|
# if some of default methods uses self self.env.user to compute default value
|
||||||
|
res = super(ResConfigSettings, self.sudo()).default_get(fields)
|
||||||
|
|
||||||
|
# modules: which modules are installed/to install
|
||||||
|
classified = self._get_classified_fields()
|
||||||
|
for module in classified["to_uninstall"]:
|
||||||
|
name = f"module_{module.name}"
|
||||||
|
res[name] = module.state in ("installed", "to install", "to upgrade")
|
||||||
|
if self._fields[name].type == "selection":
|
||||||
|
res[name] = str(int(res[name]))
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_classified_fields(self, fnames=None):
|
||||||
|
# classify mudules to install and uninstall independently
|
||||||
|
res = super(ResConfigSettings, self)._get_classified_fields(fnames=fnames)
|
||||||
|
|
||||||
|
to_uninstall = res["module"].filtered(
|
||||||
|
lambda m: not self[f"module_{m.name}"]
|
||||||
|
and m.state in ("installed", "to upgrade")
|
||||||
|
)
|
||||||
|
|
||||||
|
modules = res["module"] - to_uninstall
|
||||||
|
|
||||||
|
res["module"] = modules
|
||||||
|
res["to_uninstall"] = to_uninstall
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
# base `exectute` doesn't know about new classification - it only has a list of modules to install now
|
||||||
|
res = super(ResConfigSettings, self).execute()
|
||||||
|
# uninstall modules if needed and a user has access
|
||||||
|
to_uninstall = self._get_classified_fields()["to_uninstall"]
|
||||||
|
if to_uninstall and self.env.user.has_group(
|
||||||
|
"access_apps.group_allow_apps_only_from_settings"
|
||||||
|
):
|
||||||
|
to_uninstall_modules = self.env["ir.module.module"]
|
||||||
|
for module in to_uninstall:
|
||||||
|
to_uninstall_modules += module
|
||||||
|
to_uninstall_modules.sudo().button_immediate_uninstall()
|
||||||
|
return res
|
||||||
32
access_apps/security/access_apps_security.xml
Normal file
32
access_apps/security/access_apps_security.xml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<!-- Copyright 2018,2021 Ivan Yelizariev <https://twitter.com/yelizariev>
|
||||||
|
Copyright 2018 Ildar Nasyrov <https://it-projects.info/team/iledarn>
|
||||||
|
License MIT (https://opensource.org/licenses/MIT).
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<record id="base.access_ir_module_module_group_user" model="ir.model.access">
|
||||||
|
<field name="active" eval="False" />
|
||||||
|
</record>
|
||||||
|
<record id="module_category_access_apps" model="ir.module.category">
|
||||||
|
<field name="name">Apps access</field>
|
||||||
|
<field name="sequence">18</field>
|
||||||
|
</record>
|
||||||
|
<record id="group_allow_apps_only_from_settings" model="res.groups">
|
||||||
|
<field name="name">Allow installing apps only from settings</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_system'))]" />
|
||||||
|
</record>
|
||||||
|
<record id="group_allow_apps" model="res.groups">
|
||||||
|
<field name="name">Allow installing apps</field>
|
||||||
|
<field
|
||||||
|
name="user_ids"
|
||||||
|
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="implied_ids"
|
||||||
|
eval="[(4, ref('group_allow_apps_only_from_settings'))]"
|
||||||
|
/>
|
||||||
|
</record>
|
||||||
|
<record model="ir.ui.menu" id="base.menu_management">
|
||||||
|
<field name="group_ids" eval="[(6,0, [ref('access_apps.group_allow_apps')])]" />
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
6
access_apps/security/ir.model.access.csv
Normal file
6
access_apps/security/ir.model.access.csv
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
||||||
|
"access_ir_module_category_group_user","ir_module_category group_user","base.model_ir_module_category","group_allow_apps",1,0,0,0
|
||||||
|
"access_ir_module_module_group_user","ir_module_module group_user","base.model_ir_module_module","group_allow_apps",1,1,1,1
|
||||||
|
"access_ir_module_module_dependency_group_allow_apps","ir_module_module_dependency group_allow_apps","base.model_ir_module_module_dependency","group_allow_apps",1,1,1,1
|
||||||
|
"access_ir_module_module_group_allow_apps_only_from_settings","ir_module_module_group_allow_apps_only_from_settings","base.model_ir_module_module","group_allow_apps_only_from_settings",1,1,0,0
|
||||||
|
"access_ir_module_module_all_can_read","ir_module_module_all_can_read","base.model_ir_module_module","base.group_system",1,0,0,0
|
||||||
|
BIN
access_apps/static/description/icon.png
Normal file
BIN
access_apps/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
1
access_apps/tests/__init__.py
Normal file
1
access_apps/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_access
|
||||||
64
access_apps/tests/test_access.py
Normal file
64
access_apps/tests/test_access.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Copyright 2024 DOB
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||||
|
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
from odoo.tests import new_test_user
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccessApps(TransactionCase):
|
||||||
|
"""Tests for access_apps: access control to the Apps section.
|
||||||
|
|
||||||
|
Validates: Requirement 6.1
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.env = cls.env(
|
||||||
|
context=dict(cls.env.context, tracking_disable=True, no_reset_password=True)
|
||||||
|
)
|
||||||
|
# Regular user without admin rights and without group_allow_apps
|
||||||
|
cls.regular_user = new_test_user(
|
||||||
|
cls.env,
|
||||||
|
name="Regular User",
|
||||||
|
login="test_regular_user_access_apps",
|
||||||
|
groups="base.group_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_admin_cannot_read_ir_module_module(self):
|
||||||
|
"""WHEN a user without admin rights tries to access the Apps section,
|
||||||
|
access_apps SHALL deny access.
|
||||||
|
|
||||||
|
Validates: Requirement 6.1
|
||||||
|
"""
|
||||||
|
# The module deactivates base.access_ir_module_module_group_user,
|
||||||
|
# so only users in group_allow_apps can read ir.module.module.
|
||||||
|
# A regular user (not in group_allow_apps) should get AccessError.
|
||||||
|
with self.assertRaises(AccessError):
|
||||||
|
self.env["ir.module.module"].with_user(self.regular_user).search([])
|
||||||
|
|
||||||
|
def test_admin_can_read_ir_module_module(self):
|
||||||
|
"""WHEN the admin user (in group_allow_apps) accesses the Apps section,
|
||||||
|
access_apps SHALL allow access.
|
||||||
|
|
||||||
|
Validates: Requirement 6.1 (positive case)
|
||||||
|
"""
|
||||||
|
# Admin is in group_allow_apps by default (see security XML)
|
||||||
|
admin_user = self.env.ref("base.user_admin")
|
||||||
|
modules = self.env["ir.module.module"].with_user(admin_user).search([], limit=1)
|
||||||
|
# Should not raise; result may be empty or non-empty
|
||||||
|
self.assertIsNotNone(modules)
|
||||||
|
|
||||||
|
def test_non_admin_not_in_group_allow_apps(self):
|
||||||
|
"""WHEN a regular user is not in group_allow_apps,
|
||||||
|
they SHALL NOT have access to ir.module.module.
|
||||||
|
|
||||||
|
Validates: Requirement 6.1
|
||||||
|
"""
|
||||||
|
group_allow_apps = self.env.ref("access_apps.group_allow_apps")
|
||||||
|
self.assertNotIn(
|
||||||
|
self.regular_user,
|
||||||
|
group_allow_apps.users,
|
||||||
|
"Regular user should not be in group_allow_apps",
|
||||||
|
)
|
||||||
27
access_restricted/README.rst
Normal file
27
access_restricted/README.rst
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
.. image:: https://itpp.dev/images/infinity-readme.png
|
||||||
|
:alt: Tested and maintained by IT Projects Labs
|
||||||
|
:target: https://itpp.dev
|
||||||
|
|
||||||
|
Restricted administration rights
|
||||||
|
================================
|
||||||
|
|
||||||
|
The module makes impossible for administrator to set (and see) more access rights (groups) than he already has.
|
||||||
|
The only partial exception of this rule is made if you are already a member of the *Allow add implied groups from settings* security group.
|
||||||
|
Then you are allowed to escalate your privileges but just from ``Settings`` menus (by means of ``group_XXX`` boolean fields of ``res.config.settings`` models views).
|
||||||
|
|
||||||
|
This doesn't affect superuser, of course.
|
||||||
|
|
||||||
|
Typical usage of the module
|
||||||
|
===========================
|
||||||
|
|
||||||
|
The superuser creates an administrator user without access group "Show Apps Menu" (see **access_apps** module). Then the administrator has access to settings, but not able to install new apps (without this module he can add himself to "Show Apps Menu" and get access to apps).
|
||||||
|
|
||||||
|
Roadmap
|
||||||
|
=======
|
||||||
|
|
||||||
|
* Settings menu shows group fields are not updated without *Allow add implied groups from settings* (ok), but it shows the fields as not editable (not ok)
|
||||||
|
|
||||||
|
Further information
|
||||||
|
===================
|
||||||
|
|
||||||
|
Tested on `Odoo 17.0 <https://github.com/odoo/odoo/commit/40b19d89846303016098840f4958fe7cc105067c>`_
|
||||||
1
access_restricted/__init__.py
Normal file
1
access_restricted/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
15
access_restricted/__manifest__.py
Normal file
15
access_restricted/__manifest__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Restricted administration rights",
|
||||||
|
"summary": "Apply strict restriction on what an admin can do",
|
||||||
|
"version": "19.0.1.3.5",
|
||||||
|
"author": "IT-Projects LLC, Ivan Yelizariev",
|
||||||
|
"category": "Extra Tools",
|
||||||
|
"images": ["images/banner.jpg"],
|
||||||
|
"support": "apps@itpp.dev",
|
||||||
|
"website": "https://twitter.com/OdooFree",
|
||||||
|
"license": "Other OSI approved licence", # MIT
|
||||||
|
"currency": "EUR",
|
||||||
|
"depends": ["ir_rule_protected"],
|
||||||
|
"data": ["security/access_restricted_security.xml"],
|
||||||
|
"installable": True,
|
||||||
|
}
|
||||||
50
access_restricted/doc/changelog.rst
Normal file
50
access_restricted/doc/changelog.rst
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
`1.3.5`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** Fixed possibility to write inappropriate groups directly via write method after some odoo updates https://github.com/odoo/odoo/commit/5f12e244f6e57b8edb56788147774150e2ae134d
|
||||||
|
|
||||||
|
`1.3.4`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** Allow superuser to write groups via settings menu
|
||||||
|
|
||||||
|
`1.3.3`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** If no permission to add groups then just ignore write operations to groups model records but apply any other valid settings. In other words - do not block rest of the settings from applying if there is only no permission to add groups
|
||||||
|
|
||||||
|
`1.3.2`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** regardless of ``Allow add implied groups from settings`` always allow to uncheck **group_XXX** fields from settings menu. This makes possible for ``access_apps`` module to independently install apps from settings menu. Otherwise users of ``access_apps`` need always be in ``Allow add implied...`` to install from settings
|
||||||
|
|
||||||
|
`1.3.1`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- **Fix:** a user, if he has ``Allow add implied groups from settings`` group access right, should be able to uncheck **group_XXX** fields from a settings menu to exit from implied groups (all other users that are in implying group also quit from the implied group)
|
||||||
|
|
||||||
|
`1.3.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- [ADD] security group that allows increasing rights from settings menu (by checking ``res.config.settings`` 'group_XXX' boolean fields)
|
||||||
|
|
||||||
|
`1.2.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- [REF] clean and simplify code
|
||||||
|
|
||||||
|
`1.1.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- ADD: Make restricted groups readonly in Settigs pages (res.config.settings)
|
||||||
|
- ADD: don't restrict access to Technical Settings group
|
||||||
|
|
||||||
|
`1.0.1`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- FIX: update to the latest odoo 9.0 version due to this comit from Mar 24, 2016 https://github.com/odoo/odoo/commit/40a299c580c4608edab8781fda4e66f39611543b
|
||||||
|
|
||||||
|
`1.0.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- init version
|
||||||
37
access_restricted/doc/index.rst
Normal file
37
access_restricted/doc/index.rst
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
==================================
|
||||||
|
Restricted administration rights
|
||||||
|
==================================
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
* `Install <https://odoo-development.readthedocs.io/en/latest/odoo/usage/install-module.html>`__ this module in a usual way
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
|
||||||
|
* `Log in as SUPERUSER <https://odoo-development.readthedocs.io/en/latest/odoo/usage/login-as-superuser.html>`__
|
||||||
|
* Navigate to menu ``[[ Settings ]] >> Users & Companies >> Users``
|
||||||
|
* In ``Access Rights`` tab you can select *Allow add implied groups from settings*:
|
||||||
|
it allows a user to configure groups via ``group_XXX`` fields from ``Settings`` menu.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
By default all users except a superuser restricted to escalate the privileges.
|
||||||
|
|
||||||
|
Let's take ``Sales (sale_management)`` module as an example.
|
||||||
|
|
||||||
|
Without this module installed:
|
||||||
|
|
||||||
|
* Say you have a user with administration rights ``Administration: Access Rights``. This user thus may increase his own rights in ``Application Accesses`` from ``Sales: User: Own Documents Only``
|
||||||
|
to ``Sales: Administrator``. Also he can open menu ``[[ Sales ]] >> Configuration >> Settings`` and select ``Customer Addresses`` there
|
||||||
|
and then click ``[Apply]`` button (adding ``group_sale_delivery_address``)
|
||||||
|
|
||||||
|
With this module installed:
|
||||||
|
|
||||||
|
* The user from previous example cannot increase his privileges. There is no ``Sales: Manager`` option for him, and also no ``Customer Addresses``
|
||||||
|
option in module configuration
|
||||||
|
* The only exception is done for users who are in special group *Allow add implied groups from settings*: if your user is included in this group by the superuser then you may select
|
||||||
|
``Customer Addresses`` from ``Sale`` module ``Configuration >> Settings`` menu
|
||||||
94
access_restricted/i18n/access_restricted.pot
Normal file
94
access_restricted/i18n/access_restricted.pot
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_restricted
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 12.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"Last-Translator: <>\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_config_settings.py:48
|
||||||
|
#, python-format
|
||||||
|
msgid "\n"
|
||||||
|
"\n"
|
||||||
|
"You don't have access to change this settings, because you administration rights are restricted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_groups
|
||||||
|
msgid "Access Groups"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:res.groups,name:access_restricted.group_allow_add_implied_from_settings
|
||||||
|
msgid "Allow add implied groups from settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_uid
|
||||||
|
msgid "Created by"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_date
|
||||||
|
msgid "Created on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__display_name
|
||||||
|
msgid "Display Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__group_private_addresses
|
||||||
|
msgid "Group Private Addresses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__id
|
||||||
|
msgid "ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings____last_update
|
||||||
|
msgid "Last Modified on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_uid
|
||||||
|
msgid "Last Updated by"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_date
|
||||||
|
msgid "Last Updated on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_users.py:95
|
||||||
|
#, python-format
|
||||||
|
msgid "You cannot add groups to Implied groups, because you are not allowed to increase your rights. Please contact your system administrator."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_test_config_settings
|
||||||
|
msgid "test.config.settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
105
access_restricted/i18n/de.po
Normal file
105
access_restricted/i18n/de.po
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_restricted
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Sergej Briesin <20bs18@gmail.com>, 2018
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 11.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"Last-Translator: Sergej Briesin <20bs18@gmail.com>, 2018\n"
|
||||||
|
"Language-Team: German (https://www.transifex.com/it-projects-llc/teams/76080/"
|
||||||
|
"de/)\n"
|
||||||
|
"Language: de\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_config_settings.py:48
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"You don't have access to change this settings, because you administration "
|
||||||
|
"rights are restricted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_groups
|
||||||
|
msgid "Access Groups"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:res.groups,name:access_restricted.group_allow_add_implied_from_settings
|
||||||
|
msgid "Allow add implied groups from settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_uid
|
||||||
|
msgid "Created by"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_date
|
||||||
|
msgid "Created on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__display_name
|
||||||
|
msgid "Display Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__group_private_addresses
|
||||||
|
msgid "Group Private Addresses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__id
|
||||||
|
msgid "ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings____last_update
|
||||||
|
msgid "Last Modified on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_uid
|
||||||
|
msgid "Last Updated by"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_date
|
||||||
|
msgid "Last Updated on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_users.py:95
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The requested operation cannot be completed due to security restrictions. "
|
||||||
|
"Please contact your system administrator.\n"
|
||||||
|
"\n"
|
||||||
|
"(Document type: %s, Operation: %s)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Nutzer"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_test_config_settings
|
||||||
|
msgid "test.config.settings"
|
||||||
|
msgstr ""
|
||||||
105
access_restricted/i18n/es.po
Normal file
105
access_restricted/i18n/es.po
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_restricted
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# charles paul requena palomino <rcharles84@gmail.com>, 2018
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 10.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-26 19:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2018-04-26 19:17+0000\n"
|
||||||
|
"Last-Translator: charles paul requena palomino <rcharles84@gmail.com>, 2018\n"
|
||||||
|
"Language-Team: Spanish (https://www.transifex.com/it-projects-llc/"
|
||||||
|
"teams/76080/es/)\n"
|
||||||
|
"Language: es\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_config_settings.py:48
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"You don't have access to change this settings, because you administration "
|
||||||
|
"rights are restricted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_groups
|
||||||
|
msgid "Access Groups"
|
||||||
|
msgstr "Grupos de acceso"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:res.groups,name:access_restricted.group_allow_add_implied_from_settings
|
||||||
|
msgid "Allow add implied groups from settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_uid
|
||||||
|
msgid "Created by"
|
||||||
|
msgstr "Creado por"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_date
|
||||||
|
msgid "Created on"
|
||||||
|
msgstr "Creado en"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__display_name
|
||||||
|
msgid "Display Name"
|
||||||
|
msgstr "Nombre para mostrar"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__group_private_addresses
|
||||||
|
msgid "Group Private Addresses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__id
|
||||||
|
msgid "ID"
|
||||||
|
msgstr "ID"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings____last_update
|
||||||
|
msgid "Last Modified on"
|
||||||
|
msgstr "Última modificación en"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_uid
|
||||||
|
msgid "Last Updated by"
|
||||||
|
msgstr "Última actualización por"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_date
|
||||||
|
msgid "Last Updated on"
|
||||||
|
msgstr "Ultima actualizacion en"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_users.py:95
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The requested operation cannot be completed due to security restrictions. "
|
||||||
|
"Please contact your system administrator.\n"
|
||||||
|
"\n"
|
||||||
|
"(Document type: %s, Operation: %s)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_test_config_settings
|
||||||
|
msgid "test.config.settings"
|
||||||
|
msgstr ""
|
||||||
114
access_restricted/i18n/es_CR.po
Normal file
114
access_restricted/i18n/es_CR.po
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_restricted
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Randall <randall_castro@me.com>, 2018
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 11.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2017-11-28 13:02+0000\n"
|
||||||
|
"Last-Translator: Randall <randall_castro@me.com>, 2018\n"
|
||||||
|
"Language-Team: Spanish (Costa Rica) (https://www.transifex.com/it-projects-"
|
||||||
|
"llc/teams/76080/es_CR/)\n"
|
||||||
|
"Language: es_CR\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_config_settings.py:48
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"You don't have access to change this settings, because you administration "
|
||||||
|
"rights are restricted"
|
||||||
|
msgstr ""
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"No tiene acceso para cambiar esta configuración, ya que los derechos de "
|
||||||
|
"administración están restringidos."
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_groups
|
||||||
|
msgid "Access Groups"
|
||||||
|
msgstr "Grupos de Accesos"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:res.groups,name:access_restricted.group_allow_add_implied_from_settings
|
||||||
|
msgid "Allow add implied groups from settings"
|
||||||
|
msgstr "Permitir agregar grupos implícitos desde la configuración"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_config_settings
|
||||||
|
#, fuzzy
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr "res.config.settings"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_uid
|
||||||
|
msgid "Created by"
|
||||||
|
msgstr "Creado por"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__create_date
|
||||||
|
msgid "Created on"
|
||||||
|
msgstr "Creado en"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__display_name
|
||||||
|
msgid "Display Name"
|
||||||
|
msgstr "Mostrar Nombre"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__group_private_addresses
|
||||||
|
msgid "Group Private Addresses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__id
|
||||||
|
msgid "ID"
|
||||||
|
msgstr "ID"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings____last_update
|
||||||
|
msgid "Last Modified on"
|
||||||
|
msgstr "Última modificación el"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_uid
|
||||||
|
msgid "Last Updated by"
|
||||||
|
msgstr "Última actualización por"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model.fields,field_description:access_restricted.field_test_config_settings__write_date
|
||||||
|
msgid "Last Updated on"
|
||||||
|
msgstr "Última actualización en "
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: code:addons/access_restricted/models/res_users.py:95
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The requested operation cannot be completed due to security restrictions. "
|
||||||
|
"Please contact your system administrator.\n"
|
||||||
|
"\n"
|
||||||
|
"(Document type: %s, Operation: %s)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Usuarios"
|
||||||
|
|
||||||
|
#. module: access_restricted
|
||||||
|
#: model:ir.model,name:access_restricted.model_test_config_settings
|
||||||
|
msgid "test.config.settings"
|
||||||
|
msgstr "test.config.settings"
|
||||||
|
|
||||||
|
#~ msgid "Group User"
|
||||||
|
#~ msgstr "Grupos de Usuario"
|
||||||
BIN
access_restricted/images/banner.jpg
Normal file
BIN
access_restricted/images/banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
3
access_restricted/models/__init__.py
Normal file
3
access_restricted/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import res_config_settings
|
||||||
|
from . import res_users
|
||||||
|
from . import test_config_settings
|
||||||
71
access_restricted/models/res_config_settings.py
Normal file
71
access_restricted/models/res_config_settings.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from odoo import SUPERUSER_ID, api, models
|
||||||
|
from odoo.tools import ustr
|
||||||
|
from odoo.tools.translate import _
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = "res.config.settings"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_classified_fields(self, fnames=None):
|
||||||
|
uid = self.env.uid
|
||||||
|
classified = super()._get_classified_fields(fnames)
|
||||||
|
config = self.env.context.get("config")
|
||||||
|
is_execute_stage = config and isinstance(config, models.Model)
|
||||||
|
|
||||||
|
if uid == SUPERUSER_ID or is_execute_stage:
|
||||||
|
return classified
|
||||||
|
|
||||||
|
group = []
|
||||||
|
user = self.env.user
|
||||||
|
|
||||||
|
for name, groups, implied_group in classified["group"]:
|
||||||
|
if (
|
||||||
|
implied_group in user.group_ids
|
||||||
|
or user.has_group(
|
||||||
|
"access_restricted.group_allow_add_implied_from_settings"
|
||||||
|
)
|
||||||
|
):
|
||||||
|
group.append((name, groups, implied_group))
|
||||||
|
|
||||||
|
classified["group"] = group
|
||||||
|
return classified
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fields_get(self, allfields=None, **kwargs):
|
||||||
|
uid = self.env.uid
|
||||||
|
fields = super().fields_get(allfields, **kwargs)
|
||||||
|
|
||||||
|
if uid == SUPERUSER_ID:
|
||||||
|
return fields
|
||||||
|
|
||||||
|
user = self.env.user
|
||||||
|
|
||||||
|
for name in fields:
|
||||||
|
if not name.startswith("group_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
f = self._fields[name]
|
||||||
|
|
||||||
|
if (
|
||||||
|
f.implied_group in user.group_ids
|
||||||
|
or user.has_group(
|
||||||
|
"access_restricted.group_allow_add_implied_from_settings"
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
fields[name].update(
|
||||||
|
readonly=True,
|
||||||
|
help=ustr(fields[name].get("help", ""))
|
||||||
|
+ _(
|
||||||
|
"\n\nYou don't have access to change this settings, because your administration rights are restricted"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
return super(
|
||||||
|
ResConfigSettings, self.with_context({"config": self})
|
||||||
|
).execute()
|
||||||
112
access_restricted/models/res_users.py
Normal file
112
access_restricted/models/res_users.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from odoo import SUPERUSER_ID, _, api, models
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
|
||||||
|
IR_CONFIG_NAME = "access_restricted.fields_view_get_uid"
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = "res.users"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_views(self, views, options=None):
|
||||||
|
last_uid = self.env["ir.config_parameter"].sudo().get_param(IR_CONFIG_NAME)
|
||||||
|
if int(last_uid) != self.env.uid:
|
||||||
|
self.env["res.groups"]._update_user_groups_view()
|
||||||
|
return super().get_views(views, options)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if 'groups_id' in vals:
|
||||||
|
self.env["ir.config_parameter"].sudo().set_param(IR_CONFIG_NAME, "0")
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
|
||||||
|
class ResGroups(models.Model):
|
||||||
|
_inherit = "res.groups"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _update_user_groups_view(self):
|
||||||
|
real_uid = self.env.uid
|
||||||
|
self.env["ir.config_parameter"].sudo().set_param(IR_CONFIG_NAME, real_uid)
|
||||||
|
self.env.flush_all()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_application_groups(self, domain=None):
|
||||||
|
if domain is None:
|
||||||
|
domain = []
|
||||||
|
domain.append(("share", "=", False))
|
||||||
|
|
||||||
|
real_uid = int(
|
||||||
|
self.env["ir.config_parameter"].sudo().get_param(IR_CONFIG_NAME, "0")
|
||||||
|
)
|
||||||
|
if real_uid and real_uid != SUPERUSER_ID:
|
||||||
|
group_no_one_id = self.env.ref("base.group_no_one").id
|
||||||
|
domain = domain + [
|
||||||
|
"|",
|
||||||
|
("user_ids", "in", [real_uid]),
|
||||||
|
("id", "=", group_no_one_id),
|
||||||
|
]
|
||||||
|
return self.sudo().search(domain)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
config = self.env.context.get("config")
|
||||||
|
|
||||||
|
# `isinstance` check is a non-xmplrpc proof.
|
||||||
|
if config and isinstance(config, models.Model):
|
||||||
|
implied_ids = vals.get("implied_ids")
|
||||||
|
classified_group = config._get_classified_fields()["group"]
|
||||||
|
# when `res.config.settings`'s `execute` method writes the `users` field to group,
|
||||||
|
# it is always to remove users and the `users` field is the only key in the write dict
|
||||||
|
users = vals.get("user_ids")
|
||||||
|
implied_group = implied_ids and implied_ids[0][1]
|
||||||
|
users_exclude_operation = (
|
||||||
|
users and len(vals) == 1 and all(u[0] == 3 for u in users)
|
||||||
|
)
|
||||||
|
# ``all(u[0] == 3 for u in users)`` is to be sure that all operations are for removing.
|
||||||
|
# `(3, id)` tuple removes the record from the set (the Many2many field `users`)
|
||||||
|
add_implied_group_operation = implied_group in [
|
||||||
|
group[2].id for group in classified_group
|
||||||
|
]
|
||||||
|
curr_user_allowed = self.env.user._is_superuser() or self.env.user.has_group(
|
||||||
|
"access_restricted.group_allow_add_implied_from_settings"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
users_exclude_operation
|
||||||
|
or add_implied_group_operation
|
||||||
|
and curr_user_allowed
|
||||||
|
):
|
||||||
|
self = self.with_user(SUPERUSER_ID)
|
||||||
|
else:
|
||||||
|
# do nothing with groups if there is no permission to add from settings
|
||||||
|
return
|
||||||
|
|
||||||
|
# in the https://github.com/odoo/odoo/commit/5f12e244f6e57b8edb56788147774150e2ae134d commit
|
||||||
|
# the method was refactored due to a higher performance.
|
||||||
|
# Super method lacks of orm part so as consequent ir rules are not checked and we check its conditions manually.
|
||||||
|
# We apply super method and check the difference of implied groups,
|
||||||
|
# if the not proper group was set error is raised
|
||||||
|
check_for_implied_ids = (
|
||||||
|
"implied_ids" in vals
|
||||||
|
and vals["implied_ids"]
|
||||||
|
and self.env.user.id != SUPERUSER_ID
|
||||||
|
)
|
||||||
|
if check_for_implied_ids:
|
||||||
|
implied_ids_before = self.mapped("implied_ids")
|
||||||
|
groups_before = self.env.user.group_ids
|
||||||
|
|
||||||
|
result = super(ResGroups, self).write(vals)
|
||||||
|
|
||||||
|
if check_for_implied_ids:
|
||||||
|
implied_ids_after = self.mapped("implied_ids")
|
||||||
|
group_no_one = self.env.ref("base.group_no_one")
|
||||||
|
implied_group_ids = implied_ids_after - implied_ids_before - group_no_one
|
||||||
|
|
||||||
|
# R1 <= R2 True if all records of R1 are also in R2
|
||||||
|
if not implied_group_ids <= groups_before:
|
||||||
|
raise AccessError(
|
||||||
|
_(
|
||||||
|
"You cannot add groups to Implied groups, because you are not allowed to increase your rights. "
|
||||||
|
"Please contact your system administrator."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
13
access_restricted/models/test_config_settings.py
Normal file
13
access_restricted/models/test_config_settings.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSettings(models.TransientModel):
|
||||||
|
|
||||||
|
_description = "Test config settings"
|
||||||
|
_inherit = ["res.config.settings"]
|
||||||
|
|
||||||
|
group_test_access_restricted = fields.Boolean(
|
||||||
|
group="base.group_system",
|
||||||
|
# random group for test purposes
|
||||||
|
implied_group="base.group_multi_currency",
|
||||||
|
)
|
||||||
30
access_restricted/security/access_restricted_security.xml
Normal file
30
access_restricted/security/access_restricted_security.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<odoo>
|
||||||
|
<record id="group_allow_add_implied_from_settings" model="res.groups">
|
||||||
|
<field name="name">Allow add implied groups from settings</field>
|
||||||
|
</record>
|
||||||
|
<record id="not_admin_tenant_rule" model="ir.rule">
|
||||||
|
<field name="name">Only admin can edit admin</field>
|
||||||
|
<field name="model_id" ref="base.model_res_users" />
|
||||||
|
<field name="global" eval="1" />
|
||||||
|
<field name="domain_force">[('id', '!=', 1)]</field>
|
||||||
|
<field name="perm_read" eval="False" />
|
||||||
|
<field name="perm_write" eval="True" />
|
||||||
|
<field name="perm_create" eval="True" />
|
||||||
|
<field name="perm_unlink" eval="True" />
|
||||||
|
<field name="protected" eval="True" />
|
||||||
|
</record>
|
||||||
|
<record id="res_groups_restricted" model="ir.rule">
|
||||||
|
<field name="name">res.groups.restricted</field>
|
||||||
|
<field name="model_id" ref="base.model_res_groups" />
|
||||||
|
<field
|
||||||
|
name="domain_force"
|
||||||
|
eval=""['|', ('user_ids','in', [user.id]), ('id', '=', '%s')]" % ref('base.group_no_one')"
|
||||||
|
/>
|
||||||
|
<field name="perm_read" eval="False" />
|
||||||
|
<field name="perm_write" eval="True" />
|
||||||
|
<field name="perm_create" eval="False" />
|
||||||
|
<field name="perm_unlink" eval="True" />
|
||||||
|
<field name="protected" eval="True" />
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
BIN
access_restricted/static/description/icon.png
Normal file
BIN
access_restricted/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
3
access_restricted/tests/__init__.py
Normal file
3
access_restricted/tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import test_fields_view_get
|
||||||
|
from . import test_fields_get
|
||||||
|
from . import test_allow_implied
|
||||||
86
access_restricted/tests/test_allow_implied.py
Normal file
86
access_restricted/tests/test_allow_implied.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
from odoo.exceptions import AccessError
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
GROUP = "base.group_multi_currency"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowImplied(TransactionCase):
|
||||||
|
def _get_classified_groups(self, config):
|
||||||
|
groups = config._get_classified_fields()["group"]
|
||||||
|
return [g[0] for g in groups]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.group = self.env.ref(GROUP)
|
||||||
|
self.demo_user = self.env["res.users"].create({
|
||||||
|
"name": "Test Demo User",
|
||||||
|
"login": "test_demo_allow_implied",
|
||||||
|
"groups_id": [(6, 0, [self.env.ref("base.group_user").id])],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_base(self):
|
||||||
|
demo_user = self.demo_user
|
||||||
|
|
||||||
|
group_system = self.env.ref("base.group_system")
|
||||||
|
|
||||||
|
demo_user.write({"group_ids": [(3, self.group.id)]})
|
||||||
|
self.group.write({"users": [(3, demo_user.id)]})
|
||||||
|
self.assertFalse(self.env["res.users"].with_user(demo_user.id).has_group(GROUP))
|
||||||
|
|
||||||
|
demo_user.write({"group_ids": [(4, group_system.id)]})
|
||||||
|
|
||||||
|
test_config_settings = (
|
||||||
|
self.env["res.config.settings"]
|
||||||
|
.with_user(demo_user.id)
|
||||||
|
.create({"group_test_access_restricted": True})
|
||||||
|
)
|
||||||
|
self.assertFalse(self.env["res.users"].with_user(demo_user.id).has_group(GROUP))
|
||||||
|
|
||||||
|
# check that the field is readonly
|
||||||
|
self.assertTrue(
|
||||||
|
test_config_settings.fields_get()["group_test_access_restricted"][
|
||||||
|
"readonly"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# check that test group hasn't got appended to classified
|
||||||
|
self.assertNotIn(
|
||||||
|
"self.group", self._get_classified_groups(test_config_settings)
|
||||||
|
)
|
||||||
|
|
||||||
|
group_allow = self.env.ref(
|
||||||
|
"access_restricted.group_allow_add_implied_from_settings"
|
||||||
|
)
|
||||||
|
demo_user.write({"group_ids": [(4, group_allow.id)]})
|
||||||
|
|
||||||
|
# check that now the field is not readonly
|
||||||
|
self.assertFalse(
|
||||||
|
test_config_settings.fields_get()["group_test_access_restricted"][
|
||||||
|
"readonly"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# check that now the group is in classified
|
||||||
|
self.assertIn(
|
||||||
|
"group_test_access_restricted",
|
||||||
|
self._get_classified_groups(test_config_settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(self.env["res.users"].with_user(demo_user.id).has_group(GROUP))
|
||||||
|
test_config_settings.with_user(demo_user.id).execute()
|
||||||
|
self.assertTrue(self.env["res.users"].with_user(demo_user.id).has_group(GROUP))
|
||||||
|
|
||||||
|
def test_assert_raises(self):
|
||||||
|
demo_user = self.demo_user
|
||||||
|
group_system = self.env.ref("base.group_system")
|
||||||
|
|
||||||
|
demo_user.write({"group_ids": [(3, self.group.id)]})
|
||||||
|
self.group.write({"users": [(3, demo_user.id)]})
|
||||||
|
self.assertFalse(self.env["res.users"].with_user(demo_user.id).has_group(GROUP))
|
||||||
|
|
||||||
|
demo_user.write({"group_ids": [(4, group_system.id)]})
|
||||||
|
self.assertFalse(self.env["res.users"].with_user(demo_user.id).has_group(GROUP))
|
||||||
|
|
||||||
|
# check that there is no access to put test group into implied_ids anyways
|
||||||
|
with self.assertRaises(AccessError):
|
||||||
|
group_system.with_user(demo_user.id).write(
|
||||||
|
{"implied_ids": [(4, self.group.id)]}
|
||||||
|
)
|
||||||
39
access_restricted/tests/test_fields_get.py
Normal file
39
access_restricted/tests/test_fields_get.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
def _sel_groups_field_name(group_ids):
|
||||||
|
"""Generate sel_groups field name from group ids (replaces removed name_selection_groups)."""
|
||||||
|
return 'sel_groups_' + '_'.join(str(i) for i in sorted(group_ids))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldsGet(TransactionCase):
|
||||||
|
def test_base(self):
|
||||||
|
demo_user = self.env["res.users"].create({
|
||||||
|
"name": "Test Demo User Fields",
|
||||||
|
"login": "test_demo_fields_get",
|
||||||
|
"groups_id": [(6, 0, [self.env.ref("base.group_user").id])],
|
||||||
|
})
|
||||||
|
group_erp_manager = self.env.ref("base.group_erp_manager")
|
||||||
|
group_system = self.env.ref("base.group_system")
|
||||||
|
|
||||||
|
demo_user.write({"group_ids": [(3, group_system.id)]})
|
||||||
|
group_system.write({"user_ids": [(3, demo_user.id)]})
|
||||||
|
demo_user.write({"group_ids": [(4, group_erp_manager.id)]})
|
||||||
|
|
||||||
|
view_users_form = self.env.ref("base.view_users_form")
|
||||||
|
(
|
||||||
|
self.env["res.users"]
|
||||||
|
.with_user(demo_user)
|
||||||
|
.with_context({"uid": demo_user.id})
|
||||||
|
.get_views([[view_users_form.id, "form"]])
|
||||||
|
)
|
||||||
|
|
||||||
|
sel_groups = _sel_groups_field_name([group_erp_manager.id])
|
||||||
|
res = self.env["res.users"].with_user(demo_user).fields_get()
|
||||||
|
self.assertTrue(res.get(sel_groups))
|
||||||
|
|
||||||
|
demo_user.write({"groups_id": [(4, group_system.id)]})
|
||||||
|
|
||||||
|
sel_groups = _sel_groups_field_name([group_erp_manager.id, group_system.id])
|
||||||
|
res = self.env["res.users"].with_user(demo_user).fields_get()
|
||||||
|
self.assertTrue(res.get(sel_groups))
|
||||||
71
access_restricted/tests/test_fields_view_get.py
Normal file
71
access_restricted/tests/test_fields_view_get.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
IR_CONFIG_NAME = "access_restricted.fields_view_get_uid"
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
|
class TestFieldsViewGet(TransactionCase):
|
||||||
|
def clear_config(self):
|
||||||
|
self.env["ir.config_parameter"].search([("key", "=", IR_CONFIG_NAME)]).unlink()
|
||||||
|
|
||||||
|
def clear_access(self, user):
|
||||||
|
user.write(
|
||||||
|
{
|
||||||
|
"group_ids": [
|
||||||
|
(3, self.env.ref("base.group_erp_manager").id, 0),
|
||||||
|
(3, self.env.ref("base.group_system").id, 0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_access(self, user, group_xmlid):
|
||||||
|
user.write({"group_ids": [(4, self.env.ref(group_xmlid).id, 0)]})
|
||||||
|
|
||||||
|
def _view_form(self, user, view_xmlid):
|
||||||
|
view_id = self.env.ref(view_xmlid).id
|
||||||
|
# context = {'lang': "en_US", 'tz': "Europe/Brussels", 'uid': user.id}
|
||||||
|
self.env["res.users"].with_user(user.id).get_view(view_id=view_id)
|
||||||
|
|
||||||
|
def view_preference_form(self, user):
|
||||||
|
self._view_form(user, "base.view_users_form_simple_modif")
|
||||||
|
|
||||||
|
def view_user_form(self, user):
|
||||||
|
self._view_form(user, "base.view_users_form")
|
||||||
|
|
||||||
|
def view_form_all(self, user):
|
||||||
|
self.view_preference_form(user)
|
||||||
|
self.clear_config()
|
||||||
|
|
||||||
|
self.view_user_form(user)
|
||||||
|
self.clear_config()
|
||||||
|
|
||||||
|
def view_form_mix(self, user1, user2):
|
||||||
|
self.view_preference_form(user1)
|
||||||
|
self.view_user_form(user2)
|
||||||
|
|
||||||
|
self.view_preference_form(user1)
|
||||||
|
self.view_preference_form(user2)
|
||||||
|
|
||||||
|
self.view_form_all(user1)
|
||||||
|
self.view_user_form(user2)
|
||||||
|
|
||||||
|
def test_base(self):
|
||||||
|
admin = self.env.ref("base.user_root")
|
||||||
|
demo = self.env.ref("base.user_demo")
|
||||||
|
|
||||||
|
# test for admin
|
||||||
|
self.view_form_all(admin)
|
||||||
|
|
||||||
|
# demo doesn't have admin rights
|
||||||
|
self.clear_access(demo)
|
||||||
|
self.view_preference_form(demo)
|
||||||
|
|
||||||
|
# demo has "Access Rights"
|
||||||
|
self.add_access(demo, "base.group_erp_manager")
|
||||||
|
self.view_form_all(demo)
|
||||||
|
|
||||||
|
# demo has "Settings"
|
||||||
|
self.add_access(demo, "base.group_system")
|
||||||
|
self.view_form_all(demo)
|
||||||
|
|
||||||
|
self.view_form_mix(admin, demo)
|
||||||
18
access_settings_menu/README.rst
Normal file
18
access_settings_menu/README.rst
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.. image:: https://itpp.dev/images/infinity-readme.png
|
||||||
|
:alt: Tested and maintained by IT Projects Labs
|
||||||
|
:target: https://itpp.dev
|
||||||
|
|
||||||
|
Show settings menu for non-admin
|
||||||
|
================================
|
||||||
|
|
||||||
|
Adds "Show Settings Menu" checkbox in user's access rights tab.
|
||||||
|
|
||||||
|
Uninstallation
|
||||||
|
==============
|
||||||
|
|
||||||
|
After uninstalling, you need to update ``base`` module to return restriction to ``Settings`` menu back.
|
||||||
|
|
||||||
|
Further information
|
||||||
|
===================
|
||||||
|
|
||||||
|
Tested on `Odoo 17.0 <https://github.com/odoo/odoo/commit/40b19d89846303016098840f4958fe7cc105067c>`_
|
||||||
1
access_settings_menu/__init__.py
Normal file
1
access_settings_menu/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
15
access_settings_menu/__manifest__.py
Normal file
15
access_settings_menu/__manifest__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Show settings menu for non-admin",
|
||||||
|
"summary": """Allows to make Settings menu visible for non-admins""",
|
||||||
|
"version": "19.0.2.0.0",
|
||||||
|
"author": "IT-Projects LLC, Ivan Yelizariev",
|
||||||
|
"category": "Extra Tools",
|
||||||
|
"images": ["images/banner.png"],
|
||||||
|
"support": "apps@itpp.dev",
|
||||||
|
"website": "https://twitter.com/OdooFree",
|
||||||
|
"license": "Other OSI approved licence", # MIT
|
||||||
|
"depends": ["access_restricted"],
|
||||||
|
"data": ["security/access_settings_menu_security.xml"],
|
||||||
|
"demo": ["security/access_settings_menu_security_demo.xml"],
|
||||||
|
"installable": True,
|
||||||
|
}
|
||||||
20
access_settings_menu/doc/changelog.rst
Normal file
20
access_settings_menu/doc/changelog.rst
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
`2.0.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- Improvement: replace `access_apps` dependency with `access_restricted` since only the latter one is needed
|
||||||
|
|
||||||
|
`1.0.2`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- FIX: make compatible with CI-tests of other modules
|
||||||
|
|
||||||
|
|
||||||
|
`1.0.1`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- FIX issue occured when user has ``[x] Show Settings Menu`` access, but doesn't have administration rights
|
||||||
|
|
||||||
|
`1.0.0`
|
||||||
|
-------
|
||||||
|
|
||||||
|
- Init version
|
||||||
33
access_settings_menu/doc/index.rst
Normal file
33
access_settings_menu/doc/index.rst
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
======================
|
||||||
|
Access settings menu
|
||||||
|
======================
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
* `Install <https://odoo-development.readthedocs.io/en/latest/odoo/usage/install-module.html>`__ this module in a usual way
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
* Open menu ``[[ Settings ]] >> Users & Companies >> Users``
|
||||||
|
* Open user form view (click on the line with the user)
|
||||||
|
* Click ``[Edit]``
|
||||||
|
* Select ``[Show Settings Menu]`` and click ``[Save]``
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
Without this module installed:
|
||||||
|
|
||||||
|
* Non-admin user can't see the ``[[ Settings ]]`` menu.
|
||||||
|
|
||||||
|
|
||||||
|
With this module installed:
|
||||||
|
|
||||||
|
* If non-admin user has the ``[Show Settings Menu]`` right he can see the ``[[ Settings ]]`` menu.
|
||||||
|
|
||||||
|
Uninstallation
|
||||||
|
==============
|
||||||
|
|
||||||
|
After uninstalling, you need to update ``base`` module to return restriction to ``Settings`` menu back.
|
||||||
25
access_settings_menu/i18n/access_settings_menu.pot
Normal file
25
access_settings_menu/i18n/access_settings_menu.pot
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_settings_menu
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 12.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"Last-Translator: <>\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: access_settings_menu
|
||||||
|
#: model:res.groups,name:access_settings_menu.group_show_settings_menu
|
||||||
|
msgid "Show Settings Menu"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: access_settings_menu
|
||||||
|
#: model:ir.model,name:access_settings_menu.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
30
access_settings_menu/i18n/de.po
Normal file
30
access_settings_menu/i18n/de.po
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_settings_menu
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Sergej Briesin <20bs18@gmail.com>, 2018
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 11.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"Last-Translator: Sergej Briesin <20bs18@gmail.com>, 2018\n"
|
||||||
|
"Language-Team: German (https://www.transifex.com/it-projects-llc/teams/76080/"
|
||||||
|
"de/)\n"
|
||||||
|
"Language: de\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_settings_menu
|
||||||
|
#: model:res.groups,name:access_settings_menu.group_show_settings_menu
|
||||||
|
msgid "Show Settings Menu"
|
||||||
|
msgstr "Einstellungsmenü anzeigen"
|
||||||
|
|
||||||
|
#. module: access_settings_menu
|
||||||
|
#: model:ir.model,name:access_settings_menu.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Nutzer"
|
||||||
31
access_settings_menu/i18n/es_CR.po
Normal file
31
access_settings_menu/i18n/es_CR.po
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * access_settings_menu
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Randall <randall_castro@me.com>, 2018
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 11.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2018-04-21 00:17+0000\n"
|
||||||
|
"PO-Revision-Date: 2017-11-28 13:02+0000\n"
|
||||||
|
"Last-Translator: Randall <randall_castro@me.com>, 2018\n"
|
||||||
|
"Language-Team: Spanish (Costa Rica) (https://www.transifex.com/it-projects-"
|
||||||
|
"llc/teams/76080/es_CR/)\n"
|
||||||
|
"Language: es_CR\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#. module: access_settings_menu
|
||||||
|
#: model:res.groups,name:access_settings_menu.group_show_settings_menu
|
||||||
|
msgid "Show Settings Menu"
|
||||||
|
msgstr "Mostrar Menu de Ajustes"
|
||||||
|
|
||||||
|
#. module: access_settings_menu
|
||||||
|
#: model:ir.model,name:access_settings_menu.model_res_users
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Usuarios"
|
||||||
BIN
access_settings_menu/images/banner.png
Normal file
BIN
access_settings_menu/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
10
access_settings_menu/models.py
Normal file
10
access_settings_menu/models.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = "res.users"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fields_get(self, *args, **kwargs):
|
||||||
|
# switch to superuser to get access to virtual fields
|
||||||
|
return super(ResUsers, self.sudo()).fields_get(*args, **kwargs)
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Show Settings Menu -->
|
||||||
|
<record id="group_show_settings_menu" model="res.groups">
|
||||||
|
<field name="name">Show Settings Menu</field>
|
||||||
|
<field name="user_ids" eval="[(4, ref('base.user_root'))]" />
|
||||||
|
</record>
|
||||||
|
<record model="ir.ui.menu" id="base.menu_administration">
|
||||||
|
<field
|
||||||
|
name="group_ids"
|
||||||
|
eval="[(6,0, [ref('access_settings_menu.group_show_settings_menu')])]"
|
||||||
|
/>
|
||||||
|
</record>
|
||||||
|
<record model="res.groups" id="base.group_erp_manager">
|
||||||
|
<field
|
||||||
|
name="implied_ids"
|
||||||
|
eval="[(4, ref('access_settings_menu.group_show_settings_menu'))]"
|
||||||
|
/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<!-- Copyright 2018 Ivan Yelizariev <https://it-projects.info/team/yelizariev>
|
||||||
|
License MIT (https://opensource.org/licenses/MIT). -->
|
||||||
|
<odoo>
|
||||||
|
<!-- Grant access to Demo User to don't break CI of other modules -->
|
||||||
|
<record id="group_show_settings_menu" model="res.groups">
|
||||||
|
<field name="user_ids" eval="[(4, ref('base.user_demo'))]" />
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
BIN
access_settings_menu/static/description/icon.png
Normal file
BIN
access_settings_menu/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
1
access_settings_menu/tests/__init__.py
Normal file
1
access_settings_menu/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_fields_view_get
|
||||||
20
access_settings_menu/tests/test_fields_view_get.py
Normal file
20
access_settings_menu/tests/test_fields_view_get.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from odoo.tests import common
|
||||||
|
|
||||||
|
from odoo.addons.access_restricted.tests.test_fields_view_get import (
|
||||||
|
TestFieldsViewGet as TestFieldsViewGetBase,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@common.tagged("post_install", "-at_install")
|
||||||
|
class TestFieldsViewGet(TestFieldsViewGetBase):
|
||||||
|
def test_access_settings_menu(self):
|
||||||
|
admin = self.env.ref("base.user_root")
|
||||||
|
demo = self.env.ref("base.user_demo")
|
||||||
|
|
||||||
|
# demo doesn't have admin rights, but has "Show Settings Menu"
|
||||||
|
self.clear_access(demo)
|
||||||
|
self.add_access(demo, "access_settings_menu.group_show_settings_menu")
|
||||||
|
self.view_form_all(demo)
|
||||||
|
self.view_form_mix(admin, demo)
|
||||||
|
self.clear_config()
|
||||||
|
self.view_form_mix(demo, admin)
|
||||||
2
account_bank_statement_1c_import/__init__.py
Normal file
2
account_bank_statement_1c_import/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import wizard
|
||||||
|
from . import models
|
||||||
19
account_bank_statement_1c_import/__manifest__.py
Normal file
19
account_bank_statement_1c_import/__manifest__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
'name': 'Account Bank Statement 1C Import',
|
||||||
|
'category': 'Accounting',
|
||||||
|
'version': '19.0.0.1',
|
||||||
|
'author': 'MK.Lab',
|
||||||
|
'website': 'https://inf-centre.ru/',
|
||||||
|
'depends': [
|
||||||
|
'account',
|
||||||
|
'l10n_ru_doc',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'wizard/invoice_import_wizard.xml',
|
||||||
|
'views/account_journal_wizard_action.xml',
|
||||||
|
'views/account_journal_dashboard_inherit.xml',
|
||||||
|
'views/account_journal_settings.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
}
|
||||||
160
account_bank_statement_1c_import/i18n/ru.po
Normal file
160
account_bank_statement_1c_import/i18n/ru.po
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * account_bank_statement_1c_import
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 17.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2024-03-18 22:00+0000\n"
|
||||||
|
"PO-Revision-Date: 2024-03-18 22:00+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model,name:account_bank_statement_1c_import.model_account_bank_statement
|
||||||
|
msgid "Bank Statement"
|
||||||
|
msgstr "Банковская выписка"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model_terms:ir.ui.view,arch_db:account_bank_statement_1c_import.view_invoice_import_wizard_form
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "Отменить"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__create_uid
|
||||||
|
msgid "Created by"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__create_date
|
||||||
|
msgid "Created on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_account_bank_statement__date_from
|
||||||
|
msgid "Date From"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__display_name
|
||||||
|
msgid "Display Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Error Import: %s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__file
|
||||||
|
msgid "File"
|
||||||
|
msgstr "Файл"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__file_name
|
||||||
|
msgid "File Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__id
|
||||||
|
msgid "ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model_terms:ir.ui.view,arch_db:account_bank_statement_1c_import.view_invoice_import_wizard_form
|
||||||
|
msgid "Import"
|
||||||
|
msgstr "Импортировать"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.actions.act_window,name:account_bank_statement_1c_import.action_invoice_import_wizard
|
||||||
|
#: model:ir.model,name:account_bank_statement_1c_import.model_invoice_import_wizard
|
||||||
|
#: model:ir.ui.menu,name:account_bank_statement_1c_import.menu_invoice_import
|
||||||
|
#: model_terms:ir.ui.view,arch_db:account_bank_statement_1c_import.account_journal_dashboard_inherit
|
||||||
|
#: model_terms:ir.ui.view,arch_db:account_bank_statement_1c_import.view_invoice_import_wizard_form
|
||||||
|
msgid "Import Invoice"
|
||||||
|
msgstr "Импортировать выписку"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.actions.server,name:account_bank_statement_1c_import.action_import_invoice_wizard
|
||||||
|
msgid "Import Invoice Wizard"
|
||||||
|
msgstr "Импорт выписки"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model,name:account_bank_statement_1c_import.model_account_journal
|
||||||
|
msgid "Journal"
|
||||||
|
msgstr "Журнал"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__write_uid
|
||||||
|
msgid "Last Updated by"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_invoice_import_wizard__write_date
|
||||||
|
msgid "Last Updated on"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Only TXT files are allowed."
|
||||||
|
msgstr "Разрешены только файлы TXT"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Please upload the file."
|
||||||
|
msgstr "Пожалуйста, загрузите файл"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Statement not found for journal {} on date {}."
|
||||||
|
msgstr "Не найдено выписки по журналу {} и дате {}"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Suitable journal not found."
|
||||||
|
msgstr ""
|
||||||
|
"Подходящий журнал не найден. Проверьте стоит ли галочка 'Использовать в "
|
||||||
|
"банковской выписке' в журнале"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#: model:ir.model.fields,field_description:account_bank_statement_1c_import.field_account_journal__use_in_bank_statement
|
||||||
|
msgid "Use in Bank Statement"
|
||||||
|
msgstr "Использовать в банковской выписке"
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "inn your company not found."
|
||||||
|
msgstr ""
|
||||||
|
"ИНН вашей компании не найден в файле. Пожалуйста, проверьте корректность "
|
||||||
|
"файла."
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Выписка успешно импортирована."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: account_bank_statement_1c_import
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/account_bank_statement_1c_import/wizard/invoice_import_wizard.py:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Импорт завершен"
|
||||||
|
msgstr ""
|
||||||
2
account_bank_statement_1c_import/models/__init__.py
Normal file
2
account_bank_statement_1c_import/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import account_journal
|
||||||
|
from . import account_bank_statement
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
class AccountBankStatement(models.Model):
|
||||||
|
_inherit = 'account.bank.statement'
|
||||||
|
|
||||||
|
date_from = fields.Date(string="Date From")
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
class AccountJournal(models.Model):
|
||||||
|
_inherit = 'account.journal'
|
||||||
|
|
||||||
|
use_in_bank_statement = fields.Boolean(string="Use in Bank Statement")
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_invoice_import_wizard,access_invoice_import_wizard,model_invoice_import_wizard,base.group_user,1,1,1,1
|
||||||
|
1
account_bank_statement_1c_import/tests/__init__.py
Normal file
1
account_bank_statement_1c_import/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_parser
|
||||||
293
account_bank_statement_1c_import/tests/test_parser.py
Normal file
293
account_bank_statement_1c_import/tests/test_parser.py
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
Tests for Parser_1C (account_bank_statement_1c_import).
|
||||||
|
|
||||||
|
Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.9
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COMPANY_INN = "7700000001"
|
||||||
|
COMPANY_KPP = "770001001"
|
||||||
|
|
||||||
|
PARTNER_INN = "7700000002"
|
||||||
|
PARTNER_KPP = "770001002"
|
||||||
|
|
||||||
|
VALID_TXT_TEMPLATE = """\
|
||||||
|
1CClientBankExchange
|
||||||
|
ВерсияФормата=1.03
|
||||||
|
Кодировка=Windows
|
||||||
|
Отправитель=Бухгалтерия предприятия
|
||||||
|
ДатаСоздания=01.01.2024
|
||||||
|
ВремяСоздания=12:00:00
|
||||||
|
ДатаНачала=01.01.2024
|
||||||
|
ДатаКонца=31.01.2024
|
||||||
|
РасчСчет=40702810000000000001
|
||||||
|
СекцияРасчСчет
|
||||||
|
ДатаНачала=01.01.2024
|
||||||
|
ДатаКонца=31.01.2024
|
||||||
|
РасчСчет=40702810000000000001
|
||||||
|
НачальныйОстаток=10000.00
|
||||||
|
ВсегоПоступило=5000.00
|
||||||
|
ВсегоСписано=3000.00
|
||||||
|
КонечныйОстаток=12000.00
|
||||||
|
КонецРасчСчет
|
||||||
|
{transactions}КонецФайла
|
||||||
|
"""
|
||||||
|
|
||||||
|
TRANSACTION_PAYER = """\
|
||||||
|
СекцияДокумент=Платежное поручение
|
||||||
|
Номер=1
|
||||||
|
Дата=15.01.2024
|
||||||
|
Сумма=1000.00
|
||||||
|
ПлательщикСчет=40702810000000000001
|
||||||
|
ДатаСписано=15.01.2024
|
||||||
|
ПлательщикИНН={company_inn}
|
||||||
|
ПлательщикКПП={company_kpp}
|
||||||
|
Плательщик=ООО Наша Компания
|
||||||
|
Плательщик1=ООО Наша Компания
|
||||||
|
ПлательщикРасчСчет=40702810000000000001
|
||||||
|
ПлательщикБанк1=Банк Плательщика
|
||||||
|
ПлательщикБИК=044525001
|
||||||
|
ПлательщикКорсчет=30101810400000000001
|
||||||
|
ПолучательСчет=40702810000000000002
|
||||||
|
ПолучательИНН={partner_inn}
|
||||||
|
ПолучательКПП={partner_kpp}
|
||||||
|
Получатель=ООО Контрагент
|
||||||
|
Получатель1=ООО Контрагент
|
||||||
|
ПолучательРасчСчет=40702810000000000002
|
||||||
|
ПолучательБанк1=Банк Получателя
|
||||||
|
ПолучательБИК=044525002
|
||||||
|
ПолучательКорсчет=30101810400000000002
|
||||||
|
НазначениеПлатежа=Оплата по договору №1
|
||||||
|
КонецДокумента
|
||||||
|
"""
|
||||||
|
|
||||||
|
TRANSACTION_RECIPIENT = """\
|
||||||
|
СекцияДокумент=Платежное поручение
|
||||||
|
Номер=2
|
||||||
|
Дата=16.01.2024
|
||||||
|
Сумма=2000.00
|
||||||
|
ПлательщикСчет=40702810000000000002
|
||||||
|
ДатаПоступило=16.01.2024
|
||||||
|
ПлательщикИНН={partner_inn}
|
||||||
|
ПлательщикКПП={partner_kpp}
|
||||||
|
Плательщик=ООО Контрагент
|
||||||
|
Плательщик1=ООО Контрагент
|
||||||
|
ПлательщикРасчСчет=40702810000000000002
|
||||||
|
ПлательщикБанк1=Банк Плательщика
|
||||||
|
ПлательщикБИК=044525002
|
||||||
|
ПлательщикКорсчет=30101810400000000002
|
||||||
|
ПолучательСчет=40702810000000000001
|
||||||
|
ПолучательИНН={company_inn}
|
||||||
|
ПолучательКПП={company_kpp}
|
||||||
|
Получатель=ООО Наша Компания
|
||||||
|
Получатель1=ООО Наша Компания
|
||||||
|
ПолучательРасчСчет=40702810000000000001
|
||||||
|
ПолучательБанк1=Банк Получателя
|
||||||
|
ПолучательБИК=044525001
|
||||||
|
ПолучательКорсчет=30101810400000000001
|
||||||
|
НазначениеПлатежа=Поступление по договору №2
|
||||||
|
КонецДокумента
|
||||||
|
"""
|
||||||
|
|
||||||
|
TRANSACTION_UNKNOWN_INN = """\
|
||||||
|
СекцияДокумент=Платежное поручение
|
||||||
|
Номер=3
|
||||||
|
Дата=17.01.2024
|
||||||
|
Сумма=500.00
|
||||||
|
ПлательщикСчет=40702810000000000003
|
||||||
|
ДатаСписано=17.01.2024
|
||||||
|
ПлательщикИНН=9999999999
|
||||||
|
ПлательщикКПП=999999999
|
||||||
|
Плательщик=ООО Чужая Компания
|
||||||
|
Плательщик1=ООО Чужая Компания
|
||||||
|
ПлательщикРасчСчет=40702810000000000003
|
||||||
|
ПлательщикБанк1=Чужой Банк
|
||||||
|
ПлательщикБИК=044525003
|
||||||
|
ПлательщикКорсчет=30101810400000000003
|
||||||
|
ПолучательСчет=40702810000000000004
|
||||||
|
ПолучательИНН=8888888888
|
||||||
|
ПолучательКПП=888888888
|
||||||
|
Получатель=ООО Другая Компания
|
||||||
|
Получатель1=ООО Другая Компания
|
||||||
|
ПолучательРасчСчет=40702810000000000004
|
||||||
|
ПолучательБанк1=Другой Банк
|
||||||
|
ПолучательБИК=044525004
|
||||||
|
ПолучательКорсчет=30101810400000000004
|
||||||
|
НазначениеПлатежа=Транзакция без нашей компании
|
||||||
|
КонецДокумента
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(text):
|
||||||
|
"""Encode text to base64 as cp1251 (as the wizard expects)."""
|
||||||
|
return base64.b64encode(text.encode('cp1251'))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_txt(transactions=""):
|
||||||
|
return VALID_TXT_TEMPLATE.format(transactions=transactions)
|
||||||
|
|
||||||
|
|
||||||
|
def _payer_tx():
|
||||||
|
return TRANSACTION_PAYER.format(
|
||||||
|
company_inn=COMPANY_INN, company_kpp=COMPANY_KPP,
|
||||||
|
partner_inn=PARTNER_INN, partner_kpp=PARTNER_KPP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recipient_tx():
|
||||||
|
return TRANSACTION_RECIPIENT.format(
|
||||||
|
company_inn=COMPANY_INN, company_kpp=COMPANY_KPP,
|
||||||
|
partner_inn=PARTNER_INN, partner_kpp=PARTNER_KPP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_inn_tx():
|
||||||
|
return TRANSACTION_UNKNOWN_INN
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures / setUp helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _Base(TransactionCase):
|
||||||
|
"""Common setUp: company VAT, bank journal with use_in_bank_statement."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# Set company VAT so the parser can match INN
|
||||||
|
self.env.company.write({'vat': COMPANY_INN})
|
||||||
|
|
||||||
|
# Create (or find) a bank journal with use_in_bank_statement=True
|
||||||
|
self.journal = self.env['account.journal'].search(
|
||||||
|
[('type', '=', 'bank'), ('use_in_bank_statement', '=', True)],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if not self.journal:
|
||||||
|
self.journal = self.env['account.journal'].create({
|
||||||
|
'name': 'Test Bank Journal',
|
||||||
|
'type': 'bank',
|
||||||
|
'code': 'TBNK',
|
||||||
|
'use_in_bank_statement': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _wizard(self, file_content_bytes, file_name='statement.txt'):
|
||||||
|
"""Create an InvoiceImportWizard record."""
|
||||||
|
return self.env['invoice.import.wizard'].create({
|
||||||
|
'file': base64.b64encode(file_content_bytes),
|
||||||
|
'file_name': file_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestParser1CBasic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParser1CBasic(_Base):
|
||||||
|
"""Validates: Requirements 2.1, 2.2, 2.3"""
|
||||||
|
|
||||||
|
def test_valid_txt_parses_without_exception(self):
|
||||||
|
"""Req 2.1 — correct TXT file is parsed without exceptions."""
|
||||||
|
content = _make_txt().encode('cp1251')
|
||||||
|
wizard = self._wizard(content, 'statement.txt')
|
||||||
|
# action_import_invoice should not raise
|
||||||
|
wizard.action_import_invoice()
|
||||||
|
|
||||||
|
def test_wrong_extension_raises_user_error(self):
|
||||||
|
"""Req 2.2 — non-.txt extension raises UserError."""
|
||||||
|
content = _make_txt().encode('cp1251')
|
||||||
|
wizard = self._wizard(content, 'statement.csv')
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wizard.action_import_invoice()
|
||||||
|
|
||||||
|
def test_no_file_raises_user_error(self):
|
||||||
|
"""Req 2.3 — missing file raises UserError."""
|
||||||
|
wizard = self.env['invoice.import.wizard'].create({
|
||||||
|
'file': False,
|
||||||
|
'file_name': 'statement.txt',
|
||||||
|
})
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wizard.action_import_invoice()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestParser1CLines
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParser1CLines(_Base):
|
||||||
|
"""Validates: Requirements 2.4, 2.5, 2.6"""
|
||||||
|
|
||||||
|
def _import_and_get_lines(self, transactions_text):
|
||||||
|
content = _make_txt(transactions_text).encode('cp1251')
|
||||||
|
wizard = self._wizard(content, 'statement.txt')
|
||||||
|
wizard.action_import_invoice()
|
||||||
|
return self.env['account.bank.statement.line'].search([], order='id desc')
|
||||||
|
|
||||||
|
def test_company_as_payer_creates_negative_amount(self):
|
||||||
|
"""Req 2.4 — company INN as payer → negative amount line."""
|
||||||
|
lines = self._import_and_get_lines(_payer_tx())
|
||||||
|
payer_lines = lines.filtered(lambda l: l.payment_ref == '1')
|
||||||
|
self.assertTrue(payer_lines, "Expected a statement line with payment_ref='1'")
|
||||||
|
self.assertLess(payer_lines[0].amount, 0, "Amount should be negative when company is payer")
|
||||||
|
|
||||||
|
def test_company_as_recipient_creates_positive_amount(self):
|
||||||
|
"""Req 2.5 — company INN as recipient → positive amount line."""
|
||||||
|
lines = self._import_and_get_lines(_recipient_tx())
|
||||||
|
recipient_lines = lines.filtered(lambda l: l.payment_ref == '2')
|
||||||
|
self.assertTrue(recipient_lines, "Expected a statement line with payment_ref='2'")
|
||||||
|
self.assertGreater(recipient_lines[0].amount, 0, "Amount should be positive when company is recipient")
|
||||||
|
|
||||||
|
def test_duplicate_line_is_skipped(self):
|
||||||
|
"""Req 2.6 — importing the same transaction twice does not create a duplicate."""
|
||||||
|
tx = _payer_tx()
|
||||||
|
content = _make_txt(tx).encode('cp1251')
|
||||||
|
|
||||||
|
# First import
|
||||||
|
self._wizard(content, 'statement.txt').action_import_invoice()
|
||||||
|
count_after_first = self.env['account.bank.statement.line'].search_count(
|
||||||
|
[('payment_ref', '=', '1')]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second import of the same file
|
||||||
|
self._wizard(content, 'statement.txt').action_import_invoice()
|
||||||
|
count_after_second = self.env['account.bank.statement.line'].search_count(
|
||||||
|
[('payment_ref', '=', '1')]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(count_after_first, count_after_second,
|
||||||
|
"Duplicate transaction should be skipped on second import")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestParser1CPartner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParser1CPartner(_Base):
|
||||||
|
"""Validates: Requirements 2.7, 2.9"""
|
||||||
|
|
||||||
|
def test_unknown_inn_kpp_creates_new_partner(self):
|
||||||
|
"""Req 2.7 — unknown INN/KPP → new partner of type 'company' is created."""
|
||||||
|
# Ensure partner does not exist
|
||||||
|
self.env['res.partner'].search([('vat', '=', PARTNER_INN)]).unlink()
|
||||||
|
|
||||||
|
content = _make_txt(_payer_tx()).encode('cp1251')
|
||||||
|
self._wizard(content, 'statement.txt').action_import_invoice()
|
||||||
|
|
||||||
|
partner = self.env['res.partner'].search([('vat', '=', PARTNER_INN)], limit=1)
|
||||||
|
self.assertTrue(partner, "A new partner should have been created")
|
||||||
|
self.assertEqual(partner.company_type, 'company',
|
||||||
|
"New partner should have company_type='company'")
|
||||||
|
|
||||||
|
def test_unknown_company_inn_in_transaction_raises_user_error(self):
|
||||||
|
"""Req 2.9 — company INN not found as payer or recipient → UserError."""
|
||||||
|
content = _make_txt(_unknown_inn_tx()).encode('cp1251')
|
||||||
|
wizard = self._wizard(content, 'statement.txt')
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wizard.action_import_invoice()
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="account_journal_dashboard_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">account.dashboard.inherit</field>
|
||||||
|
<field name="model">account.journal</field>
|
||||||
|
<field name="inherit_id" ref="account.account_journal_dashboard_kanban_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[@name='bank_cash_buttons']" position="inside">
|
||||||
|
<button type="action"
|
||||||
|
name="%(account_bank_statement_1c_import.action_import_invoice_wizard)d"
|
||||||
|
class="btn btn-primary">
|
||||||
|
Импортировать выписку
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="view_account_journal_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">account.journal.form.inherit</field>
|
||||||
|
<field name="model">account.journal</field>
|
||||||
|
<field name="inherit_id" ref="account.view_account_journal_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='type']" position="after">
|
||||||
|
<field name="use_in_bank_statement"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="account_bank_statement_1c_import.action_import_invoice_wizard" model="ir.actions.server">
|
||||||
|
<field name="name">Import Invoice Wizard</field>
|
||||||
|
<field name="model_id" ref="model_invoice_import_wizard"/>
|
||||||
|
<field name="binding_model_id" ref="account.model_account_journal"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
action = {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_model': 'invoice.import.wizard',
|
||||||
|
'target': 'new',
|
||||||
|
'name': 'Import Invoice'
|
||||||
|
}
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_invoice_import"
|
||||||
|
name="Import Invoice"
|
||||||
|
action="action_invoice_import_wizard"
|
||||||
|
parent="account.menu_finance"
|
||||||
|
sequence="5"/>
|
||||||
|
</odoo>
|
||||||
1
account_bank_statement_1c_import/wizard/__init__.py
Normal file
1
account_bank_statement_1c_import/wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import invoice_import_wizard
|
||||||
202
account_bank_statement_1c_import/wizard/invoice_import_wizard.py
Normal file
202
account_bank_statement_1c_import/wizard/invoice_import_wizard.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
from odoo import models, fields, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
import base64
|
||||||
|
import dateutil.parser
|
||||||
|
# import logging
|
||||||
|
|
||||||
|
# _logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceImportWizard(models.TransientModel):
|
||||||
|
_name = 'invoice.import.wizard'
|
||||||
|
_description = 'Import Invoice'
|
||||||
|
|
||||||
|
file = fields.Binary(string='File', required=True)
|
||||||
|
file_name = fields.Char(string='File Name')
|
||||||
|
|
||||||
|
def action_import_invoice(self):
|
||||||
|
# _logger.info("Начало импорта файла выписки")
|
||||||
|
|
||||||
|
if not self.file:
|
||||||
|
# _logger.error("Файл не загружен")
|
||||||
|
raise UserError(_("Please upload the file."))
|
||||||
|
|
||||||
|
if not self.file_name.endswith('.txt'):
|
||||||
|
# _logger.error("Неподдерживаемый формат файла: %s", self.file_name)
|
||||||
|
raise UserError(_("Only TXT files are allowed."))
|
||||||
|
|
||||||
|
file_content = base64.b64decode(self.file).decode('cp1251')
|
||||||
|
journal = self.env['account.journal'].search([('type', '=', 'bank'), ('use_in_bank_statement', '=', True)],
|
||||||
|
limit=1)
|
||||||
|
if not journal:
|
||||||
|
raise UserError(_("Suitable journal not found."))
|
||||||
|
|
||||||
|
try:
|
||||||
|
sections = file_content.split('СекцияДокумент')
|
||||||
|
invoice_sections = sections[0]
|
||||||
|
transaction_sections = sections[1:]
|
||||||
|
|
||||||
|
invoices = invoice_sections.split('СекцияРасчСчет')[1:]
|
||||||
|
for invoice in invoices:
|
||||||
|
statement_data = self.parse_statement_data(invoice)
|
||||||
|
|
||||||
|
existing_statement = self.env['account.bank.statement'].search([
|
||||||
|
('date_from', '=', dateutil.parser.parse(statement_data.get('ДатаНачала', '').strip(), dayfirst=True).date()),
|
||||||
|
('name', '=', statement_data.get('РасчСчет', '').strip()),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not existing_statement:
|
||||||
|
statement = self.create_statement(journal, statement_data)
|
||||||
|
# _logger.info("Выписка создана успешно: %s", statement.name)
|
||||||
|
self.create_statement_lines(journal, transaction_sections)
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# _logger.error("Ошибка при импорте файла: %s", e)
|
||||||
|
raise UserError(_("Error Import: %s" % str(e)))
|
||||||
|
|
||||||
|
message_title = _("Импорт завершен")
|
||||||
|
message_content = _("Выписка успешно импортирована.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': message_title,
|
||||||
|
'message': message_content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_statement_data(self, section):
|
||||||
|
lines = section.strip().split('\n')
|
||||||
|
statement_data = {}
|
||||||
|
for line in lines:
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
statement_data[key] = value
|
||||||
|
return statement_data
|
||||||
|
|
||||||
|
def create_statement(self, journal, statement_data):
|
||||||
|
statement_vals = {
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'name': statement_data.get('РасчСчет', '').strip(),
|
||||||
|
'balance_start': float(statement_data.get('НачальныйОстаток', '0.0').strip()),
|
||||||
|
'balance_end_real': float(statement_data.get('КонечныйОстаток', '0.0').strip()),
|
||||||
|
'date_from': dateutil.parser.parse(statement_data.get('ДатаНачала', '').strip(), dayfirst=True).date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.env['account.bank.statement'].sudo().create(statement_vals)
|
||||||
|
|
||||||
|
def create_statement_lines(self, journal, sections):
|
||||||
|
for section in sections:
|
||||||
|
if section.strip():
|
||||||
|
lines = section.strip().split('\n')
|
||||||
|
transaction = self.parse_transaction_data(lines)
|
||||||
|
if transaction:
|
||||||
|
# _logger.info("<==================================>")
|
||||||
|
# _logger.info("Создание строки выписки для транзакции №%s", transaction.get('Номер').strip())
|
||||||
|
self.create_line(journal, transaction)
|
||||||
|
# _logger.info("Строка выписки для транзакции №%s создана успешно", transaction.get('Номер').strip())
|
||||||
|
|
||||||
|
def parse_transaction_data(self, lines):
|
||||||
|
transaction = {}
|
||||||
|
for line in lines:
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
transaction[key] = value
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
def create_line(self, journal, transaction):
|
||||||
|
my_company_inn = journal.company_id.vat
|
||||||
|
|
||||||
|
transaction_date = dateutil.parser.parse(transaction.get('Дата', '').strip(), dayfirst=True).date()
|
||||||
|
statement = self.env['account.bank.statement'].search([
|
||||||
|
('date_from', '=', transaction_date),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not statement:
|
||||||
|
raise UserError(_("Statement not found for journal {} on date {}.").format(journal.name, transaction_date))
|
||||||
|
|
||||||
|
amount = float(transaction.get('Сумма', '0.0').strip())
|
||||||
|
|
||||||
|
if transaction.get('ПлательщикИНН').strip() == my_company_inn:
|
||||||
|
amount = -amount
|
||||||
|
partner_inn = transaction.get('ПолучательИНН').strip()
|
||||||
|
partner_kpp = transaction.get('ПолучательКПП').strip()
|
||||||
|
partner_name = transaction.get('Получатель').strip()
|
||||||
|
partner_account = transaction.get('ПолучательСчет').strip()
|
||||||
|
bank_bik = transaction.get('ПолучательБИК').strip()
|
||||||
|
bank_account = transaction.get('ПолучательКорсчет').strip()
|
||||||
|
bank_name = transaction.get('ПолучательБанк1').strip()
|
||||||
|
elif transaction.get('ПолучательИНН').strip() == my_company_inn:
|
||||||
|
# Если наша компания - получатель, значит это доход
|
||||||
|
partner_inn = transaction.get('ПлательщикИНН').strip()
|
||||||
|
partner_kpp = transaction.get('ПлательщикКПП').strip()
|
||||||
|
partner_name = transaction.get('Плательщик').strip()
|
||||||
|
partner_account = transaction.get('ПлательщикСчет').strip()
|
||||||
|
bank_bik = transaction.get('ПлательщикБИК').strip()
|
||||||
|
bank_account = transaction.get('ПлательщикКорсчет').strip()
|
||||||
|
bank_name = transaction.get('ПлательщикБанк1').strip()
|
||||||
|
else:
|
||||||
|
error_message = "ИНН вашей компании не найден в транзакции. Запись не создана."
|
||||||
|
error_message += "Журнал: {}, Дата транзакции: {}. ".format(journal.name, transaction_date)
|
||||||
|
error_message += "Плательщик: {}, ИНН плательщика: {}. ".format(transaction.get('Плательщик1', 'не указан').strip(),
|
||||||
|
transaction.get('ПлательщикИНН',
|
||||||
|
'не указан').strip())
|
||||||
|
error_message += "Получатель: {}, ИНН получателя: {}. ".format(transaction.get('Получатель1', 'не указан').strip(),
|
||||||
|
transaction.get('ПолучательИНН',
|
||||||
|
'не указан').strip())
|
||||||
|
raise UserError(_(f"{error_message}"))
|
||||||
|
|
||||||
|
existing_line = self.env['account.bank.statement.line'].search([
|
||||||
|
('date', '=', transaction_date),
|
||||||
|
('amount', '=', amount),
|
||||||
|
('narration', '=', transaction.get('НазначениеПлатежа', '').strip()),
|
||||||
|
('payment_ref', '=', transaction.get('Номер').strip()),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if existing_line:
|
||||||
|
# _logger.info("Строка выписки уже существует: %s", existing_line.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
partner = self.env['res.partner'].search([('vat', '=', partner_inn), ('kpp', '=', partner_kpp)], limit=1)
|
||||||
|
if not partner:
|
||||||
|
# _logger.info("Создание нового партнера: ИНН %s, КПП %s", partner_inn, partner_kpp)
|
||||||
|
partner = self.env['res.partner'].create({
|
||||||
|
'name': partner_name,
|
||||||
|
'vat': partner_inn,
|
||||||
|
'kpp': partner_kpp,
|
||||||
|
'company_type': 'company',
|
||||||
|
})
|
||||||
|
|
||||||
|
bank = self.env['res.bank'].search([('bic', '=', bank_bik), ('corr_acc', '=', bank_account)], limit=1)
|
||||||
|
if not bank:
|
||||||
|
# _logger.info("Создание нового банка: БИК %s", bank_bik)
|
||||||
|
bank = self.env['res.bank'].create({
|
||||||
|
'name': bank_name,
|
||||||
|
'bic': bank_bik,
|
||||||
|
'corr_acc': bank_account,
|
||||||
|
})
|
||||||
|
|
||||||
|
partner_bank_account = self.env['res.partner.bank'].search(
|
||||||
|
[('acc_number', '=', partner_account), ('partner_id', '=', partner.id)], limit=1)
|
||||||
|
if not partner_bank_account:
|
||||||
|
# _logger.info("Создание нового банковского счета для партнера %s", partner.name)
|
||||||
|
partner_bank_account = self.env['res.partner.bank'].create({
|
||||||
|
'acc_number': partner_account,
|
||||||
|
'bank_id': bank.id,
|
||||||
|
'partner_id': partner.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
line_vals = {
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'partner_bank_id': partner_bank_account.id,
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'company_id': journal.company_id.id,
|
||||||
|
'date': transaction_date,
|
||||||
|
'amount': amount,
|
||||||
|
'narration': transaction.get('НазначениеПлатежа', '').strip(),
|
||||||
|
'payment_ref': transaction.get('Номер').strip(),
|
||||||
|
}
|
||||||
|
self.env['account.bank.statement.line'].sudo().create(line_vals)
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="view_invoice_import_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">invoice.import.wizard.form</field>
|
||||||
|
<field name="model">invoice.import.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Import Invoice">
|
||||||
|
<group>
|
||||||
|
<field name="file" filename="file_name"/>
|
||||||
|
<field name="file_name" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button string="Import" name="action_import_invoice" type="object" class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_invoice_import_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Import Invoice</field>
|
||||||
|
<field name="res_model">invoice.import.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
2
account_demo_data_loader/__init__.py
Normal file
2
account_demo_data_loader/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
15
account_demo_data_loader/__manifest__.py
Normal file
15
account_demo_data_loader/__manifest__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'Account Demo Data Loader',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Technical',
|
||||||
|
'summary': 'Demo data for Account module',
|
||||||
|
'depends': ['account', 'demo_data_loader_base'],
|
||||||
|
'data': [],
|
||||||
|
'demo': [
|
||||||
|
'demo/demo_account.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
72
account_demo_data_loader/demo/demo_account.xml
Normal file
72
account_demo_data_loader/demo/demo_account.xml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- Демо контрагенты -->
|
||||||
|
<record id="demo_partner_invoice_1" model="res.partner">
|
||||||
|
<field name="name">Клиент Альфа</field>
|
||||||
|
<field name="email">alpha@demo.example.com</field>
|
||||||
|
<field name="phone">+1 555 400 1000</field>
|
||||||
|
<field name="customer_rank">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_partner_invoice_2" model="res.partner">
|
||||||
|
<field name="name">Клиент Бета</field>
|
||||||
|
<field name="email">beta@demo.example.com</field>
|
||||||
|
<field name="phone">+1 555 400 2000</field>
|
||||||
|
<field name="customer_rank">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_partner_vendor_1" model="res.partner">
|
||||||
|
<field name="name">Поставщик Гамма</field>
|
||||||
|
<field name="email">gamma@demo.example.com</field>
|
||||||
|
<field name="phone">+1 555 400 3000</field>
|
||||||
|
<field name="supplier_rank">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Демо счёт клиенту 1 -->
|
||||||
|
<record id="demo_account_move_invoice_1" model="account.move">
|
||||||
|
<field name="move_type">out_invoice</field>
|
||||||
|
<field name="partner_id" ref="demo_partner_invoice_1"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_account_move_invoice_1_line_1" model="account.move.line">
|
||||||
|
<field name="move_id" ref="demo_account_move_invoice_1"/>
|
||||||
|
<field name="name">Консалтинговые услуги</field>
|
||||||
|
<field name="quantity">10</field>
|
||||||
|
<field name="price_unit">150.0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Демо счёт клиенту 2 -->
|
||||||
|
<record id="demo_account_move_invoice_2" model="account.move">
|
||||||
|
<field name="move_type">out_invoice</field>
|
||||||
|
<field name="partner_id" ref="demo_partner_invoice_2"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_account_move_invoice_2_line_1" model="account.move.line">
|
||||||
|
<field name="move_id" ref="demo_account_move_invoice_2"/>
|
||||||
|
<field name="name">Лицензия на ПО</field>
|
||||||
|
<field name="quantity">3</field>
|
||||||
|
<field name="price_unit">500.0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_account_move_invoice_2_line_2" model="account.move.line">
|
||||||
|
<field name="move_id" ref="demo_account_move_invoice_2"/>
|
||||||
|
<field name="name">Годовая поддержка</field>
|
||||||
|
<field name="quantity">1</field>
|
||||||
|
<field name="price_unit">1200.0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Демо счёт от поставщика -->
|
||||||
|
<record id="demo_account_move_bill_1" model="account.move">
|
||||||
|
<field name="move_type">in_invoice</field>
|
||||||
|
<field name="partner_id" ref="demo_partner_vendor_1"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_account_move_bill_1_line_1" model="account.move.line">
|
||||||
|
<field name="move_id" ref="demo_account_move_bill_1"/>
|
||||||
|
<field name="name">Канцелярские товары</field>
|
||||||
|
<field name="quantity">5</field>
|
||||||
|
<field name="price_unit">80.0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
2
account_demo_data_loader/models/__init__.py
Normal file
2
account_demo_data_loader/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import res_config_settings
|
||||||
6
account_demo_data_loader/models/res_config_settings.py
Normal file
6
account_demo_data_loader/models/res_config_settings.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
97
account_move_templates/README.rst
Normal file
97
account_move_templates/README.rst
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
============================
|
||||||
|
Шаблоны типовых операций
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||||
|
:target: https://odoo-community.org/page/development-status
|
||||||
|
:alt: Beta
|
||||||
|
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
|
||||||
|
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
|
||||||
|
:alt: License: LGPL-3
|
||||||
|
|
||||||
|
|badge1| |badge2|
|
||||||
|
|
||||||
|
Модуль позволяет создавать шаблоны типовых финансовых операций и применять их
|
||||||
|
для создания проводок с заранее определённым распределением по счетам.
|
||||||
|
|
||||||
|
**Содержание**
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Возможности
|
||||||
|
===========
|
||||||
|
|
||||||
|
* Создание многократно используемых шаблонов типовых операций
|
||||||
|
* Определение распределения по счетам с использованием процентов
|
||||||
|
* Применение шаблонов для создания проводок через визард
|
||||||
|
* Категоризация шаблонов с помощью признаков (тегов)
|
||||||
|
* Автоматическая проверка баланса шаблона (дебет = кредит)
|
||||||
|
* Поддержка двух типов строк:
|
||||||
|
|
||||||
|
* Продуктовая строка - заменяет счёт в существующих строках
|
||||||
|
* Строка оплаты - создаёт новые строки дебиторки/кредиторки
|
||||||
|
|
||||||
|
Настройка
|
||||||
|
=========
|
||||||
|
|
||||||
|
Для настройки модуля необходимо:
|
||||||
|
|
||||||
|
#. Перейти в *Бухгалтерия > Настройка > Шаблоны типовых операций*
|
||||||
|
#. Создать новый шаблон
|
||||||
|
#. Добавить строки шаблона с указанием:
|
||||||
|
|
||||||
|
* Счёт
|
||||||
|
* Сторона (Дебет/Кредит)
|
||||||
|
* Процент (0.01-100.00%)
|
||||||
|
* Тип строки:
|
||||||
|
|
||||||
|
* Продуктовая строка - заменяет счёт в существующих product-строках
|
||||||
|
* Строка оплаты - создаёт новые receivable/payable строки
|
||||||
|
|
||||||
|
#. При необходимости добавить признаки для категоризации
|
||||||
|
|
||||||
|
Модуль автоматически проверяет, что сумма процентов по дебету равна сумме процентов по кредиту.
|
||||||
|
|
||||||
|
Использование
|
||||||
|
=============
|
||||||
|
|
||||||
|
Для использования модуля:
|
||||||
|
|
||||||
|
#. Откройте любой документ, который наследует ``account.move.template.mixin``
|
||||||
|
#. Нажмите кнопку "Создать проводку по шаблону"
|
||||||
|
#. Выберите шаблон
|
||||||
|
#. Укажите базовую сумму
|
||||||
|
#. Нажмите "Создать проводку"
|
||||||
|
|
||||||
|
Модуль создаст новую проводку со счетами, распределёнными согласно процентам в шаблоне.
|
||||||
|
|
||||||
|
Отслеживание ошибок
|
||||||
|
===================
|
||||||
|
|
||||||
|
Ошибки отслеживаются на `GitHub Issues <https://github.com/OCA/account-financial-tools/issues>`_.
|
||||||
|
В случае проблем, пожалуйста, проверьте, не была ли ваша проблема уже зарегистрирована.
|
||||||
|
|
||||||
|
Авторы
|
||||||
|
======
|
||||||
|
|
||||||
|
Авторы
|
||||||
|
~~~~~~
|
||||||
|
|
||||||
|
* MK.Lab, RuOdoo
|
||||||
|
|
||||||
|
Участники
|
||||||
|
~~~~~~~~~
|
||||||
|
|
||||||
|
* MK.Lab, RuOdoo
|
||||||
|
|
||||||
|
Сопровождающие
|
||||||
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Модуль сопровождается MK.Lab, RuOdoo.
|
||||||
|
|
||||||
|
.. image:: https://ruodoo.ru/logo.png
|
||||||
|
:alt: MK.Lab, RuOdoo
|
||||||
|
:target: https://ruodoo.ru
|
||||||
|
|
||||||
|
Модуль является частью проекта расширений для бухгалтерии.
|
||||||
1
account_move_templates/__init__.py
Normal file
1
account_move_templates/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
36
account_move_templates/__manifest__.py
Normal file
36
account_move_templates/__manifest__.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
'name': 'Accounting Journal Templates',
|
||||||
|
'version': '19.0.1.0.0',
|
||||||
|
'summary': 'Шаблоны типовых финансовых операций',
|
||||||
|
'description': """
|
||||||
|
Шаблоны типовых финансовых операций
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Создание многократно используемых шаблонов типовых проводок с заранее
|
||||||
|
определённым распределением по счетам с использованием процентов.
|
||||||
|
|
||||||
|
Возможности:
|
||||||
|
------------
|
||||||
|
* Создание шаблонов с несколькими строками счетов
|
||||||
|
* Определение сторон (дебет/кредит) и процентов
|
||||||
|
* Автоматическая проверка баланса шаблона
|
||||||
|
* Применение шаблонов через визард для создания проводок
|
||||||
|
* Категоризация шаблонов с помощью признаков (тегов)
|
||||||
|
* Поддержка продуктовых строк и строк оплаты
|
||||||
|
""",
|
||||||
|
'author': 'MK.Lab, RuOdoo',
|
||||||
|
'website': 'https://ruodoo.ru',
|
||||||
|
'category': 'Accounting/Accounting',
|
||||||
|
'depends': ['account'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/demo_templates.xml',
|
||||||
|
'views/account_move_template_views.xml',
|
||||||
|
'views/account_move_template_wizard_views.xml',
|
||||||
|
],
|
||||||
|
'demo': [],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'maintainers': ['mklab', 'ruodoo'],
|
||||||
|
}
|
||||||
13
account_move_templates/data/demo_templates.xml
Normal file
13
account_move_templates/data/demo_templates.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="demo_template_tag_warehouse" model="account.move.template.tag">
|
||||||
|
<field name="name">Склад</field>
|
||||||
|
<field name="color">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_template_tag_sales" model="account.move.template.tag">
|
||||||
|
<field name="name">Продажи</field>
|
||||||
|
<field name="color">2</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
20
account_move_templates/demo/demo.xml
Normal file
20
account_move_templates/demo/demo.xml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Теги шаблонов -->
|
||||||
|
<record id="demo_tag_general" model="account.move.template.tag">
|
||||||
|
<field name="name">Общие</field>
|
||||||
|
<field name="color">3</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_tag_tax" model="account.move.template.tag">
|
||||||
|
<field name="name">Налоги</field>
|
||||||
|
<field name="color">4</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Шаблон типовой операции: Начисление НДС -->
|
||||||
|
<!-- Примечание: account_id должны быть заменены на реальные счета из вашего плана счетов -->
|
||||||
|
<!-- Шаблон намеренно не создаётся здесь, т.к. требует конкретных счетов из плана счетов -->
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
3
account_move_templates/models/__init__.py
Normal file
3
account_move_templates/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import account_move_template
|
||||||
|
from . import account_move_template_mixin
|
||||||
|
from . import account_move_template_wizard
|
||||||
94
account_move_templates/models/account_move_template.py
Normal file
94
account_move_templates/models/account_move_template.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveTemplateTag(models.Model):
|
||||||
|
_name = 'account.move.template.tag'
|
||||||
|
_description = 'Признак шаблона'
|
||||||
|
|
||||||
|
name = fields.Char(string='Название', required=True, translate=True)
|
||||||
|
color = fields.Integer(string='Цвет')
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveTemplate(models.Model):
|
||||||
|
_name = 'account.move.template'
|
||||||
|
_description = 'Шаблон типовой операции'
|
||||||
|
|
||||||
|
name = fields.Char(string='Название', required=True)
|
||||||
|
description = fields.Text(string='Описание')
|
||||||
|
tag_ids = fields.Many2many(
|
||||||
|
comodel_name='account.move.template.tag',
|
||||||
|
string='Признаки',
|
||||||
|
)
|
||||||
|
line_ids = fields.One2many(
|
||||||
|
comodel_name='account.move.template.line',
|
||||||
|
inverse_name='template_id',
|
||||||
|
string='Строки шаблона',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('line_ids')
|
||||||
|
def _check_balance(self):
|
||||||
|
for template in self:
|
||||||
|
if not template.line_ids:
|
||||||
|
raise ValidationError(
|
||||||
|
'Шаблон должен содержать хотя бы одну строку.'
|
||||||
|
)
|
||||||
|
debit_sum = sum(
|
||||||
|
line.percent for line in template.line_ids if line.move_type == 'debit'
|
||||||
|
)
|
||||||
|
credit_sum = sum(
|
||||||
|
line.percent for line in template.line_ids if line.move_type == 'credit'
|
||||||
|
)
|
||||||
|
if round(debit_sum, 2) != round(credit_sum, 2):
|
||||||
|
diff = round(abs(debit_sum - credit_sum), 2)
|
||||||
|
raise ValidationError(
|
||||||
|
f'Сумма дебета ({debit_sum:.2f}%) не равна сумме кредита '
|
||||||
|
f'({credit_sum:.2f}%), разница: {diff:.2f}%'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveTemplateLine(models.Model):
|
||||||
|
_name = 'account.move.template.line'
|
||||||
|
_description = 'Строка шаблона типовой операции'
|
||||||
|
|
||||||
|
template_id = fields.Many2one(
|
||||||
|
comodel_name='account.move.template',
|
||||||
|
string='Шаблон',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
account_id = fields.Many2one(
|
||||||
|
comodel_name='account.account',
|
||||||
|
string='Счёт',
|
||||||
|
required=True,
|
||||||
|
ondelete='restrict',
|
||||||
|
)
|
||||||
|
move_type = fields.Selection(
|
||||||
|
selection=[('debit', 'Дебет'), ('credit', 'Кредит')],
|
||||||
|
string='Сторона',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
percent = fields.Float(
|
||||||
|
string='Процент',
|
||||||
|
required=True,
|
||||||
|
digits=(5, 2),
|
||||||
|
)
|
||||||
|
line_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('product', 'Продуктовая строка (заменяет счёт)'),
|
||||||
|
('payment', 'Строка оплаты (receivable/payable)')
|
||||||
|
],
|
||||||
|
string='Тип строки',
|
||||||
|
required=True,
|
||||||
|
default='product',
|
||||||
|
help='Продуктовая строка заменяет счёт в существующих product-строках инвойса. '
|
||||||
|
'Строка оплаты создаёт новую payment_term строку.'
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('percent')
|
||||||
|
def _check_percent(self):
|
||||||
|
for line in self:
|
||||||
|
if line.percent < 0.01 or line.percent > 100.0:
|
||||||
|
raise ValidationError(
|
||||||
|
'Процент должен быть от 0.01 до 100.00.'
|
||||||
|
)
|
||||||
57
account_move_templates/models/account_move_template_mixin.py
Normal file
57
account_move_templates/models/account_move_template_mixin.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveTemplateMixin(models.AbstractModel):
|
||||||
|
_name = 'account.move.template.mixin'
|
||||||
|
_description = 'Миксин для создания проводок через шаблоны'
|
||||||
|
|
||||||
|
move_ids = fields.Many2many(
|
||||||
|
comodel_name='account.move',
|
||||||
|
string='Проводки',
|
||||||
|
)
|
||||||
|
move_count = fields.Integer(
|
||||||
|
compute='_compute_move_count',
|
||||||
|
string='Количество проводок',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('move_ids')
|
||||||
|
def _compute_move_count(self):
|
||||||
|
for record in self:
|
||||||
|
record.move_count = len(record.move_ids)
|
||||||
|
|
||||||
|
def action_open_journal_wizard(self):
|
||||||
|
"""If move_ids is empty, open wizard. Otherwise delegate to action_view_moves."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.move_ids:
|
||||||
|
return self.action_view_moves()
|
||||||
|
amounts = self.get_move_line_amounts()
|
||||||
|
default_amount = amounts[0]['amount'] if amounts else 0.0
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Создать проводку',
|
||||||
|
'res_model': 'account.move.template.wizard',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {
|
||||||
|
'default_res_model': self._name,
|
||||||
|
'default_res_id': self.id,
|
||||||
|
'default_amount': default_amount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_moves(self):
|
||||||
|
"""Return action to view related account.move records."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Проводки',
|
||||||
|
'res_model': 'account.move',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('id', 'in', self.move_ids.ids)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_move_line_amounts(self):
|
||||||
|
"""Override in inheriting models to provide line amounts.
|
||||||
|
Returns list of dicts: [{'name': str, 'amount': float}, ...]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
103
account_move_templates/models/account_move_template_wizard.py
Normal file
103
account_move_templates/models/account_move_template_wizard.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveTemplateWizardLine(models.TransientModel):
|
||||||
|
_name = 'account.move.template.wizard.line'
|
||||||
|
_description = 'Черновая строка визарда шаблона'
|
||||||
|
|
||||||
|
wizard_id = fields.Many2one(
|
||||||
|
comodel_name='account.move.template.wizard',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
account_id = fields.Many2one(
|
||||||
|
comodel_name='account.account',
|
||||||
|
string='Счёт',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
move_type = fields.Selection(
|
||||||
|
selection=[('debit', 'Дебет'), ('credit', 'Кредит')],
|
||||||
|
string='Сторона',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
amount = fields.Float(
|
||||||
|
string='Сумма',
|
||||||
|
digits=(16, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveTemplateWizard(models.TransientModel):
|
||||||
|
_name = 'account.move.template.wizard'
|
||||||
|
_description = 'Визард создания проводки по шаблону'
|
||||||
|
|
||||||
|
res_model = fields.Char(string='Модель документа', required=True)
|
||||||
|
res_id = fields.Integer(string='ID документа', required=True)
|
||||||
|
template_id = fields.Many2one(
|
||||||
|
comodel_name='account.move.template',
|
||||||
|
string='Шаблон',
|
||||||
|
)
|
||||||
|
amount = fields.Float(
|
||||||
|
string='Базовая сумма',
|
||||||
|
digits=(16, 2),
|
||||||
|
)
|
||||||
|
draft_line_ids = fields.One2many(
|
||||||
|
comodel_name='account.move.template.wizard.line',
|
||||||
|
inverse_name='wizard_id',
|
||||||
|
string='Черновые строки',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.onchange('template_id', 'amount')
|
||||||
|
def _onchange_compute_draft_lines(self):
|
||||||
|
"""Recompute draft lines when template or amount changes."""
|
||||||
|
self.draft_line_ids = [(5, 0, 0)] # clear existing
|
||||||
|
if not self.template_id or not self.amount:
|
||||||
|
return
|
||||||
|
lines = []
|
||||||
|
for tpl_line in self.template_id.line_ids:
|
||||||
|
line_amount = self.amount * tpl_line.percent / 100.0
|
||||||
|
lines.append((0, 0, {
|
||||||
|
'account_id': tpl_line.account_id.id,
|
||||||
|
'move_type': tpl_line.move_type,
|
||||||
|
'amount': line_amount,
|
||||||
|
}))
|
||||||
|
self.draft_line_ids = lines
|
||||||
|
|
||||||
|
def action_post(self):
|
||||||
|
"""Create account.move from draft lines and link to source document."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.template_id or not self.amount:
|
||||||
|
raise UserError('Необходимо выбрать шаблон и указать базовую сумму.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build move lines
|
||||||
|
move_line_vals = []
|
||||||
|
for draft_line in self.draft_line_ids:
|
||||||
|
if draft_line.move_type == 'debit':
|
||||||
|
debit = draft_line.amount
|
||||||
|
credit = 0.0
|
||||||
|
else:
|
||||||
|
debit = 0.0
|
||||||
|
credit = draft_line.amount
|
||||||
|
move_line_vals.append((0, 0, {
|
||||||
|
'account_id': draft_line.account_id.id,
|
||||||
|
'debit': debit,
|
||||||
|
'credit': credit,
|
||||||
|
'name': self.template_id.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Create the move
|
||||||
|
move = self.env['account.move'].create({
|
||||||
|
'move_type': 'entry',
|
||||||
|
'state': 'draft',
|
||||||
|
'line_ids': move_line_vals,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Link move to source document via move_ids
|
||||||
|
source_record = self.env[self.res_model].browse(self.res_id)
|
||||||
|
source_record.move_ids = [(4, move.id)]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError(f'Ошибка при создании проводки: {str(e)}') from e
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
6
account_move_templates/security/ir.model.access.csv
Normal file
6
account_move_templates/security/ir.model.access.csv
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_account_move_template_user,account.move.template user,model_account_move_template,account.group_account_user,1,1,1,1
|
||||||
|
access_account_move_template_line_user,account.move.template.line user,model_account_move_template_line,account.group_account_user,1,1,1,1
|
||||||
|
access_account_move_template_tag_user,account.move.template.tag user,model_account_move_template_tag,account.group_account_user,1,1,1,1
|
||||||
|
access_account_move_template_wizard_user,account.move.template.wizard user,model_account_move_template_wizard,account.group_account_user,1,1,1,1
|
||||||
|
access_account_move_template_wizard_line_user,account.move.template.wizard.line user,model_account_move_template_wizard_line,account.group_account_user,1,1,1,1
|
||||||
|
1
account_move_templates/tests/__init__.py
Normal file
1
account_move_templates/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_template
|
||||||
200
account_move_templates/tests/test_template.py
Normal file
200
account_move_templates/tests/test_template.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Tests for Template_Engine (account_move_templates).
|
||||||
|
|
||||||
|
Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5
|
||||||
|
"""
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_account(env, account_type='asset_current'):
|
||||||
|
"""Return an existing account of the given type, or create one."""
|
||||||
|
account = env['account.account'].search(
|
||||||
|
[('account_type', '=', account_type)], limit=1
|
||||||
|
)
|
||||||
|
if not account:
|
||||||
|
account = env['account.account'].create({
|
||||||
|
'name': f'Test Account ({account_type})',
|
||||||
|
'code': f'TST{account_type[:4].upper()}',
|
||||||
|
'account_type': account_type,
|
||||||
|
})
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def _make_template(env, name='Test Template', lines=None):
|
||||||
|
"""Create an AccountMoveTemplate with the given lines.
|
||||||
|
|
||||||
|
lines: list of dicts with keys: account_id, move_type, percent, line_type
|
||||||
|
"""
|
||||||
|
vals = {'name': name}
|
||||||
|
if lines is not None:
|
||||||
|
vals['line_ids'] = [(0, 0, line) for line in lines]
|
||||||
|
return env['account.move.template'].create(vals)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMoveTemplateValidation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMoveTemplateValidation(TransactionCase):
|
||||||
|
"""Validates: Requirements 3.1, 3.2, 3.3, 3.4"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.account_debit = _get_account(self.env, 'asset_current')
|
||||||
|
self.account_credit = _get_account(self.env, 'liability_current')
|
||||||
|
|
||||||
|
def _line(self, move_type, percent, account=None):
|
||||||
|
if account is None:
|
||||||
|
account = self.account_debit if move_type == 'debit' else self.account_credit
|
||||||
|
return {
|
||||||
|
'account_id': account.id,
|
||||||
|
'move_type': move_type,
|
||||||
|
'percent': percent,
|
||||||
|
'line_type': 'product',
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_no_lines_raises_validation_error(self):
|
||||||
|
"""Req 3.1 — template without lines raises ValidationError."""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
_make_template(self.env, name='Empty Template', lines=[])
|
||||||
|
|
||||||
|
def test_imbalanced_percentages_raises_validation_error(self):
|
||||||
|
"""Req 3.2 — debit sum != credit sum raises ValidationError."""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
_make_template(self.env, name='Imbalanced', lines=[
|
||||||
|
self._line('debit', 60.0),
|
||||||
|
self._line('credit', 40.0),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_balanced_percentages_saves_without_error(self):
|
||||||
|
"""Req 3.3 — debit sum == credit sum saves without error."""
|
||||||
|
template = _make_template(self.env, name='Balanced', lines=[
|
||||||
|
self._line('debit', 100.0),
|
||||||
|
self._line('credit', 100.0),
|
||||||
|
])
|
||||||
|
self.assertTrue(template.id, "Template should be saved with a valid ID")
|
||||||
|
|
||||||
|
def test_percent_below_minimum_raises_validation_error(self):
|
||||||
|
"""Req 3.4 — percent < 0.01 raises ValidationError."""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
_make_template(self.env, name='Low Percent', lines=[
|
||||||
|
self._line('debit', 0.001),
|
||||||
|
self._line('credit', 0.001),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_percent_above_maximum_raises_validation_error(self):
|
||||||
|
"""Req 3.4 — percent > 100.0 raises ValidationError."""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
_make_template(self.env, name='High Percent', lines=[
|
||||||
|
self._line('debit', 101.0),
|
||||||
|
self._line('credit', 101.0),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMoveTemplateWizard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMoveTemplateWizard(TransactionCase):
|
||||||
|
"""Validates: Requirement 3.5"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.account_debit = _get_account(self.env, 'asset_current')
|
||||||
|
self.account_credit = _get_account(self.env, 'liability_current')
|
||||||
|
|
||||||
|
# Create a balanced template: 100% debit / 100% credit
|
||||||
|
self.template = self.env['account.move.template'].create({
|
||||||
|
'name': 'Wizard Test Template',
|
||||||
|
'line_ids': [
|
||||||
|
(0, 0, {
|
||||||
|
'account_id': self.account_debit.id,
|
||||||
|
'move_type': 'debit',
|
||||||
|
'percent': 100.0,
|
||||||
|
'line_type': 'product',
|
||||||
|
}),
|
||||||
|
(0, 0, {
|
||||||
|
'account_id': self.account_credit.id,
|
||||||
|
'move_type': 'credit',
|
||||||
|
'percent': 100.0,
|
||||||
|
'line_type': 'product',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a source document that uses the mixin (account.move itself
|
||||||
|
# does not use the mixin, so we create a minimal move to act as source)
|
||||||
|
self.source_move = self.env['account.move'].create({
|
||||||
|
'move_type': 'entry',
|
||||||
|
'state': 'draft',
|
||||||
|
'line_ids': [
|
||||||
|
(0, 0, {
|
||||||
|
'account_id': self.account_debit.id,
|
||||||
|
'debit': 0.0,
|
||||||
|
'credit': 0.0,
|
||||||
|
'name': 'Source line',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_apply_template_creates_draft_move_with_correct_distribution(self):
|
||||||
|
"""Req 3.5 — applying template via wizard creates a draft account.move
|
||||||
|
with correct account distribution."""
|
||||||
|
base_amount = 1000.0
|
||||||
|
|
||||||
|
wizard = self.env['account.move.template.wizard'].create({
|
||||||
|
'res_model': 'account.move',
|
||||||
|
'res_id': self.source_move.id,
|
||||||
|
'template_id': self.template.id,
|
||||||
|
'amount': base_amount,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Trigger onchange to populate draft lines
|
||||||
|
wizard._onchange_compute_draft_lines()
|
||||||
|
|
||||||
|
# Verify draft lines were computed
|
||||||
|
self.assertEqual(len(wizard.draft_line_ids), 2,
|
||||||
|
"Wizard should have 2 draft lines (one debit, one credit)")
|
||||||
|
|
||||||
|
debit_line = wizard.draft_line_ids.filtered(lambda l: l.move_type == 'debit')
|
||||||
|
credit_line = wizard.draft_line_ids.filtered(lambda l: l.move_type == 'credit')
|
||||||
|
|
||||||
|
self.assertAlmostEqual(debit_line.amount, base_amount,
|
||||||
|
msg="Debit line amount should equal base_amount * 100%")
|
||||||
|
self.assertAlmostEqual(credit_line.amount, base_amount,
|
||||||
|
msg="Credit line amount should equal base_amount * 100%")
|
||||||
|
|
||||||
|
# Post the wizard — creates the account.move
|
||||||
|
wizard.action_post()
|
||||||
|
|
||||||
|
# Reload source move and check linked moves
|
||||||
|
self.source_move.invalidate_recordset()
|
||||||
|
linked_moves = self.source_move.move_ids
|
||||||
|
|
||||||
|
self.assertTrue(linked_moves, "A new account.move should be linked to the source")
|
||||||
|
|
||||||
|
new_move = linked_moves[-1]
|
||||||
|
self.assertEqual(new_move.state, 'draft',
|
||||||
|
"Created move should be in draft state")
|
||||||
|
|
||||||
|
move_lines = new_move.line_ids
|
||||||
|
debit_move_line = move_lines.filtered(lambda l: l.debit > 0)
|
||||||
|
credit_move_line = move_lines.filtered(lambda l: l.credit > 0)
|
||||||
|
|
||||||
|
self.assertTrue(debit_move_line, "Move should have a debit line")
|
||||||
|
self.assertTrue(credit_move_line, "Move should have a credit line")
|
||||||
|
|
||||||
|
self.assertAlmostEqual(debit_move_line[0].debit, base_amount,
|
||||||
|
msg="Debit amount on move line should match base_amount")
|
||||||
|
self.assertAlmostEqual(credit_move_line[0].credit, base_amount,
|
||||||
|
msg="Credit amount on move line should match base_amount")
|
||||||
|
|
||||||
|
self.assertEqual(debit_move_line[0].account_id, self.account_debit,
|
||||||
|
"Debit line should use the debit account from template")
|
||||||
|
self.assertEqual(credit_move_line[0].account_id, self.account_credit,
|
||||||
|
"Credit line should use the credit account from template")
|
||||||
82
account_move_templates/views/account_move_template_views.xml
Normal file
82
account_move_templates/views/account_move_template_views.xml
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
|
||||||
|
<!-- List view -->
|
||||||
|
<record id="view_account_move_template_tree" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.template.list</field>
|
||||||
|
<field name="model">account.move.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="description"/>
|
||||||
|
<field name="tag_ids" widget="many2many_tags"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Form view -->
|
||||||
|
<record id="view_account_move_template_form" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.template.form</field>
|
||||||
|
<field name="model">account.move.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Название шаблона"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="description"/>
|
||||||
|
<field name="tag_ids" widget="many2many_tags"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Строки шаблона" name="lines">
|
||||||
|
<field name="line_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="account_id"/>
|
||||||
|
<field name="move_type"/>
|
||||||
|
<field name="percent"/>
|
||||||
|
<field name="line_type"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Search view -->
|
||||||
|
<record id="view_account_move_template_search" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.template.search</field>
|
||||||
|
<field name="model">account.move.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name" string="Название"/>
|
||||||
|
<field name="tag_ids" string="Признак"/>
|
||||||
|
<filter name="group_by_tag" string="По признаку" context="{'group_by': 'tag_ids'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_account_move_template" model="ir.actions.act_window">
|
||||||
|
<field name="name">Шаблоны типовых операций</field>
|
||||||
|
<field name="res_model">account.move.template</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_account_move_template_search"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu item under Accounting → Configuration -->
|
||||||
|
<menuitem
|
||||||
|
id="menu_account_move_template"
|
||||||
|
name="Шаблоны типовых операций"
|
||||||
|
parent="account.menu_finance_configuration"
|
||||||
|
action="action_account_move_template"
|
||||||
|
sequence="100"/>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="view_account_move_template_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.template.wizard.form</field>
|
||||||
|
<field name="model">account.move.template.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Создать проводку по шаблону">
|
||||||
|
<group>
|
||||||
|
<field name="template_id"/>
|
||||||
|
<field name="amount"/>
|
||||||
|
</group>
|
||||||
|
<field name="draft_line_ids" readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="account_id"/>
|
||||||
|
<field name="move_type"/>
|
||||||
|
<field name="amount"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
<footer>
|
||||||
|
<button name="action_post" type="object" string="Провести"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="not template_id or amount <= 0"/>
|
||||||
|
<button string="Отмена" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
103
account_move_templates_invoice/README.rst
Normal file
103
account_move_templates_invoice/README.rst
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
====================================================
|
||||||
|
Шаблоны типовых операций - Интеграция с инвойсами
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||||
|
:target: https://odoo-community.org/page/development-status
|
||||||
|
:alt: Beta
|
||||||
|
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
|
||||||
|
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
|
||||||
|
:alt: License: LGPL-3
|
||||||
|
|
||||||
|
|badge1| |badge2|
|
||||||
|
|
||||||
|
Модуль расширяет функциональность шаблонов типовых операций для работы
|
||||||
|
с инвойсами клиентов и счетами поставщиков, автоматически применяя шаблоны
|
||||||
|
при проведении документов.
|
||||||
|
|
||||||
|
**Содержание**
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Возможности
|
||||||
|
===========
|
||||||
|
|
||||||
|
* Автоматическое применение шаблонов при проведении инвойсов
|
||||||
|
* Замена стандартного распределения по счетам на распределение из шаблона
|
||||||
|
* Поддержка инвойсов клиентов и счетов поставщиков
|
||||||
|
* Настройка через параметры системы
|
||||||
|
* Сохранение строк инвойса с обновлением счетов в проводках
|
||||||
|
* Автоматическое создание строк оплаты с датами погашения
|
||||||
|
|
||||||
|
Настройка
|
||||||
|
=========
|
||||||
|
|
||||||
|
Для настройки модуля необходимо:
|
||||||
|
|
||||||
|
#. Перейти в *Бухгалтерия > Настройка > Параметры*
|
||||||
|
#. В разделе "Шаблоны типовых операций" включить опцию "Использовать шаблоны для инвойсов"
|
||||||
|
#. Сохранить настройки
|
||||||
|
|
||||||
|
Настройка шаблонов:
|
||||||
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
При создании шаблонов для инвойсов настройте типы строк:
|
||||||
|
|
||||||
|
* **Продуктовая строка**: Заменяет счёт в существующих продуктовых строках (счета доходов/расходов)
|
||||||
|
* **Строка оплаты**: Создаёт строки дебиторки/кредиторки с датами погашения
|
||||||
|
|
||||||
|
Использование
|
||||||
|
=============
|
||||||
|
|
||||||
|
После настройки модуль работает автоматически:
|
||||||
|
|
||||||
|
#. Создайте инвойс клиента или счёт поставщика
|
||||||
|
#. Добавьте строки инвойса как обычно
|
||||||
|
#. Выберите шаблон типовой операции в поле "Шаблон типовой операции"
|
||||||
|
#. Нажмите "Провести"
|
||||||
|
|
||||||
|
Модуль выполнит следующие действия:
|
||||||
|
|
||||||
|
* Удалит стандартные строки условий оплаты
|
||||||
|
* Обновит счета в продуктовых строках согласно шаблону (line_type='product')
|
||||||
|
* Создаст новые строки оплаты согласно шаблону (line_type='payment')
|
||||||
|
* Установит корректные даты погашения для счетов дебиторки/кредиторки
|
||||||
|
* Проведёт инвойс с распределением по счетам из шаблона
|
||||||
|
|
||||||
|
Известные проблемы / Планы развития
|
||||||
|
====================================
|
||||||
|
|
||||||
|
* В настоящее время поддерживаются только типы документов out_invoice и in_invoice
|
||||||
|
* Шаблон должен быть выбран до проведения документа
|
||||||
|
* В планах: Автоматический выбор шаблона на основе свойств инвойса
|
||||||
|
|
||||||
|
Отслеживание ошибок
|
||||||
|
===================
|
||||||
|
|
||||||
|
Ошибки отслеживаются на `GitHub Issues <https://github.com/OCA/account-financial-tools/issues>`_.
|
||||||
|
В случае проблем, пожалуйста, проверьте, не была ли ваша проблема уже зарегистрирована.
|
||||||
|
|
||||||
|
Авторы
|
||||||
|
======
|
||||||
|
|
||||||
|
Авторы
|
||||||
|
~~~~~~
|
||||||
|
|
||||||
|
* MK.Lab, RuOdoo
|
||||||
|
|
||||||
|
Участники
|
||||||
|
~~~~~~~~~
|
||||||
|
|
||||||
|
* MK.Lab, RuOdoo
|
||||||
|
|
||||||
|
Сопровождающие
|
||||||
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Модуль сопровождается MK.Lab, RuOdoo.
|
||||||
|
|
||||||
|
.. image:: https://ruodoo.ru/logo.png
|
||||||
|
:alt: MK.Lab, RuOdoo
|
||||||
|
:target: https://ruodoo.ru
|
||||||
|
|
||||||
|
Модуль является частью проекта расширений для бухгалтерии.
|
||||||
1
account_move_templates_invoice/__init__.py
Normal file
1
account_move_templates_invoice/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
42
account_move_templates_invoice/__manifest__.py
Normal file
42
account_move_templates_invoice/__manifest__.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
'name': 'Accounting Journal Templates - Invoice',
|
||||||
|
'version': '19.0.1.0.0',
|
||||||
|
'summary': 'Интеграция шаблонов типовых операций с Invoice и Vendor Bill',
|
||||||
|
'description': """
|
||||||
|
Шаблоны типовых операций - Интеграция с инвойсами
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Автоматическое применение шаблонов типовых операций к инвойсам клиентов и счетам
|
||||||
|
поставщиков при проведении, заменяя стандартное распределение по счетам на
|
||||||
|
распределение из шаблона.
|
||||||
|
|
||||||
|
Возможности:
|
||||||
|
------------
|
||||||
|
* Автоматическое применение шаблонов при проведении инвойсов
|
||||||
|
* Замена счетов в продуктовых строках на счета из шаблона
|
||||||
|
* Создание строк условий оплаты с корректными датами погашения
|
||||||
|
* Поддержка инвойсов клиентов и счетов поставщиков
|
||||||
|
* Настройка через параметры системы
|
||||||
|
* Сохранение итоговых сумм инвойса
|
||||||
|
|
||||||
|
Технические детали:
|
||||||
|
-------------------
|
||||||
|
* Расширяет модель account.move
|
||||||
|
* Переопределяет метод action_post()
|
||||||
|
* Обновляет существующие продуктовые строки вместо создания дубликатов
|
||||||
|
* Создаёт строки payment_term для счетов дебиторки/кредиторки
|
||||||
|
* Поддерживает баланс и валидацию инвойса
|
||||||
|
""",
|
||||||
|
'author': 'MK.Lab, RuOdoo',
|
||||||
|
'website': 'https://ruodoo.ru',
|
||||||
|
'category': 'Accounting/Accounting',
|
||||||
|
'depends': ['account', 'account_move_templates'],
|
||||||
|
'data': [
|
||||||
|
'views/account_move_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'maintainers': ['mklab', 'ruodoo'],
|
||||||
|
}
|
||||||
3
account_move_templates_invoice/models/__init__.py
Normal file
3
account_move_templates_invoice/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import account_move
|
||||||
|
from . import account_move_line
|
||||||
|
from . import res_config_settings
|
||||||
151
account_move_templates_invoice/models/account_move.py
Normal file
151
account_move_templates_invoice/models/account_move.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = ['account.move', 'account.move.template.mixin']
|
||||||
|
|
||||||
|
move_ids = fields.Many2many(
|
||||||
|
comodel_name='account.move',
|
||||||
|
relation='account_move_template_invoice_move_rel',
|
||||||
|
column1='document_id',
|
||||||
|
column2='move_id',
|
||||||
|
string='Проводки по шаблону',
|
||||||
|
)
|
||||||
|
|
||||||
|
journal_template_id = fields.Many2one(
|
||||||
|
comodel_name='account.move.template',
|
||||||
|
string='Шаблон типовой операции',
|
||||||
|
default=lambda self: self._default_journal_template(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _default_journal_template(self):
|
||||||
|
use = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'account_move_templates_invoice.use_journal_templates_for_invoices'
|
||||||
|
)
|
||||||
|
if use:
|
||||||
|
return self.env['account.move.template'].search([], limit=1, order='id asc')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_move_line_amounts(self):
|
||||||
|
"""Return invoice total as the base amount for the wizard."""
|
||||||
|
self.ensure_one()
|
||||||
|
return [{'name': self.name or '/', 'amount': self.amount_total}]
|
||||||
|
|
||||||
|
def _replace_lines_from_template(self, move):
|
||||||
|
"""
|
||||||
|
Replace ALL lines (product + payment_term) with template lines
|
||||||
|
plus one explicit payment_term line with date_maturity.
|
||||||
|
|
||||||
|
We pass 'balance' explicitly in vals so _get_protected_vals protects
|
||||||
|
it from being overwritten by _sync_invoice.
|
||||||
|
"""
|
||||||
|
_logger.info("TEMPLATE DEBUG: _replace_lines_from_template START for move %s", move.id)
|
||||||
|
template = move.journal_template_id
|
||||||
|
_logger.info("TEMPLATE DEBUG: template = %s", template)
|
||||||
|
base_amount = move.amount_untaxed or move.amount_total
|
||||||
|
_logger.info("TEMPLATE DEBUG: base_amount = %s", base_amount)
|
||||||
|
|
||||||
|
# Determine receivable/payable account
|
||||||
|
partner = move.commercial_partner_id.with_company(move.company_id)
|
||||||
|
if move.is_sale_document(include_receipts=True):
|
||||||
|
term_account = partner.property_account_receivable_id
|
||||||
|
else:
|
||||||
|
term_account = partner.property_account_payable_id
|
||||||
|
_logger.info("TEMPLATE DEBUG: term_account = %s", term_account)
|
||||||
|
|
||||||
|
# due date: use invoice_date_due, invoice_date, or today
|
||||||
|
due_date = (
|
||||||
|
move.invoice_date_due
|
||||||
|
or move.invoice_date
|
||||||
|
or fields.Date.context_today(move)
|
||||||
|
)
|
||||||
|
_logger.info("TEMPLATE DEBUG: due_date = %s", due_date)
|
||||||
|
|
||||||
|
# Remove only payment_term lines, keep product lines from invoice_line_ids
|
||||||
|
_logger.info("TEMPLATE DEBUG: Removing existing payment_term lines")
|
||||||
|
lines_to_remove = move.line_ids.filtered(lambda l: l.display_type == 'payment_term')
|
||||||
|
_logger.info("TEMPLATE DEBUG: Lines to remove: %s", [(l.id, l.account_id.code, l.display_type) for l in lines_to_remove])
|
||||||
|
lines_to_remove.with_context(
|
||||||
|
dynamic_unlink=True,
|
||||||
|
check_move_validity=False,
|
||||||
|
skip_invoice_sync=True,
|
||||||
|
).unlink()
|
||||||
|
_logger.info("TEMPLATE DEBUG: payment_term lines removed")
|
||||||
|
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
ctx = dict(check_move_validity=False, skip_invoice_sync=True)
|
||||||
|
|
||||||
|
# 1. Process template lines based on line_type
|
||||||
|
_logger.info("TEMPLATE DEBUG: Processing template lines")
|
||||||
|
|
||||||
|
# Get existing product lines from invoice_line_ids
|
||||||
|
product_lines = move.line_ids.filtered(lambda l: l.display_type == 'product')
|
||||||
|
_logger.info("TEMPLATE DEBUG: Found %d product lines", len(product_lines))
|
||||||
|
|
||||||
|
# Separate template lines by type
|
||||||
|
template_product_lines = template.line_ids.filtered(lambda l: l.line_type == 'product')
|
||||||
|
template_payment_lines = template.line_ids.filtered(lambda l: l.line_type == 'payment')
|
||||||
|
|
||||||
|
_logger.info("TEMPLATE DEBUG: Template has %d product lines and %d payment lines",
|
||||||
|
len(template_product_lines), len(template_payment_lines))
|
||||||
|
|
||||||
|
# Update existing product lines with accounts from template (line_type='product')
|
||||||
|
for idx, product_line in enumerate(product_lines):
|
||||||
|
if idx < len(template_product_lines):
|
||||||
|
tpl_line = template_product_lines[idx]
|
||||||
|
_logger.info("TEMPLATE DEBUG: Updating product line %d: old account=%s, new account=%s",
|
||||||
|
product_line.id, product_line.account_id.code, tpl_line.account_id.code)
|
||||||
|
product_line.with_context(**ctx).write({'account_id': tpl_line.account_id.id})
|
||||||
|
|
||||||
|
# Create payment lines from template (line_type='payment')
|
||||||
|
_logger.info("TEMPLATE DEBUG: Creating payment lines from template")
|
||||||
|
for tpl_line in template_payment_lines:
|
||||||
|
line_amount = base_amount * tpl_line.percent / 100.0
|
||||||
|
balance = line_amount if tpl_line.move_type == 'debit' else -line_amount
|
||||||
|
_logger.info("TEMPLATE DEBUG: Creating payment line: account=%s, balance=%s",
|
||||||
|
tpl_line.account_id.id, balance)
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'move_id': move.id,
|
||||||
|
'account_id': tpl_line.account_id.id,
|
||||||
|
'name': template.name,
|
||||||
|
'balance': balance,
|
||||||
|
'amount_currency': balance,
|
||||||
|
'date_maturity': due_date,
|
||||||
|
'display_type': 'payment_term',
|
||||||
|
}
|
||||||
|
|
||||||
|
AML.with_context(**ctx).create(vals)
|
||||||
|
_logger.info("TEMPLATE DEBUG: Template lines processed")
|
||||||
|
|
||||||
|
_logger.info("TEMPLATE DEBUG: _replace_lines_from_template END")
|
||||||
|
|
||||||
|
def action_post(self):
|
||||||
|
_logger.info("TEMPLATE DEBUG: action_post called for moves: %s", self.ids)
|
||||||
|
use_templates = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'account_move_templates_invoice.use_journal_templates_for_invoices'
|
||||||
|
)
|
||||||
|
_logger.info("TEMPLATE DEBUG: use_templates = %s", use_templates)
|
||||||
|
|
||||||
|
if use_templates:
|
||||||
|
for move in self.filtered(
|
||||||
|
lambda m: m.move_type in ('out_invoice', 'in_invoice')
|
||||||
|
):
|
||||||
|
_logger.info("TEMPLATE DEBUG: Processing move %s, template: %s", move.id, move.journal_template_id)
|
||||||
|
if not move.journal_template_id:
|
||||||
|
raise UserError(
|
||||||
|
'Выберите шаблон типовой операции перед проведением.'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_logger.info("TEMPLATE DEBUG: Calling _replace_lines_from_template")
|
||||||
|
self._replace_lines_from_template(move)
|
||||||
|
_logger.info("TEMPLATE DEBUG: _replace_lines_from_template completed")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("TEMPLATE DEBUG: Error in _replace_lines_from_template: %s", e, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return super().action_post()
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLine(models.Model):
|
||||||
|
_inherit = 'account.move.line'
|
||||||
|
|
||||||
|
# No overrides needed - we handle everything in action_post()
|
||||||
10
account_move_templates_invoice/models/res_config_settings.py
Normal file
10
account_move_templates_invoice/models/res_config_settings.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
use_journal_templates_for_invoices = fields.Boolean(
|
||||||
|
string='Использовать шаблоны типовых операций для счетов',
|
||||||
|
config_parameter='account_move_templates_invoice.use_journal_templates_for_invoices',
|
||||||
|
)
|
||||||
31
account_move_templates_invoice/views/account_move_views.xml
Normal file
31
account_move_templates_invoice/views/account_move_views.xml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="view_account_move_template_invoice_form" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.template.invoice.form</field>
|
||||||
|
<field name="model">account.move</field>
|
||||||
|
<field name="inherit_id" ref="account.view_move_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
|
||||||
|
<!-- Smart button: visible when move_count > 0 -->
|
||||||
|
<xpath expr="//div[@name='button_box']" position="inside">
|
||||||
|
<button type="object"
|
||||||
|
name="action_view_moves"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-book"
|
||||||
|
invisible="move_count == 0">
|
||||||
|
<field name="move_count" widget="statinfo" string="Проводки"/>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Template selector after invoice_date -->
|
||||||
|
<xpath expr="//field[@name='invoice_date']" position="after">
|
||||||
|
<field name="journal_template_id"
|
||||||
|
string="Шаблон типовой операции"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="res_config_settings_view_form_template_invoice" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.template.invoice</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//app[@name='account']" position="inside">
|
||||||
|
<block title="Шаблоны типовых операций" name="journal_templates_setting_container">
|
||||||
|
<setting help="Использовать шаблоны типовых операций вместо стандартных проводок для счетов">
|
||||||
|
<field name="use_journal_templates_for_invoices"/>
|
||||||
|
<label for="use_journal_templates_for_invoices"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
307
base_tier_validation/README.rst
Normal file
307
base_tier_validation/README.rst
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
.. image:: https://odoo-community.org/readme-banner-image
|
||||||
|
:target: https://odoo-community.org/get-involved?utm_source=readme
|
||||||
|
:alt: Odoo Community Association
|
||||||
|
|
||||||
|
====================
|
||||||
|
Base Tier Validation
|
||||||
|
====================
|
||||||
|
|
||||||
|
..
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
!! This file is generated by oca-gen-addon-readme !!
|
||||||
|
!! changes will be overwritten. !!
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
!! source digest: sha256:3e44413fd72f8949deba313c3f31047cbf30ba3b17d78342590d2376001d905f
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
|
||||||
|
.. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png
|
||||||
|
:target: https://odoo-community.org/page/development-status
|
||||||
|
:alt: Mature
|
||||||
|
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
|
||||||
|
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||||
|
:alt: License: AGPL-3
|
||||||
|
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--ux-lightgray.png?logo=github
|
||||||
|
:target: https://github.com/OCA/server-ux/tree/18.0/base_tier_validation
|
||||||
|
:alt: OCA/server-ux
|
||||||
|
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
|
||||||
|
:target: https://translation.odoo-community.org/projects/server-ux-18-0/server-ux-18-0-base_tier_validation
|
||||||
|
:alt: Translate me on Weblate
|
||||||
|
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
|
||||||
|
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-ux&target_branch=18.0
|
||||||
|
:alt: Try me on Runboat
|
||||||
|
|
||||||
|
|badge1| |badge2| |badge3| |badge4| |badge5|
|
||||||
|
|
||||||
|
Validating some operations is a common need across different areas in a
|
||||||
|
company and sometimes it also involves several people and stages in the
|
||||||
|
process. With this module you will be able to define your custom
|
||||||
|
validation workflows for any Odoo document.
|
||||||
|
|
||||||
|
This module does not provide a functionality by itself but an abstract
|
||||||
|
model to implement a validation process based on tiers on other models
|
||||||
|
(e.g. purchase orders, sales orders, budgets, expenses...).
|
||||||
|
|
||||||
|
**Note:** To be able to use this module in a new model you will need
|
||||||
|
some development.
|
||||||
|
|
||||||
|
See
|
||||||
|
`purchase_tier_validation <https://github.com/OCA/purchase-workflow>`__
|
||||||
|
as an example of implementation.
|
||||||
|
|
||||||
|
Additionally, if your state field is a (stored) computed field, you need
|
||||||
|
to set ``_tier_validation_state_field_is_computed`` to ``True`` in your
|
||||||
|
model Python file, and you will want to add the dependent fields of the
|
||||||
|
compute method in ``_get_after_validation_exceptions`` and
|
||||||
|
``_get_under_validation_exceptions``.
|
||||||
|
|
||||||
|
**Table of contents**
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
To configure this module, you need to:
|
||||||
|
|
||||||
|
1. Go to *Settings > Technical > Tier Validations > Tier Definition*.
|
||||||
|
2. Create as many tiers as you want for any model having tier validation
|
||||||
|
functionality.
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- If check *Notify Reviewers on Creation*, all possible reviewers will
|
||||||
|
be notified by email when this definition is triggered.
|
||||||
|
- If check *Notify reviewers on reaching pending* if you want to send a
|
||||||
|
notification when pending status is reached. This is usefull in a
|
||||||
|
approve by sequence scenario to only notify reviewers when it is their
|
||||||
|
turn in the sequence.
|
||||||
|
- If check *Comment*, reviewers can comment after click Validate or
|
||||||
|
Reject.
|
||||||
|
- If check *Approve by sequence*, reviewers is forced to review by
|
||||||
|
specified sequence.
|
||||||
|
|
||||||
|
To configure Tier Validation Exceptions, you need to:
|
||||||
|
|
||||||
|
1. Go to *Settings > Technical > Tier Validations > Tier Validation
|
||||||
|
Exceptions*.
|
||||||
|
2. Create as many tiers validation exceptions as you want for any model
|
||||||
|
having tier validation functionality.
|
||||||
|
3. Add desired fields to be checked in *Fields*.
|
||||||
|
4. Add desired groups that can use this Exception in *Groups*.
|
||||||
|
5. You must check *Write under Validation*, *Write after Validation* or
|
||||||
|
both.
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- If you don't create any exception, the Validated record will be
|
||||||
|
readonly and cannot be modified.
|
||||||
|
- If check *Write under Validation*, records will be able to be modified
|
||||||
|
only in the defined fields when the Validation process is ongoing.
|
||||||
|
- If check *Write after Validation*, records will be able to be modified
|
||||||
|
only in the defined fields when the Validation process is finished.
|
||||||
|
- If check *Write after Validation* and *Write under Validation*,
|
||||||
|
records will be able to be modified defined fields always.
|
||||||
|
|
||||||
|
Known issues / Roadmap
|
||||||
|
======================
|
||||||
|
|
||||||
|
This is the list of known issues for this module. Any proposal for
|
||||||
|
improvement will be very valuable.
|
||||||
|
|
||||||
|
- **Issue:**
|
||||||
|
|
||||||
|
When using approve_sequence option in any tier.definition there can be
|
||||||
|
inconsistencies in the systray notifications.
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
|
||||||
|
Field can_review in tier.review is used to filter out, in the systray
|
||||||
|
notifications, the reviews a user can approve. This can_review field
|
||||||
|
is updated **in the database** in method review_user_count, this can
|
||||||
|
make it very inconsistent for databases with a lot of users and
|
||||||
|
recurring updates that can change the expected behavior.
|
||||||
|
|
||||||
|
- **Migration to 15.0:**
|
||||||
|
|
||||||
|
The parameter \_tier_validation_manual_config will become False, on
|
||||||
|
14.0, the default value is True, as the change is applied after the
|
||||||
|
migration. In order to use the new behavior we need to modify the
|
||||||
|
value on our expected model.
|
||||||
|
|
||||||
|
Changelog
|
||||||
|
=========
|
||||||
|
|
||||||
|
17.0.1.0.0 (2024-01-10)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Migrated to Odoo 17. Merged module with tier_validation_waiting. To
|
||||||
|
support sending messages in a validation sequence when it is their turn
|
||||||
|
to validate.
|
||||||
|
|
||||||
|
14.0.1.0.0 (2020-11-19)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Migrated to Odoo 14.
|
||||||
|
|
||||||
|
13.0.1.2.2 (2020-08-30)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- When using approve_sequence option in any tier.definition there can be
|
||||||
|
inconsistencies in the systray notifications
|
||||||
|
- When using approve_sequence, still not approve only the needed
|
||||||
|
sequence, but also other sequence for the same approver
|
||||||
|
|
||||||
|
12.0.3.3.1 (2019-12-02)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Show comment on Reviews Table.
|
||||||
|
- Edit notification with approve_sequence.
|
||||||
|
|
||||||
|
12.0.3.3.0 (2019-11-27)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
New features:
|
||||||
|
|
||||||
|
- Add comment on Reviews Table.
|
||||||
|
- Approve by sequence.
|
||||||
|
|
||||||
|
12.0.3.2.1 (2019-11-26)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Remove message_subscribe_users
|
||||||
|
|
||||||
|
12.0.3.2.0 (2019-11-25)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
New features:
|
||||||
|
|
||||||
|
- Notify reviewers
|
||||||
|
|
||||||
|
12.0.3.1.0 (2019-07-08)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Singleton error
|
||||||
|
|
||||||
|
12.0.3.0.0 (2019-12-02)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Edit Reviews Table
|
||||||
|
|
||||||
|
12.0.2.1.0 (2019-05-29)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Edit drop-down style width and position
|
||||||
|
|
||||||
|
12.0.2.0.0 (2019-05-28)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
New features:
|
||||||
|
|
||||||
|
- Pass parameters as functions.
|
||||||
|
- Add Systray.
|
||||||
|
|
||||||
|
12.0.1.0.0 (2019-02-18)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Migrated to Odoo 12.
|
||||||
|
|
||||||
|
11.0.1.0.0 (2018-05-09)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Migrated to Odoo 11.
|
||||||
|
|
||||||
|
10.0.1.0.0 (2018-03-26)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Migrated to Odoo 10.
|
||||||
|
|
||||||
|
9.0.1.0.0 (2017-12-02)
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
First version.
|
||||||
|
|
||||||
|
Bug Tracker
|
||||||
|
===========
|
||||||
|
|
||||||
|
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-ux/issues>`_.
|
||||||
|
In case of trouble, please check there if your issue has already been reported.
|
||||||
|
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||||
|
`feedback <https://github.com/OCA/server-ux/issues/new?body=module:%20base_tier_validation%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||||
|
|
||||||
|
Do not contact contributors directly about support or help with technical issues.
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
Authors
|
||||||
|
-------
|
||||||
|
|
||||||
|
* ForgeFlow
|
||||||
|
|
||||||
|
Contributors
|
||||||
|
------------
|
||||||
|
|
||||||
|
- Lois Rilo <lois.rilo@forgeflow.com>
|
||||||
|
- Naglis Jonaitis <naglis@versada.eu>
|
||||||
|
- Adrià Gil Sorribes <adria.gil@forgeflow.com>
|
||||||
|
- Pimolnat Suntian <pimolnats@ecosoft.co.th>
|
||||||
|
- Pedro Gonzalez <pedro.gonzalez@pesol.es>
|
||||||
|
- Kitti U. <kittiu@ecosoft.co.th>
|
||||||
|
- Saran Lim. <saranl@ecosoft.co.th>
|
||||||
|
- Carlos Lopez <celm1990@gmail.com>
|
||||||
|
- Javier Colmeiro <javier.colmeiro@braintec.com>
|
||||||
|
- bosd
|
||||||
|
- Evan Soh <evan.soh@omnisoftsolution.com>
|
||||||
|
- Manuel Regidor <manuel.regidor@sygel.es>
|
||||||
|
- Eduardo de Miguel <edu@moduon.team>
|
||||||
|
- `XCG Consulting <https://xcg-consulting.fr>`__:
|
||||||
|
|
||||||
|
- Houzéfa Abbasbhay
|
||||||
|
|
||||||
|
- Stefan Rijnhart <stefan@opener.amsterdam>
|
||||||
|
- Kevin Khao <kevinkhao@gmail.com>
|
||||||
|
- Do Anh Duy <duyda@trobz.com>
|
||||||
|
|
||||||
|
Other credits
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The migration of this module from 17.0 to 18.0 was financially supported
|
||||||
|
by Camptocamp.
|
||||||
|
|
||||||
|
Maintainers
|
||||||
|
-----------
|
||||||
|
|
||||||
|
This module is maintained by the OCA.
|
||||||
|
|
||||||
|
.. image:: https://odoo-community.org/logo.png
|
||||||
|
:alt: Odoo Community Association
|
||||||
|
:target: https://odoo-community.org
|
||||||
|
|
||||||
|
OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||||
|
mission is to support the collaborative development of Odoo features and
|
||||||
|
promote its widespread use.
|
||||||
|
|
||||||
|
.. |maintainer-LoisRForgeFlow| image:: https://github.com/LoisRForgeFlow.png?size=40px
|
||||||
|
:target: https://github.com/LoisRForgeFlow
|
||||||
|
:alt: LoisRForgeFlow
|
||||||
|
|
||||||
|
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|
||||||
|
|
||||||
|
|maintainer-LoisRForgeFlow|
|
||||||
|
|
||||||
|
This module is part of the `OCA/server-ux <https://github.com/OCA/server-ux/tree/18.0/base_tier_validation>`_ project on GitHub.
|
||||||
|
|
||||||
|
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
|
||||||
4
base_tier_validation/__init__.py
Normal file
4
base_tier_validation/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
35
base_tier_validation/__manifest__.py
Normal file
35
base_tier_validation/__manifest__.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Copyright 2017-24 ForgeFlow S.L. (https://www.forgeflow.com)
|
||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||||
|
{
|
||||||
|
"name": "Base Tier Validation",
|
||||||
|
"summary": "Implement a validation process based on tiers.",
|
||||||
|
"version": "19.0.2025.12.03",
|
||||||
|
"development_status": "Mature",
|
||||||
|
"maintainers": ["LoisRForgeFlow"],
|
||||||
|
"category": "Tools",
|
||||||
|
"website": "https://github.com/OCA/server-ux",
|
||||||
|
"author": "ForgeFlow, Odoo Community Association (OCA)",
|
||||||
|
"license": "AGPL-3",
|
||||||
|
"application": False,
|
||||||
|
"installable": True,
|
||||||
|
"depends": ["mail"],
|
||||||
|
"data": [
|
||||||
|
"data/cron_data.xml",
|
||||||
|
"data/mail_data.xml",
|
||||||
|
"security/ir.model.access.csv",
|
||||||
|
"security/tier_validation_security.xml",
|
||||||
|
"views/res_config_settings_views.xml",
|
||||||
|
"views/tier_definition_view.xml",
|
||||||
|
"views/tier_review_view.xml",
|
||||||
|
"views/tier_validation_exception_view.xml",
|
||||||
|
"wizard/comment_wizard_view.xml",
|
||||||
|
"templates/tier_validation_templates.xml",
|
||||||
|
],
|
||||||
|
"demo": [],
|
||||||
|
"assets": {
|
||||||
|
"web.assets_backend": [
|
||||||
|
"base_tier_validation/static/src/components/**/*",
|
||||||
|
"base_tier_validation/static/src/js/**/*",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user