New Module Documents and Fix Recruitment
|
|
@ -0,0 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import wizard
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': "Documents",
|
||||||
|
|
||||||
|
'summary': "Collect, organize and share documents.",
|
||||||
|
|
||||||
|
'description': """
|
||||||
|
App to upload and manage your documents.
|
||||||
|
""",
|
||||||
|
|
||||||
|
'category': 'Productivity/Documents',
|
||||||
|
'sequence': 80,
|
||||||
|
'version': '1.4',
|
||||||
|
'application': True,
|
||||||
|
'website': 'https://www.ftprotech.in/',
|
||||||
|
|
||||||
|
# any module necessary for this one to work correctly
|
||||||
|
'depends': ['base', 'mail', 'portal', 'attachment_indexation', 'digest'],
|
||||||
|
|
||||||
|
# always loaded
|
||||||
|
'data': [
|
||||||
|
'security/security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/digest_data.xml',
|
||||||
|
'data/mail_template_data.xml',
|
||||||
|
'data/mail_activity_type_data.xml',
|
||||||
|
'data/documents_tag_data.xml',
|
||||||
|
'data/documents_document_data.xml',
|
||||||
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'data/documents_tour.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
'views/res_partner_views.xml',
|
||||||
|
'views/documents_access_views.xml',
|
||||||
|
'views/documents_document_views.xml',
|
||||||
|
'views/documents_folder_views.xml',
|
||||||
|
'views/documents_tag_views.xml',
|
||||||
|
'views/mail_activity_views.xml',
|
||||||
|
'views/mail_activity_plan_views.xml',
|
||||||
|
'views/mail_alias_views.xml',
|
||||||
|
'views/documents_menu_views.xml',
|
||||||
|
'views/documents_templates_portal.xml',
|
||||||
|
'views/documents_templates_share.xml',
|
||||||
|
'wizard/documents_link_to_record_wizard_views.xml',
|
||||||
|
'wizard/documents_request_wizard_views.xml',
|
||||||
|
# Need the `ir.actions.act_window` to exist
|
||||||
|
'data/ir_actions_server_data.xml',
|
||||||
|
],
|
||||||
|
|
||||||
|
'demo': [
|
||||||
|
'demo/documents_document_demo.xml',
|
||||||
|
],
|
||||||
|
'license': 'OEEL-1',
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'documents/static/src/model/**/*',
|
||||||
|
'documents/static/src/scss/documents_views.scss',
|
||||||
|
'documents/static/src/scss/documents_kanban_view.scss',
|
||||||
|
'documents/static/src/attachments/**/*',
|
||||||
|
'documents/static/src/core/**/*',
|
||||||
|
'documents/static/src/js/**/*',
|
||||||
|
'documents/static/src/owl/**/*',
|
||||||
|
'documents/static/src/views/**/*',
|
||||||
|
('remove', 'documents/static/src/views/activity/**'),
|
||||||
|
('after', 'web/static/src/core/errors/error_dialogs.xml', 'documents/static/src/web/error_dialog/error_dialog_patch.xml'),
|
||||||
|
'documents/static/src/web/**/*',
|
||||||
|
'documents/static/src/components/**/*',
|
||||||
|
],
|
||||||
|
'web.assets_backend_lazy': [
|
||||||
|
'documents/static/src/views/activity/**',
|
||||||
|
],
|
||||||
|
'web._assets_primary_variables': [
|
||||||
|
'documents/static/src/scss/documents.variables.scss',
|
||||||
|
],
|
||||||
|
"web.dark_mode_variables": [
|
||||||
|
('before', 'documents/static/src/scss/documents.variables.scss', 'documents/static/src/scss/documents.variables.dark.scss'),
|
||||||
|
],
|
||||||
|
'documents.public_page_assets': [
|
||||||
|
('include', 'web._assets_helpers'),
|
||||||
|
('include', 'web._assets_backend_helpers'),
|
||||||
|
'web/static/src/scss/pre_variables.scss',
|
||||||
|
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||||
|
'web/static/lib/bootstrap/scss/_variables-dark.scss',
|
||||||
|
'web/static/lib/bootstrap/scss/_maps.scss',
|
||||||
|
('include', 'web._assets_bootstrap_backend'),
|
||||||
|
'documents/static/src/scss/documents_public_pages.scss',
|
||||||
|
],
|
||||||
|
'documents.webclient': [
|
||||||
|
('include', 'web.assets_backend'),
|
||||||
|
# documents webclient overrides
|
||||||
|
'documents/static/src/portal_webclient/**/*',
|
||||||
|
'web/static/src/start.js',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import documents
|
||||||
|
from . import home
|
||||||
|
from . import portal
|
||||||
|
|
@ -0,0 +1,695 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import zipfile
|
||||||
|
from collections import defaultdict
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from werkzeug.exceptions import BadRequest, Forbidden
|
||||||
|
|
||||||
|
from odoo import conf, fields, http, _
|
||||||
|
from odoo.exceptions import MissingError
|
||||||
|
from odoo.http import request, content_disposition
|
||||||
|
from odoo.osv import expression
|
||||||
|
from odoo.tools import replace_exceptions, str2bool, consteq
|
||||||
|
|
||||||
|
from odoo.addons.mail.controllers.attachment import AttachmentController
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRoute(http.Controller):
|
||||||
|
|
||||||
|
# util methods #################################################################################
|
||||||
|
def _max_content_length(self):
|
||||||
|
return request.env['documents.document'].get_document_max_upload_limit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_folder_children(cls, folder_sudo):
|
||||||
|
if request.env.user._is_public():
|
||||||
|
permission_domain = expression.AND([
|
||||||
|
[('is_access_via_link_hidden', '=', False)],
|
||||||
|
[('access_via_link', 'in', ('edit', 'view'))],
|
||||||
|
# public user cannot access a request, unless access_via_link='edit'
|
||||||
|
expression.OR([
|
||||||
|
[('access_via_link', '=', 'edit')],
|
||||||
|
[('type', '!=', 'binary')],
|
||||||
|
expression.OR([
|
||||||
|
[('attachment_id', '!=', False)],
|
||||||
|
[('shortcut_document_id.attachment_id', '!=', False)],
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
permission_domain = [('user_permission', '!=', 'none')] # needed for search in sudo
|
||||||
|
|
||||||
|
children_sudo = request.env['documents.document'].sudo().search(expression.AND([
|
||||||
|
[('folder_id', '=', folder_sudo.id)],
|
||||||
|
permission_domain,
|
||||||
|
]), order='name')
|
||||||
|
|
||||||
|
return children_sudo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_access_token(cls, access_token, *, skip_log=False, follow_shortcut=True):
|
||||||
|
"""Get existing document with matching ``access_token``.
|
||||||
|
|
||||||
|
It returns an empty recordset when either:
|
||||||
|
* the document is not found ;
|
||||||
|
* the user cannot already access the document and the link
|
||||||
|
doesn't grant access ;
|
||||||
|
* the matching document is a shortcut but it is not allowed to
|
||||||
|
follow the shortcut.
|
||||||
|
Otherwise it returns the matching document in sudo.
|
||||||
|
|
||||||
|
A ``documents.access`` record is created/updated with the
|
||||||
|
current date unless the ``skip_log`` flag is set.
|
||||||
|
|
||||||
|
:param str access_token: the access token to the document record
|
||||||
|
:param bool skip_log: flag to prevent updating the record last
|
||||||
|
access date of internal users, useful to prevent silly
|
||||||
|
serialization errors, best used with read-only controllers.
|
||||||
|
:param bool follow_shortcut: flag to prevent returning the target
|
||||||
|
from a shortcut and instead return the shortcut itself.
|
||||||
|
"""
|
||||||
|
Doc = request.env['documents.document']
|
||||||
|
|
||||||
|
# Document record
|
||||||
|
try:
|
||||||
|
document_token, __, encoded_id = access_token.rpartition('o')
|
||||||
|
document_id = int(encoded_id, 16)
|
||||||
|
except ValueError:
|
||||||
|
return Doc
|
||||||
|
if not document_token or document_id < 1:
|
||||||
|
return Doc
|
||||||
|
document_sudo = Doc.browse(document_id).sudo()
|
||||||
|
if not document_sudo.document_token: # like exists() but prefetch
|
||||||
|
return Doc
|
||||||
|
if not request.env.user._is_internal() and not document_sudo.active:
|
||||||
|
return Doc
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
if not (
|
||||||
|
consteq(document_token, document_sudo.document_token)
|
||||||
|
and (document_sudo.user_permission != 'none'
|
||||||
|
or document_sudo.access_via_link != 'none')
|
||||||
|
):
|
||||||
|
return Doc
|
||||||
|
|
||||||
|
# Document access
|
||||||
|
skip_log = skip_log or request.env.user._is_public()
|
||||||
|
if not skip_log:
|
||||||
|
for doc_sudo in filter(bool, (document_sudo, document_sudo.shortcut_document_id)):
|
||||||
|
if access := request.env['documents.access'].sudo().search([
|
||||||
|
('partner_id', '=', request.env.user.partner_id.id),
|
||||||
|
('document_id', '=', doc_sudo.id),
|
||||||
|
]):
|
||||||
|
access.last_access_date = fields.Datetime.now()
|
||||||
|
else:
|
||||||
|
request.env['documents.access'].sudo().create([{
|
||||||
|
'document_id': doc_sudo.id,
|
||||||
|
'partner_id': request.env.user.partner_id.id,
|
||||||
|
'last_access_date': fields.Datetime.now(),
|
||||||
|
}])
|
||||||
|
|
||||||
|
# Shortcut
|
||||||
|
if follow_shortcut:
|
||||||
|
if target_sudo := document_sudo.shortcut_document_id:
|
||||||
|
if (target_sudo.user_permission != 'none'
|
||||||
|
or (target_sudo.access_via_link != 'none'
|
||||||
|
and not target_sudo.is_access_via_link_hidden)):
|
||||||
|
document_sudo = target_sudo
|
||||||
|
else:
|
||||||
|
document_sudo = Doc
|
||||||
|
|
||||||
|
# Extra validation step, to run with the target
|
||||||
|
if (
|
||||||
|
request.env.user._is_public()
|
||||||
|
and document_sudo.type == 'binary'
|
||||||
|
and not document_sudo.attachment_id
|
||||||
|
and document_sudo.access_via_link != 'edit'
|
||||||
|
):
|
||||||
|
# public cannot access a document request, unless access_via_link='edit'
|
||||||
|
return Doc
|
||||||
|
|
||||||
|
return document_sudo
|
||||||
|
|
||||||
|
def _make_zip(self, name, documents):
|
||||||
|
"""
|
||||||
|
Create a zip file in memory out of the given ``documents``,
|
||||||
|
recursively exploring the folders, get an HTTP response to
|
||||||
|
download that zip file.
|
||||||
|
|
||||||
|
:param str name: the name to give to the zip file
|
||||||
|
:param odoo.models.Model documents: documents to load in the ZIP
|
||||||
|
:return: a http response to download the zip file
|
||||||
|
"""
|
||||||
|
class Item(NamedTuple):
|
||||||
|
path: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
seen_folders = set() # because of shortcuts, we can have loops
|
||||||
|
# many documents can have the same name
|
||||||
|
seen_names = defaultdict(int)
|
||||||
|
|
||||||
|
def unique(pathname):
|
||||||
|
# files inside a zip can not have the same name
|
||||||
|
# (files in the documents application can)
|
||||||
|
seen_names[pathname] += 1
|
||||||
|
if seen_names[pathname] <= 1:
|
||||||
|
return pathname
|
||||||
|
|
||||||
|
ext = ''.join(pathlib.Path(pathname).suffixes)
|
||||||
|
return f'{pathname.removesuffix(ext)}-{seen_names[pathname]}{ext}'
|
||||||
|
|
||||||
|
def make_zip_item(document, folder):
|
||||||
|
if document.type == 'url':
|
||||||
|
raise ValueError("cannot create a zip item out of an url")
|
||||||
|
if document.type == 'folder':
|
||||||
|
# it is the ending slash that makes it appears as a
|
||||||
|
# folder inside the zip file.
|
||||||
|
return Item(unique(f'{folder.path}{document.name}') + '/', '')
|
||||||
|
try:
|
||||||
|
stream = self._documents_content_stream(document.shortcut_document_id or document)
|
||||||
|
except (ValueError, MissingError):
|
||||||
|
return None # skip
|
||||||
|
return Item(unique(f'{folder.path}{stream.download_name}'), stream.read())
|
||||||
|
|
||||||
|
def generate_zip_items(documents_sudo, folder):
|
||||||
|
documents_sudo = documents_sudo.sorted(lambda d: d.id)
|
||||||
|
|
||||||
|
yield from (
|
||||||
|
item
|
||||||
|
for doc in documents_sudo
|
||||||
|
if doc.type == 'binary' and (doc.shortcut_document_id or doc).attachment_id
|
||||||
|
if (item := make_zip_item(doc, folder)) is not None
|
||||||
|
)
|
||||||
|
for folder_sudo in documents_sudo:
|
||||||
|
if folder_sudo.type != 'folder' or folder_sudo in seen_folders:
|
||||||
|
continue
|
||||||
|
seen_folders.add(folder_sudo)
|
||||||
|
|
||||||
|
yield (sub_folder := make_zip_item(folder_sudo, folder))
|
||||||
|
for sub_document_sudo in self._get_folder_children(folder_sudo):
|
||||||
|
yield from generate_zip_items(sub_document_sudo, sub_folder)
|
||||||
|
|
||||||
|
# TODO: zip on-the-fly while streaming instead of loading the
|
||||||
|
# entire zip in memory and sending it all at once.
|
||||||
|
|
||||||
|
stream = io.BytesIO()
|
||||||
|
root_folder = Item('', '')
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(stream, 'w') as doc_zip:
|
||||||
|
for (path, content) in generate_zip_items(documents, root_folder):
|
||||||
|
doc_zip.writestr(path, content, compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
except zipfile.BadZipfile:
|
||||||
|
logger.exception("BadZipfile exception")
|
||||||
|
|
||||||
|
content = stream.getvalue()
|
||||||
|
headers = [
|
||||||
|
('Content-Type', 'zip'),
|
||||||
|
('X-Content-Type-Options', 'nosniff'),
|
||||||
|
('Content-Length', len(content)),
|
||||||
|
('Content-Disposition', content_disposition(name))
|
||||||
|
]
|
||||||
|
return request.make_response(content, headers)
|
||||||
|
|
||||||
|
# Download & upload routes #####################################################################
|
||||||
|
@http.route('/documents/pdf_split', type='http', methods=['POST'], auth="user")
|
||||||
|
def pdf_split(self, new_files=None, ufile=None, archive=False, vals=None):
|
||||||
|
"""Used to split and/or merge pdf documents.
|
||||||
|
|
||||||
|
The data can come from different sources: multiple existing documents
|
||||||
|
(at least one must be provided) and any number of extra uploaded files.
|
||||||
|
|
||||||
|
:param new_files: the array that represents the new pdf structure:
|
||||||
|
[{
|
||||||
|
'name': 'New File Name',
|
||||||
|
'new_pages': [{
|
||||||
|
'old_file_type': 'document' or 'file',
|
||||||
|
'old_file_index': document_id or index in ufile,
|
||||||
|
'old_page_number': 5,
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
:param ufile: extra uploaded files that are not existing documents
|
||||||
|
:param archive: whether to archive the original documents
|
||||||
|
:param vals: values for the create of the new documents.
|
||||||
|
"""
|
||||||
|
vals = json.loads(vals)
|
||||||
|
new_files = json.loads(new_files)
|
||||||
|
# find original documents
|
||||||
|
document_ids = set()
|
||||||
|
for new_file in new_files:
|
||||||
|
for page in new_file['new_pages']:
|
||||||
|
if page['old_file_type'] == 'document':
|
||||||
|
document_ids.add(page['old_file_index'])
|
||||||
|
documents = request.env['documents.document'].browse(document_ids)
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
files = request.httprequest.files.getlist('ufile')
|
||||||
|
open_files = [stack.enter_context(io.BytesIO(file.read())) for file in files]
|
||||||
|
|
||||||
|
# merge together data from existing documents and from extra uploads
|
||||||
|
document_id_index_map = {}
|
||||||
|
current_index = len(open_files)
|
||||||
|
for document in documents:
|
||||||
|
open_files.append(stack.enter_context(io.BytesIO(base64.b64decode(document.datas))))
|
||||||
|
document_id_index_map[document.id] = current_index
|
||||||
|
current_index += 1
|
||||||
|
|
||||||
|
# update new_files structure with the new indices from documents
|
||||||
|
for new_file in new_files:
|
||||||
|
for page in new_file['new_pages']:
|
||||||
|
if page.pop('old_file_type') == 'document':
|
||||||
|
page['old_file_index'] = document_id_index_map[page['old_file_index']]
|
||||||
|
|
||||||
|
# apply the split/merge
|
||||||
|
new_documents = documents._pdf_split(new_files=new_files, open_files=open_files, vals=vals)
|
||||||
|
|
||||||
|
# archive original documents if needed
|
||||||
|
if archive == 'true':
|
||||||
|
documents.write({'active': False})
|
||||||
|
|
||||||
|
response = request.make_response(json.dumps(new_documents.ids), [('Content-Type', 'application/json')])
|
||||||
|
return response
|
||||||
|
|
||||||
|
@http.route('/documents/<access_token>', type='http', auth='public')
|
||||||
|
def documents_home(self, access_token):
|
||||||
|
document_sudo = self._from_access_token(access_token)
|
||||||
|
|
||||||
|
if not document_sudo:
|
||||||
|
Redirect = request.env['documents.redirect'].sudo()
|
||||||
|
if document_sudo := Redirect._get_redirection(access_token):
|
||||||
|
return request.redirect(
|
||||||
|
f'/documents/{document_sudo.access_token}',
|
||||||
|
HTTPStatus.MOVED_PERMANENTLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.env.user._is_public():
|
||||||
|
return self._documents_render_public_view(document_sudo)
|
||||||
|
elif request.env.user._is_portal():
|
||||||
|
return self._documents_render_portal_view(document_sudo)
|
||||||
|
else: # assume internal user
|
||||||
|
# Internal users use the /odoo/documents/<access_token> route
|
||||||
|
return request.redirect(
|
||||||
|
f'/odoo/documents/{access_token}',
|
||||||
|
HTTPStatus.TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _documents_render_public_view(self, document_sudo):
|
||||||
|
target_sudo = document_sudo.shortcut_document_id
|
||||||
|
if (
|
||||||
|
target_sudo
|
||||||
|
and target_sudo.access_via_link != 'none'
|
||||||
|
and not target_sudo.is_access_via_link_hidden
|
||||||
|
):
|
||||||
|
return request.redirect(f'/odoo/documents/{target_sudo.access_token}')
|
||||||
|
if target_sudo or not document_sudo:
|
||||||
|
return request.render(
|
||||||
|
'documents.not_available', {'document': document_sudo}, status=404)
|
||||||
|
if document_sudo.type == 'url':
|
||||||
|
return request.redirect(
|
||||||
|
document_sudo.url, code=HTTPStatus.TEMPORARY_REDIRECT, local=False)
|
||||||
|
if document_sudo.type == 'binary' and document_sudo.attachment_id:
|
||||||
|
return request.render('documents.share_file', {'document': document_sudo})
|
||||||
|
if document_sudo.type == 'binary':
|
||||||
|
return request.render('documents.document_request_page', {'document': document_sudo})
|
||||||
|
if document_sudo.type == 'folder':
|
||||||
|
sub_documents_sudo = ShareRoute._get_folder_children(document_sudo)
|
||||||
|
return request.render('documents.public_folder_page', {
|
||||||
|
'folder': document_sudo,
|
||||||
|
'documents': sub_documents_sudo,
|
||||||
|
'subfolders': {
|
||||||
|
sub_folder_sudo.id: ShareRoute._get_folder_children(sub_folder_sudo)
|
||||||
|
for sub_folder_sudo in sub_documents_sudo
|
||||||
|
if sub_folder_sudo.type == 'folder'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
e = f"unknown document type {document_sudo.type}"
|
||||||
|
raise NotImplementedError(e)
|
||||||
|
|
||||||
|
def _documents_render_portal_view(self, document):
|
||||||
|
""" Render the portal version (stripped version of the backend Documents app). """
|
||||||
|
# We build the session information necessary for the web client to load
|
||||||
|
session_info = request.env['ir.http'].session_info()
|
||||||
|
mods = conf.server_wide_modules or []
|
||||||
|
lang = request.env.context.get('lang')
|
||||||
|
cache_hashes = {
|
||||||
|
"translations": request.env['ir.http'].get_web_translations_hash(mods, lang),
|
||||||
|
}
|
||||||
|
|
||||||
|
session_info.update(
|
||||||
|
cache_hashes=cache_hashes,
|
||||||
|
user_companies={
|
||||||
|
'current_company': request.env.company.id,
|
||||||
|
'allowed_companies': {
|
||||||
|
request.env.company.id: {
|
||||||
|
'id': request.env.company.id,
|
||||||
|
'name': request.env.company.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documents_init=self._documents_get_init_data(document, request.env.user),
|
||||||
|
)
|
||||||
|
|
||||||
|
return request.render(
|
||||||
|
'documents.document_portal_view',
|
||||||
|
{'session_info': session_info},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _documents_get_init_data(cls, document, user):
|
||||||
|
""" Get initialization data to restore the interface on the selected document. """
|
||||||
|
if not document or not user:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
document.ensure_one()
|
||||||
|
documents_init = {}
|
||||||
|
|
||||||
|
# If the user does not have access to the parent folder, we open it in the "SHARED" folder.
|
||||||
|
if document.type != 'folder':
|
||||||
|
parent = document.folder_id
|
||||||
|
shared_root = False if user.share else "SHARED" # Portal don't have 'Shared with me'
|
||||||
|
if parent:
|
||||||
|
documents_init['folder_id'] = parent.id if parent.user_permission in {'view', 'edit'} else shared_root
|
||||||
|
else:
|
||||||
|
documents_init['folder_id'] = (
|
||||||
|
"MY" if document.owner_id == user
|
||||||
|
else "COMPANY" if not user.share and (
|
||||||
|
document.owner_id == document.env.ref('base.user_root') or document.access_internal != 'none')
|
||||||
|
else shared_root
|
||||||
|
)
|
||||||
|
documents_init['document_id'] = document.id
|
||||||
|
target = document.shortcut_document_id or document
|
||||||
|
if document.type == 'binary' and target.attachment_id:
|
||||||
|
documents_init['open_preview'] = True
|
||||||
|
else:
|
||||||
|
documents_init['folder_id'] = document.id
|
||||||
|
|
||||||
|
return documents_init
|
||||||
|
|
||||||
|
@http.route('/documents/avatar/<access_token>',
|
||||||
|
type='http', auth='public', readonly=True)
|
||||||
|
def documents_avatar(self, access_token):
|
||||||
|
"""Show the avatar of the document's owner, or the avatar placeholder.
|
||||||
|
|
||||||
|
:param access_token: the access token to the document record
|
||||||
|
"""
|
||||||
|
partner_sudo = self._from_access_token(access_token, skip_log=True).owner_id.partner_id
|
||||||
|
return request.env['ir.binary']._get_image_stream_from(
|
||||||
|
partner_sudo, 'avatar_128', placeholder=partner_sudo._avatar_get_placeholder_path()
|
||||||
|
).get_response(as_attachment=False)
|
||||||
|
|
||||||
|
@http.route('/documents/content/<access_token>',
|
||||||
|
type='http', auth='public', readonly=True)
|
||||||
|
def documents_content(self, access_token, download=True):
|
||||||
|
"""Serve the file of the document.
|
||||||
|
|
||||||
|
:param access_token: the access token to the document record
|
||||||
|
:param download: whether to download the document on the user's
|
||||||
|
file system or to preview the document within the browser
|
||||||
|
"""
|
||||||
|
document_sudo = self._from_access_token(access_token, skip_log=True)
|
||||||
|
if not document_sudo:
|
||||||
|
raise request.not_found()
|
||||||
|
if document_sudo.type == 'url':
|
||||||
|
return request.redirect(
|
||||||
|
document_sudo.url, code=HTTPStatus.TEMPORARY_REDIRECT, local=False)
|
||||||
|
if document_sudo.type == 'folder':
|
||||||
|
return self._make_zip(
|
||||||
|
f'{document_sudo.name}.zip',
|
||||||
|
self._get_folder_children(document_sudo),
|
||||||
|
)
|
||||||
|
if document_sudo.type == 'binary':
|
||||||
|
if not document_sudo.attachment_id:
|
||||||
|
raise request.not_found()
|
||||||
|
with replace_exceptions(ValueError, by=BadRequest):
|
||||||
|
download = str2bool(download)
|
||||||
|
with replace_exceptions(ValueError, MissingError, by=request.not_found()):
|
||||||
|
stream = self._documents_content_stream(document_sudo)
|
||||||
|
return stream.get_response(as_attachment=download)
|
||||||
|
e = f"unknown document type {document_sudo.type!r}"
|
||||||
|
raise NotImplementedError(e)
|
||||||
|
|
||||||
|
def _documents_content_stream(self, document_sudo):
|
||||||
|
return request.env['ir.binary']._get_stream_from(document_sudo)
|
||||||
|
|
||||||
|
@http.route('/documents/redirect/<access_token>', type='http', auth='public', readonly=True)
|
||||||
|
def documents_redirect(self, access_token):
|
||||||
|
return request.redirect(f'/odoo/documents/{access_token}', HTTPStatus.MOVED_PERMANENTLY)
|
||||||
|
|
||||||
|
@http.route('/documents/touch/<access_token>', type='json', auth='user')
|
||||||
|
def documents_touch(self, access_token):
|
||||||
|
self._from_access_token(access_token)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@http.route(['/documents/thumbnail/<access_token>',
|
||||||
|
'/documents/thumbnail/<access_token>/<int:width>x<int:height>'],
|
||||||
|
type='http', auth='public', readonly=True)
|
||||||
|
def documents_thumbnail(self, access_token, width='0', height='0', unique=''):
|
||||||
|
"""Show the thumbnail of the document, or a placeholder.
|
||||||
|
|
||||||
|
:param access_token: the access token to the document record
|
||||||
|
:param width: resize the thumbnail to this maximum width
|
||||||
|
:param height: resize the thumbnail to this maximum height
|
||||||
|
:param unique: force storing the file in the browser cache, best
|
||||||
|
used with the checksum of the attachment
|
||||||
|
"""
|
||||||
|
with replace_exceptions(ValueError, by=BadRequest):
|
||||||
|
width = int(width)
|
||||||
|
height = int(height)
|
||||||
|
send_file_kwargs = {}
|
||||||
|
if unique:
|
||||||
|
send_file_kwargs['immutable'] = True
|
||||||
|
send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
|
||||||
|
document_sudo = self._from_access_token(access_token, skip_log=True)
|
||||||
|
return request.env['ir.binary']._get_image_stream_from(
|
||||||
|
document_sudo, 'thumbnail', width=width, height=height
|
||||||
|
).get_response(as_attachment=False, **send_file_kwargs)
|
||||||
|
|
||||||
|
@http.route(['/documents/document/<int:document_id>/update_thumbnail'], type='json', auth='user')
|
||||||
|
def documents_update_thumbnail(self, document_id, thumbnail):
|
||||||
|
"""Update the thumbnail of the document (after it has been generated by the browser).
|
||||||
|
|
||||||
|
We update the thumbnail in SUDO, after checking the read access, so it will work
|
||||||
|
if the user that generates the thumbnail is not the user who uploaded the document.
|
||||||
|
"""
|
||||||
|
document = request.env['documents.document'].browse(document_id)
|
||||||
|
document.check_access('read')
|
||||||
|
if document.thumbnail_status != 'client_generated':
|
||||||
|
return
|
||||||
|
document.sudo().write({
|
||||||
|
'thumbnail': thumbnail,
|
||||||
|
'thumbnail_status': 'present' if thumbnail else 'error',
|
||||||
|
})
|
||||||
|
|
||||||
|
@http.route(['/documents/zip'], type='http', auth='user')
|
||||||
|
def documents_zip(self, file_ids, zip_name, **kw):
|
||||||
|
"""Select many files / folders in the interface and click on download.
|
||||||
|
|
||||||
|
:param file_ids: if of the files to zip.
|
||||||
|
:param zip_name: name of the zip file.
|
||||||
|
"""
|
||||||
|
ids_list = [int(x) for x in file_ids.split(',')]
|
||||||
|
documents = request.env['documents.document'].browse(ids_list)
|
||||||
|
documents.check_access('read')
|
||||||
|
return self._make_zip(zip_name, documents)
|
||||||
|
|
||||||
|
@http.route([
|
||||||
|
'/document/download/all/<int:share_id>/<access_token>',
|
||||||
|
'/document/download/all/<access_token>'], type='http', auth='public')
|
||||||
|
def documents_download_all_legacy(self, access_token=None, share_id=None):
|
||||||
|
logger.warning("Deprecated since Odoo 18. Please access /documents/content/<access_token> instead.")
|
||||||
|
return request.redirect(f'/documents/content/{access_token}', HTTPStatus.MOVED_PERMANENTLY)
|
||||||
|
|
||||||
|
@http.route([
|
||||||
|
'/document/share/<int:share_id>/<token>',
|
||||||
|
'/document/share/<token>'], type='http', auth='public')
|
||||||
|
def share_portal(self, share_id=None, token=None):
|
||||||
|
logger.warning("Deprecated since Odoo 18. Please access /odoo/documents/<access_token> instead.")
|
||||||
|
return request.redirect(f'/odoo/documents/{token}', code=HTTPStatus.MOVED_PERMANENTLY)
|
||||||
|
|
||||||
|
@http.route(['/documents/upload/', '/documents/upload/<access_token>'],
|
||||||
|
type='http', auth='public', methods=['POST'],
|
||||||
|
max_content_length=_max_content_length)
|
||||||
|
def documents_upload(
|
||||||
|
self,
|
||||||
|
ufile,
|
||||||
|
access_token='',
|
||||||
|
owner_id='',
|
||||||
|
partner_id='',
|
||||||
|
res_id='',
|
||||||
|
res_model='',
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Replace an existing document or create new ones.
|
||||||
|
|
||||||
|
:param ufile: a list of multipart/form-data files.
|
||||||
|
:param access_token: the access token to a folder in which to
|
||||||
|
create new documents, or the access token to an existing
|
||||||
|
document where to upload/replace its attachment.
|
||||||
|
A falsy value means no folder_id and is allowed for
|
||||||
|
internal users to upload at the root of "My Drive".
|
||||||
|
:param owner_id, partner_id, res_id, res_model: field values
|
||||||
|
when creating new documents, for internal users only
|
||||||
|
"""
|
||||||
|
is_internal_user = request.env.user._is_internal()
|
||||||
|
if is_internal_user and not access_token:
|
||||||
|
document_sudo = request.env['documents.document'].sudo()
|
||||||
|
else:
|
||||||
|
document_sudo = self._from_access_token(access_token)
|
||||||
|
if (
|
||||||
|
not document_sudo
|
||||||
|
or (document_sudo.user_permission != 'edit'
|
||||||
|
and document_sudo.access_via_link != 'edit')
|
||||||
|
or document_sudo.type not in ('binary', 'folder')
|
||||||
|
):
|
||||||
|
raise request.not_found()
|
||||||
|
|
||||||
|
files = request.httprequest.files.getlist('ufile')
|
||||||
|
if not files:
|
||||||
|
raise BadRequest("missing files")
|
||||||
|
if len(files) > 1 and document_sudo.type not in (False, 'folder'):
|
||||||
|
raise BadRequest("cannot save multiple files inside a single document")
|
||||||
|
|
||||||
|
if is_internal_user:
|
||||||
|
with replace_exceptions(ValueError, by=BadRequest):
|
||||||
|
owner_id = int(owner_id) if owner_id else request.env.user.id
|
||||||
|
partner_id = int(partner_id) if partner_id else False
|
||||||
|
res_model = res_model or 'documents.document'
|
||||||
|
res_id = int(res_id) if res_id else False
|
||||||
|
elif owner_id or partner_id or res_id or res_model:
|
||||||
|
raise Forbidden("only internal users can provide field values")
|
||||||
|
else:
|
||||||
|
owner_id = document_sudo.owner_id.id if request.env.user.is_public else request.env.user.id
|
||||||
|
partner_id = False
|
||||||
|
res_model = 'documents.document'
|
||||||
|
res_id = False # replaced by the document's id
|
||||||
|
|
||||||
|
document_ids = self._documents_upload(
|
||||||
|
document_sudo, files, owner_id, partner_id, res_id, res_model)
|
||||||
|
if len(document_ids) == 1:
|
||||||
|
document_sudo = document_sudo.browse(document_ids)
|
||||||
|
|
||||||
|
if request.env.user._is_public():
|
||||||
|
return request.redirect(document_sudo.access_url)
|
||||||
|
else:
|
||||||
|
return request.make_json_response(document_ids)
|
||||||
|
|
||||||
|
def _documents_upload(self,
|
||||||
|
document_sudo, files, owner_id, partner_id, res_id, res_model):
|
||||||
|
""" Replace an existing document or upload a new one. """
|
||||||
|
is_internal_user = request.env.user._is_internal()
|
||||||
|
|
||||||
|
document_ids = []
|
||||||
|
AttachmentSudo = request.env['ir.attachment'].sudo(not is_internal_user)
|
||||||
|
|
||||||
|
if document_sudo.type == 'binary':
|
||||||
|
attachment_sudo = AttachmentSudo._from_request_file(
|
||||||
|
files[0], mimetype='TRUST' if is_internal_user else 'GUESS'
|
||||||
|
)
|
||||||
|
attachment_sudo.res_model = document_sudo.res_model
|
||||||
|
attachment_sudo.res_id = document_sudo.res_id
|
||||||
|
values = {'attachment_id': attachment_sudo.id}
|
||||||
|
if not document_sudo.attachment_id: # is a request
|
||||||
|
if document_sudo.access_via_link == 'edit':
|
||||||
|
values['access_via_link'] = 'view'
|
||||||
|
self._documents_upload_create_write(document_sudo, values)
|
||||||
|
document_ids.append(document_sudo.id)
|
||||||
|
else:
|
||||||
|
folder_sudo = document_sudo
|
||||||
|
for file in files:
|
||||||
|
document_sudo = self._documents_upload_create_write(folder_sudo, {
|
||||||
|
'attachment_id': AttachmentSudo._from_request_file(
|
||||||
|
file, mimetype='TRUST' if is_internal_user else 'GUESS'
|
||||||
|
).id,
|
||||||
|
'type': 'binary',
|
||||||
|
'access_via_link': 'none' if folder_sudo.access_via_link in (False, 'none') else 'view',
|
||||||
|
'folder_id': folder_sudo.id,
|
||||||
|
'owner_id': owner_id,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
'res_model': res_model,
|
||||||
|
'res_id': res_id,
|
||||||
|
})
|
||||||
|
document_ids.append(document_sudo.id)
|
||||||
|
if folder_sudo.create_activity_option:
|
||||||
|
folder_sudo.browse(document_ids).documents_set_activity(
|
||||||
|
settings_record=folder_sudo)
|
||||||
|
|
||||||
|
return document_ids
|
||||||
|
|
||||||
|
def _documents_upload_create_write(self, document_sudo, vals):
|
||||||
|
"""
|
||||||
|
The actual function that either write vals on a binary document
|
||||||
|
or create a new document with vals inside a folder document.
|
||||||
|
"""
|
||||||
|
if document_sudo.type == 'binary':
|
||||||
|
document_sudo.write(vals)
|
||||||
|
else:
|
||||||
|
vals.setdefault('folder_id', document_sudo.id)
|
||||||
|
document_sudo = document_sudo.create(vals)
|
||||||
|
if not document_sudo.res_model:
|
||||||
|
document_sudo.res_model = 'documents.document'
|
||||||
|
if (
|
||||||
|
document_sudo.res_model == 'documents.document'
|
||||||
|
and not document_sudo.res_id
|
||||||
|
):
|
||||||
|
document_sudo.res_id = document_sudo.id
|
||||||
|
if (any(field_name in vals for field_name in [
|
||||||
|
'raw', 'datas', 'attachment_id'])):
|
||||||
|
document_sudo.message_post(body=_(
|
||||||
|
"Document uploaded by %(user)s",
|
||||||
|
user=request.env.user.name
|
||||||
|
))
|
||||||
|
|
||||||
|
return document_sudo
|
||||||
|
|
||||||
|
@http.route('/documents/upload_traceback', type='http', methods=['POST'], auth='user')
|
||||||
|
def documents_upload_traceback(self, ufile, max_content_length=1 << 20): # 1MiB
|
||||||
|
if not request.env.user._is_internal():
|
||||||
|
raise Forbidden()
|
||||||
|
|
||||||
|
folder_sudo = request.env.ref(
|
||||||
|
'documents.document_support_folder',
|
||||||
|
raise_if_not_found=False
|
||||||
|
).sudo()
|
||||||
|
if not folder_sudo or not folder_sudo.active:
|
||||||
|
raise request.not_found()
|
||||||
|
|
||||||
|
files = request.httprequest.files.getlist('ufile')
|
||||||
|
if not files:
|
||||||
|
raise BadRequest("missing files")
|
||||||
|
if len(files) > 1:
|
||||||
|
raise BadRequest("This route only accepts one file at a time.")
|
||||||
|
|
||||||
|
traceback_sudo = self._documents_upload_create_write(folder_sudo, {
|
||||||
|
'attachment_id': request.env['ir.attachment']._from_request_file(
|
||||||
|
files[0], mimetype='text/plain').id,
|
||||||
|
'type': 'binary',
|
||||||
|
'access_internal': 'none',
|
||||||
|
'access_via_link': 'view',
|
||||||
|
'folder_id': folder_sudo.id,
|
||||||
|
'owner_id': request.env.ref('base.user_root').id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return request.make_json_response([traceback_sudo.access_url])
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentsAttachmentController(AttachmentController):
|
||||||
|
|
||||||
|
@http.route()
|
||||||
|
def mail_attachment_upload(self, *args, **kw):
|
||||||
|
""" Override to prevent the creation of a document when uploading
|
||||||
|
an attachment from an activity already linked to a document."""
|
||||||
|
if kw.get('activity_id'):
|
||||||
|
document = request.env['documents.document'].search([('request_activity_id', '=', int(kw['activity_id']))])
|
||||||
|
if document:
|
||||||
|
request.update_context(no_document=True)
|
||||||
|
return super().mail_attachment_upload(*args, **kw)
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from odoo.http import request, route
|
||||||
|
|
||||||
|
from odoo.addons.web.controllers import home as web_home
|
||||||
|
from odoo.addons.web.controllers.utils import ensure_db
|
||||||
|
from .documents import ShareRoute
|
||||||
|
|
||||||
|
|
||||||
|
class Home(web_home.Home):
|
||||||
|
def _web_client_readonly(self):
|
||||||
|
""" Force a read/write cursor for documents.access """
|
||||||
|
path = request.httprequest.path
|
||||||
|
if (
|
||||||
|
path.startswith('/odoo/documents')
|
||||||
|
and (request.httprequest.args.get('access_token') or path.removeprefix('/odoo/documents/'))
|
||||||
|
and request.session.uid
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return super()._web_client_readonly()
|
||||||
|
|
||||||
|
@route(readonly=_web_client_readonly)
|
||||||
|
def web_client(self, s_action=None, **kw):
|
||||||
|
""" Handle direct access to a document with a backend URL (/odoo/documents/<access_token>).
|
||||||
|
|
||||||
|
It redirects to the document either in:
|
||||||
|
- the backend if the user is logged and has access to the Documents module
|
||||||
|
- or a lightweight version of the backend if the user is logged and has not access
|
||||||
|
to the Document module but well to the documents.document model
|
||||||
|
- or the document portal otherwise
|
||||||
|
|
||||||
|
Goal: Allow to share directly the backend URL of a document.
|
||||||
|
"""
|
||||||
|
subpath = kw.get('subpath', '')
|
||||||
|
access_token = request.params.get('access_token') or subpath.removeprefix('documents/')
|
||||||
|
if not subpath.startswith('documents') or not access_token or '/' in access_token:
|
||||||
|
return super().web_client(s_action, **kw)
|
||||||
|
|
||||||
|
# This controller should be auth='public' but it actually is
|
||||||
|
# auth='none' for technical reasons (see super). Those three
|
||||||
|
# lines restore the public behavior.
|
||||||
|
ensure_db()
|
||||||
|
request.update_env(user=request.session.uid)
|
||||||
|
request.env['ir.http']._authenticate_explicit('public')
|
||||||
|
|
||||||
|
# Public/Portal users use the /documents/<access_token> route
|
||||||
|
if not request.env.user._is_internal():
|
||||||
|
return request.redirect(
|
||||||
|
f'/documents/{access_token}',
|
||||||
|
HTTPStatus.TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
document_sudo = ShareRoute._from_access_token(access_token, follow_shortcut=False)
|
||||||
|
|
||||||
|
if not document_sudo:
|
||||||
|
Redirect = request.env['documents.redirect'].sudo()
|
||||||
|
if document_sudo := Redirect._get_redirection(access_token):
|
||||||
|
return request.redirect(
|
||||||
|
f'/odoo/documents/{document_sudo.access_token}',
|
||||||
|
HTTPStatus.MOVED_PERMANENTLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# We want (1) the webclient renders the webclient template and load
|
||||||
|
# the document action. We also want (2) the router rewrites
|
||||||
|
# /odoo/documents/<id> to /odoo/documents/<access-token> in the
|
||||||
|
# URL.
|
||||||
|
# We redirect on /web so this override does kicks in again,
|
||||||
|
# super() is loaded and renders the normal home template. We add
|
||||||
|
# custom fragments so we can load them inside the router and
|
||||||
|
# rewrite the URL.
|
||||||
|
query = {}
|
||||||
|
if request.session.debug:
|
||||||
|
query['debug'] = request.session.debug
|
||||||
|
fragment = {
|
||||||
|
'action': request.env.ref("documents.document_action").id,
|
||||||
|
'menu_id': request.env.ref('documents.menu_root').id,
|
||||||
|
'model': 'documents.document',
|
||||||
|
}
|
||||||
|
if document_sudo:
|
||||||
|
fragment.update({
|
||||||
|
f'documents_init_{key}': value
|
||||||
|
for key, value
|
||||||
|
in ShareRoute._documents_get_init_data(document_sudo, request.env.user).items()
|
||||||
|
})
|
||||||
|
return request.redirect(f'/web?{urlencode(query)}#{urlencode(fragment)}')
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCustomerPortal(CustomerPortal):
|
||||||
|
|
||||||
|
def _prepare_home_portal_values(self, counters):
|
||||||
|
values = super()._prepare_home_portal_values(counters)
|
||||||
|
if 'document_count' in counters:
|
||||||
|
Document = request.env['documents.document']
|
||||||
|
try:
|
||||||
|
count = Document.search_count([])
|
||||||
|
except AccessError:
|
||||||
|
count = 0
|
||||||
|
values['document_count'] = count
|
||||||
|
return values
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="digest_tip_documents_0" model="digest.tip">
|
||||||
|
<field name="name">Tip: Become a paperless company</field>
|
||||||
|
<field name="sequence">300</field>
|
||||||
|
<field name="group_id" ref="documents.group_documents_user" />
|
||||||
|
<field name="tip_description" type="html">
|
||||||
|
<div>
|
||||||
|
<t t-set="record" t-value="object.env['documents.document'].search([('alias_name', '!=', False), ('alias_domain_id', '!=', False)], limit=1)" />
|
||||||
|
<b class="tip_title">Tip: Become a paperless company</b>
|
||||||
|
<t t-if="record.alias_email">
|
||||||
|
<p class="tip_content">An easy way to process incoming mails is to configure your scanner to send PDFs to <t t-out="record.alias_email"/>. Scanned files will appear automatically in your workspace. Then, process your documents in bulk with the split tool: launch user defined actions, request a signature, convert to vendor bills with AI, etc.</p>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<p class="tip_content">An easy way to process incoming mails is to configure your scanner to send PDFs to your workspace email. Scanned files will appear automatically in your workspace. Then, process your documents in bulk with the split tool: launch user defined actions, request a signature, convert to vendor bills with AI, etc.</p>
|
||||||
|
</t>
|
||||||
|
<img src="/documents/static/src/img/documents-paperless.png" width="540" class="illustration_border" />
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Folders -->
|
||||||
|
<record id="document_internal_folder" model="documents.document" forcecreate="0">
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="access_internal">view</field>
|
||||||
|
<field name="name">Internal</field>
|
||||||
|
<field name="is_pinned_folder">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="document_finance_folder" model="documents.document" forcecreate="0">
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="name">Finance</field>
|
||||||
|
<field name="is_pinned_folder">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="document_marketing_folder" model="documents.document" forcecreate="0">
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="name">Marketing</field>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="is_pinned_folder">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="document_support_folder" model="documents.document" forcecreate="True">
|
||||||
|
<field name="name">Support</field>
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="access_internal">none</field>
|
||||||
|
<field name="access_via_link">none</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- base data -->
|
||||||
|
<record id="documents_attachment_video_documents" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">Video: Odoo Documents</field>
|
||||||
|
<field name="type">url</field>
|
||||||
|
<field name="url">https://youtu.be/Ayab6wZ_U1A</field>
|
||||||
|
<field name="folder_id" ref="documents.document_internal_folder"/>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_presentations'),
|
||||||
|
ref('documents.documents_tag_validated')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data></odoo>
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
<!-- tags internal -->
|
||||||
|
<record id="documents_tag_draft" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Draft</field>
|
||||||
|
<field name="sequence">2</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_inbox" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Inbox</field>
|
||||||
|
<field name="sequence">4</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_to_validate" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">To Validate</field>
|
||||||
|
<field name="sequence">6</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_validated" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Validated</field>
|
||||||
|
<field name="sequence">8</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_deprecated" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Deprecated</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_hr" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">HR</field>
|
||||||
|
<field name="sequence">9</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_sales" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Sales</field>
|
||||||
|
<field name="sequence">9</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_legal" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Legal</field>
|
||||||
|
<field name="sequence">9</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_other" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Other</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_presentations" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Presentations</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_contracts" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Contracts</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_project" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Project</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_text" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Text</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- tags finance -->
|
||||||
|
|
||||||
|
<record id="documents_tag_bill" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Bill</field>
|
||||||
|
<field name="sequence">4</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_expense" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Expense</field>
|
||||||
|
<field name="sequence">5</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_vat" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">VAT</field>
|
||||||
|
<field name="sequence">6</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_fiscal" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Fiscal</field>
|
||||||
|
<field name="sequence">7</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_financial" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Financial</field>
|
||||||
|
<field name="sequence">8</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_year_current" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name" eval="str(datetime.now().year)"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_year_previous" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name" eval="str(datetime.now().year-1)"/>
|
||||||
|
<field name="sequence">11</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- tags marketing -->
|
||||||
|
|
||||||
|
<record id="documents_tag_ads" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Ads</field>
|
||||||
|
<field name="sequence">12</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_brochures" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Brochures</field>
|
||||||
|
<field name="sequence">13</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_images" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Images</field>
|
||||||
|
<field name="sequence">14</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_videos" model="documents.tag" forcecreate="0">
|
||||||
|
<field name="name">Videos</field>
|
||||||
|
<field name="sequence">15</field>
|
||||||
|
</record>
|
||||||
|
</data></odoo>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="documents_tour" model="web_tour.tour">
|
||||||
|
<field name="name">documents_tour</field>
|
||||||
|
<field name="sequence">180</field>
|
||||||
|
<field name="rainbow_man_message"><![CDATA[
|
||||||
|
Wow... 6 documents processed in a few seconds, You're good.<br/>The tour is complete. Try uploading your own documents now.
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -0,0 +1,106 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="ir_actions_server_create_activity" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Create Activity</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="state">next_activity</field>
|
||||||
|
<field name="activity_type_id" ref="documents.mail_documents_activity_data_tv"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_actions_server_remove_activities" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Mark activities as completed</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
for record in records:
|
||||||
|
record.activity_ids.action_feedback(feedback="completed")
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_actions_server_remove_tags" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Remove all tags</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="state">object_write</field>
|
||||||
|
<field name="update_m2m_operation">clear</field>
|
||||||
|
<field name="update_path">tag_ids</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_actions_server_send_to_finance" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Send To Finance</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
target = env.ref('documents.document_finance_folder', raise_if_not_found=False)
|
||||||
|
if target:
|
||||||
|
permissions = records.mapped('user_permission')
|
||||||
|
records.action_move_documents(target.id)
|
||||||
|
for record, permission in zip(records, permissions):
|
||||||
|
record.sudo().action_update_access_rights(partners={env.user.partner_id: (permission, None)})
|
||||||
|
action = {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'message': env._("%(nb_records)s document(s) sent to Finance", nb_records=len(records)),
|
||||||
|
'type': 'success',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<function model="documents.document" name="action_folder_embed_action" eval="[
|
||||||
|
ref('documents.document_internal_folder'),
|
||||||
|
ref('documents.ir_actions_server_send_to_finance'),
|
||||||
|
]"/>
|
||||||
|
|
||||||
|
|
||||||
|
<record id="ir_actions_server_tag_remove_inbox" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Remove Tag Inbox</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="update_path">tag_ids</field>
|
||||||
|
<field name="usage">ir_actions_server</field>
|
||||||
|
<field name="state">object_write</field>
|
||||||
|
<field name="update_m2m_operation">remove</field>
|
||||||
|
<field name="resource_ref" ref="documents.documents_tag_inbox"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_actions_server_tag_remove_to_validate" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Remove Tag To Validate</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="update_path">tag_ids</field>
|
||||||
|
<field name="usage">ir_actions_server</field>
|
||||||
|
<field name="state">object_write</field>
|
||||||
|
<field name="update_m2m_operation">remove</field>
|
||||||
|
<field name="resource_ref" ref="documents.documents_tag_to_validate"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_actions_server_tag_add_validated" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Add Tag Validated</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="update_path">tag_ids</field>
|
||||||
|
<field name="usage">ir_actions_server</field>
|
||||||
|
<field name="state">object_write</field>
|
||||||
|
<field name="update_m2m_operation">add</field>
|
||||||
|
<field name="resource_ref" ref="documents.documents_tag_validated"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_actions_server_tag_add_bill" model="ir.actions.server" forcecreate="0">
|
||||||
|
<field name="name">Add Tag Bill</field>
|
||||||
|
<field name="model_id" ref="documents.model_documents_document"/>
|
||||||
|
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="update_path">tag_ids</field>
|
||||||
|
<field name="usage">ir_actions_server</field>
|
||||||
|
<field name="state">object_write</field>
|
||||||
|
<field name="update_m2m_operation">add</field>
|
||||||
|
<field name="resource_ref" ref="documents.documents_tag_bill"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
<record id="ir_config_document_upload_limit" model="ir.config_parameter">
|
||||||
|
<field name="key">document.max_fileupload_size</field>
|
||||||
|
<field name="value">67000000</field>
|
||||||
|
</record>
|
||||||
|
<record id="ir_config_deletion_delay" model="ir.config_parameter">
|
||||||
|
<field name="key">documents.deletion_delay</field>
|
||||||
|
<field name="value">30</field>
|
||||||
|
</record>
|
||||||
|
</data></odoo>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
<record id="mail_documents_activity_data_Inbox" model="mail.activity.type">
|
||||||
|
<field name="name">Inbox</field>
|
||||||
|
<field name="res_model">documents.document</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="mail_documents_activity_data_tv" model="mail.activity.type">
|
||||||
|
<field name="name">To validate</field>
|
||||||
|
<field name="res_model">documents.document</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="mail_documents_activity_data_md" model="mail.activity.type">
|
||||||
|
<field name="name">Requested Document</field>
|
||||||
|
<field name="category">upload_file</field>
|
||||||
|
<field name="res_model">documents.document</field>
|
||||||
|
<field name="mail_template_ids" eval="[(4, ref('documents.mail_template_document_request_reminder'))]"/>
|
||||||
|
</record>
|
||||||
|
</data></odoo>
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!--Email template -->
|
||||||
|
<record id="mail_template_document_request" model="mail.template">
|
||||||
|
<field name="name">Document: Document Request</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="subject">Document Request {{ object.name != False and ': '+ object.name or '' }}</field>
|
||||||
|
<field name="email_to" eval="False"/>
|
||||||
|
<field name="partner_to">{{ object.requestee_partner_id.id or '' }}</field>
|
||||||
|
<field name="description">Sent to partner when requesting a document from them</field>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
|
||||||
|
<tbody>
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="middle">
|
||||||
|
<span style="font-size: 10px;">
|
||||||
|
Document Request: <br/>
|
||||||
|
<t t-if="object.name">
|
||||||
|
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Inbox Financial</span>
|
||||||
|
</t>
|
||||||
|
</span><br/>
|
||||||
|
</td><td valign="middle" align="right" t-if="not object.create_uid.company_id.uses_default_logo">
|
||||||
|
<img t-attf-src="/logo.png?company={{ object.create_uid.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.create_uid.company_id.name"/>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td colspan="2" style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="top" style="font-size: 13px;">
|
||||||
|
<div>
|
||||||
|
Hello <t t-out="object.owner_id.name or ''">OdooBot</t>,
|
||||||
|
<br/><br/>
|
||||||
|
<t t-out="object.create_uid.name or ''">OdooBot</t> (<t t-out="object.create_uid.email or ''">odoobot@example.com</t>) asks you to provide the following document:
|
||||||
|
<br/><br/>
|
||||||
|
<center>
|
||||||
|
<div>
|
||||||
|
<t t-if="object.name">
|
||||||
|
<b t-out="object.name or ''">Inbox Financial</b>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<t t-if="object.request_activity_id.note">
|
||||||
|
<i t-out="object.request_activity_id.note or ''">Example of a note.</i>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<br/>
|
||||||
|
<div style="margin: 16px 0px 16px 0px;">
|
||||||
|
<a t-att-href="object.access_url"
|
||||||
|
style="background-color: #875A7B; padding: 20px 30px 20px 30px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
|
||||||
|
Upload the requested document
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</center><br/>
|
||||||
|
Please provide us with the missing document before <t t-out="object.request_activity_id.date_deadline">2021-05-17</t>.
|
||||||
|
<t t-if="user and user.signature">
|
||||||
|
<br/>
|
||||||
|
<t t-out="user.signature or ''">--<br/>Mitchell Admin</t>
|
||||||
|
<br/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="middle" align="left">
|
||||||
|
<t t-out="object.create_uid.company_id.name or ''">YourCompany</t>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||||
|
<t t-out="object.create_uid.company_id.phone or ''">+1 650-123-4567</t>
|
||||||
|
<t t-if="object.create_uid.company_id.email">
|
||||||
|
| <a t-attf-href="'mailto:%s' % {{ object.create_uid.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.email or ''">info@yourcompany.com</a>
|
||||||
|
</t>
|
||||||
|
<t t-if="object.create_uid.company_id.website">
|
||||||
|
| <a t-attf-href="'%s' % {{ object.create_uid.company_id.website }}" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.website or ''">http://www.example.com</a>
|
||||||
|
</t>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<!-- POWERED BY -->
|
||||||
|
<tr><td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
|
||||||
|
<tr><td style="text-align: center; font-size: 13px;">
|
||||||
|
Powered by <a target="_blank" href="https://www.ftprotech.in/app/documents" style="color: #875A7B;">Odoo Documents</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</field>
|
||||||
|
<field name="lang">{{ object.owner_id.lang }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<template id="mail_template_document_share">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate; margin-bottom:13px;">
|
||||||
|
<tr><td valign="top">
|
||||||
|
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||||
|
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||||
|
<t t-if="record.name">
|
||||||
|
<t t-if="record.type == 'folder'">
|
||||||
|
<t t-out="user.name or ''"/> shared this folder with you: <t t-out="record.name"/>.<br/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-out="user.name or ''"/> shared this document with you: <t t-out="record.name"/>.<br/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-elif="record.type == 'folder'">
|
||||||
|
<t t-out="user.name or ''"/> shared a folder with you.<br/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-out="user.name or ''"/> shared a document with you.<br/>
|
||||||
|
</t>
|
||||||
|
<div t-if="message" style="color:#777; margin-top:13px;" t-out="message"/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Manual reminder; copy of document request template -->
|
||||||
|
<record id="mail_template_document_request_reminder" model="mail.template">
|
||||||
|
<field name="name">Document Request: Reminder</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="subject">Reminder to upload your document{{ object.name and ' : ' + object.name or '' }}</field>
|
||||||
|
<field name="email_to" eval="False"/>
|
||||||
|
<field name="partner_to">{{ object.requestee_partner_id.id or '' }}</field>
|
||||||
|
<field name="description">Set reminders in activities to notify users who didn't upload their requested document</field>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
|
||||||
|
<tbody>
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="middle">
|
||||||
|
<span style="font-size: 10px;">
|
||||||
|
Document Request: <br/>
|
||||||
|
<t t-if="object.name">
|
||||||
|
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Inbox Financial</span>
|
||||||
|
</t>
|
||||||
|
</span><br/>
|
||||||
|
</td><td valign="middle" align="right">
|
||||||
|
<img t-attf-src="/logo.png?company={{ object.create_uid.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.create_uid.company_id.name"/>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td colspan="2" style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="top" style="font-size: 13px;">
|
||||||
|
<div>
|
||||||
|
Hello <t t-out="object.owner_id.name or ''">OdooBot</t>,
|
||||||
|
<br/><br/>
|
||||||
|
This is a friendly reminder to upload your requested document:
|
||||||
|
<br/><br/>
|
||||||
|
<center>
|
||||||
|
<div>
|
||||||
|
<t t-if="object.name">
|
||||||
|
<b t-out="object.name or ''">Inbox Financial</b>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<t t-if="object.request_activity_id.note">
|
||||||
|
<i t-out="object.request_activity_id.note or ''">Example of a note.</i>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<br/>
|
||||||
|
<div style="margin: 16px 0px 16px 0px;">
|
||||||
|
<a t-att-href="object.access_url"
|
||||||
|
style="background-color: #875A7B; padding: 20px 30px 20px 30px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
|
||||||
|
Upload the requested document
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</center><br/>
|
||||||
|
Please provide us with the missing document before <t t-out="object.request_activity_id.date_deadline or ''">2021-05-17</t>.
|
||||||
|
<br/><br/>
|
||||||
|
Thank you,
|
||||||
|
<t t-if="user and user.signature">
|
||||||
|
<br/>
|
||||||
|
<t t-out="user.signature">--<br/>Mitchell Admin</t>
|
||||||
|
<br/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="middle" align="left">
|
||||||
|
<t t-out="object.create_uid.company_id.name or ''">YourCompany</t>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||||
|
<t t-out="object.create_uid.company_id.phone or ''">+1 650-123-4567</t>
|
||||||
|
<t t-if="object.create_uid.company_id.email">
|
||||||
|
| <a t-attf-href="'mailto:%s' % {{ object.create_uid.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.email">info@yourcompany.com</a>
|
||||||
|
</t>
|
||||||
|
<t t-if="object.create_uid.company_id.website">
|
||||||
|
| <a t-att-href="object.create_uid.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.website">http://www.example.com</a>
|
||||||
|
</t>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<!-- POWERED BY -->
|
||||||
|
<tr><td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
|
||||||
|
<tr><td style="text-align: center; font-size: 13px;">
|
||||||
|
Powered by <a target="_blank" href="https://www.ftprotech.in/app/documents" style="color: #875A7B;">Odoo Documents</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="base.user_demo" model="res.users">
|
||||||
|
<field name="groups_id" eval="[(3, ref('documents.group_documents_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- folders -->
|
||||||
|
<record id="document_marketing_brand1_folder" model="documents.document" forcecreate="0">
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="folder_id" ref="document_marketing_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="name">Brand 1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="document_marketing_brand1_shared_folder" model="documents.document" forcecreate="0">
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="folder_id" ref="document_marketing_brand1_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="access_via_link">view</field>
|
||||||
|
<field name="name">Shared</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="document_marketing_brand2_folder" model="documents.document" forcecreate="0">
|
||||||
|
<field name="type">folder</field>
|
||||||
|
<field name="folder_id" ref="document_marketing_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="name">Brand 2</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- internal -->
|
||||||
|
|
||||||
|
<record id="documents_data_multi_pdf_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">Mails_inbox.pdf</field>
|
||||||
|
<field name="datas" type="base64" file="documents/data/files/Mails_inbox.pdf"/>
|
||||||
|
<field name="folder_id" ref="documents.document_internal_folder"/>
|
||||||
|
<field name="access_internal">view</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_image_city_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">city.jpg</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/city.jpg"/>
|
||||||
|
<field name="folder_id" ref="documents.document_internal_folder"/>
|
||||||
|
<field name="access_internal">view</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_image_mail_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">mail.png</field>
|
||||||
|
<field name="datas" type="base64" file="documents/data/files/mail.png"/>
|
||||||
|
<field name="folder_id" ref="documents.document_internal_folder"/>
|
||||||
|
<field name="access_internal">view</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
|
||||||
|
</record>
|
||||||
|
<!-- The thumbnail is added after -->
|
||||||
|
<record id="documents_image_mail_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="thumbnail" type="base64" file="documents/data/files/mail_thumbnail.png"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_image_people_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">people.jpg</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/people.jpg"/>
|
||||||
|
<field name="folder_id" ref="documents.document_internal_folder"/>
|
||||||
|
<field name="access_internal">view</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- finance -->
|
||||||
|
|
||||||
|
<record id="documents_vendor_bill_inv_007" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">Invoice-INV_2018_0007.pdf</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/Invoice2018_0007.pdf"/>
|
||||||
|
<field name="folder_id" ref="documents.document_finance_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_validated')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_vendor_bill_extract_azure_interior_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">invoice Azure Interior.pdf</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/invoice_azure_interior.pdf"/>
|
||||||
|
<field name="folder_id" ref="documents.document_finance_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_to_validate')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_vendor_bill_extract_open_value_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">invoice OpenValue.pdf</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/invoice_openvalue.pdf"/>
|
||||||
|
<field name="folder_id" ref="documents.document_finance_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_data_comercial_tenancy_agreement" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">Commercial-Tenancy-Agreement.pdf</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/Commercial-Tenancy-Agreement.pdf"/>
|
||||||
|
<field name="folder_id" ref="documents.document_finance_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- marketing -->
|
||||||
|
|
||||||
|
<record id="documents_image_La_landscape_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">LA landscape.jpg</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/la.jpg"/>
|
||||||
|
<field name="folder_id" ref="documents.document_marketing_brand1_folder"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_images')])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_attachment_sorry_netsuite_document" model="documents.document" forcecreate="0">
|
||||||
|
<field name="name">Sorry Netsuite.jpg</field>
|
||||||
|
<field name="datas" type="base64" file="documents/demo/files/sorry_netsuite.jpg"/>
|
||||||
|
<field name="folder_id" ref="documents.document_marketing_brand1_shared_folder"/>
|
||||||
|
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_ads')])]"/>
|
||||||
|
<field name="access_internal">edit</field>
|
||||||
|
<field name="access_via_link">view</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -0,0 +1,23 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
# mixin
|
||||||
|
from . import documents_unlink_mixin
|
||||||
|
from . import documents_mixin
|
||||||
|
|
||||||
|
# documents
|
||||||
|
from . import documents_access
|
||||||
|
from . import documents_document
|
||||||
|
from . import documents_redirect
|
||||||
|
from . import documents_tag
|
||||||
|
|
||||||
|
# orm
|
||||||
|
from . import ir_attachment
|
||||||
|
from . import ir_binary
|
||||||
|
|
||||||
|
# inherit
|
||||||
|
from . import mail_activity
|
||||||
|
from . import mail_activity_type
|
||||||
|
from . import res_partner
|
||||||
|
from . import res_users
|
||||||
|
from . import res_config_settings
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccess(models.Model):
|
||||||
|
_name = 'documents.access'
|
||||||
|
_description = 'Document / Partner'
|
||||||
|
_log_access = False
|
||||||
|
|
||||||
|
document_id = fields.Many2one('documents.document', required=True, ondelete='cascade')
|
||||||
|
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade', index=True)
|
||||||
|
role = fields.Selection(
|
||||||
|
[('view', 'Viewer'), ('edit', 'Editor')],
|
||||||
|
string='Role', required=False, index=True)
|
||||||
|
last_access_date = fields.Datetime('Last Accessed On', required=False)
|
||||||
|
expiration_date = fields.Datetime('Expiration', index=True)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_document_access_partner', 'unique(document_id, partner_id)',
|
||||||
|
'This partner is already set on this document.'),
|
||||||
|
('role_or_last_access_date', 'check (role IS NOT NULL or last_access_date IS NOT NULL)',
|
||||||
|
'NULL roles must have a set last_access_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _prepare_create_values(self, vals_list):
|
||||||
|
vals_list = super()._prepare_create_values(vals_list)
|
||||||
|
documents = self.env['documents.document'].browse(
|
||||||
|
[vals['document_id'] for vals in vals_list])
|
||||||
|
documents.check_access('write')
|
||||||
|
return vals_list
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if 'partner_id' in vals or 'document_id' in vals:
|
||||||
|
raise AccessError(_('Access documents and partners cannot be changed.'))
|
||||||
|
|
||||||
|
self.document_id.check_access('write')
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
@api.autovacuum
|
||||||
|
def _gc_expired(self):
|
||||||
|
self.search([('expiration_date', '<=', fields.Datetime.now())], limit=1000).unlink()
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import Command, models
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentMixin(models.AbstractModel):
|
||||||
|
"""
|
||||||
|
Inherit this mixin to automatically create a `documents.document` when
|
||||||
|
an `ir.attachment` is linked to a record and add the default values when
|
||||||
|
creating a document related to the model that inherits from this mixin.
|
||||||
|
|
||||||
|
Override this mixin's methods to specify an owner, a folder, tags or
|
||||||
|
access_rights for the document.
|
||||||
|
|
||||||
|
Note: this mixin can be disabled with the context variable "no_document=True".
|
||||||
|
"""
|
||||||
|
_name = 'documents.mixin'
|
||||||
|
_inherit = 'documents.unlink.mixin'
|
||||||
|
_description = "Documents creation mixin"
|
||||||
|
|
||||||
|
def _get_document_vals(self, attachment):
|
||||||
|
"""
|
||||||
|
Return values used to create a `documents.document`
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
document_vals = {}
|
||||||
|
if self._check_create_documents():
|
||||||
|
access_rights_vals = self._get_document_vals_access_rights()
|
||||||
|
if set(access_rights_vals) - {'access_via_link', 'access_internal', 'is_access_via_link_hidden'}:
|
||||||
|
raise ValueError("Invalid access right values")
|
||||||
|
document_vals = {
|
||||||
|
'attachment_id': attachment.id,
|
||||||
|
'name': attachment.name or self.display_name,
|
||||||
|
'folder_id': self._get_document_folder().id,
|
||||||
|
'owner_id': self._get_document_owner().id,
|
||||||
|
'partner_id': self._get_document_partner().id,
|
||||||
|
'tag_ids': [(6, 0, self._get_document_tags().ids)],
|
||||||
|
} | access_rights_vals
|
||||||
|
return document_vals
|
||||||
|
|
||||||
|
def _get_document_vals_access_rights(self):
|
||||||
|
""" Return access rights values to create a `documents.document`
|
||||||
|
|
||||||
|
In the default implementation, we give the minimal permission and rely on the propagation of the folder
|
||||||
|
permission but this method can be overridden to set more open rights.
|
||||||
|
|
||||||
|
Authorized fields: access_via_link, access_internal, is_access_via_link_hidden.
|
||||||
|
Note: access_ids are handled differently because when set, it prevents inheritance from the parent folder
|
||||||
|
(see specific document override).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'access_via_link': 'none',
|
||||||
|
'access_internal': 'none',
|
||||||
|
'is_access_via_link_hidden': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_document_owner(self):
|
||||||
|
""" Return the owner value to create a `documents.document`
|
||||||
|
|
||||||
|
In the default implementation, we return OdooBot as owner to avoid giving full access to a user and to rely
|
||||||
|
instead on explicit access managed via `document.access` or via parent folder access inheritance but this
|
||||||
|
method can be overridden to for example give the ownership to the current user.
|
||||||
|
"""
|
||||||
|
return self.env.ref('base.user_root')
|
||||||
|
|
||||||
|
def _get_document_tags(self):
|
||||||
|
return self.env['documents.tag']
|
||||||
|
|
||||||
|
def _get_document_folder(self):
|
||||||
|
return self.env['documents.document']
|
||||||
|
|
||||||
|
def _get_document_partner(self):
|
||||||
|
return self.env['res.partner']
|
||||||
|
|
||||||
|
def _get_document_access_ids(self):
|
||||||
|
""" Add or remove members
|
||||||
|
|
||||||
|
:return boolean|list: list of tuple (partner, (role, expiration_date)) or False to avoid
|
||||||
|
inheriting members from parent folder.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _check_create_documents(self):
|
||||||
|
return bool(self and self._get_document_folder())
|
||||||
|
|
||||||
|
def _prepare_document_create_values_for_linked_records(
|
||||||
|
self, res_model, vals_list, pre_vals_list):
|
||||||
|
""" Set default value defined on the document mixin implementation of the related record if there are not
|
||||||
|
explicitly set.
|
||||||
|
|
||||||
|
:param str res_model: model referenced by the documents to consider
|
||||||
|
:param list[dict] vals_list: list of values
|
||||||
|
:param list[dict] pre_vals_list: list of values before _prepare_create_values (no permission inherited yet)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- This method doesn't override existing values (permission, owner, ...).
|
||||||
|
- The related record res_model must inherit from DocumentMixin
|
||||||
|
"""
|
||||||
|
if self._name != res_model:
|
||||||
|
raise ValueError(f'Invalid model {res_model} (expected {self._name})')
|
||||||
|
|
||||||
|
related_record_by_id = self.env[res_model].browse([
|
||||||
|
res_id for vals in vals_list if (res_id := vals.get('res_id'))]).grouped('id')
|
||||||
|
for vals, pre_vals in zip(vals_list, pre_vals_list):
|
||||||
|
if not vals.get('res_id'):
|
||||||
|
continue
|
||||||
|
related_record = related_record_by_id.get(vals['res_id'])
|
||||||
|
vals.update(
|
||||||
|
{
|
||||||
|
'owner_id': pre_vals.get('owner_id', related_record._get_document_owner().id),
|
||||||
|
'partner_id': pre_vals.get('partner_id', related_record._get_document_partner().id),
|
||||||
|
'tag_ids': pre_vals.get('tag_ids', [(6, 0, related_record._get_document_tags().ids)]),
|
||||||
|
} | {
|
||||||
|
key: value
|
||||||
|
for key, value in related_record._get_document_vals_access_rights().items()
|
||||||
|
if key not in pre_vals
|
||||||
|
})
|
||||||
|
if 'access_ids' in pre_vals:
|
||||||
|
continue
|
||||||
|
access_ids = vals.get('access_ids') or []
|
||||||
|
partner_with_access = {access[2]['partner_id'] for access in access_ids} # list of Command.create tuples
|
||||||
|
related_document_access = related_record._get_document_access_ids()
|
||||||
|
if related_document_access is False:
|
||||||
|
# Keep logs but remove members
|
||||||
|
access_ids = [a for a in access_ids if not a[2].get('role')]
|
||||||
|
else:
|
||||||
|
accesses_to_add = [
|
||||||
|
(partner, access)
|
||||||
|
for partner, access in related_record._get_document_access_ids()
|
||||||
|
if partner.id not in partner_with_access
|
||||||
|
]
|
||||||
|
if accesses_to_add:
|
||||||
|
access_ids.extend(
|
||||||
|
Command.create({
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'role': role,
|
||||||
|
'expiration_date': expiration_date,
|
||||||
|
})
|
||||||
|
for partner, (role, expiration_date) in accesses_to_add
|
||||||
|
)
|
||||||
|
vals['access_ids'] = access_ids
|
||||||
|
return vals_list
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRedirect(models.Model):
|
||||||
|
"""Model used to keep the old links valid after the 18.0 migration.
|
||||||
|
|
||||||
|
Do *NOT* use that model or inherit from it, it will be removed in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = "documents.redirect"
|
||||||
|
_description = "Document Redirect"
|
||||||
|
_log_access = False
|
||||||
|
|
||||||
|
access_token = fields.Char(required=True, index="btree")
|
||||||
|
document_id = fields.Many2one("documents.document", ondelete="cascade")
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_redirection(self, access_token):
|
||||||
|
"""Redirect to the right document, only if its access is view.
|
||||||
|
|
||||||
|
We won't redirect if the access is not "view" to not give write access
|
||||||
|
if the permission has been changed on the document (or to not give the
|
||||||
|
token if the access is "none").
|
||||||
|
"""
|
||||||
|
return self.search(
|
||||||
|
# do not give write access for old token
|
||||||
|
[("access_token", "=", access_token), ('document_id.access_via_link', '=', 'view')],
|
||||||
|
limit=1,
|
||||||
|
).document_id
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from random import randint
|
||||||
|
|
||||||
|
from odoo import _, api, models, fields
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class Tags(models.Model):
|
||||||
|
_name = "documents.tag"
|
||||||
|
_description = "Tag"
|
||||||
|
_order = "sequence, name"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_default_color(self):
|
||||||
|
return randint(1, 11)
|
||||||
|
|
||||||
|
name = fields.Char(required=True, translate=True)
|
||||||
|
sequence = fields.Integer('Sequence', default=10)
|
||||||
|
color = fields.Integer('Color', default=_get_default_color)
|
||||||
|
tooltip = fields.Char(help="Text shown when hovering on this tag", string="Tooltip")
|
||||||
|
document_ids = fields.Many2many('documents.document', 'document_tag_rel')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('tag_name_unique', 'unique (name)', "Tag name already used"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_tags(self, domain):
|
||||||
|
"""
|
||||||
|
fetches the tag and facet ids for the document selector (custom left sidebar of the kanban view)
|
||||||
|
"""
|
||||||
|
tags = self.env['documents.document'].search(domain).tag_ids
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'sequence': tag.sequence,
|
||||||
|
'id': tag.id,
|
||||||
|
'color': tag.color,
|
||||||
|
'__count': len(tag.document_ids)
|
||||||
|
} for tag in tags
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.ondelete(at_uninstall=False)
|
||||||
|
def _unlink_except_used_in_server_action(self):
|
||||||
|
external_ids = self._get_external_ids()
|
||||||
|
if external_ids and self.env['ir.actions.server'].search_count([('resource_ref', 'in', external_ids)], limit=1):
|
||||||
|
raise UserError(_("You cannot delete tags used in server actions."))
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUnlinkMixin(models.AbstractModel):
|
||||||
|
"""Send the related documents to trash when the record is deleted."""
|
||||||
|
_name = 'documents.unlink.mixin'
|
||||||
|
_description = "Documents unlink mixin"
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
"""Prevent deletion of the attachments / documents and send them to the trash instead."""
|
||||||
|
documents = self.env['documents.document'].search([
|
||||||
|
('res_model', '=', self._name),
|
||||||
|
('res_id', 'in', self.ids),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
|
||||||
|
for document in documents:
|
||||||
|
document.write({
|
||||||
|
'res_model': 'documents.document',
|
||||||
|
'res_id': document.id,
|
||||||
|
'active': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
return super().unlink()
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
|
from odoo import models, api
|
||||||
|
from odoo.tools.pdf import PdfFileWriter, PdfFileReader
|
||||||
|
|
||||||
|
|
||||||
|
class IrAttachment(models.Model):
|
||||||
|
_inherit = ['ir.attachment']
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _pdf_split(self, new_files=None, open_files=None):
|
||||||
|
"""Creates and returns new pdf attachments based on existing data.
|
||||||
|
|
||||||
|
:param new_files: the array that represents the new pdf structure:
|
||||||
|
[{
|
||||||
|
'name': 'New File Name',
|
||||||
|
'new_pages': [{
|
||||||
|
'old_file_index': 7,
|
||||||
|
'old_page_number': 5,
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
:param open_files: array of open file objects.
|
||||||
|
:returns: the new PDF attachments
|
||||||
|
"""
|
||||||
|
vals_list = []
|
||||||
|
pdf_from_files = [PdfFileReader(open_file, strict=False) for open_file in open_files]
|
||||||
|
for new_file in new_files:
|
||||||
|
output = PdfFileWriter()
|
||||||
|
for page in new_file['new_pages']:
|
||||||
|
input_pdf = pdf_from_files[int(page['old_file_index'])]
|
||||||
|
page_index = page['old_page_number'] - 1
|
||||||
|
output.addPage(input_pdf.getPage(page_index))
|
||||||
|
with io.BytesIO() as stream:
|
||||||
|
output.write(stream)
|
||||||
|
vals_list.append({
|
||||||
|
'name': new_file['name'] + ".pdf",
|
||||||
|
'datas': base64.b64encode(stream.getvalue()),
|
||||||
|
})
|
||||||
|
return self.create(vals_list)
|
||||||
|
|
||||||
|
def _create_document(self, vals):
|
||||||
|
"""
|
||||||
|
Implemented by bridge modules that create new documents if attachments are linked to
|
||||||
|
their business models.
|
||||||
|
|
||||||
|
:param vals: the create/write dictionary of ir attachment
|
||||||
|
:return True if new documents are created
|
||||||
|
"""
|
||||||
|
# Special case for documents
|
||||||
|
if vals.get('res_model') == 'documents.document' and vals.get('res_id'):
|
||||||
|
document = self.env['documents.document'].browse(vals['res_id'])
|
||||||
|
if document.exists() and not document.attachment_id:
|
||||||
|
document.attachment_id = self[0].id
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Generic case for all other models
|
||||||
|
res_model = vals.get('res_model')
|
||||||
|
res_id = vals.get('res_id')
|
||||||
|
model = self.env.get(res_model)
|
||||||
|
if model is not None and res_id and issubclass(self.pool[res_model], self.pool['documents.mixin']):
|
||||||
|
vals_list = [
|
||||||
|
model.browse(res_id)._get_document_vals(attachment)
|
||||||
|
for attachment in self
|
||||||
|
if not attachment.res_field and model.browse(res_id)._check_create_documents()
|
||||||
|
]
|
||||||
|
vals_list = [vals for vals in vals_list if vals] # Remove empty values
|
||||||
|
self.env['documents.document'].create(vals_list)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
attachments = super().create(vals_list)
|
||||||
|
for attachment, vals in zip(attachments, vals_list):
|
||||||
|
# the context can indicate that this new attachment is created from documents, and therefore
|
||||||
|
# doesn't need a new document to contain it.
|
||||||
|
if not self._context.get('no_document') and not attachment.res_field:
|
||||||
|
attachment.sudo()._create_document(dict(vals, res_model=attachment.res_model, res_id=attachment.res_id))
|
||||||
|
return attachments
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if not self._context.get('no_document'):
|
||||||
|
self.filtered(lambda a: not (vals.get('res_field') or a.res_field)).sudo()._create_document(vals)
|
||||||
|
return super(IrAttachment, self).write(vals)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
from os.path import splitext
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class IrBinary(models.AbstractModel):
|
||||||
|
_inherit = 'ir.binary'
|
||||||
|
|
||||||
|
def _record_to_stream(self, record, field_name):
|
||||||
|
if record._name == 'documents.document' and field_name in ('raw', 'datas', 'db_datas'):
|
||||||
|
# Read access to document give implicit read access to the attachment
|
||||||
|
return super()._record_to_stream(record.attachment_id.sudo(), field_name)
|
||||||
|
|
||||||
|
return super()._record_to_stream(record, field_name)
|
||||||
|
|
||||||
|
def _get_stream_from(
|
||||||
|
self, record, field_name='raw', filename=None, filename_field='name', mimetype=None,
|
||||||
|
default_mimetype='application/octet-stream',
|
||||||
|
):
|
||||||
|
# skip magic detection of the file extension when it is provided
|
||||||
|
if (record._name == 'documents.document'
|
||||||
|
and filename is None
|
||||||
|
and record.file_extension
|
||||||
|
):
|
||||||
|
name, extension = splitext(record.name)
|
||||||
|
if extension == f'.{record.file_extension}':
|
||||||
|
filename = record.name
|
||||||
|
else:
|
||||||
|
filename = f'{name}.{record.file_extension}'
|
||||||
|
|
||||||
|
return super()._get_stream_from(
|
||||||
|
record, field_name, filename, filename_field, mimetype, default_mimetype)
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from odoo import api, models, fields, _
|
||||||
|
from odoo.osv import expression
|
||||||
|
|
||||||
|
|
||||||
|
class MailActivity(models.Model):
|
||||||
|
_inherit = 'mail.activity'
|
||||||
|
|
||||||
|
def _prepare_next_activity_values(self):
|
||||||
|
vals = super()._prepare_next_activity_values()
|
||||||
|
current_activity_type = self.activity_type_id
|
||||||
|
next_activity_type = current_activity_type.triggered_next_type_id
|
||||||
|
|
||||||
|
if current_activity_type.category == 'upload_file' and self.res_model == 'documents.document' and next_activity_type.category == 'upload_file':
|
||||||
|
existing_document = self.env['documents.document'].search([('request_activity_id', '=', self.id)], limit=1)
|
||||||
|
if 'summary' not in vals:
|
||||||
|
vals['summary'] = self.summary or _('Upload file request')
|
||||||
|
new_doc_request = self.env['documents.document'].create({
|
||||||
|
'owner_id': existing_document.owner_id.id,
|
||||||
|
'folder_id': next_activity_type.folder_id.id if next_activity_type.folder_id else existing_document.folder_id.id,
|
||||||
|
'tag_ids': [(6, 0, next_activity_type.tag_ids.ids)],
|
||||||
|
'name': vals['summary'],
|
||||||
|
})
|
||||||
|
vals['res_id'] = new_doc_request.id
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def _action_done(self, feedback=False, attachment_ids=None):
|
||||||
|
if not self:
|
||||||
|
return super()._action_done(feedback=feedback, attachment_ids=attachment_ids)
|
||||||
|
documents = self.env['documents.document'].search([('request_activity_id', 'in', self.ids)])
|
||||||
|
document_without_attachment = documents.filtered(lambda d: not d.attachment_id)
|
||||||
|
if document_without_attachment and not feedback:
|
||||||
|
feedback = _("Document Request: %(name)s Uploaded by: %(user)s",
|
||||||
|
name=documents[0].name, user=self.env.user.name)
|
||||||
|
messages, next_activities = super(MailActivity, self.with_context(no_document=True))._action_done(
|
||||||
|
feedback=feedback, attachment_ids=attachment_ids)
|
||||||
|
# Downgrade access link role from edit to view if necessary (if the requestee didn't have a user at the request
|
||||||
|
# time, we previously granted him edit access by setting access_via_link to edit on the document).
|
||||||
|
documents.filtered(lambda document: document.access_via_link == 'edit').access_via_link = 'view'
|
||||||
|
# Remove request information on the document
|
||||||
|
documents.requestee_partner_id = False
|
||||||
|
documents.request_activity_id = False
|
||||||
|
# Attachment must be set after documents.request_activity_id is set to False to prevent document write to
|
||||||
|
# trigger an action_done.
|
||||||
|
if attachment_ids and document_without_attachment:
|
||||||
|
document_without_attachment.attachment_id = attachment_ids[0]
|
||||||
|
return messages, next_activities
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
activities = super().create(vals_list)
|
||||||
|
upload_activities = activities.filtered(lambda act: act.activity_category == 'upload_file')
|
||||||
|
|
||||||
|
# link back documents and activities
|
||||||
|
upload_documents_activities = upload_activities.filtered(lambda act: act.res_model == 'documents.document')
|
||||||
|
if upload_documents_activities:
|
||||||
|
documents = self.env['documents.document'].browse(upload_documents_activities.mapped('res_id'))
|
||||||
|
for document, activity in zip(documents, upload_documents_activities):
|
||||||
|
if not document.request_activity_id:
|
||||||
|
document.request_activity_id = activity.id
|
||||||
|
|
||||||
|
# create underlying documents if related record is not a document
|
||||||
|
doc_vals = [{
|
||||||
|
'res_model': activity.res_model,
|
||||||
|
'res_id': activity.res_id,
|
||||||
|
'owner_id': activity.activity_type_id.default_user_id.id or self.env.user.id,
|
||||||
|
'folder_id': activity.activity_type_id.folder_id.id,
|
||||||
|
'tag_ids': [(6, 0, activity.activity_type_id.tag_ids.ids)],
|
||||||
|
'name': activity.summary or activity.res_name or 'upload file request',
|
||||||
|
'request_activity_id': activity.id,
|
||||||
|
} for activity in upload_activities.filtered(
|
||||||
|
lambda act: act.res_model != 'documents.document' and act.activity_type_id.folder_id
|
||||||
|
)]
|
||||||
|
if doc_vals:
|
||||||
|
self.env['documents.document'].sudo().create(doc_vals)
|
||||||
|
return activities
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
write_result = super().write(vals)
|
||||||
|
if 'date_deadline' not in vals or not (
|
||||||
|
act_on_docs := self.filtered(lambda activity: activity.res_model == 'documents.document')):
|
||||||
|
return write_result
|
||||||
|
# Update expiration access of the requestee when updating the related request activity deadline
|
||||||
|
document_requestee_partner_ids = self.env['documents.document'].search_read([
|
||||||
|
('id', 'in', act_on_docs.mapped('res_id')),
|
||||||
|
('requestee_partner_id', '!=', False),
|
||||||
|
('request_activity_id', 'in', self.ids),
|
||||||
|
], ['requestee_partner_id'])
|
||||||
|
new_expiration_date = datetime.combine(self[0].date_deadline, datetime.max.time())
|
||||||
|
self.env['documents.access'].search(expression.OR([[
|
||||||
|
('document_id', '=', document_requestee_partner_id['id']),
|
||||||
|
('partner_id', '=', document_requestee_partner_id['requestee_partner_id'][0]),
|
||||||
|
('expiration_date', '<', new_expiration_date),
|
||||||
|
] for document_requestee_partner_id in document_requestee_partner_ids
|
||||||
|
])).expiration_date = new_expiration_date
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class MailActivityType(models.Model):
|
||||||
|
_inherit = "mail.activity.type"
|
||||||
|
|
||||||
|
tag_ids = fields.Many2many('documents.tag')
|
||||||
|
folder_id = fields.Many2one('documents.document',
|
||||||
|
domain="[('type', '=', 'folder'), ('shortcut_document_id', '=', False)]",
|
||||||
|
help="By defining a folder, the upload activities will generate a document")
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
deletion_delay = fields.Integer(config_parameter="documents.deletion_delay", default=30,
|
||||||
|
help='Delay after permanent deletion of the document in the trash (days)')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('check_deletion_delay', 'CHECK(deletion_delay >= 0)', 'The deletion delay should be positive.'),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models, fields, _
|
||||||
|
from odoo.osv import expression
|
||||||
|
|
||||||
|
|
||||||
|
class Partner(models.Model):
|
||||||
|
_inherit = "res.partner"
|
||||||
|
|
||||||
|
document_count = fields.Integer('Document Count', compute='_compute_document_count')
|
||||||
|
|
||||||
|
def _compute_document_count(self):
|
||||||
|
read_group_var = self.env['documents.document']._read_group(
|
||||||
|
expression.AND([
|
||||||
|
[('partner_id', 'in', self.ids)],
|
||||||
|
[('type', '!=', 'folder')],
|
||||||
|
]),
|
||||||
|
groupby=['partner_id'],
|
||||||
|
aggregates=['__count'])
|
||||||
|
|
||||||
|
document_count_dict = {partner.id: count for partner, count in read_group_var}
|
||||||
|
for record in self:
|
||||||
|
record.document_count = document_count_dict.get(record.id, 0)
|
||||||
|
|
||||||
|
def action_see_documents(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'name': _('Documents'),
|
||||||
|
'domain': [('partner_id', '=', self.id)],
|
||||||
|
'res_model': 'documents.document',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'views': [(False, 'kanban')],
|
||||||
|
'view_mode': 'kanban',
|
||||||
|
'context': {
|
||||||
|
"default_partner_id": self.id,
|
||||||
|
"searchpanel_default_folder_id": False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_create_members_to_invite(self):
|
||||||
|
return {
|
||||||
|
'res_model': 'res.partner',
|
||||||
|
'target': 'new',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'view_id': self.env.ref('base.view_partner_simple_form').id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class Users(models.Model):
|
||||||
|
_name = "res.users"
|
||||||
|
_inherit = ["res.users"]
|
||||||
|
|
||||||
|
def _init_store_data(self, store):
|
||||||
|
super()._init_store_data(store)
|
||||||
|
has_group = self.env.user.has_group("documents.group_documents_user")
|
||||||
|
store.add({"hasDocumentsUserGroup": has_group})
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_documents_access_base_group_portal,documents_access_base_group_portal,model_documents_access,base.group_portal,1,0,0,0
|
||||||
|
access_documents_access_base_group_user,documents_access_base_group_user,model_documents_access,base.group_user,1,1,1,1
|
||||||
|
access_documents_attachment_base_group_portal,documents_attachment_base_group_portal,model_documents_document,base.group_portal,1,1,1,1
|
||||||
|
access_documents_attachment_base_group_user,documents_attachment_base_group_user,model_documents_document,base.group_user,1,1,1,1
|
||||||
|
access_documents_tag_base_group_portal,documents_tag_base_group_portal,model_documents_tag,base.group_portal,1,0,0,0
|
||||||
|
access_documents_tag_base_group_user,documents_tag_base_group_user,model_documents_tag,base.group_user,1,0,0,0
|
||||||
|
|
||||||
|
access_documents_tag_group_user,documents_tag_group_user,model_documents_tag,documents.group_documents_user,1,0,0,0
|
||||||
|
|
||||||
|
access_documents_access_group_manager,documents_access_group_manager,model_documents_access,documents.group_documents_manager,1,1,1,1
|
||||||
|
access_documents_attachment_group_manager,documents_attachment_group_manager,model_documents_document,documents.group_documents_manager,1,1,1,1
|
||||||
|
access_documents_tag_group_manager,documents_tag_group_manager,model_documents_tag,documents.group_documents_manager,1,1,1,1
|
||||||
|
|
||||||
|
access_documents_request_wizard,access.documents.request_wizard,model_documents_request_wizard,documents.group_documents_user,1,1,1,0
|
||||||
|
access_documents_link_to_record_wizard,access.documents.link_to_record_wizard,model_documents_link_to_record_wizard,documents.group_documents_user,1,1,1,0
|
||||||
|
access_mail_activity_plan_documents_manager,mail.activity.plan.documents.manager,mail.model_mail_activity_plan,documents.group_documents_manager,1,1,1,1
|
||||||
|
access_mail_activity_plan_template_documents_manager,mail.activity.plan.template.documents.manager,mail.model_mail_activity_plan_template,documents.group_documents_manager,1,1,1,1
|
||||||
|
access_documents_redirect_base_system_user,documents_redirect_base_system_user,model_documents_redirect,base.group_system,1,0,0,1
|
||||||
|
|
|
@ -0,0 +1,132 @@
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="base.module_category_productivity_documents" model="ir.module.category">
|
||||||
|
<field name="name">Documents</field>
|
||||||
|
<field name="description">Allows you to manage your documents.</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_documents_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
<field name="category_id" ref="base.module_category_productivity_documents"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_documents_manager" model="res.groups">
|
||||||
|
<field name="name">Administrator</field>
|
||||||
|
<field name="category_id" ref="base.module_category_productivity_documents"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_documents_user'))]"/>
|
||||||
|
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_documents_system" model="res.groups">
|
||||||
|
<field name="name">System Administrator</field>
|
||||||
|
<field name="category_id" ref="base.module_category_productivity_documents"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_documents_manager'))]"/>
|
||||||
|
<field name="users" eval="[(4, ref('base.user_root'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="base.default_user" model="res.users">
|
||||||
|
<field name="groups_id" eval="[(4, ref('documents.group_documents_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- documents rules -->
|
||||||
|
<!-- duplicated for spreadsheetCellThreads in rule documents_spreadsheet.documents_document_thread_global_rule
|
||||||
|
Please update aforemetioned the rule accordingly -->
|
||||||
|
<record id="documents_document_global_rule" model="ir.rule">
|
||||||
|
<field name="name">Documents.document: global read rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="domain_force">[('user_permission', '!=', 'none')]</field>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_document_global_create_rule" model="ir.rule">
|
||||||
|
<field name="name">Documents.document: global create rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|', ('folder_id.user_permission', '=', 'edit'), ('folder_id', '=', False),
|
||||||
|
]</field>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_document_global_write_rule" model="ir.rule">
|
||||||
|
<field name="name">Documents.document: global write rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="domain_force">[('user_permission', '=', 'edit')]</field>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- duplicated for spreadsheetCellThreads in rule documents_spreadsheet.spreadsheet_cell_thread_write_rule -->
|
||||||
|
<record id="documents_document_write_base_rule" model="ir.rule">
|
||||||
|
<field name="name">Documents.document: write+unlink base rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="groups" eval="[
|
||||||
|
(4, ref('base.group_public')),
|
||||||
|
(4, ref('base.group_portal')),
|
||||||
|
(4, ref('base.group_user')),
|
||||||
|
]"/>
|
||||||
|
<field name="domain_force">[('user_permission', '=', 'edit'), ('is_pinned_folder', '=', False)]</field>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_document_write_manager_rule" model="ir.rule">
|
||||||
|
<field name="name">Documents.document: write+unlink manager rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_document"/>
|
||||||
|
<field name="groups" eval="[(4, ref('documents.group_documents_manager'))]"/>
|
||||||
|
<field name="domain_force">[('user_permission', '=', 'edit')]</field>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- same as documents.document -->
|
||||||
|
<record id="documents_access_global_rule_read" model="ir.rule">
|
||||||
|
<field name="name">Documents.access: global read rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_access"/>
|
||||||
|
<field name="domain_force">[('document_id.user_permission', '!=', 'none')]</field>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- same as documents.document -->
|
||||||
|
<record id="documents_access_global_rule_write" model="ir.rule">
|
||||||
|
<field name="name">Documents.access: global write rule</field>
|
||||||
|
<field name="model_id" ref="model_documents_access"/>
|
||||||
|
<field name="domain_force">[('document_id.user_permission', '=', 'edit')]</field>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="mail_plan_rule_group_document_manager_document" model="ir.rule">
|
||||||
|
<field name="name">Manager can manage document plans</field>
|
||||||
|
<field name="groups" eval="[(4, ref('documents.group_documents_manager'))]"/>
|
||||||
|
<field name="model_id" ref="mail.model_mail_activity_plan"/>
|
||||||
|
<field name="domain_force">[('res_model', '=', 'documents.document')]</field>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="mail_plan_template_rule_group_document_manager_document" model="ir.rule">
|
||||||
|
<field name="name">Manager can manage document plan templates</field>
|
||||||
|
<field name="groups" eval="[(4, ref('documents.group_documents_manager'))]"/>
|
||||||
|
<field name="model_id" ref="mail.model_mail_activity_plan_template"/>
|
||||||
|
<field name="domain_force">[('plan_id.res_model', '=', 'documents.document')]</field>
|
||||||
|
<field name="perm_read" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="documents_tag_rule_portal" model="ir.rule">
|
||||||
|
<field name="name">Tag portal: Read access to the tags of the documents the user has access to</field>
|
||||||
|
<field name="model_id" ref="model_documents_tag"/>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||||
|
<field name="domain_force">[('document_ids.user_permission', '!=', 'none')]</field>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M30.576 14.907a4.029 4.029 0 0 1 5.697 0l8.547 8.546a4.029 4.029 0 0 1 0 5.698l-15.669 15.67a4.029 4.029 0 0 1-5.698 0l-8.546-8.547a4.029 4.029 0 0 1 0-5.698l15.669-15.67Z" fill="#FBB945"/><path d="M18.721 9.047a4.029 4.029 0 0 1 5.264-2.18l11.167 4.625a4.029 4.029 0 0 1 2.18 5.264l-8.48 20.472a4.029 4.029 0 0 1-5.263 2.181l-11.167-4.626a4.029 4.029 0 0 1-2.18-5.264l8.48-20.472Z" fill="#FC868B"/><path d="M37.527 16.16c-.048.2-.113.4-.194.596l-8.48 20.472a4.029 4.029 0 0 1-5.265 2.18l-9.236-3.825a4.03 4.03 0 0 1 .555-5.007l15.669-15.67a4.029 4.029 0 0 1 5.697 0l1.254 1.254Z" fill="#F86126"/><path d="M4 8.029A4.029 4.029 0 0 1 8.029 4h12.087a4.029 4.029 0 0 1 4.029 4.029v22.16a4.029 4.029 0 0 1-4.03 4.028H8.03A4.029 4.029 0 0 1 4 30.188V8.03Z" fill="#2EBCFA"/><path d="M23.973 6.861c.112.37.172.762.172 1.168v22.16a4.029 4.029 0 0 1-4.029 4.029h-8.658a4.03 4.03 0 0 1-1.217-4.699l8.48-20.472a4.029 4.029 0 0 1 5.252-2.186Z" fill="#2D6388"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1,37 @@
|
||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Attachment } from "@mail/core/common/attachment_model";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Attachment.prototype, {
|
||||||
|
documentId: null,
|
||||||
|
documentData: null,
|
||||||
|
|
||||||
|
get urlRoute() {
|
||||||
|
if (this.documentId) {
|
||||||
|
return this.isImage
|
||||||
|
? `/web/image/${this.documentId}`
|
||||||
|
: `/web/content/${this.documentId}`;
|
||||||
|
}
|
||||||
|
return super.urlRoute;
|
||||||
|
},
|
||||||
|
|
||||||
|
get defaultSource() {
|
||||||
|
if (this.isPdf && this.documentId) {
|
||||||
|
const encodedRoute = encodeURIComponent(
|
||||||
|
`/documents/content/${this.documentData.access_token}?download=0`
|
||||||
|
);
|
||||||
|
return `/web/static/lib/pdfjs/web/viewer.html?file=${encodedRoute}#pagemode=none`;
|
||||||
|
}
|
||||||
|
return super.defaultSource;
|
||||||
|
},
|
||||||
|
|
||||||
|
get urlQueryParams() {
|
||||||
|
const res = super.urlQueryParams;
|
||||||
|
if (this.documentId) {
|
||||||
|
res["model"] = "documents.document";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { FileViewer as WebFileViewer } from "@web/core/file_viewer/file_viewer";
|
||||||
|
import { onWillUpdateProps } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class FileViewer extends WebFileViewer {
|
||||||
|
static template = "documents.FileViewer";
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
/** @type {import("@documents/core/document_service").DocumentService} */
|
||||||
|
this.documentService = useService("document.document");
|
||||||
|
this.onSelectDocument = this.documentService.documentList?.onSelectDocument;
|
||||||
|
onWillUpdateProps((nextProps) => {
|
||||||
|
const indexOfFileToPreview = nextProps.startIndex;
|
||||||
|
if (
|
||||||
|
indexOfFileToPreview !== this.state.index &&
|
||||||
|
indexOfFileToPreview !== this.props.startIndex
|
||||||
|
) {
|
||||||
|
this.activateFile(indexOfFileToPreview);
|
||||||
|
}
|
||||||
|
this.documentService.setPreviewedDocument(
|
||||||
|
this.documentService.documentList.documents[nextProps.startIndex]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
get hasSplitPdf() {
|
||||||
|
if (this.documentService.documentList?.initialRecordSelectionLength === 1) {
|
||||||
|
return this.documentService.documentList.selectedDocument.attachment.isPdf;
|
||||||
|
}
|
||||||
|
return this.documentService.documentList?.documents.every(
|
||||||
|
(document) => document.attachment.isPdf
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get withDownload() {
|
||||||
|
if (this.documentService.documentList?.initialRecordSelectionLength === 1) {
|
||||||
|
return this.documentService.documentList.selectedDocument.attachment.isUrlYoutube;
|
||||||
|
}
|
||||||
|
return this.documentService.documentList?.documents.every(
|
||||||
|
(document) => document.attachment.isUrlYoutube
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onClickPdfSplit() {
|
||||||
|
this.close();
|
||||||
|
if (this.documentService.documentList?.initialRecordSelectionLength === 1) {
|
||||||
|
return this.documentService.documentList?.pdfManagerOpenCallback([
|
||||||
|
this.documentService.documentList.selectedDocument.record,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return this.documentService.documentList?.pdfManagerOpenCallback(
|
||||||
|
this.documentService.documentList.documents.map((document) => document.record)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.documentService.documentList?.onDeleteCallback();
|
||||||
|
this.documentService.setPreviewedDocument(null);
|
||||||
|
super.close();
|
||||||
|
}
|
||||||
|
next() {
|
||||||
|
super.next();
|
||||||
|
this.documentService.setPreviewedDocument(
|
||||||
|
this.documentService.documentList.documents[this.state.index]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.onSelectDocument) {
|
||||||
|
const documentList = this.documentService.documentList;
|
||||||
|
if (
|
||||||
|
!documentList ||
|
||||||
|
!documentList.selectedDocument ||
|
||||||
|
!documentList.documents ||
|
||||||
|
!documentList.documents.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = documentList.documents.findIndex(
|
||||||
|
(document) => document === documentList.selectedDocument
|
||||||
|
);
|
||||||
|
const nextIndex = index === documentList.documents.length - 1 ? 0 : index + 1;
|
||||||
|
documentList.selectedDocument = documentList.documents[nextIndex];
|
||||||
|
this.onSelectDocument(documentList.selectedDocument.record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous() {
|
||||||
|
super.previous();
|
||||||
|
this.documentService.setPreviewedDocument(
|
||||||
|
this.documentService.documentList.documents[this.state.index]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.onSelectDocument) {
|
||||||
|
const documentList = this.documentService.documentList;
|
||||||
|
if (
|
||||||
|
!documentList ||
|
||||||
|
!documentList.selectedDocument ||
|
||||||
|
!documentList.documents ||
|
||||||
|
!documentList.documents.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = documentList.documents.findIndex(
|
||||||
|
(doc) => doc === documentList.selectedDocument
|
||||||
|
);
|
||||||
|
// if we're on the first document, go "back" to the last one
|
||||||
|
const previousIndex = index === 0 ? documentList.documents.length - 1 : index - 1;
|
||||||
|
documentList.selectedDocument = documentList.documents[previousIndex];
|
||||||
|
this.onSelectDocument(documentList.selectedDocument.record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="documents.FileViewer" t-inherit="web.FileViewer" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//div[hasclass('o-FileViewer-download')]" position="before">
|
||||||
|
<div t-if="hasSplitPdf" class="o-FileViewer-headerButton d-flex align-items-center px-3 cursor-pointer" t-on-click.stop="onClickPdfSplit" role="button" title="Split PDF">
|
||||||
|
<i class="fa fa-scissors fa-fw" t-att-class="{ 'o-hasLabel me-2': !env.isSmall }" role="img"/>
|
||||||
|
<t t-if="!env.isSmall">
|
||||||
|
<span>Split PDF</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('o-FileViewer-download')]" position="replace">
|
||||||
|
<t t-if="!withDownload">$0</t>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('o-FileViewer-navigation')][@aria-label='Previous']" position="attributes">
|
||||||
|
<attribute name="class" add="bg-dark" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('o-FileViewer-navigation')][@aria-label='Next']" position="attributes">
|
||||||
|
<attribute name="class" add="bg-dark" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//span[hasclass('oi-chevron-left')]" position="attributes">
|
||||||
|
<attribute name="class" add="pe-1 text-white" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//span[hasclass('oi-chevron-right')]" position="attributes">
|
||||||
|
<attribute name="class" add="ps-1 text-white" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "models" {
|
||||||
|
export interface Attachment {
|
||||||
|
documentId: number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { DocumentsCogMenu } from "../views/cog_menu/documents_cog_menu";
|
||||||
|
import { Breadcrumbs } from "@web/search/breadcrumbs/breadcrumbs";
|
||||||
|
|
||||||
|
export class DocumentsBreadcrumbs extends Breadcrumbs {
|
||||||
|
static components = {
|
||||||
|
...Breadcrumbs.components,
|
||||||
|
DocumentsCogMenu,
|
||||||
|
};
|
||||||
|
static template = "documents.Breadcrumbs";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="documents.Breadcrumbs" t-inherit="web.Breadcrumbs" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//div[hasclass('o_breadcrumb')]" position="attributes">
|
||||||
|
<attribute name="class" remove="align-self-stretch" add="cursor-pointer" separator=" "/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//div[hasclass('o_last_breadcrumb_item')]" position="after">
|
||||||
|
<DocumentsCogMenu/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { useDateTimePicker } from "@web/core/datetime/datetime_hook";
|
||||||
|
import { deserializeDateTime, today } from "@web/core/l10n/dates";
|
||||||
|
import { user } from "@web/core/user";
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class DocumentsAccessExpirationDateBtn extends Component {
|
||||||
|
static defaultProps = { editionMode: false };
|
||||||
|
static props = {
|
||||||
|
accessPartner: Object,
|
||||||
|
disabled: Boolean,
|
||||||
|
setExpirationDate: Function,
|
||||||
|
editionMode: { type: Boolean, optional: true },
|
||||||
|
};
|
||||||
|
static template = "documents.AccessExpirationDateBtn";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const pickerProps = {
|
||||||
|
type: "datetime",
|
||||||
|
value: this.props.accessPartner.expiration_date
|
||||||
|
? deserializeDateTime(this.props.accessPartner.expiration_date, {
|
||||||
|
tz: user.context.tz,
|
||||||
|
})
|
||||||
|
: today(),
|
||||||
|
};
|
||||||
|
this.dateTimePicker = useDateTimePicker({
|
||||||
|
target: `datetime-picker-target-${this.props.accessPartner.partner_id.id}`,
|
||||||
|
onApply: (date) => {
|
||||||
|
this.props.setExpirationDate(this.props.accessPartner, date);
|
||||||
|
},
|
||||||
|
get pickerProps() {
|
||||||
|
return pickerProps;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickDateTimePickerBtn() {
|
||||||
|
if (!this.props.disabled) {
|
||||||
|
this.dateTimePicker.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||