Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
# mklab_dms_document
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin https://gitlab.inf-centre.ru/mklab-base/documents/mklab_dms_document.git
git branch -M master
git push -uf origin master
```
## Integrate with your tools
- [ ] [Set up project integrations](https://gitlab.inf-centre.ru/mklab-base/documents/mklab_dms_document/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

View File

@@ -0,0 +1,2 @@
from . import models

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
{
'name': 'DMS — Документы (входящие/исходящие/внутренние)',
'summary': 'Учёт входящих, исходящих и внутренних документов с хранением в DMS',
'description': """
DMS — Документы
===============
Учёт входящих, исходящих и внутренних документов организации:
шаблоны текста, формирование PDF, запись в хранилище DMS,
цепочки документов и многоступенчатая валидация (tier validation).
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Document Management',
'version': '19.0.2026.09.17',
'depends': ['base', 'dms', 'base_tier_validation'],
'data': [
'security/ir.model.access.csv',
'data/data.xml',
'views/views.xml',
'views/report.xml',
],
'demo': [
'demo/demo.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,2 @@
from . import controllers

View File

@@ -0,0 +1,20 @@
# from odoo import http
# class MklabDmsDocument(http.Controller):
# @http.route('/mklab_dms_document/mklab_dms_document/', auth='public')
# def index(self, **kw):
# return "Hello, world"
# @http.route('/mklab_dms_document/mklab_dms_document/objects/', auth='public')
# def list(self, **kw):
# return http.request.render('mklab_dms_document.listing', {
# 'root': '/mklab_dms_document/mklab_dms_document',
# 'objects': http.request.env['mklab_dms_document.mklab_dms_document'].search([]),
# })
# @http.route('/mklab_dms_document/mklab_dms_document/objects/<model("mklab_dms_document.mklab_dms_document"):obj>/', auth='public')
# def object(self, obj, **kw):
# return http.request.render('mklab_dms_document.object', {
# 'object': obj
# })

View File

@@ -0,0 +1,25 @@
<odoo>
<record id="seq_ir_dms_document" model="ir.sequence">
<field name="name">Входящий документ</field>
<field name="code">dms.document_number</field>
<field name="prefix">InDoc</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_ir_dms_document_out" model="ir.sequence">
<field name="name">Исходящий документ</field>
<field name="code">dms.document_number_out</field>
<field name="prefix">OutDoc</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_ir_dms_document_internal" model="ir.sequence">
<field name="name">Внутренний документ документ</field>
<field name="code">dms.document_number_internal</field>
<field name="prefix">InternalDoc</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Шаблон документа -->
<record id="demo_dms_template_letter" model="dms.template">
<field name="name">Шаблон исходящего письма</field>
<field name="text">&lt;p&gt;Уважаемые коллеги,&lt;/p&gt;&lt;p&gt;Настоящим сообщаем вам о ...&lt;/p&gt;&lt;p&gt;С уважением,&lt;br/&gt;${user.name}&lt;/p&gt;</field>
<field name="company_id" ref="base.main_company"/>
</record>
<record id="demo_dms_template_request" model="dms.template">
<field name="name">Шаблон запроса документов</field>
<field name="text">&lt;p&gt;Просим предоставить следующие документы:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Счёт-фактура&lt;/li&gt;&lt;li&gt;Товарная накладная&lt;/li&gt;&lt;/ul&gt;</field>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Входящий документ -->
<record id="demo_dms_document_incoming" model="dms.document">
<field name="type_document">incoming</field>
<field name="name">ВХ-2026-001</field>
<field name="partner_id" ref="base.res_partner_1"/>
<field name="date">2026-01-10</field>
<field name="text">&lt;p&gt;Входящее письмо от поставщика с коммерческим предложением.&lt;/p&gt;</field>
<field name="incoming_file_type">pdf</field>
<field name="state">draft</field>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Исходящий документ -->
<record id="demo_dms_document_outgoing" model="dms.document">
<field name="type_document">outgoing</field>
<field name="name">ИСХ-2026-001</field>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="date">2026-01-12</field>
<field name="text">&lt;p&gt;Исходящее письмо покупателю с подтверждением заказа.&lt;/p&gt;</field>
<field name="incoming_file_type">pdf</field>
<field name="state">draft</field>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Внутренний документ -->
<record id="demo_dms_document_internal" model="dms.document">
<field name="type_document">internal</field>
<field name="name">ВН-2026-001</field>
<field name="partner_id" ref="base.main_partner"/>
<field name="date">2026-01-15</field>
<field name="text">&lt;p&gt;Внутренняя служебная записка о порядке работы с документами.&lt;/p&gt;</field>
<field name="incoming_file_type">pdf</field>
<field name="state">draft</field>
<field name="company_id" ref="base.main_company"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
from . import res_partner
from . import document
from . import tier_definition
from . import tier_dms

View File

@@ -0,0 +1,291 @@
import logging
from odoo import api, fields, models, tools, exceptions
import datetime
from dateutil.relativedelta import relativedelta
from werkzeug import urls
import functools
import base64
_logger = logging.getLogger(__name__)
try:
from jinja2.sandbox import SandboxedEnvironment
mako_template_env = SandboxedEnvironment(
block_start_string="<%",
block_end_string="%>",
variable_start_string="${",
variable_end_string="}",
comment_start_string="<%doc>",
comment_end_string="</%doc>",
line_statement_prefix="%",
line_comment_prefix="##",
trim_blocks=True, # do not output newline after blocks
autoescape=True, # XML/HTML automatic escaping
)
mako_template_env.globals.update({
'str': str,
'quote': urls.url_quote,
'urlencode': urls.url_encode,
'datetime': datetime,
'len': len,
'abs': abs,
'min': min,
'max': max,
'sum': sum,
'filter': filter,
'reduce': functools.reduce,
'map': map,
'round': round,
'relativedelta': lambda *a, **kw: relativedelta(*a, **kw),
})
except ImportError:
_logger.warning("jinja2 not available, templating features will not work!")
class DmsDocumentChoiseTemplate(models.TransientModel):
_name = 'dms.choise_template'
_description = 'mklab_dms_document_choise_template'
temp_id = fields.Many2one(comodel_name='dms.template', string='Шаблон', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
doc_id = fields.Many2one(comodel_name='dms.document', string='Документ')
company_id = fields.Many2one(comodel_name='res.company', string='Компания', required=True, index=True, default=lambda self: self.env.company)
def get_choise(self):
for s in self:
if s.temp_id:
s.doc_id.text = s.temp_id.text
class DmsDocumentTemplate(models.Model):
_name = 'dms.template'
_description = 'mklab_dms_document_template'
name = fields.Char(string='Наименование', required=True, tracking=True)
text = fields.Html(string='Текст', required=True, tracking=True)
company_id = fields.Many2one(comodel_name='res.company', string='Компания', required=True, index=True, default=lambda self: self.env.company, tracking=True)
class DmsDocument(models.Model):
_name = 'dms.document'
_description = 'Документ DMS'
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
def create_pdf(self):
report_action = self.env.ref('mklab_dms_document.action_report_dms_document').sudo()
for s in self:
if s.type_document=='outgoing':
pdf, _ = report_action._render_qweb_pdf(report_action.report_name, [s.id])
s.incoming_file = base64.b64encode(pdf)
s.incoming_file_type = 'pdf'
if not s.incoming_file and s.incoming_file_type == 'pdf':
raise exceptions.UserError('Не указан файл для сохранения')
if not s.incoming_file_other and s.incoming_file_type != 'pdf':
raise exceptions.UserError('Не указан файл для сохранения')
if not s.directory_id:
raise exceptions.UserError('Не указана директория для сохранения файла')
content = s.incoming_file if s.incoming_file_type == 'pdf' else s.incoming_file_other
save_file = self.env['dms.file'].sudo().create({
'name': s.name,
'directory_id': s.directory_id.id,
'content': content,
})
s.file = save_file
s.state = 'done'
def open_choise_template(self):
company_id = False
if self.company_id:
company_id = self.company_id.id
return {
'type': 'ir.actions.act_window',
'name': "Выберите шаблон",
'res_model': 'dms.choise_template',
'target': 'new',
'view_mode': 'form',
'context': {'default_doc_id': self.id, 'company_id': company_id},
}
@api.ondelete(at_uninstall=False)
def _unlink_except_done(self):
# Штатный механизм запрета удаления (вместо raise прямо в unlink):
# корректно уживается с каскадами ondelete и групповыми операциями.
for s in self:
if s.state == 'done':
raise exceptions.ValidationError('Нельзя удалить документ, записанный в хранилище')
# Соответствие типа документа коду последовательности нумерации.
_SEQUENCE_BY_TYPE = {
'incoming': 'dms.document_number',
'outgoing': 'dms.document_number_out',
'internal': 'dms.document_number_internal',
}
def _next_document_number(self, type_document):
"""Следующий номер по типу документа из соответствующей последовательности."""
code = self._SEQUENCE_BY_TYPE.get(type_document)
if not code:
return False
return self.env['ir.sequence'].next_by_code(code)
type_document = fields.Selection(
[('incoming', 'Входящий документ'),
('outgoing', 'Исходящий документ'),
('internal', 'Внутренний документ')],
string='Тип', required=True, tracking=True)
name = fields.Char(string='Номер', copy=False, tracking=True)
partner_id = fields.Many2one(
comodel_name='res.partner', string='Контакт', required=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
tracking=True)
date = fields.Date(
string='Дата документа', default=fields.Date.context_today,
required=True, tracking=True)
text = fields.Html(string='Текст')
incoming_file = fields.Binary(string='Входящий файл (PDF)')
incoming_file_other = fields.Binary(string='Входящий файл')
incoming_file_type = fields.Selection(
[('pdf', 'PDF'), ('other', 'Другой')],
string='Тип входящего файла', default='pdf', tracking=True)
text_str = fields.Text(string='Текст шапки')
text_render = fields.Html(string='Обработанный текст')
file = fields.Many2one(
comodel_name='dms.file', string='Связанный файл',
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
tracking=True)
link_model = fields.Many2one(
comodel_name='ir.model', string='Связанная модель', tracking=True)
res_id = fields.Integer(
string='ID документа в связанной модели', tracking=True)
print_head = fields.Boolean(
string='Печатать шапку', default=False, tracking=True)
state = fields.Selection(
[('draft', 'Черновик'), ('done', 'Записано в хранилище')],
string='Статус', default='draft', tracking=True)
parent_id = fields.Many2one(
comodel_name='dms.document', string='Родительский документ',
domain="[('id', '!=', id), '|', ('partner_id', '=', partner_id),"
" ('partner_id.parent_id', '=', partner_id),"
" '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
tracking=True)
child_ids = fields.One2many(
comodel_name='dms.document', inverse_name='parent_id',
string='Дочерние документы')
subordinate_ids = fields.One2many(
comodel_name='dms.document', string='Все связанные документы',
compute='_compute_subordinates', compute_sudo=True)
company_id = fields.Many2one(comodel_name='res.company', string='Компания', required=True, index=True, default=lambda self: self.env.company, tracking=True)
directory_id = fields.Many2one(
comodel_name="dms.directory",
string="Директория хранения",
domain="[('permission_create', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
context={'dms_directory_show_path': True},
ondelete="restrict",
auto_join=True,
index=True,
tracking=True
#domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"
)
child_all_count = fields.Integer(
string='Количество связанных документов',
compute='_compute_subordinates', store=False, compute_sudo=True)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
type_document = vals.get('type_document') \
or self.env.context.get('default_type_document')
number = self._next_document_number(type_document)
if number:
vals['name'] = number
return super().create(vals_list)
def _get_subordinates(self, parents=None):
if not parents:
parents = self.env[self._name]
indirect_subordinates = self.env[self._name]
parents |= self
direct_subordinates = self.child_ids - parents
for child in direct_subordinates:
child_subordinate = child._get_subordinates(parents=parents)
indirect_subordinates |= child_subordinate
return indirect_subordinates | direct_subordinates
@api.depends('child_ids', 'child_ids.child_all_count')
def _compute_subordinates(self):
for doc in self:
doc.subordinate_ids = doc._get_subordinates()
doc.child_all_count = len(doc.subordinate_ids)
def render_template(self):
for s in self:
template = s.text
template_new = s.text
model = s.link_model.model
res_id = s.res_id
render_result = 'Не удалось обработать текст'
substrstart = "${"
substrend = "}"
substr_new_start = "<"
substr_new_end = ">"
flag = True
start = 0
s.text_str = ''
end = len(template)-1
while flag:
try: #search for the item
index = template.index(substrstart,start,end)
start = index+len(substrstart)
indexend = template.index(substrend,start,end)
substr = template[index:indexend+1]
substr_new_str = substr
#s.text_str += str(substr)+ '; '
if substr.find(substr_new_start)>0 and substr.find(substr_new_end)>0:
new_flag = True
start_new = 0
end_new = len(substr)-1
while new_flag:
try: #search for the item
index_new = substr.index(substr_new_start,start_new,end_new)
start_new = index_new+len(substr_new_start)
indexend_new = substr.index(substr_new_end,start_new,end_new)
substr_new = substr[index_new:indexend_new+1]
#s.text_str += str(index_new) + '-' + str(indexend_new) +':'+str(substr_new)+ '; '
substr_new_str = substr_new_str.replace(substr_new,'')
except ValueError:
new_flag=False
template_new = template_new.replace(substr,substr_new_str)
except ValueError:
flag = False
parent = ''
if s.parent_id:
type_doc_parent = dict(self._fields['type_document'].selection).get(s.parent_id.type_document)
parent = ' В ответ на {} {} от {}.'.format(type_doc_parent, s.parent_id.name,s.parent_id.date.strftime('%d.%m.%Y'))
type_doc = dict(self._fields['type_document'].selection).get(s.type_document)
s.text_str = '{} {} от {}.{}'.format(type_doc, s.name,s.date.strftime('%d.%m.%Y'), parent)
template = template_new
#res = re.findall(r"(${})", template)
try:
template = mako_template_env.from_string(tools.ustr(template))
except:
template = mako_template_env.from_string(tools.ustr(''))
user = self.env.user
variables = {
'user': user
}
if model and res_id:
record = self.env[model].browse(res_id)
variables['object'] = record
try:
render_result = template.render(variables)
except Exception:
_logger.error("Failed to render template %r using values %r" % (template, variables))
render_result = ""
if render_result == "False":
render_result = ""
s.text_render = render_result
return render_result

View File

@@ -0,0 +1,25 @@
from odoo import models
class DmsResPartner(models.Model):
_inherit = 'res.partner'
def action_show_document_incoming(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id(
'mklab_dms_document.action_window_incoming')
action['domain'] = [('partner_id', '=', self.id),
('type_document', '=', 'incoming')]
action['context'] = {'default_partner_id': self.id,
'default_type_document': 'incoming'}
return action
def action_show_document_outgoing(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id(
'mklab_dms_document.action_window_outgoing')
action['domain'] = [('partner_id', '=', self.id),
('type_document', '=', 'outgoing')]
action['context'] = {'default_partner_id': self.id,
'default_type_document': 'outgoing'}
return action

View File

@@ -0,0 +1,11 @@
from odoo import api, models
class TierDefinition(models.Model):
_inherit = "tier.definition"
@api.model
def _get_tier_validation_model_names(self):
res = super()._get_tier_validation_model_names()
res.append("dms.document")
return res

View File

@@ -0,0 +1,9 @@
from odoo import models
class TirerDmsDocument(models.Model):
_name = "dms.document"
_inherit = ["dms.document", "tier.validation"]
_state_field = "state"
_state_from = ["draft"]
_state_to = ['done']

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dms_document,dms.document,model_dms_document,base.group_user,1,1,1,1
access_dms_template_admin,dms_template_admin,model_dms_template,dms.group_dms_manager,1,1,1,1
access_dms_template,dms_template,model_dms_template,base.group_user,1,0,0,0
access_dms_choise_template,dms_choise_template,model_dms_choise_template,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dms_document dms.document model_dms_document base.group_user 1 1 1 1
3 access_dms_template_admin dms_template_admin model_dms_template dms.group_dms_manager 1 1 1 1
4 access_dms_template dms_template model_dms_template base.group_user 1 0 0 0
5 access_dms_choise_template dms_choise_template model_dms_choise_template base.group_user 1 1 1 1

View File

@@ -0,0 +1 @@
from . import test_dms_document

View File

@@ -0,0 +1,113 @@
"""
Tests for mklab_dms_document — DMS document lifecycle, PDF creation,
and template rendering.
Validates: Requirements 18.1, 18.2, 18.3, 18.4
"""
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError, UserError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_partner(env):
"""Create a minimal company partner."""
return env['res.partner'].create({
'name': 'Test DMS Partner',
'is_company': True,
})
def _make_document(env, partner, state='draft', text=None):
"""Create a minimal dms.document record."""
vals = {
'type_document': 'incoming',
'partner_id': partner.id,
'name': 'TEST-DOC-001',
'state': state,
}
if text is not None:
vals['text'] = text
return env['dms.document'].create(vals)
# ---------------------------------------------------------------------------
# TestDmsDocument
# ---------------------------------------------------------------------------
class TestDmsDocument(TransactionCase):
"""
Tests for dms.document: unlink restrictions, create_pdf validation,
and render_template output.
Validates: Requirements 18.1, 18.2, 18.3, 18.4
"""
def setUp(self):
super().setUp()
self.partner = _make_partner(self.env)
# ------------------------------------------------------------------
# Requirement 18.1 — unlink of a 'done' document raises ValidationError
# ------------------------------------------------------------------
def test_unlink_done_raises(self):
"""
Req 18.1 — deleting a document in state 'done' must raise
ValidationError.
"""
doc = _make_document(self.env, self.partner, state='done')
with self.assertRaises(ValidationError):
doc.unlink()
# ------------------------------------------------------------------
# Requirement 18.2 — unlink of a draft document succeeds
# ------------------------------------------------------------------
def test_unlink_draft_ok(self):
"""
Req 18.2 — deleting a document in state 'draft' must succeed
without raising any exception.
"""
doc = _make_document(self.env, self.partner, state='draft')
# Should not raise
doc.unlink()
# ------------------------------------------------------------------
# Requirement 18.3 — create_pdf without directory raises UserError
# ------------------------------------------------------------------
def test_create_pdf_no_directory_raises(self):
"""
Req 18.3 — calling create_pdf on a document with no directory_id
set must raise UserError.
"""
doc = _make_document(self.env, self.partner, state='draft')
# Ensure no directory is set
doc.directory_id = False
with self.assertRaises(UserError):
doc.create_pdf()
# ------------------------------------------------------------------
# Requirement 18.4 — render_template with a simple template returns
# a non-empty string
# ------------------------------------------------------------------
def test_render_template_basic(self):
"""
Req 18.4 — render_template on a document with a simple Mako/Jinja2
template must return a non-empty string.
"""
doc = _make_document(
self.env,
self.partner,
state='draft',
text='<p>Hello World</p>',
)
result = doc.render_template()
self.assertTrue(
result and len(result) > 0,
"render_template should return a non-empty string for a simple template",
)

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<template id="report_dms_document">
<t t-call="web.basic_layout">
<t t-foreach="docs" t-as="o">
<t t-set="company" t-value="o.company_id or res_company"/>
<div class="header">
<table class="table" style="width:100%">
<tbody>
<tr>
<t t-set="render" t-value="o.render_template()"/>
<td>
<t t-if="o.print_head">
<span t-out="o.text_str"/>
</t>
</td>
</tr>
</tbody>
</table>
</div>
<div class="page">
<span t-out="o.text_render"/>
</div>
</t>
</t>
</template>
<record id="paperformat_a4" model="report.paperformat">
<field name="name">DMS Документ A4</field>
<field name="default" eval="True"/>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<field name="margin_bottom">20</field>
<field name="margin_left">7</field>
<field name="margin_right">7</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">10</field>
<field name="dpi">90</field>
</record>
<record id="action_report_dms_document" model="ir.actions.report">
<field name="name">DMS Документ</field>
<field name="model">dms.document</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mklab_dms_document.report_dms_document</field>
<field name="report_file">mklab_dms_document.report_dms_document</field>
<field name="print_report_name">'Документ - %s' % (object.name)</field>
<field name="paperformat_id" ref="paperformat_a4"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,292 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Кнопки документов в карточке контакта -->
<record id="res_partner_inherit" model="ir.ui.view">
<field name="name">mklab_dms_document.res_partner_inherit</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" type="object" icon="fa-inbox"
name="action_show_document_incoming"
string="Входящие"/>
<button class="oe_stat_button" type="object" icon="fa-paper-plane"
name="action_show_document_outgoing"
string="Исходящие"/>
</xpath>
</field>
</record>
<!-- Мультикомпанийное правило -->
<record id="rule_company_document" model="ir.rule">
<field name="name">Документы: мультикомпания</field>
<field name="model_id" ref="model_dms_document"/>
<field name="global" eval="True"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<!-- Визард выбора шаблона -->
<record model="ir.ui.view" id="form_template_wiz">
<field name="name">dms.choise_template.form</field>
<field name="model">dms.choise_template</field>
<field name="arch" type="xml">
<form string="Выбор шаблона">
<sheet>
<group>
<field name="temp_id"/>
<field name="doc_id" invisible="1"/>
<field name="company_id" invisible="1"/>
</group>
</sheet>
<footer>
<button string="Выбрать" name="get_choise" type="object"
class="btn-primary"/>
<button string="Отмена" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Шаблоны: список и форма -->
<record model="ir.ui.view" id="list_template">
<field name="name">dms.template.list</field>
<field name="model">dms.template</field>
<field name="arch" type="xml">
<list string="Шаблоны документов">
<field name="name"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record model="ir.ui.view" id="form_template">
<field name="name">dms.template.form</field>
<field name="model">dms.template</field>
<field name="arch" type="xml">
<form string="Шаблон документа">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="Наименование шаблона"/></h1>
</div>
<field name="text"/>
<group>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Документы: список -->
<record model="ir.ui.view" id="list">
<field name="name">dms.document.list</field>
<field name="model">dms.document</field>
<field name="arch" type="xml">
<list string="Документы"
decoration-muted="state == 'done'">
<field name="name"/>
<field name="date"/>
<field name="partner_id"/>
<field name="type_document"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'done'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<!-- Документы: поиск -->
<record model="ir.ui.view" id="search_document">
<field name="name">dms.document.search</field>
<field name="model">dms.document</field>
<field name="arch" type="xml">
<search string="Поиск документов">
<field name="name"/>
<field name="partner_id"/>
<filter name="draft" string="Черновики"
domain="[('state', '=', 'draft')]"/>
<filter name="done" string="В хранилище"
domain="[('state', '=', 'done')]"/>
<separator/>
<filter name="incoming" string="Входящие"
domain="[('type_document', '=', 'incoming')]"/>
<filter name="outgoing" string="Исходящие"
domain="[('type_document', '=', 'outgoing')]"/>
<filter name="internal" string="Внутренние"
domain="[('type_document', '=', 'internal')]"/>
<separator/>
<filter name="group_type" string="Тип"
context="{'group_by': 'type_document'}"/>
<filter name="group_partner" string="Контакт"
context="{'group_by': 'partner_id'}"/>
<filter name="group_state" string="Статус"
context="{'group_by': 'state'}"/>
</search>
</field>
</record>
<!-- Документы: форма -->
<record model="ir.ui.view" id="form">
<field name="name">dms.document.form</field>
<field name="model">dms.document</field>
<field name="arch" type="xml">
<form string="Документ">
<header>
<button name="open_choise_template" type="object"
string="Текст из шаблона" class="btn-secondary"
invisible="type_document != 'outgoing' or state == 'done'"/>
<button name="create_pdf" type="object"
string="Сформировать PDF и записать в хранилище"
class="btn-primary"
invisible="type_document != 'outgoing' or state == 'done'"/>
<button name="create_pdf" type="object"
string="Записать в хранилище" class="btn-primary"
invisible="type_document == 'outgoing' or state == 'done'"/>
<button name="request_validation" type="object"
string="Запросить согласование"
invisible="need_validation != True or rejected == True or state not in ['draft']"/>
<button name="restart_validation" type="object"
string="Перезапустить согласование"
invisible="not review_ids or state not in ['draft']"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<field name="need_validation" invisible="1"/>
<field name="validated" invisible="1"/>
<field name="rejected" invisible="1"/>
<field name="review_ids" invisible="1"/>
<field name="can_review" invisible="1"/>
<div class="alert alert-warning" role="alert"
invisible="validated == True or state not in ['draft'] or rejected == True or not review_ids">
<span><i class="fa fa-info-circle" title="Информация"/> Требуется согласование.</span>
<button name="validate_tier" string="Согласовать"
invisible="can_review == False" type="object"
class="oe_inline btn-success" icon="fa-thumbs-up"/>
<button name="reject_tier" string="Отклонить"
invisible="can_review == False" type="object"
class="oe_inline btn-danger" icon="fa-thumbs-down"/>
</div>
<div class="alert alert-success" role="alert"
invisible="validated != True or state not in ['draft'] or not review_ids">
<span><i class="fa fa-thumbs-up" title="Согласовано"/> <b>Согласование пройдено.</b></span>
</div>
<div class="alert alert-danger" role="alert"
invisible="rejected != True or state not in ['draft'] or not review_ids">
<span><i class="fa fa-thumbs-down" title="Отклонено"/> Согласование <b>отклонено.</b></span>
</div>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="state == 'done'"
placeholder="Присвоится автоматически"/></h1>
</div>
<group>
<group string="Основное">
<field name="type_document" readonly="state != 'draft'"/>
<field name="date" readonly="state == 'done'"/>
<field name="partner_id" readonly="state == 'done'"/>
<field name="print_head" readonly="state == 'done'"/>
<field name="company_id" groups="base.group_multi_company"
readonly="state == 'done'"/>
</group>
<group string="Хранение и связи">
<field name="directory_id" readonly="state == 'done'"/>
<field name="file" readonly="state == 'done'"/>
<field name="parent_id" readonly="state == 'done'"/>
<field name="link_model" readonly="state == 'done'"/>
<field name="res_id" readonly="state == 'done'"/>
</group>
</group>
<notebook>
<page name="page_document" string="Документ">
<field name="text"
invisible="type_document != 'outgoing'"
readonly="state == 'done'"/>
<field name="text_str" invisible="1"/>
<field name="text_render" invisible="1"/>
<group>
<field name="incoming_file_type"
invisible="type_document == 'outgoing'"
readonly="state == 'done'"/>
<field name="incoming_file"
invisible="type_document == 'outgoing' or incoming_file_type != 'pdf'"
readonly="state == 'done'" widget="pdf_viewer"/>
<field name="incoming_file" string="Сформированный документ"
invisible="type_document != 'outgoing' or state != 'done'"
readonly="1" widget="pdf_viewer"/>
<field name="incoming_file_other"
invisible="type_document == 'outgoing' or incoming_file_type == 'pdf'"
readonly="state == 'done'"/>
</group>
</page>
<page name="page_chain_document" string="Цепочка документов">
<field name="child_ids" readonly="state == 'done'">
<list>
<field name="name"/>
<field name="date"/>
<field name="type_document"/>
<field name="state"/>
</list>
</field>
</page>
</notebook>
<field name="review_ids" widget="tier_validation"
invisible="not review_ids"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Действия -->
<record model="ir.actions.act_window" id="action_window_template">
<field name="name">Шаблоны</field>
<field name="res_model">dms.template</field>
<field name="view_mode">list,form</field>
</record>
<record model="ir.actions.act_window" id="action_window_incoming">
<field name="name">Входящие</field>
<field name="res_model">dms.document</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="search_document"/>
<field name="context">{'default_type_document': 'incoming'}</field>
<field name="domain">[('type_document', '=', 'incoming')]</field>
</record>
<record model="ir.actions.act_window" id="action_window_outgoing">
<field name="name">Исходящие</field>
<field name="res_model">dms.document</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="search_document"/>
<field name="context">{'default_type_document': 'outgoing'}</field>
<field name="domain">[('type_document', '=', 'outgoing')]</field>
</record>
<record model="ir.actions.act_window" id="action_window_internal">
<field name="name">Внутренние</field>
<field name="res_model">dms.document</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="search_document"/>
<field name="context">{'default_type_document': 'internal'}</field>
<field name="domain">[('type_document', '=', 'internal')]</field>
</record>
<!-- Меню -->
<menuitem name="Документы" id="documents" parent="dms.main_menu_dms"/>
<menuitem name="Входящие" id="incoming" parent="documents"
action="action_window_incoming" sequence="10"/>
<menuitem name="Исходящие" id="outgoing" parent="documents"
action="action_window_outgoing" sequence="20"/>
<menuitem name="Внутренние" id="internal" parent="documents"
action="action_window_internal" sequence="30"/>
<menuitem name="Шаблоны" id="template_menu" parent="documents"
action="action_window_template" sequence="40"/>
</data>
</odoo>