Add oca-dms submodule with 10 DMS modules

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ernad Husremovic 2025-08-30 17:46:17 +02:00
parent c674eb0508
commit ae2c6775ba
569 changed files with 63341 additions and 0 deletions

View file

@ -0,0 +1,45 @@
# Add dms field for account
Odoo addon: account_dms_field
## Installation
```bash
pip install odoo-bringout-oca-dms-account_dms_field
```
## Dependencies
This addon depends on:
- account
- dms_field
## Manifest Information
- **Name**: Add dms field for account
- **Version**: 16.0.1.0.1
- **Category**: Accounting/Accounting
- **License**: LGPL-3
- **Installable**: True
## Source
Based on [OCA/dms](https://github.com/OCA/dms) branch 16.0, addon `account_dms_field`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,101 @@
=========================
Add dms field for account
=========================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:b7f18580a215d7c509b59f1e27607ad97ff06df4cc445bd45e838ead8c9e5164
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdms-lightgray.png?logo=github
:target: https://github.com/OCA/dms/tree/16.0/account_dms_field
:alt: OCA/dms
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/dms-16-0/dms-16-0-account_dms_field
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/dms&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
Add the Documents tab with the files in the account move form view.
**Table of contents**
.. contents::
:local:
Configuration
=============
#. *Go to Documents > Configuration > File templates* and create a new record.
#. Set a storage, a model (account.move) and the access groups you want.
#. Click on the "Documents" tab icon and a folder hierarchy will be created.
#. You can set here the hierarchy of directories, subdirectories and files you need, this hierarchy will be used as a base when creating a new record (res.partner for example).
Usage
=====
#. Go to the form view of an existing account move and click on the "Documents" tab icon, a hierarchy of
folders and files linked to that record will be created.
#. Create a new account.move. A hierarchy of folders and files linked to that record will be created.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/dms/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/dms/issues/new?body=module:%20account_dms_field%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Agenterp
Contributors
~~~~~~~~~~~~
* `Agenterp <https://www.agenterp.com/>`_:
* Georg Notter
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
.. |maintainer-victoralmau| image:: https://github.com/victoralmau.png?size=40px
:target: https://github.com/victoralmau
:alt: victoralmau
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-victoralmau|
This module is part of the `OCA/dms <https://github.com/OCA/dms/tree/16.0/account_dms_field>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

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

View file

@ -0,0 +1,12 @@
{
"name": "Add dms field for account",
"version": "16.0.1.0.1",
"category": "Accounting/Accounting",
"website": "https://github.com/OCA/dms",
"author": "Agent ERP GmbH, Odoo Community Association (OCA)",
"depends": ["account", "dms_field"],
"data": ["views/account_move_view.xml"],
"demo": ["demo/account_dms_data.xml"],
"installable": True,
"license": "LGPL-3",
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="read_access_account_move_group" model="dms.access.group">
<field name="name">Everyone for Account DMS</field>
<field name="group_ids" eval="[(4, ref('account.group_account_invoice'))]" />
<field name="perm_create" eval="True" />
<field name="perm_write" eval="True" />
<field name="perm_unlink" eval="True" />
</record>
<record id="field_template_account" model="dms.field.template">
<field name="name">Account</field>
<field name="storage_id" ref="dms.storage_demo" />
<field name="model_id" ref="account.model_account_move" />
<field name="user_field_id" ref="account.field_account_move__user_id" />
<field name="group_ids" eval="[(4, ref('read_access_account_move_group'))]" />
</record>
</odoo>

View file

@ -0,0 +1,39 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * account_dms_field
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: account_dms_field
#: model:ir.model.fields,field_description:account_dms_field.field_account_move__dms_directory_ids
msgid "DMS Directories"
msgstr ""
#. module: account_dms_field
#: model:ir.model,name:account_dms_field.model_dms_field_template
msgid "Dms Field Template"
msgstr ""
#. module: account_dms_field
#: model_terms:ir.ui.view,arch_db:account_dms_field.view_move_form_inherit_account_dms_field
msgid "Documents"
msgstr ""
#. module: account_dms_field
#: model:dms.access.group,name:account_dms_field.read_access_account_move_group
msgid "Everyone for Account DMS"
msgstr ""
#. module: account_dms_field
#: model:ir.model,name:account_dms_field.model_account_move
msgid "Journal Entry"
msgstr ""

View file

@ -0,0 +1,39 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * account_dms_field
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: account_dms_field
#: model:ir.model.fields,field_description:account_dms_field.field_account_move__dms_directory_ids
msgid "DMS Directories"
msgstr "DMS direktoriji"
#. module: account_dms_field
#: model:ir.model,name:account_dms_field.model_dms_field_template
msgid "Dms Field Template"
msgstr "DMS templejt polja"
#. module: account_dms_field
#: model_terms:ir.ui.view,arch_db:account_dms_field.view_move_form_inherit_account_dms_field
msgid "Documents"
msgstr "Dokumenti"
#. module: account_dms_field
#: model:dms.access.group,name:account_dms_field.read_access_account_move_group
msgid "Everyone for Account DMS"
msgstr "Svi za Account DMS"
#. module: account_dms_field
#: model:ir.model,name:account_dms_field.model_account_move
msgid "Journal Entry"
msgstr "Žurnal"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * account_dms_field
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-10-17 16:06+0000\n"
"Last-Translator: mymage <stefano.consolaro@mymage.it>\n"
"Language-Team: none\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.6.2\n"
#. module: account_dms_field
#: model:ir.model.fields,field_description:account_dms_field.field_account_move__dms_directory_ids
msgid "DMS Directories"
msgstr "Cartelle DMS"
#. module: account_dms_field
#: model:ir.model,name:account_dms_field.model_dms_field_template
msgid "Dms Field Template"
msgstr "Modello campo DMS"
#. module: account_dms_field
#: model_terms:ir.ui.view,arch_db:account_dms_field.view_move_form_inherit_account_dms_field
msgid "Documents"
msgstr "Documenti"
#. module: account_dms_field
#: model:dms.access.group,name:account_dms_field.read_access_account_move_group
msgid "Everyone for Account DMS"
msgstr "Tutti per conto DMS"
#. module: account_dms_field
#: model:ir.model,name:account_dms_field.model_account_move
msgid "Journal Entry"
msgstr "Registrazione contabile"

View file

@ -0,0 +1,2 @@
from . import account_move
from . import dms_field_template

View file

@ -0,0 +1,6 @@
from odoo import models
class AccountMove(models.Model):
_name = "account.move"
_inherit = ["account.move", "dms.field.mixin"]

View file

@ -0,0 +1,12 @@
from odoo import models
class DmsFieldTemplate(models.Model):
_inherit = "dms.field.template"
def _prepare_directory_vals(self, directory, record):
vals = super()._prepare_directory_vals(directory, record)
if "/" not in vals["name"]:
return vals
vals["name"] = vals["name"].replace("/", "-")
return vals

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

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

View file

@ -0,0 +1,54 @@
from odoo.addons.base.tests.common import BaseCommon
class TestAccountDmsField(BaseCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, test_dms_field=True))
cls.template = cls.env.ref("account_dms_field.field_template_account")
cls.storage = cls.template.storage_id
cls.access_group = cls.template.group_ids
cls.account_model = cls.env["account.move"]
cls.partner = cls.env.ref("base.res_partner_12")
cls.test_directory = cls.env["dms.directory"].create(
{
"name": "Test Directory",
"parent_id": cls.template.dms_directory_ids[0].id,
"storage_id": cls.template.storage_id.id,
}
)
def test_01_account_document_directory(self):
account_move = self.account_model.create(
{
"partner_id": self.partner.id,
}
)
account_move.invalidate_model()
directory = account_move.dms_directory_ids
# Assert that only one directory is created for the account move.
self.assertEqual(len(directory), 1, "Directory length must be 1.")
# Assert that the storage associated with the directory is the same as the
# template's storage.
self.assertEqual(
directory.storage_id,
self.storage,
"Account move directory storage is different from the template storage.",
)
# Assert that the custom access group is present in the directory's group
# list.
self.assertIn(
self.access_group,
directory.group_ids,
"Account move directory groups are different from the template groups.",
)
# Map the names of child directories related to the account move directory.
child_directory_names = directory.mapped("child_directory_ids.name")
# Assert that a specific child directory, "Test Directory", exists.
self.assertIn(
"Test Directory",
child_directory_names,
"Test Directory is not in the child directory of the account move "
"directory.",
)

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_move_form_inherit_account_dms_field" model="ir.ui.view">
<field name="name">view.move.form.inherit.account.dms.field</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page
name="documents"
string="Documents"
attrs="{'invisible': [('id', '=', False)]}"
>
<field name="dms_directory_ids" mode="dms_list" />
</page>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Account_dms_field Module - account_dms_field
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for account_dms_field. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,6 @@
# Dependencies
This addon depends on:
- [account](https://github.com/bringout/oca-ocb-accounting/tree/b11fb50e2ed11eec1e305a0df730b49554c01199/odoo-bringout-oca-ocb-account)
- [dms_field](https://github.com/bringout/oca-technical)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon account_dms_field or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-dms-account_dms_field"
# or
uv pip install odoo-bringout-oca-dms-account_dms_field"
```

View file

@ -0,0 +1,13 @@
# Models
Detected core models and extensions in account_dms_field.
```mermaid
classDiagram
class account_move
class dms_field_template
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: account_dms_field. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon account_dms_field
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,8 @@
# Security
This module does not define custom security rules or access controls beyond Odoo defaults.
Default Odoo security applies:
- Base user access through standard groups
- Model access inherited from dependencies
- No custom row-level security rules

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon account_dms_field
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,43 @@
[project]
name = "odoo-bringout-oca-dms-account_dms_field"
version = "16.0.0"
description = "Add dms field for account - Odoo addon"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-account>=16.0.0",
"odoo-bringout-oca-dms-dms_field>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["account_dms_field"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]

View file

@ -0,0 +1,47 @@
# Document Management System
Odoo addon: dms
## Installation
```bash
pip install odoo-bringout-oca-dms-dms
```
## Dependencies
This addon depends on:
- mail
- http_routing
- portal
- base
## Manifest Information
- **Name**: Document Management System
- **Version**: 16.0.1.8.6
- **Category**: Document Management
- **License**: LGPL-3
- **Installable**: False
## Source
Based on [OCA/dms](https://github.com/OCA/dms) branch 16.0, addon `dms`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,189 @@
==========================
Document Management System
==========================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:4ca37da84fb902307a08168a40a5048ba34c2af01b05c97a139cca5eb19b8301
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdms-lightgray.png?logo=github
:target: https://github.com/OCA/dms/tree/16.0/dms
:alt: OCA/dms
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/dms-16-0/dms-16-0-dms
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/dms&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
DMS is a module for creating, managing and viewing document files directly
within Odoo.
This module is only the basis for an entire ecosystem of apps that extend and
seamlessly integrate with the document management system.
This module adds portal functionality for directories and files for allowed users, both portal or internal users. You can get as well a tokenized link from a directory or a file for sharing it with any anonymous user.
**Table of contents**
.. contents::
:local:
Installation
============
Preview
~~~~~~~
``mail_preview_base`` is required for DMS but it is recommended to install all
the other `mail_preview` modules from `social` OCA repository
in order to improve the preview of files.
``python-magic`` library is recommended to be installed for having whole support
to get proper file types and file preview.
Configuration
=============
To configure this module, you need to:
#. Go to *Documents -> Configuration -> Storages*.
#. Create a new document storage. You can choose between two options on `Save Type`:
* `Database`: Store the files on the database as a field
* `Attachment`: Store the files as attachments
#. Next create an administrative access group. Go to *Configuration -> Access Groups*.
* Create a new group, name it appropriately, and turn on all three permissions (Create, Write and Unlink - Read is implied and always enabled).
* Add any other top-level administrative users to the group if needed (your user should already be there).
* You can create other groups in here later for fine grained access control.
#. Afterwards go to *Documents -> Directories*.
#. Create a new directory, mark it as root and select the previously created setting.
* Select the *Groups* tab and add your administrative group created above.
#. On the Directory you can also add other access groups (created above) that will be able to:
* read
* create
* write
* delete
Migration
~~~~~~~~~
If you need to modify the storage Save Type you might want to migrate the file data.
In order to achieve it you need to:
#. Go to *Documents -> Configuration -> Storage* and select the storage you want to modify
#. Modify the save type
#. Press the button `Migrate files` if you want to migrate all the files at once
#. Press the button `Manual File Migration` in order to specify files one by one
You can check all the files that still needs to be migrated from all storages
and migrate them manually on *Documents -> Configuration -> Migration*
File Wizard Selection
~~~~~~~~~~~~~~~~~~~~~
There is an action called `action_dms_file_wizard_selector` to open a wizard to list files in kanban view.
This can be used (example `dms_attachment_link` module) to add a button in kanban view with the action we need.
Usage
=====
The best way to manage the documents is to switch to the Documents view.
Existing documents can be managed there and new documents can be created.
Portal functionality
~~~~~~~~~~~~~~~~~~~~
You can add any portal user to DMS access groups, and then allow that group in directories, so they will see in the portal such directories and their files.
Another possibility is to click on "Share" button inside a directory or a file for obtaining a tokenized link for single access to that resource, no matter if logged or not.
Known issues / Roadmap
======================
- Files preview in portal
- Allow to download folder in portal and create zip file with all content
- Save in cache own_root directories and update in every create/write/unlink function
- Add a migration procedure for converting an storage to attachment one for populating existing records with attachments as folders
- Add a link from attachment view in chatter to linked documents
- If Inherit permissions from related record (the inherit_access_from_parent_record field from storage) is changed when directories already exist, inconsistencies may occur because groups defined in the directories and subdirectories will still exist, all groups in these directories should be removed before changing.
- Since portal users can read ``dms.storage`` records, if your module extends this model to another storage backend that needs using secrets, remember to forbid access to the secrets fields by other means. It would be nice to be able to remove that rule at some point.
- Searchpanel in files: Highlight items (shading) without records when filtering something (by name for example).
- Accessing the clipboard (for example copy share link of file/directory) is limited to secure connections. It also happens in any part of Odoo.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/dms/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/dms/issues/new?body=module:%20dms%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* MuK IT
* Tecnativa
Contributors
~~~~~~~~~~~~
* Mathias Markl <mathias.markl@mukit.at>
* Enric Tobella <etobella@creublanca.es>
* Antoni Romera
* Gelu Boros <gelu.boros@rgbconsulting.com>
* `Tecnativa <https://www.tecnativa.com>`_:
* Víctor Martínez
* Pedro M. Baeza
* Jairo Llopis
* `Elego <https://www.elegosoft.com>`_:
* Yu Weng <yweng@elegosoft.com>
* Philip Witte <phillip.witte@elegosoft.com>
* Khanh Bui <khanh.bui@mail.elegosoft.com>
Other credits
~~~~~~~~~~~~~
The migration of this module from 15.0 to 16.0 was financially supported by `AgentERP <https://www.agenterp.com>`_
Some pictures are based on or inspired by:
* `Roundicons <https://www.flaticon.com/authors/roundicons>`_
* `Smashicons <https://www.flaticon.com/authors/smashicons>`_
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/dms <https://github.com/OCA/dms/tree/16.0/dms>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizards

View file

@ -0,0 +1,60 @@
# Copyright 2017-2019 MuK IT GmbH
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
{
"name": "Document Management System",
"summary": """Document Management System for Odoo""",
"version": "16.0.1.8.6",
"category": "Document Management",
"license": "LGPL-3",
"website": "https://github.com/OCA/dms",
"author": "MuK IT, Tecnativa, Odoo Community Association (OCA)",
"depends": [
"mail",
"http_routing",
"portal",
"base",
],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"actions/file.xml",
"template/onboarding.xml",
"views/menu.xml",
"views/tag.xml",
"views/category.xml",
"views/dms_file.xml",
"views/directory.xml",
"views/storage.xml",
"views/dms_access_groups_views.xml",
"views/res_config_settings.xml",
"views/dms_portal_templates.xml",
"wizards/wizard_dms_file_move_views.xml",
"wizards/wizard_dms_share_views.xml",
],
"assets": {
"mail.assets_messaging": [
("include", "mail.assets_core_messaging"),
"dms/static/src/models/*.js",
],
"web.assets_backend": [
"dms/static/src/scss/*",
"dms/static/src/js/fields/*",
"dms/static/src/js/views/*.esm.js",
"dms/static/src/js/views/*.xml",
"dms/static/src/js/views/fields/binary/*",
],
"web.assets_frontend": ["dms/static/src/js/dms_portal_tour.js"],
},
"demo": [
"demo/res_users.xml",
"demo/access_group.xml",
"demo/category.xml",
"demo/tag.xml",
"demo/storage.xml",
"demo/directory.xml",
"demo/file.xml",
],
"images": ["static/description/banner.png"],
"application": True,
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo>
<record id="action_dms_attachment_migrate" model="ir.actions.server">
<field name="name">Migrate</field>
<field name="model_id" ref="model_dms_file" />
<field name="binding_model_id" ref="dms.model_dms_file" />
<field name="state">code</field>
<field name="code">records.action_migrate()</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import main
from . import portal

View file

@ -0,0 +1,49 @@
# Copyright 2017-2019 MuK IT GmbH
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import http
from odoo.http import request
class OnboardingController(http.Controller):
@http.route("/dms/document_onboarding/directory", auth="user", type="json")
def document_onboarding_directory(self):
company = request.env.user.company_id
closed = company.documents_onboarding_state == "closed"
check = request.env.user.has_group("dms.group_dms_manager")
if check and not closed:
return {
"html": request.env["ir.qweb"]._render(
request.env.ref("dms.document_onboarding_directory_panel").id,
{
"state": company.get_and_update_documents_onboarding_state(),
"company": company,
},
)
}
return {}
@http.route("/dms/document_onboarding/file", auth="user", type="json")
def document_onboarding_file(self):
company = request.env.user.company_id
closed = company.documents_onboarding_state == "closed"
check = request.env.user.has_group("dms.group_dms_manager")
if check and not closed:
return {
"html": request.env["ir.qweb"]._render(
request.env.ref("dms.document_onboarding_file_panel").id,
{
"state": company.get_and_update_documents_onboarding_state(),
"company": company,
},
)
}
return {}
@http.route("/config/dms.forbidden_extensions", type="json", auth="user")
def forbidden_extensions(self, **_kwargs):
params = request.env["ir.config_parameter"].sudo()
return {
"forbidden_extensions": params.get_param(
"dms.forbidden_extensions", default=""
)
}

View file

@ -0,0 +1,202 @@
# Copyright 2020-2021 Tecnativa - Víctor Martínez
import base64
from odoo import _, http
from odoo.http import content_disposition, request
from odoo.osv.expression import OR
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.addons.web.controllers.utils import ensure_db
class CustomerPortal(CustomerPortal):
def _dms_check_access(self, model, res_id, access_token=None):
item = request.env[model].browse(res_id)
if access_token:
item = item.sudo()
if not item.check_access_token(access_token):
return False
else:
if not item.permission_read:
return False
return item
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if "dms_directory_count" in counters:
ids = request.env["dms.directory"]._get_own_root_directories()
values["dms_directory_count"] = len(ids)
return values
@http.route(["/my/dms"], type="http", auth="user", website=True)
def portal_my_dms(
self, sortby=None, filterby=None, search=None, search_in="name", **kw
):
values = self._prepare_portal_layout_values()
searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}}
# default sortby br
if not sortby:
sortby = "name"
sort_br = searchbar_sortings[sortby]["order"]
# search
searchbar_inputs = {
"name": {"input": "name", "label": _("Name")},
}
if not filterby:
filterby = "name"
# domain
domain = [
(
"id",
"in",
request.env["dms.directory"]._get_own_root_directories(),
)
]
# search
if search and search_in:
search_domain = []
if search_in == "name":
search_domain = OR([search_domain, [("name", "ilike", search)]])
domain += search_domain
# content according to pager and archive selected
items = request.env["dms.directory"].search(domain, order=sort_br)
request.session["my_dms_folder_history"] = items.ids
# values
values.update(
{
"dms_directories": items,
"page_name": "dms_directory",
"default_url": "/my/dms",
"searchbar_sortings": searchbar_sortings,
"searchbar_inputs": searchbar_inputs,
"search_in": search_in,
"sortby": sortby,
"filterby": filterby,
"access_token": None,
}
)
return request.render("dms.portal_my_dms", values)
@http.route(
["/my/dms/directory/<int:dms_directory_id>"],
type="http",
auth="public",
website=True,
)
def portal_my_dms_directory(
self,
dms_directory_id=False,
sortby=None,
filterby=None,
search=None,
search_in="name",
access_token=None,
**kw
):
ensure_db()
# operations
searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}}
# default sortby br
if not sortby:
sortby = "name"
sort_br = searchbar_sortings[sortby]["order"]
# search
searchbar_inputs = {
"name": {"input": "name", "label": _("Name")},
}
if not filterby:
filterby = "name"
# domain
domain = [("is_hidden", "=", False), ("parent_id", "=", dms_directory_id)]
# search
if search and search_in:
search_domain = []
if search_in == "name":
search_domain = OR([search_domain, [("name", "ilike", search)]])
domain += search_domain
# content according to pager and archive selected
if access_token:
dms_directory_items = (
request.env["dms.directory"].sudo().search(domain, order=sort_br)
)
else:
dms_directory_items = request.env["dms.directory"].search(
domain, order=sort_br
)
request.session["my_dms_folder_history"] = dms_directory_items.ids
res = self._dms_check_access("dms.directory", dms_directory_id, access_token)
if not res:
if access_token:
return request.redirect("/")
else:
return request.redirect("/my")
dms_directory_sudo = res
# dms_files_count
domain = [
("is_hidden", "=", False),
("directory_id", "=", dms_directory_id),
]
# search
if search and search_in:
search_domain = []
if search_in == "name":
search_domain = OR([search_domain, [("name", "ilike", search)]])
domain += search_domain
# items
if access_token:
dms_file_items = (
request.env["dms.file"].sudo().search(domain, order=sort_br)
)
else:
dms_file_items = request.env["dms.file"].search(domain, order=sort_br)
request.session["my_dms_file_history"] = dms_file_items.ids
dms_parent_categories = dms_directory_sudo.sudo()._get_parent_categories(
access_token
)
# values
values = {
"dms_directories": dms_directory_items,
"page_name": "dms_directory",
"default_url": "/my/dms",
"searchbar_sortings": searchbar_sortings,
"searchbar_inputs": searchbar_inputs,
"search_in": search_in,
"sortby": sortby,
"filterby": filterby,
"access_token": access_token,
"dms_directory": dms_directory_sudo,
"dms_files": dms_file_items,
"dms_parent_categories": dms_parent_categories,
}
return request.render("dms.portal_my_dms", values)
@http.route(
["/my/dms/file/<int:dms_file_id>/download"],
type="http",
auth="public",
website=True,
)
def portal_my_dms_file_download(self, dms_file_id, access_token=None, **kw):
"""Process user's consent acceptance or rejection."""
ensure_db()
# operations
res = self._dms_check_access("dms.file", dms_file_id, access_token)
if not res:
if access_token:
return request.redirect("/")
else:
return request.redirect("/my")
dms_file_sudo = res
# It's necessary to prevent AccessError in ir_attachment .check() function
if dms_file_sudo.attachment_id and request.env.user.has_group(
"base.group_portal"
):
dms_file_sudo = dms_file_sudo.sudo()
filecontent = base64.b64decode(dms_file_sudo.content)
content_type = ["Content-Type", "application/octet-stream"]
disposition_content = [
"Content-Disposition",
content_disposition(dms_file_sudo.name),
]
return request.make_response(filecontent, [content_type, disposition_content])

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="access_group_01_demo" model="dms.access.group">
<field name="name">Admin</field>
<field name="perm_create">True</field>
<field name="perm_write">True</field>
<field name="perm_unlink">True</field>
<field
name="explicit_user_ids"
eval="[(6, 0, [ref('base.user_admin'), ref('base.user_demo')])]"
/>
</record>
<record id="access_group_02_demo" model="dms.access.group">
<field name="name">Portal</field>
<field name="group_ids" eval="[(6, 0, [ref('base.group_portal')])]" />
</record>
<record id="access_group_03_demo" model="dms.access.group">
<field name="name">Only admin user</field>
<field name="perm_create">True</field>
<field name="perm_write">True</field>
<field name="perm_unlink">True</field>
<field name="explicit_user_ids" eval="[(6, 0, [ref('base.user_admin')])]" />
</record>
</odoo>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo noupdate="1">
<record id="category_01_demo" model="dms.category">
<field name="name">Internal</field>
</record>
<record id="category_02_demo" model="dms.category">
<field name="name">Human Resource</field>
<field name="parent_id" ref="dms.category_01_demo" />
</record>
<record id="category_03_demo" model="dms.category">
<field name="name">Contracts</field>
<field name="parent_id" ref="dms.category_02_demo" />
</record>
<record id="category_04_demo" model="dms.category">
<field name="name">Traveling</field>
<field name="parent_id" ref="dms.category_02_demo" />
</record>
<record id="category_05_demo" model="dms.category">
<field name="name">External</field>
</record>
<record id="category_06_demo" model="dms.category">
<field name="name">News</field>
<field name="parent_id" ref="dms.category_05_demo" />
</record>
</odoo>

View file

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo noupdate="1">
<record id="directory_01_demo" model="dms.directory">
<field name="name">Documents</field>
<field name="is_root_directory" eval="True" />
<field name="parent_id" eval="False" />
<field name="color" eval="1" />
<field name="storage_id" ref="dms.storage_demo" />
<field name="category_id" ref="dms.category_01_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_05_demo')])]"
/>
<field name="group_ids" eval="[(6, 0, [ref('dms.access_group_01_demo')])]" />
</record>
<record id="directory_02_demo" model="dms.directory">
<field name="name">Media</field>
<field name="is_root_directory" eval="True" />
<field name="parent_id" eval="False" />
<field name="color" eval="2" />
<field name="storage_id" ref="dms.storage_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_03_demo')])]"
/>
<field name="group_ids" eval="[(6, 0, [ref('dms.access_group_01_demo')])]" />
</record>
<record id="directory_03_demo" model="dms.directory">
<field name="name">Sheets</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="1" />
<field name="parent_id" ref="dms.directory_01_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_04_demo')])]"
/>
</record>
<record id="directory_04_demo" model="dms.directory">
<field name="name">Templates</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="1" />
<field name="parent_id" ref="dms.directory_01_demo" />
<field name="category_id" ref="dms.category_01_demo" />
<field name="tag_ids" eval="[(6, 0, [ref('dms.tag_07_demo')])]" />
</record>
<record id="directory_05_demo" model="dms.directory">
<field name="name">Photos</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="2" />
<field name="category_id" ref="dms.category_02_demo" />
<field name="parent_id" ref="dms.directory_02_demo" />
<field
name="group_ids"
eval="[(6, 0, [ref('dms.access_group_01_demo'), ref('dms.access_group_02_demo')])]"
/>
</record>
<record id="directory_06_demo" model="dms.directory">
<field name="name">2017</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="2" />
<field name="parent_id" ref="dms.directory_05_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_02_demo'), ref('dms.tag_03_demo')])]"
/>
</record>
<record id="directory_07_demo" model="dms.directory">
<field name="name">2018</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="2" />
<field name="parent_id" ref="dms.directory_05_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_02_demo'), ref('dms.tag_06_demo')])]"
/>
</record>
<record id="directory_08_demo" model="dms.directory">
<field name="name">Videos</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="2" />
<field name="parent_id" ref="dms.directory_02_demo" />
</record>
<record id="directory_09_demo" model="dms.directory">
<field name="name">Music</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="2" />
<field name="parent_id" ref="dms.directory_02_demo" />
</record>
<record id="directory_10_demo" model="dms.directory">
<field name="name">Graphics</field>
<field name="is_root_directory" eval="False" />
<field name="parent_id" ref="dms.directory_02_demo" />
</record>
<record id="directory_11_demo" model="dms.directory">
<field name="name">Mails</field>
<field name="is_root_directory" eval="True" />
<field name="parent_id" eval="False" />
<field name="color" eval="3" />
<field name="storage_id" ref="dms.storage_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_04_demo'), ref('dms.tag_05_demo')])]"
/>
<field
name="group_ids"
eval="[(6, 0, [ref('dms.access_group_01_demo'), ref('dms.access_group_02_demo')])]"
/>
</record>
<record id="directory_12_demo" model="dms.directory">
<field name="name">Data</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="1" />
<field name="parent_id" ref="dms.directory_01_demo" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_06_demo'), ref('dms.tag_07_demo')])]"
/>
</record>
<record id="directory_13_demo" model="dms.directory">
<field name="name">Code</field>
<field name="is_root_directory" eval="False" />
<field name="color" eval="1" />
<field name="category_id" ref="dms.category_01_demo" />
<field name="parent_id" ref="dms.directory_01_demo" />
</record>
<record id="directory_14_demo" model="dms.directory">
<field name="name">Slides</field>
<field name="is_root_directory" eval="False" />
<field name="category_id" ref="dms.category_01_demo" />
<field name="parent_id" ref="dms.directory_01_demo" />
</record>
<record id="directory_root_res_partner_demo" model="dms.directory">
<field name="name">Partners</field>
<field name="is_root_directory" eval="True" />
<field name="color" eval="1" />
<field name="storage_id" ref="dms.storage_attachment_demo" />
<field name="category_id" ref="dms.category_01_demo" />
<field name="model_id" ref="base.model_res_partner" />
<field name="res_model">res.partner</field>
</record>
</odoo>

View file

@ -0,0 +1,241 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo noupdate="1">
<record id="file_01_demo" model="dms.file">
<field name="name">Sydney.jpg</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_06_demo" />
<field name="content" type="base64" file="dms/test/image01.jpg" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_05_demo')])]"
/>
</record>
<record id="file_02_demo" model="dms.file">
<field name="name">Logo_01.jpg</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_07_demo" />
<field name="content" type="base64" file="dms/test/image02.jpg" />
</record>
<record id="file_03_demo" model="dms.file">
<field name="name">Logo_02.jpg</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_07_demo" />
<field name="category_id" ref="dms.category_02_demo" />
<field name="content" type="base64" file="dms/test/image03.jpg" />
</record>
<record id="file_04_demo" model="dms.file">
<field name="name">Logo_03.jpg</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_07_demo" />
<field name="content" type="base64" file="dms/test/image04.jpg" />
</record>
<record id="file_05_demo" model="dms.file">
<field name="name">Logo.svg</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_10_demo" />
<field name="category_id" ref="dms.category_03_demo" />
<field name="content" type="base64" file="dms/test/vector.svg" />
</record>
<record id="file_06_demo" model="dms.file">
<field name="name">Loop_01.wav</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_09_demo" />
<field name="content" type="base64" file="dms/test/audio01.wav" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_03_demo')])]"
/>
</record>
<record id="file_07_demo" model="dms.file">
<field name="name">Loop_02.wav</field>
<field name="color" eval="2" />
<field name="directory_id" ref="dms.directory_09_demo" />
<field name="content" type="base64" file="dms/test/audio02.wav" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_03_demo')])]"
/>
</record>
<record id="file_08_demo" model="dms.file">
<field name="name">Loop_03.mp3</field>
<field name="color" eval="2" />
<field name="directory_id" ref="dms.directory_09_demo" />
<field name="content" type="base64" file="dms/test/audio03.mp3" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_03_demo')])]"
/>
</record>
<record id="file_09_demo" model="dms.file">
<field name="name">Loop_04.mp3</field>
<field name="color" eval="2" />
<field name="directory_id" ref="dms.directory_09_demo" />
<field name="content" type="base64" file="dms/test/audio04.mp3" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_01_demo'), ref('dms.tag_03_demo')])]"
/>
</record>
<record id="file_10_demo" model="dms.file">
<field name="name">Video.mp4</field>
<field name="color" eval="3" />
<field name="directory_id" ref="dms.directory_08_demo" />
<field name="content" type="base64" file="dms/test/video.mp4" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_02_demo'), ref('dms.tag_03_demo')])]"
/>
</record>
<record id="file_11_demo" model="dms.file">
<field name="name">Mail_01.eml</field>
<field name="color" eval="4" />
<field name="directory_id" ref="dms.directory_11_demo" />
<field name="content" type="base64" file="dms/test/mail01.eml" />
<field name="category_id" ref="dms.category_03_demo" />
</record>
<record id="file_12_demo" model="dms.file">
<field name="name">Mail_02.eml</field>
<field name="color" eval="4" />
<field name="directory_id" ref="dms.directory_11_demo" />
<field name="content" type="base64" file="dms/test/mail02.eml" />
</record>
<record id="file_13_demo" model="dms.file">
<field name="name">Text.txt</field>
<field name="directory_id" ref="dms.directory_12_demo" />
<field name="content" type="base64" file="dms/test/text.txt" />
<field
name="tag_ids"
eval="[(6, 0, [ref('dms.tag_05_demo'), ref('dms.tag_06_demo')])]"
/>
</record>
<record id="file_14_demo" model="dms.file">
<field name="name">ASPECTJ.aj</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code01.aj" />
<field name="category_id" ref="dms.category_01_demo" />
</record>
<record id="file_15_demo" model="dms.file">
<field name="name">Bash.sh</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code02.sh" />
</record>
<record id="file_16_demo" model="dms.file">
<field name="name">C.c</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code03.c" />
</record>
<record id="file_17_demo" model="dms.file">
<field name="name">Cplusplus.cc</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code04.cc" />
</record>
<record id="file_18_demo" model="dms.file">
<field name="name">CSharp.cs</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code05.cs" />
</record>
<record id="file_19_demo" model="dms.file">
<field name="name">COBOL.cbl</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code06.cbl" />
</record>
<record id="file_20_demo" model="dms.file">
<field name="name">CoffeeScript.coffee</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code07.coffee" />
</record>
<record id="file_21_demo" model="dms.file">
<field name="name">Fortran.f</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code08.f" />
</record>
<record id="file_22_demo" model="dms.file">
<field name="name">Go.go</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code09.go" />
</record>
<record id="file_23_demo" model="dms.file">
<field name="name">Groovy.groovy</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code10.groovy" />
</record>
<record id="file_24_demo" model="dms.file">
<field name="name">Java.java</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code11.java" />
</record>
<record id="file_25_demo" model="dms.file">
<field name="name">Scala.sc</field>
<field name="directory_id" ref="dms.directory_13_demo" />
<field name="content" type="base64" file="dms/test/code12.sc" />
</record>
<record id="file_26_demo" model="dms.file">
<field name="name">Sample.md</field>
<field name="directory_id" ref="dms.directory_04_demo" />
<field name="content" type="base64" file="dms/test/markdown.md" />
</record>
<record id="file_27_demo" model="dms.file">
<field name="name">Document_05.pdf</field>
<field name="color" eval="1" />
<field name="directory_id" ref="dms.directory_12_demo" />
<field name="content" type="base64" file="dms/test/document01.pdf" />
</record>
<record id="file_28_demo" model="dms.file">
<field name="name">Slide_01.odp</field>
<field name="directory_id" ref="dms.directory_14_demo" />
<field name="content" type="base64" file="dms/test/slide01.odp" />
</record>
<record id="file_29_demo" model="dms.file">
<field name="name">Slide_02.ppt</field>
<field name="directory_id" ref="dms.directory_14_demo" />
<field name="content" type="base64" file="dms/test/slide02.ppt" />
</record>
<record id="file_30_demo" model="dms.file">
<field name="name">Document_02.doc</field>
<field name="color" eval="5" />
<field name="directory_id" ref="dms.directory_12_demo" />
<field name="content" type="base64" file="dms/test/document02.doc" />
</record>
<record id="file_31_demo" model="dms.file">
<field name="name">Document_03.odt</field>
<field name="color" eval="5" />
<field name="directory_id" ref="dms.directory_12_demo" />
<field name="content" type="base64" file="dms/test/document03.odt" />
</record>
<record id="file_32_demo" model="dms.file">
<field name="name">Sheet_01.xls</field>
<field name="color" eval="6" />
<field name="directory_id" ref="dms.directory_03_demo" />
<field name="content" type="base64" file="dms/test/sheet01.xls" />
</record>
<record id="file_33_demo" model="dms.file">
<field name="name">Sheet_02.csv</field>
<field name="color" eval="6" />
<field name="directory_id" ref="dms.directory_03_demo" />
<field name="content" type="base64" file="dms/test/sheet02.csv" />
</record>
<record id="file_34_demo" model="dms.file">
<field name="name">Sheet_03.ods</field>
<field name="color" eval="6" />
<field name="directory_id" ref="dms.directory_03_demo" />
<field name="content" type="base64" file="dms/test/sheet03.ods" />
</record>
<record id="file_35_demo" model="dms.file">
<field name="name">Document_04.rtf</field>
<field name="color" eval="6" />
<field name="directory_id" ref="dms.directory_03_demo" />
<field name="content" type="base64" file="dms/test/document04.rtf" />
</record>
<record id="file_36_demo" model="dms.file">
<field name="name">Text.rst</field>
<field name="color" eval="3" />
<field name="directory_id" ref="dms.directory_02_demo" />
<field name="content" type="base64" file="dms/test/text.rst" />
</record>
</odoo>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo noupdate="1">
<record id="base.user_demo" model="res.users">
<field eval="[(4, ref('dms.group_dms_user'))]" name="groups_id" />
</record>
</odoo>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo noupdate="1">
<record id="storage_demo" model="dms.storage">
<field name="name">Documents Storage</field>
<field name="save_type">database</field>
</record>
<record id="storage_attachment_demo" model="dms.storage">
<field name="name">Attachment Storage</field>
<field name="save_type">attachment</field>
<field name="inherit_access_from_parent_record" eval="True" />
<field name="include_message_attachments" eval="True" />
<field name="model_ids" eval="[(6, 0, [ref('base.model_res_partner')])]" />
</record>
</odoo>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo noupdate="1">
<record id="tag_01_demo" model="dms.tag">
<field name="name">Customer</field>
<field name="color">1</field>
<field name="category_id" ref="category_03_demo" />
</record>
<record id="tag_02_demo" model="dms.tag">
<field name="name">Partner</field>
<field name="color">2</field>
<field name="category_id" ref="category_03_demo" />
</record>
<record id="tag_03_demo" model="dms.tag">
<field name="name">Project</field>
<field name="color">3</field>
<field name="category_id" ref="category_04_demo" />
</record>
<record id="tag_04_demo" model="dms.tag">
<field name="name">Sales</field>
<field name="color">4</field>
<field name="category_id" ref="category_05_demo" />
</record>
<record id="tag_05_demo" model="dms.tag">
<field name="name">Portal</field>
<field name="color">5</field>
<field name="category_id" ref="category_05_demo" />
</record>
<record id="tag_06_demo" model="dms.tag">
<field name="name">Apps</field>
<field name="color">6</field>
<field name="category_id" ref="category_05_demo" />
</record>
<record id="tag_07_demo" model="dms.tag">
<field name="name">Accounting</field>
<field name="color">7</field>
<field name="category_id" ref="category_05_demo" />
</record>
<record id="tag_08_demo" model="dms.tag">
<field name="name">Customer Invoice</field>
<field name="color">8</field>
<field name="category_id" ref="category_05_demo" />
</record>
<record id="tag_09_demo" model="dms.tag">
<field name="name">Vendor Bill</field>
<field name="color">9</field>
<field name="category_id" ref="category_05_demo" />
</record>
<record id="tag_10_demo" model="dms.tag">
<field name="name">Product</field>
<field name="color">10</field>
<field name="category_id" ref="category_06_demo" />
</record>
<record id="tag_11_demo" model="dms.tag">
<field name="name">Contract</field>
<field name="color">11</field>
<field name="category_id" ref="category_01_demo" />
</record>
</odoo>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
from . import access_groups
from . import base
from . import mixins_thumbnail
from . import dms_security_mixin
from . import abstract_dms_mixin
from . import storage
from . import directory
from . import dms_file
from . import category
from . import tag
from . import res_company
from . import res_config_settings
from . import ir_attachment
from . import mail_thread

View file

@ -0,0 +1,57 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
class AbstractDmsMixin(models.AbstractModel):
_name = "abstract.dms.mixin"
_description = "Abstract Dms Mixin"
name = fields.Char(required=True, index="btree")
# Only defined to prevent error in other fields that related it
storage_id = fields.Many2one(
comodel_name="dms.storage", string="Storage", store=True, copy=True
)
is_hidden = fields.Boolean(
string="Storage is Hidden",
related="storage_id.is_hidden",
readonly=True,
store=True,
)
company_id = fields.Many2one(
related="storage_id.company_id",
comodel_name="res.company",
string="Company",
readonly=True,
store=True,
index="btree",
)
storage_id_save_type = fields.Selection(related="storage_id.save_type", store=False)
color = fields.Integer(default=0)
category_id = fields.Many2one(
comodel_name="dms.category",
context="{'dms_category_show_path': True}",
string="Category",
)
@api.model
def search_panel_select_range(self, field_name, **kwargs):
"""Add context to display short folder name."""
_self = self.with_context(
directory_short_name=True, skip_sanitized_parent_hierarchy=True
)
return super(AbstractDmsMixin, _self).search_panel_select_range(
field_name, **kwargs
)
def _search_panel_sanitized_parent_hierarchy(self, records, parent_name, ids):
if self.env.context.get("skip_sanitized_parent_hierarchy"):
all_ids = [value["id"] for value in records]
# Prevent error if user not access to parent record
for value in records:
if value["parent_id"] and value["parent_id"][0] not in all_ids:
value["parent_id"] = False
return records
return super()._search_panel_sanitized_parent_hierarchy(
records=records, parent_name=parent_name, ids=ids
)

View file

@ -0,0 +1,169 @@
# Copyright 2017-2019 MuK IT GmbH
# Copyright 2020 RGB Consulting
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class DmsAccessGroups(models.Model):
_name = "dms.access.group"
_description = "Record Access Groups"
_parent_store = True
_parent_name = "parent_group_id"
name = fields.Char(string="Group Name", required=True, translate=True)
parent_path = fields.Char(index="btree", unaccent=False)
# Permissions written directly on this group
perm_create = fields.Boolean(string="Create Access")
perm_write = fields.Boolean(string="Write Access")
perm_unlink = fields.Boolean(string="Unlink Access")
# Permissions computed including parent group
perm_inclusive_create = fields.Boolean(
string="Inherited Create Access",
compute="_compute_inclusive_permissions",
store=True,
recursive=True,
)
perm_inclusive_write = fields.Boolean(
string="Inherited Write Access",
compute="_compute_inclusive_permissions",
store=True,
recursive=True,
)
perm_inclusive_unlink = fields.Boolean(
string="Inherited Unlink Access",
compute="_compute_inclusive_permissions",
store=True,
recursive=True,
)
directory_ids = fields.Many2many(
comodel_name="dms.directory",
relation="dms_directory_groups_rel",
string="Directories",
column1="gid",
column2="aid",
auto_join=True,
readonly=True,
)
complete_directory_ids = fields.Many2many(
comodel_name="dms.directory",
relation="dms_directory_complete_groups_rel",
column1="gid",
column2="aid",
string="Complete directories",
auto_join=True,
readonly=True,
)
count_users = fields.Integer(compute="_compute_users", store=True)
count_directories = fields.Integer(compute="_compute_count_directories")
parent_group_id = fields.Many2one(
comodel_name="dms.access.group",
string="Parent Group",
ondelete="cascade",
index="btree",
)
child_group_ids = fields.One2many(
comodel_name="dms.access.group",
inverse_name="parent_group_id",
string="Child Groups",
)
group_ids = fields.Many2many(
comodel_name="res.groups",
relation="dms_access_group_groups_rel",
column1="gid",
column2="rid",
string="Groups",
)
explicit_user_ids = fields.Many2many(
comodel_name="res.users",
relation="dms_access_group_explicit_users_rel",
column1="gid",
column2="uid",
string="Explicit Users",
)
users = fields.Many2many(
comodel_name="res.users",
relation="dms_access_group_users_rel",
column1="gid",
column2="uid",
string="Group Users",
compute="_compute_users",
auto_join=True,
store=True,
recursive=True,
)
@api.depends("directory_ids")
def _compute_count_directories(self):
for record in self:
record.count_directories = len(record.directory_ids)
_sql_constraints = [
("name_uniq", "unique (name)", "The name of the group must be unique!")
]
@api.depends(
"parent_group_id.perm_inclusive_create",
"parent_group_id.perm_inclusive_unlink",
"parent_group_id.perm_inclusive_write",
"parent_path",
"perm_create",
"perm_unlink",
"perm_write",
)
def _compute_inclusive_permissions(self):
"""Provide full permissions inheriting from parent recursively."""
for one in self:
one.update(
{
"perm_inclusive_%s"
% perm: (
one["perm_%s" % perm]
or one.parent_group_id["perm_inclusive_%s" % perm]
)
for perm in ("create", "unlink", "write")
}
)
@api.model
def default_get(self, fields_list):
res = super(DmsAccessGroups, self).default_get(fields_list)
if "explicit_user_ids" in res and res["explicit_user_ids"]:
res["explicit_user_ids"] = res["explicit_user_ids"] + [self.env.uid]
else:
res["explicit_user_ids"] = [(6, 0, [self.env.uid])]
return res
@api.depends(
"parent_group_id",
"parent_group_id.users",
"group_ids",
"group_ids.users",
"explicit_user_ids",
)
def _compute_users(self):
for record in self:
users = record.mapped("group_ids.users")
users |= record.mapped("explicit_user_ids")
users |= record.mapped("parent_group_id.users")
record.update({"users": users, "count_users": len(users)})
@api.constrains("parent_path")
def _check_parent_recursiveness(self):
"""Forbid recursive relationships."""
for one in self:
if not one.parent_group_id:
continue
if str(one.id) in one.parent_path.split("/"):
raise ValidationError(
_("Parent group '%(parent)s' is child of '%(current)s'.")
% {
"parent": one.parent_group_id.display_name,
"current": one.display_name,
}
)

View file

@ -0,0 +1,24 @@
# Copyright 2021 Tecnativa - Jairo Llopis
# Copyright 2024 Tecnativa - Víctor Martínez
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from odoo import models
class Base(models.AbstractModel):
_inherit = "base"
def unlink(self):
"""Cascade DMS related resources removal.
Avoid executing in ir.* models (ir.mode, ir.model.fields, etc), in transient
models and in the models we want to check."""
result = super().unlink()
if (
not self._name.startswith("ir.")
and not self.is_transient()
and self._name not in ("dms.file", "dms.directory")
):
domain = [("res_model", "=", self._name), ("res_id", "in", self.ids)]
self.env["dms.file"].sudo().search(domain).unlink()
self.env["dms.directory"].sudo().search(domain).unlink()
return result

View file

@ -0,0 +1,136 @@
# Copyright 2020 Creu Blanca
# Copyright 2017-2019 MuK IT GmbH
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class Category(models.Model):
_name = "dms.category"
_description = "Document Category"
_parent_store = True
_parent_name = "parent_id"
_order = "complete_name asc"
_rec_name = "complete_name"
# ----------------------------------------------------------
# Database
# ----------------------------------------------------------
name = fields.Char(required=True, translate=True)
active = fields.Boolean(
default=True,
help="The active field allows you to hide the category without removing it.",
)
complete_name = fields.Char(
compute="_compute_complete_name", store=True, recursive=True
)
parent_id = fields.Many2one(
comodel_name="dms.category",
string="Parent Category",
ondelete="cascade",
index="btree",
)
child_category_ids = fields.One2many(
comodel_name="dms.category",
inverse_name="parent_id",
string="Child Categories",
)
parent_path = fields.Char(index="btree", unaccent=False)
tag_ids = fields.One2many(
comodel_name="dms.tag", inverse_name="category_id", string="Tags"
)
directory_ids = fields.One2many(
comodel_name="dms.directory",
inverse_name="category_id",
string="Directories",
readonly=True,
)
file_ids = fields.One2many(
comodel_name="dms.file",
inverse_name="category_id",
string="Files",
readonly=True,
)
count_categories = fields.Integer(
compute="_compute_count_categories", string="Count Subcategories"
)
count_tags = fields.Integer(compute="_compute_count_tags")
count_directories = fields.Integer(compute="_compute_count_directories")
count_files = fields.Integer(compute="_compute_count_files")
# ----------------------------------------------------------
# Constrains
# ----------------------------------------------------------
_sql_constraints = [
("name_uniq", "unique (name)", "Category name already exists!"),
]
# ----------------------------------------------------------
# Read
# ----------------------------------------------------------
@api.depends("name", "parent_id.complete_name")
def _compute_complete_name(self):
for category in self:
if category.parent_id:
category.complete_name = "{} / {}".format(
category.parent_id.complete_name,
category.name,
)
else:
category.complete_name = category.name
@api.depends("child_category_ids")
def _compute_count_categories(self):
for record in self:
record.count_categories = len(record.child_category_ids)
@api.depends("tag_ids")
def _compute_count_tags(self):
for record in self:
record.count_tags = len(record.tag_ids)
@api.depends("directory_ids")
def _compute_count_directories(self):
for record in self:
record.count_directories = len(record.directory_ids)
@api.depends("file_ids")
def _compute_count_files(self):
for record in self:
record.count_files = len(record.file_ids)
def name_get(self):
if not self.env.context.get("category_short_name", False):
return super().name_get()
vals = []
for record in self:
vals.append(tuple([record.id, record.name]))
return vals
# ----------------------------------------------------------
# Create
# ----------------------------------------------------------
@api.constrains("parent_id")
def _check_category_recursion(self):
if not self._check_recursion():
raise ValidationError(_("Error! You cannot create recursive categories."))
return True

View file

@ -0,0 +1,800 @@
# Copyright 2017-2019 MuK IT GmbH.
# Copyright 2020 Creu Blanca
# Copyright 2021 Tecnativa - Víctor Martínez
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import ast
import base64
import logging
from ast import literal_eval
from collections import defaultdict
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError, ValidationError
from odoo.osv.expression import AND, OR
from odoo.tools import consteq, human_size
from odoo.addons.http_routing.models.ir_http import slugify
from ..tools.file import check_name, unique_name
_logger = logging.getLogger(__name__)
class DmsDirectory(models.Model):
_name = "dms.directory"
_description = "Directory"
_inherit = [
"portal.mixin",
"dms.security.mixin",
"dms.mixins.thumbnail",
"mail.thread",
"mail.activity.mixin",
"mail.alias.mixin",
"abstract.dms.mixin",
]
_rec_name = "complete_name"
_order = "complete_name"
_parent_store = True
_parent_name = "parent_id"
_directory_field = _parent_name
parent_path = fields.Char(index="btree", unaccent=False)
is_root_directory = fields.Boolean(
default=False,
help="""Indicates if the directory is a root directory.
A root directory has a settings object, while a directory with a set
parent inherits the settings form its parent.""",
)
# Override acording to defined in AbstractDmsMixin
storage_id = fields.Many2one(
compute="_compute_storage_id",
compute_sudo=True,
readonly=False,
comodel_name="dms.storage",
string="Storage",
ondelete="restrict",
auto_join=True,
store=True,
)
parent_id = fields.Many2one(
comodel_name="dms.directory",
string="Parent Directory",
domain="[('permission_create', '=', True)]",
ondelete="restrict",
# Access to a directory doesn't necessarily mean access its parent, so
# prefetching this field could lead to misleading access errors
prefetch=False,
index="btree",
store=True,
readonly=False,
compute="_compute_parent_id",
copy=True,
default=lambda self: self._default_parent_id(),
)
root_directory_id = fields.Many2one(
"dms.directory", "Root Directory", compute="_compute_root_id", store=True
)
def _default_parent_id(self):
context = self.env.context
if context.get("active_model") == self._name and context.get("active_id"):
return context["active_id"]
else:
return False
group_ids = fields.Many2many(
comodel_name="dms.access.group",
relation="dms_directory_groups_rel",
column1="aid",
column2="gid",
string="Groups",
)
complete_group_ids = fields.Many2many(
comodel_name="dms.access.group",
relation="dms_directory_complete_groups_rel",
column1="aid",
column2="gid",
string="Complete Groups",
compute="_compute_groups",
readonly=True,
store=True,
compute_sudo=True,
recursive=True,
)
complete_name = fields.Char(
compute="_compute_complete_name", store=True, recursive=True
)
child_directory_ids = fields.One2many(
comodel_name="dms.directory",
inverse_name="parent_id",
string="Subdirectories",
auto_join=False,
copy=False,
)
tag_ids = fields.Many2many(
comodel_name="dms.tag",
relation="dms_directory_tag_rel",
domain="""[
'|', ['category_id', '=', False],
['category_id', 'child_of', category_id]]
""",
column1="did",
column2="tid",
string="Tags",
compute="_compute_tags",
readonly=False,
store=True,
)
user_star_ids = fields.Many2many(
comodel_name="res.users",
relation="dms_directory_star_rel",
column1="did",
column2="uid",
string="Stars",
)
starred = fields.Boolean(
compute="_compute_starred",
inverse="_inverse_starred",
search="_search_starred",
)
file_ids = fields.One2many(
comodel_name="dms.file",
inverse_name="directory_id",
string="Files",
auto_join=False,
copy=False,
)
count_directories = fields.Integer(
compute="_compute_count_directories", string="Count Subdirectories Title"
)
count_files = fields.Integer(
compute="_compute_count_files", string="Count Files Title"
)
count_directories_title = fields.Char(
compute="_compute_count_directories", string="Count Subdirectories"
)
count_files_title = fields.Char(
compute="_compute_count_files", string="Count Files"
)
count_elements = fields.Integer(compute="_compute_count_elements")
count_total_directories = fields.Integer(
compute="_compute_count_total_directories", string="Total Subdirectories"
)
count_total_files = fields.Integer(
compute="_compute_count_total_files", string="Total Files"
)
count_total_elements = fields.Integer(
compute="_compute_count_total_elements", string="Total Elements"
)
size = fields.Float(compute="_compute_size")
human_size = fields.Char(
compute="_compute_human_size", string="Size (human readable)"
)
inherit_group_ids = fields.Boolean(string="Inherit Groups", default=True)
alias_process = fields.Selection(
selection=[("files", "Single Files"), ("directory", "Subdirectory")],
required=True,
default="directory",
string="Unpack Emails as",
help="""\
Define how incoming emails are processed:\n
- Single Files: The email gets attached to the directory and
all attachments are created as files.\n
- Subdirectory: A new subdirectory is created for each email
and the mail is attached to this subdirectory. The attachments
are created as files of the subdirectory.
""",
)
@api.model
def _get_domain_by_access_groups(self, operation):
"""Special rules for directories."""
self_filter = [
("storage_id_inherit_access_from_parent_record", "=", False),
("id", "inselect", self._get_access_groups_query(operation)),
]
# Upstream only filters by parent directory
result = super()._get_domain_by_access_groups(operation)
if operation == "create":
# When creating, I need create access in parent directory, or
# self-create permission if it's a root directory
result = OR(
[
[("is_root_directory", "=", False)] + result,
[("is_root_directory", "=", True)] + self_filter,
]
)
else:
# In other operations, I only need self access
result = self_filter
return result
def _compute_access_url(self):
res = super()._compute_access_url()
for item in self:
item.access_url = "/my/dms/directory/%s" % (item.id)
return res
def check_access_token(self, access_token=False):
res = False
if access_token:
items = (
self.env["dms.directory"]
.sudo()
.search([("access_token", "=", access_token)])
)
if items:
item = items[0]
if item.id == self.id:
return True
else:
directory_item = self
while directory_item.parent_id:
if directory_item.id == item.id:
return True
directory_item = directory_item.parent_id
# Fix last level
if directory_item.id == item.id:
return True
return res
@api.model
def _get_parent_categories(self, access_token):
self.ensure_one()
directories = []
current_directory = self
while current_directory:
directories.insert(0, current_directory)
if (
(
access_token
and current_directory.access_token
and consteq(current_directory.access_token, access_token)
)
or not access_token
and current_directory.check_access_rights("read")
):
return directories
current_directory = current_directory.parent_id
if access_token:
# Reaching here means we didn't find the directory accessible by this token
return [self]
return directories
def _get_own_root_directories(self):
res = self.env["dms.directory"].search_read(
[("is_hidden", "=", False)], ["parent_id"]
)
all_ids = [value["id"] for value in res]
res_ids = []
for item in res:
if not item["parent_id"] or item["parent_id"][0] not in all_ids:
res_ids.append(item["id"])
return res_ids
allowed_model_ids = fields.Many2many(
related="storage_id.model_ids",
comodel_name="ir.model",
)
model_id = fields.Many2one(
comodel_name="ir.model",
domain="[('id', 'in', allowed_model_ids)]",
compute="_compute_model_id",
inverse="_inverse_model_id",
string="Model",
store=True,
)
storage_id_save_type = fields.Selection(
related="storage_id.save_type",
related_sudo=True,
readonly=True,
store=False,
prefetch=False,
)
storage_id_inherit_access_from_parent_record = fields.Boolean(
related="storage_id.inherit_access_from_parent_record",
related_sudo=True,
store=True,
)
@api.depends("res_model")
def _compute_model_id(self):
for record in self:
if not record.res_model:
record.model_id = False
continue
record.model_id = (
self.env["ir.model"].sudo().search([("model", "=", record.res_model)])
)
def _inverse_model_id(self):
for record in self:
record.res_model = record.model_id.model
def name_get(self):
if not self.env.context.get("directory_short_name", False):
return super().name_get()
vals = []
for record in self:
vals.append(tuple([record.id, record.name]))
return vals
def toggle_starred(self):
updates = defaultdict(set)
for record in self:
vals = {"starred": not record.starred}
updates[tools.frozendict(vals)].add(record.id)
with self.env.norecompute():
for vals, ids in updates.items():
self.browse(ids).write(dict(vals))
self.flush_recordset()
# ----------------------------------------------------------
# SearchPanel
# ----------------------------------------------------------
@api.model
def search_panel_select_range(self, field_name, **kwargs):
context = {}
if field_name == "parent_id":
context["directory_short_name"] = True
return super(
DmsDirectory, self.with_context(**context)
).search_panel_select_range(field_name, **kwargs)
@api.model
def search_panel_select_multi_range(self, field_name, **kwargs):
return super(
DmsDirectory, self.with_context(category_short_name=True)
).search_panel_select_multi_range(field_name, **kwargs)
# ----------------------------------------------------------
# Actions
# ----------------------------------------------------------
def action_save_onboarding_directory_step(self):
self.env.user.company_id.set_onboarding_step_done(
"documents_onboarding_directory_state"
)
# ----------------------------------------------------------
# SearchPanel
# ----------------------------------------------------------
@api.model
def _search_panel_directory(self, **kwargs):
search_domain = (kwargs.get("search_domain", []),)
if search_domain and len(search_domain):
for domain in search_domain[0]:
if domain[0] == "parent_id":
return domain[1], domain[2]
return None, None
# ----------------------------------------------------------
# Search
# ----------------------------------------------------------
@api.model
def _search_starred(self, operator, operand):
if operator == "=" and operand:
return [("user_star_ids", "in", [self.env.uid])]
return [("user_star_ids", "not in", [self.env.uid])]
@api.depends("name", "parent_id.complete_name")
def _compute_complete_name(self):
for category in self:
if category.parent_id:
category.complete_name = "{} / {}".format(
category.parent_id.complete_name,
category.name,
)
else:
category.complete_name = category.name
@api.depends("parent_id")
def _compute_storage_id(self):
for record in self:
if record.parent_id:
record.storage_id = record.parent_id.storage_id
else:
# HACK: Not needed in v14 due to odoo/odoo#64359
record.storage_id = record.storage_id
@api.depends("user_star_ids")
def _compute_starred(self):
for record in self:
record.starred = self.env.user in record.user_star_ids
@api.depends("child_directory_ids")
def _compute_count_directories(self):
for record in self:
directories = len(record.child_directory_ids)
record.count_directories = directories
record.count_directories_title = _("%s Subdirectories") % directories
@api.depends("file_ids")
def _compute_count_files(self):
for record in self:
files = len(record.file_ids)
record.count_files = files
record.count_files_title = _("%s Files") % files
@api.depends("child_directory_ids", "file_ids")
def _compute_count_elements(self):
for record in self:
elements = record.count_files
elements += record.count_directories
record.count_elements = elements
def _compute_count_total_directories(self):
for record in self:
count = (
self.search_count([("id", "child_of", record.id)]) if record.id else 0
)
record.count_total_directories = count - 1 if count > 0 else 0
def _compute_count_total_files(self):
model = self.env["dms.file"]
for record in self:
# Prevent error in some NewId cases
record.count_total_files = (
model.search_count([("directory_id", "child_of", record.id)])
if record.id
else 0
)
def _compute_count_total_elements(self):
for record in self:
total_elements = record.count_total_files
total_elements += record.count_total_directories
record.count_total_elements = total_elements
def _compute_size(self):
sudo_model = self.env["dms.file"].sudo()
for record in self:
# Avoid NewId
if not record.id:
record.size = 0
continue
recs = sudo_model.search_read(
domain=[("directory_id", "child_of", record.id)],
fields=["size"],
)
record.size = sum(rec.get("size", 0) for rec in recs)
@api.depends("size")
def _compute_human_size(self):
for item in self:
item.human_size = human_size(item.size) if item.size else False
@api.depends(
"group_ids",
"inherit_group_ids",
"parent_id.complete_group_ids",
"parent_path",
)
def _compute_groups(self):
"""Get all DMS security groups affecting this directory."""
for one in self:
groups = one.group_ids
if one.inherit_group_ids:
groups |= one.parent_id.complete_group_ids
self.complete_group_ids = groups
# ----------------------------------------------------------
# View
# ----------------------------------------------------------
@api.depends("is_root_directory")
def _compute_parent_id(self):
for record in self:
if record.is_root_directory:
record.parent_id = None
else:
# HACK: Not needed in v14 due to odoo/odoo#64359
record.parent_id = record.parent_id
@api.depends("is_root_directory", "parent_id")
def _compute_root_id(self):
for record in self:
if record.is_root_directory:
record.root_directory_id = record
else:
# recursively check all parent nodes up to the root directory
if not record.parent_id.root_directory_id:
record.parent_id._compute_root_id()
record.root_directory_id = record.parent_id.root_directory_id
@api.depends("category_id")
def _compute_tags(self):
for record in self:
tags = record.tag_ids.filtered(
lambda rec: not rec.category_id or rec.category_id == record.category_id
)
record.tag_ids = tags
@api.onchange("storage_id")
def _onchange_storage_id(self):
for record in self:
if (
record.storage_id.save_type == "attachment"
and record.storage_id.inherit_access_from_parent_record
):
record.group_ids = False
@api.onchange("model_id")
def _onchange_model_id(self):
self._inverse_model_id()
# ----------------------------------------------------------
# Constrains
# ----------------------------------------------------------
@api.constrains("parent_id")
def _check_directory_recursion(self):
if not self._check_recursion():
raise ValidationError(_("Error! You cannot create recursive directories."))
return True
@api.constrains("storage_id", "model_id")
def _check_storage_id_attachment_model_id(self):
for record in self:
if record.storage_id.save_type != "attachment":
continue
if not record.model_id:
raise ValidationError(
_("A directory has to have model in attachment storage.")
)
if not record.is_root_directory and not record.res_id:
raise ValidationError(
_("This directory needs to be associated to a record.")
)
@api.constrains("is_root_directory", "storage_id")
def _check_directory_storage(self):
for record in self:
if record.is_root_directory and not record.storage_id:
raise ValidationError(_("A root directory has to have a storage."))
@api.constrains("is_root_directory", "parent_id")
def _check_directory_parent(self):
for record in self:
if record.is_root_directory and record.parent_id:
raise ValidationError(
_("A directory can't be a root and have a parent directory.")
)
if not record.is_root_directory and not record.parent_id:
raise ValidationError(_("A directory has to have a parent directory."))
@api.constrains("name")
def _check_name(self):
for record in self:
if self.env.context.get("check_name", True) and not check_name(record.name):
raise ValidationError(_("The directory name is invalid."))
if record.is_root_directory:
childs = record.sudo().storage_id.root_directory_ids.name_get()
else:
childs = record.sudo().parent_id.child_directory_ids.name_get()
if list(
filter(
lambda child: child[1] == record.name and child[0] != record.id,
childs,
)
):
raise ValidationError(
_("A directory with the same name already exists.")
)
# ----------------------------------------------------------
# Create, Update, Delete
# ----------------------------------------------------------
def _inverse_starred(self):
starred_records = self.env["dms.directory"].sudo()
not_starred_records = self.env["dms.directory"].sudo()
for record in self:
if not record.starred and self.env.user in record.user_star_ids:
starred_records |= record
elif record.starred and self.env.user not in record.user_star_ids:
not_starred_records |= record
not_starred_records.write({"user_star_ids": [(4, self.env.uid)]})
starred_records.write({"user_star_ids": [(3, self.env.uid)]})
def copy(self, default=None):
self.ensure_one()
default = dict(default or [])
if "parent_id" in default:
parent_directory = self.browse(default["parent_id"])
names = parent_directory.sudo().child_directory_ids.mapped("name")
elif self.is_root_directory:
names = self.sudo().storage_id.root_directory_ids.mapped("name")
else:
names = self.sudo().parent_id.child_directory_ids.mapped("name")
default.update({"name": unique_name(self.name, names)})
new = super().copy(default)
for record in self.file_ids:
record.copy({"directory_id": new.id})
for record in self.child_directory_ids:
record.copy({"parent_id": new.id})
return new
def _alias_get_creation_values(self):
values = super()._alias_get_creation_values()
values["alias_model_id"] = self.env["ir.model"].sudo()._get("dms.directory").id
if self.id:
values["alias_defaults"] = defaults = ast.literal_eval(
self.alias_defaults or "{}"
)
defaults["parent_id"] = self.id
return values
@api.model
def message_new(self, msg_dict, custom_values=None):
custom_values = custom_values if custom_values is not None else {}
parent_directory_id = custom_values.get("parent_id", None)
parent_directory = self.sudo().browse(parent_directory_id)
if not parent_directory_id or not parent_directory.exists():
raise ValueError("No directory could be found!")
if parent_directory.alias_process == "files":
parent_directory._process_message(msg_dict)
return parent_directory
names = parent_directory.child_directory_ids.mapped("name")
subject = slugify(msg_dict.get("subject", _("Alias-Mail-Extraction")))
defaults = dict(
{"name": unique_name(subject, names, escape_suffix=True)}, **custom_values
)
directory = super().message_new(msg_dict, custom_values=defaults)
directory._process_message(msg_dict)
return directory
def message_update(self, msg_dict, update_vals=None):
self._process_message(msg_dict, extra_values=update_vals)
return super().message_update(msg_dict, update_vals=update_vals)
def _process_message(self, msg_dict, extra_values=False):
names = self.sudo().file_ids.mapped("name")
for attachment in msg_dict["attachments"]:
uname = unique_name(attachment.fname, names, escape_suffix=True)
vals = {
"directory_id": self.id,
"name": uname,
}
try:
vals["content"] = base64.b64encode(attachment.content)
except Exception:
vals["content"] = attachment.content
self.env["dms.file"].sudo().create(vals)
names.append(uname)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get("parent_id", False):
parent = self.browse([vals["parent_id"]])
data = next(iter(parent.sudo().read(["storage_id"])), {})
vals["storage_id"] = self._convert_to_write(data).get("storage_id")
# Hack to prevent error related to mail_message parent not exists in some cases
ctx = dict(self.env.context).copy()
ctx.update({"default_parent_id": False})
res = super(DmsDirectory, self.with_context(**ctx)).create(vals_list)
return res
def write(self, vals):
if any([k in vals.keys() for k in ["storage_id", "parent_id"]]):
for item in self:
new_storage_id = vals.get("storage_id", item.storage_id.id)
new_parent_id = vals.get("parent_id", item.parent_id.id)
old_storage_id = (
item.storage_id or item.root_directory_id.storage_id
).id
if new_parent_id:
if old_storage_id != self.browse(new_parent_id).storage_id.id:
raise UserError(
_("It is not possible to change parent to other storage.")
)
elif old_storage_id != new_storage_id:
raise UserError(_("It is not possible to change the storage."))
# Groups part
if any(key in vals for key in ["group_ids", "inherit_group_ids"]):
with self.env.norecompute():
res = super(DmsDirectory, self).write(vals)
domain = [("id", "child_of", self.ids)]
records = self.sudo().search(domain)
records.modified(["group_ids"])
records.flush_recordset()
else:
res = super().write(vals)
return res
def unlink(self):
"""Custom cascade unlink.
Cannot rely on DB backend's cascade because subfolder and subfile unlinks
must check custom permissions implementation.
"""
self.file_ids.unlink()
if self.child_directory_ids:
self.child_directory_ids.unlink()
return super().unlink()
@api.model
def _search_panel_domain_image(
self, field_name, domain, set_count=False, limit=False
):
"""We need to overwrite function from directories because odoo only return
records with childs (very weird for user perspective).
All records are returned now.
"""
if field_name == "parent_id":
res = {}
for item in self.search_read(
domain=domain, fields=["id", "name", "count_directories"]
):
res[item["id"]] = {
"id": item["id"],
"display_name": item["name"],
"__count": item["count_directories"],
}
return res
return super()._search_panel_domain_image(
field_name=field_name, domain=domain, set_count=set_count, limit=limit
)
def action_dms_directories_all_directory(self):
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"dms.action_dms_directory"
)
domain = AND(
[
literal_eval(action["domain"].strip()),
[("parent_id", "child_of", self.id)],
]
)
action["display_name"] = self.name
action["domain"] = domain
action["context"] = dict(
self.env.context,
default_parent_id=self.id,
searchpanel_default_parent_id=self.id,
)
return action
def action_dms_files_all_directory(self):
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id("dms.action_dms_file")
domain = AND(
[
literal_eval(action["domain"].strip()),
[("directory_id", "child_of", self.id)],
]
)
action["display_name"] = self.name
action["domain"] = domain
action["context"] = dict(
self.env.context,
default_directory_id=self.id,
searchpanel_default_directory_id=self.id,
)
return action

View file

@ -0,0 +1,690 @@
# Copyright 2020 Antoni Romera
# Copyright 2017-2019 MuK IT GmbH
# Copyright 2021 Tecnativa - Víctor Martínez
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import base64
import hashlib
import json
import logging
from collections import defaultdict
from PIL import Image
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
from odoo.tools import consteq, human_size
from odoo.tools.mimetypes import guess_mimetype
from ..tools import file
_logger = logging.getLogger(__name__)
class File(models.Model):
_name = "dms.file"
_description = "File"
_inherit = [
"portal.mixin",
"dms.security.mixin",
"dms.mixins.thumbnail",
"mail.thread",
"mail.activity.mixin",
"abstract.dms.mixin",
]
_order = "name asc"
# ----------------------------------------------------------
# Database
# ----------------------------------------------------------
active = fields.Boolean(
string="Archived",
default=True,
help="If a file is set to archived, it is not displayed, but still exists.",
)
directory_id = fields.Many2one(
comodel_name="dms.directory",
string="Directory",
domain="[('permission_create', '=', True)]",
context="{'dms_directory_show_path': True}",
ondelete="restrict",
auto_join=True,
required=True,
index="btree",
tracking=True, # Leave log if "moved" to another directory
)
root_directory_id = fields.Many2one(related="directory_id.root_directory_id")
# Override acording to defined in AbstractDmsMixin
storage_id = fields.Many2one(
related="directory_id.storage_id",
readonly=True,
store=True,
prefetch=False,
)
path_names = fields.Char(
compute="_compute_path",
compute_sudo=True,
readonly=True,
store=False,
)
path_json = fields.Text(
compute="_compute_path",
compute_sudo=True,
readonly=True,
store=False,
)
tag_ids = fields.Many2many(
comodel_name="dms.tag",
relation="dms_file_tag_rel",
column1="fid",
column2="tid",
domain="['|', ('category_id', '=', False),('category_id', '=?', category_id)]",
string="Tags",
)
content = fields.Binary(
compute="_compute_content",
inverse="_inverse_content",
attachment=False,
prefetch=False,
required=True,
store=False,
)
extension = fields.Char(compute="_compute_extension", readonly=True, store=True)
mimetype = fields.Char(
compute="_compute_mimetype", string="Type", readonly=True, store=True
)
size = fields.Float(readonly=True)
human_size = fields.Char(
readonly=True,
string="Size (human readable)",
compute="_compute_human_size",
store=True,
)
checksum = fields.Char(string="Checksum/SHA1", readonly=True, index="btree")
content_binary = fields.Binary(attachment=False, prefetch=False, invisible=True)
save_type = fields.Char(
compute="_compute_save_type",
string="Current Save Type",
invisible=True,
prefetch=False,
)
migration = fields.Char(
compute="_compute_migration",
string="Migration Status",
readonly=True,
prefetch=False,
compute_sudo=True,
)
require_migration = fields.Boolean(
compute="_compute_migration", store=True, compute_sudo=True
)
content_file = fields.Binary(attachment=True, prefetch=False, invisible=True)
# Extend inherited field(s)
image_1920 = fields.Image(compute="_compute_image_1920", store=True, readonly=False)
@api.depends("mimetype", "content")
def _compute_image_1920(self):
"""Provide thumbnail automatically if possible."""
for one in self.filtered("mimetype"):
# Image.MIME provides a dict of mimetypes supported by Pillow,
# SVG is not present in the dict but is also a supported image format
# lacking a better solution, it's being added manually
# Some component modifies the PIL dictionary by adding PDF as a valid
# image type, so it must be explicitly excluded.
if one.mimetype != "application/pdf" and one.mimetype in (
*Image.MIME.values(),
"image/svg+xml",
):
one.image_1920 = one.content
def check_access_rule(self, operation):
self.mapped("directory_id").check_access_rule(operation)
return super().check_access_rule(operation)
def _compute_access_url(self):
res = super()._compute_access_url()
for item in self:
item.access_url = "/my/dms/file/%s/download" % (item.id)
return res
def check_access_token(self, access_token=False):
res = False
if access_token:
if self.access_token and consteq(self.access_token, access_token):
return True
else:
items = (
self.env["dms.directory"]
.sudo()
.search([("access_token", "=", access_token)])
)
if items:
item = items[0]
if self.directory_id.id == item.id:
return True
else:
directory_item = self.directory_id
while directory_item.parent_id:
if directory_item.id == self.directory_id.id:
return True
directory_item = directory_item.parent_id
# Fix last level
if directory_item.id == self.directory_id.id:
return True
return res
res_model = fields.Char(
string="Linked attachments model", related="directory_id.res_model"
)
res_id = fields.Integer(
string="Linked attachments record ID", related="directory_id.res_id"
)
attachment_id = fields.Many2one(
comodel_name="ir.attachment",
string="Attachment File",
prefetch=False,
invisible=True,
ondelete="cascade",
index=True,
)
def get_human_size(self):
return human_size(self.size)
# ----------------------------------------------------------
# Helper
# ----------------------------------------------------------
@api.model
def _get_checksum(self, binary):
return hashlib.sha1(binary or b"").hexdigest()
@api.model
def _get_content_inital_vals(self):
return {"content_binary": False, "content_file": False}
def _update_content_vals(self, vals, binary):
new_vals = vals.copy()
new_vals.update(
{
"checksum": self._get_checksum(binary),
"size": binary and len(binary) or 0,
}
)
if self.storage_id.save_type in ["file", "attachment"]:
new_vals["content_file"] = self.content
else:
new_vals["content_binary"] = self.content and binary
return new_vals
@api.model
def _get_binary_max_size(self):
return int(
self.env["ir.config_parameter"]
.sudo()
.get_param("dms.binary_max_size", default=25)
)
@api.model
def _get_forbidden_extensions(self):
get_param = self.env["ir.config_parameter"].sudo().get_param
extensions = get_param("dms.forbidden_extensions", default="")
return [extension.strip() for extension in extensions.split(",")]
def _get_icon_placeholder_name(self):
return self.extension and "file_%s.svg" % self.extension or ""
# ----------------------------------------------------------
# Actions
# ----------------------------------------------------------
def action_migrate(self, logging=True):
record_count = len(self)
index = 1
for dms_file in self:
if logging:
_logger.info(
_(
"Migrate File %(index)s of %(record_count)s [ %(dms_file_migration)s ]"
)
% {
"index": index,
"record_count": record_count,
"dms_file_migration": dms_file.migration,
}
)
index += 1
dms_file.write({"content": dms_file.with_context(**{}).content})
def action_save_onboarding_file_step(self):
self.env.user.company_id.set_onboarding_step_done(
"documents_onboarding_file_state"
)
def action_wizard_dms_file_move(self):
items = self.browse(self.env.context.get("active_ids"))
root_directories = items.mapped("root_directory_id")
if len(root_directories) > 1:
raise UserError(_("Only files in the same root directory can be moved."))
result = self.env["ir.actions.act_window"]._for_xml_id(
"dms.wizard_dms_file_move_act_window"
)
result["context"] = dict(self.env.context)
return result
# ----------------------------------------------------------
# SearchPanel
# ----------------------------------------------------------
@api.model
def _search_panel_directory(self, **kwargs):
search_domain = (kwargs.get("search_domain", []),)
category_domain = kwargs.get("category_domain", [])
if category_domain and len(category_domain):
return "=", category_domain[0][2]
if search_domain and len(search_domain):
for domain in search_domain[0]:
if domain[0] == "directory_id":
return domain[1], domain[2]
return None, None
@api.model
def _search_panel_domain(self, field, operator, directory_id, comodel_domain=False):
if not comodel_domain:
comodel_domain = []
files_ids = self.search([("directory_id", operator, directory_id)]).ids
return expression.AND([comodel_domain, [(field, "in", files_ids)]])
@api.model
def search_panel_select_range(self, field_name, **kwargs):
"""This method is overwritten to make it 'similar' to v13.
The goal is that the directory searchpanel shows all directories
(even if some folders have no files)."""
if field_name == "directory_id":
domain = [["is_hidden", "=", False]]
# If we pass by context something, we filter more about it we filter
# the directories of the files or we show all of them
if self.env.context.get("active_model", False) == "dms.directory":
active_id = self.env.context.get("active_id")
# para saber que directorios, buscamos las posibles carpetas que nos interesan
files = self.env["dms.file"].search(
[["directory_id", "child_of", active_id]]
)
all_directories = files.mapped("directory_id")
all_directories += files.mapped("directory_id.parent_id")
domain.append(["id", "in", all_directories.ids])
# Get all possible directories
comodel_records = (
self.env["dms.directory"]
.with_context(directory_short_name=True)
.search_read(domain, ["display_name", "parent_id"])
)
all_record_ids = [rec["id"] for rec in comodel_records]
field_range = {}
enable_counters = kwargs.get("enable_counters")
for record in comodel_records:
record_id = record["id"]
parent = record["parent_id"]
record_values = {
"id": record_id,
"display_name": record["display_name"],
# If the parent directory is not in all the records we should not
# set parent_id because the user does not have access to parent.
"parent_id": (
parent[0] if parent and parent[0] in all_record_ids else False
),
}
if enable_counters:
record_values["__count"] = 0
field_range[record_id] = record_values
if enable_counters:
res = super().search_panel_select_range(field_name, **kwargs)
for item in res["values"]:
if item["id"] in field_range:
field_range[item["id"]]["__count"] = item["__count"]
return {"parent_field": "parent_id", "values": list(field_range.values())}
context = {}
if field_name == "category_id":
context["category_short_name"] = True
return super(File, self.with_context(**context)).search_panel_select_range(
field_name, **kwargs
)
@api.model
def search_panel_select_multi_range(self, field_name, **kwargs):
operator, directory_id = self._search_panel_directory(**kwargs)
if field_name == "tag_ids":
sql_query = """
SELECT t.name AS name, t.id AS id, c.name AS group_name,
c.id AS group_id, COUNT(r.fid) AS count
FROM dms_tag t
JOIN dms_category c ON t.category_id = c.id
LEFT JOIN dms_file_tag_rel r ON t.id = r.tid
WHERE %(filter_by_file_ids)s IS FALSE OR r.fid = ANY(%(file_ids)s)
GROUP BY c.name, c.id, t.name, t.id
ORDER BY c.name, c.id, t.name, t.id;
"""
file_ids = []
if directory_id:
file_ids = self.search([("directory_id", operator, directory_id)]).ids
self.env.cr.execute(
sql_query,
{"file_ids": file_ids, "filter_by_file_ids": bool(directory_id)},
)
return self.env.cr.dictfetchall()
if directory_id and field_name in ["directory_id", "category_id"]:
comodel_domain = kwargs.pop("comodel_domain", [])
directory_comodel_domain = self._search_panel_domain(
"file_ids", operator, directory_id, comodel_domain
)
return super(
File, self.with_context(directory_short_name=True)
).search_panel_select_multi_range(
field_name, comodel_domain=directory_comodel_domain, **kwargs
)
return super(
File, self.with_context(directory_short_name=True)
).search_panel_select_multi_range(field_name, **kwargs)
# ----------------------------------------------------------
# Read
# ----------------------------------------------------------
@api.depends("name", "directory_id", "directory_id.parent_path")
def _compute_path(self):
model = self.env["dms.directory"]
for record in self:
path_names = [record.display_name]
path_json = [
{
"model": record._name,
"name": record.display_name,
"id": isinstance(record.id, int) and record.id or 0,
}
]
current_dir = record.directory_id
while current_dir:
path_names.insert(0, current_dir.name)
path_json.insert(
0,
{
"model": model._name,
"name": current_dir.name,
"id": current_dir._origin.id,
},
)
current_dir = current_dir.parent_id
record.update(
{
"path_names": "/".join(path_names),
"path_json": json.dumps(path_json),
}
)
@api.depends("name", "mimetype", "content")
def _compute_extension(self):
for record in self:
record.extension = file.guess_extension(
record.name, record.mimetype, record.content
)
@api.depends("content")
def _compute_mimetype(self):
for record in self:
binary = base64.b64decode(record.content or "")
record.mimetype = guess_mimetype(binary)
@api.depends("size")
def _compute_human_size(self):
for item in self:
item.human_size = human_size(item.size)
@api.depends("content_binary", "content_file", "attachment_id")
def _compute_content(self):
bin_size = self.env.context.get("bin_size", False)
for record in self:
if record.content_file:
context = {"human_size": True} if bin_size else {"base64": True}
record.content = record.with_context(**context).content_file
elif record.content_binary:
record.content = (
record.content_binary
if bin_size
else base64.b64encode(record.content_binary)
)
elif record.attachment_id:
context = {"human_size": True} if bin_size else {"base64": True}
record.content = record.with_context(**context).attachment_id.datas
@api.depends("content_binary", "content_file")
def _compute_save_type(self):
for record in self:
if record.content_file:
record.save_type = "file"
else:
record.save_type = "database"
@api.depends("storage_id", "storage_id.save_type")
def _compute_migration(self):
storage_model = self.env["dms.storage"]
save_field = storage_model._fields["save_type"]
values = save_field._description_selection(self.env)
selection = {value[0]: value[1] for value in values}
for record in self:
storage_type = record.storage_id.save_type
if storage_type == "attachment" or storage_type == record.save_type:
record.migration = selection.get(storage_type)
record.require_migration = False
else:
storage_label = selection.get(storage_type)
file_label = selection.get(record.save_type)
record.migration = "{} > {}".format(file_label, storage_label)
record.require_migration = True
# ----------------------------------------------------------
# View
# ----------------------------------------------------------
@api.onchange("category_id")
def _change_category(self):
self.tag_ids = self.tag_ids.filtered(
lambda rec: not rec.category_id or rec.category_id == self.category_id
)
# ----------------------------------------------------------
# Constrains
# ----------------------------------------------------------
@api.constrains("storage_id", "res_model", "res_id")
def _check_storage_id_attachment_res_model(self):
for record in self:
if record.storage_id.save_type == "attachment" and not (
record.res_model and record.res_id
):
raise ValidationError(
_("A file must have model and resource ID in attachment storage.")
)
@api.constrains("name")
def _check_name(self):
for record in self:
if not file.check_name(record.name):
raise ValidationError(_("The file name is invalid."))
files = record.sudo().directory_id.file_ids.name_get()
if list(
filter(
lambda file: file[1] == record.name and file[0] != record.id, files
)
):
raise ValidationError(_("A file with the same name already exists."))
@api.constrains("extension")
def _check_extension(self):
for record in self:
if (
record.extension
and record.extension in self._get_forbidden_extensions()
):
raise ValidationError(_("The file has a forbidden file extension."))
@api.constrains("size")
def _check_size(self):
for record in self:
if record.size and record.size > self._get_binary_max_size() * 1024 * 1024:
raise ValidationError(
_("The maximum upload size is %s MB.") % self._get_binary_max_size()
)
# ----------------------------------------------------------
# Create, Update, Delete
# ----------------------------------------------------------
def _inverse_content(self):
updates = defaultdict(set)
for record in self:
values = self._get_content_inital_vals()
binary = base64.b64decode(record.content or "")
values = record._update_content_vals(values, binary)
updates[tools.frozendict(values)].add(record.id)
with self.env.norecompute():
for vals, ids in updates.items():
self.browse(ids).write(dict(vals))
def _create_model_attachment(self, vals):
res_vals = vals.copy()
if "directory_id" in res_vals:
directory_id = res_vals["directory_id"]
elif self.env.context.get("active_id"):
directory_id = self.env.context.get("active_id")
elif self.env.context.get("default_directory_id"):
directory_id = self.env.context.get("default_directory_id")
directory = self.env["dms.directory"].browse(directory_id)
if (
directory.res_model
and directory.res_id
and directory.storage_id_save_type == "attachment"
):
attachment = (
self.env["ir.attachment"]
.with_context(dms_file=True)
.create(
{
"name": vals["name"],
"datas": vals["content"],
"res_model": directory.res_model,
"res_id": directory.res_id,
}
)
)
res_vals["attachment_id"] = attachment.id
res_vals["res_model"] = attachment.res_model
res_vals["res_id"] = attachment.res_id
del res_vals["content"]
return res_vals
def copy(self, default=None):
self.ensure_one()
default = dict(default or [])
if "directory_id" in default:
model = self.env["dms.directory"]
directory = model.browse(default["directory_id"])
names = directory.sudo().file_ids.mapped("name")
else:
names = self.sudo().directory_id.file_ids.mapped("name")
default.update({"name": file.unique_name(self.name, names, self.extension)})
return super(File, self).copy(default)
@api.model_create_multi
def create(self, vals_list):
new_vals_list = []
for vals in vals_list:
if "attachment_id" not in vals:
vals = self._create_model_attachment(vals)
new_vals_list.append(vals)
return super(File, self).create(new_vals_list)
def unlink(self):
attachments = self.mapped("attachment_id")
res = super().unlink()
if not self.env.context.get("dms_file"):
attachments.with_context(dms_file=True).unlink()
return res
# ----------------------------------------------------------
# Locking fields and functions
# ----------------------------------------------------------
locked_by = fields.Many2one(comodel_name="res.users")
is_locked = fields.Boolean(compute="_compute_locked", string="Locked")
is_lock_editor = fields.Boolean(compute="_compute_locked", string="Editor")
# ----------------------------------------------------------
# Locking
# ----------------------------------------------------------
def lock(self):
self.write({"locked_by": self.env.uid})
def unlock(self):
self.write({"locked_by": None})
# ----------------------------------------------------------
# Read, View
# ----------------------------------------------------------
@api.depends("locked_by")
def _compute_locked(self):
for record in self:
if record.locked_by.exists():
record.update(
{
"is_locked": True,
"is_lock_editor": record.locked_by.id == record.env.uid,
}
)
else:
record.update({"is_locked": False, "is_lock_editor": False})
def get_attachment_object(self, attachment):
return {
"name": attachment.name,
"datas": attachment.datas,
"res_model": attachment.res_model,
"mimetype": attachment.mimetype,
}
def get_dms_files_from_attachments(self, attachment_ids=None):
"""Get the dms files from uploaded attachments.
:return: An Array of dms files.
"""
if not attachment_ids:
raise UserError(_("No attachment was provided"))
attachments = self.env["ir.attachment"].browse(attachment_ids)
if any(
attachment.res_id or attachment.res_model != "dms.file"
for attachment in attachments
):
raise UserError(_("Invalid attachments!"))
return [self.get_attachment_object(attachment) for attachment in attachments]

View file

@ -0,0 +1,259 @@
# Copyright 2020 Creu Blanca
# Copyright 2021 Tecnativa - Víctor Martínez
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from logging import getLogger
from odoo import api, fields, models
from odoo.osv.expression import FALSE_DOMAIN, NEGATIVE_TERM_OPERATORS, OR, TRUE_DOMAIN
_logger = getLogger(__name__)
class DmsSecurityMixin(models.AbstractModel):
_name = "dms.security.mixin"
_description = "DMS Security Mixin"
# Submodels must define this field that points to the owner dms.directory
_directory_field = "directory_id"
res_model = fields.Char(
string="Linked attachments model", index="btree", store=True
)
res_id = fields.Integer(
string="Linked attachments record ID", index="btree", store=True
)
record_ref = fields.Reference(
string="Record Referenced",
compute="_compute_record_ref",
selection=lambda self: self._get_ref_selection(),
)
permission_read = fields.Boolean(
compute="_compute_permissions",
search="_search_permission_read",
string="Read Access",
)
permission_create = fields.Boolean(
compute="_compute_permissions",
search="_search_permission_create",
string="Create Access",
)
permission_write = fields.Boolean(
compute="_compute_permissions",
search="_search_permission_write",
string="Write Access",
)
permission_unlink = fields.Boolean(
compute="_compute_permissions",
search="_search_permission_unlink",
string="Delete Access",
)
@api.model
def _get_ref_selection(self):
models = self.env["ir.model"].sudo().search([])
return [(model.model, model.name) for model in models]
@api.depends("res_model", "res_id")
def _compute_record_ref(self):
for record in self:
record.record_ref = False
if record.res_model and record.res_id:
record.record_ref = "{},{}".format(record.res_model, record.res_id)
def _compute_permissions(self):
"""Get permissions for the current record.
Not very performant; only display field on form views.
"""
# Superuser unrestricted 🦸
if self.env.su:
self.update(
{
"permission_create": True,
"permission_read": True,
"permission_unlink": True,
"permission_write": True,
}
)
return
# Update according to presence when applying ir.rule
creatable = self._filter_access_rules("create")
readable = self._filter_access_rules("read")
unlinkable = self._filter_access_rules("unlink")
writeable = self._filter_access_rules("write")
for one in self:
one.update(
{
"permission_create": bool(one & creatable),
"permission_read": bool(one & readable),
"permission_unlink": bool(one & unlinkable),
"permission_write": bool(one & writeable),
}
)
@api.model
def _get_domain_by_inheritance(self, operation):
"""Get domain for inherited accessible records."""
if self.env.su:
return []
inherited_access_field = "storage_id_inherit_access_from_parent_record"
if self._name != "dms.directory":
inherited_access_field = "{}.{}".format(
self._directory_field,
inherited_access_field,
)
inherited_access_domain = [
("storage_id_save_type", "=", "attachment"),
(inherited_access_field, "=", True),
]
domains = []
# Get all used related records
related_groups = self.sudo().read_group(
domain=inherited_access_domain + [("res_model", "!=", False)],
fields=["res_id:array_agg"],
groupby=["res_model"],
)
for group in related_groups:
try:
model = self.env[group["res_model"]]
except KeyError:
# Model not registered. This is normal if you are upgrading the
# database. Otherwise, you probably have garbage DMS data.
# These records will be accessible by DB users only.
domains.append(
[
("res_model", "=", group["res_model"]),
(True, "=", self.env.user.has_group("base.group_user")),
]
)
continue
# Check model access only once per batch
if not model.check_access_rights(operation, raise_exception=False):
continue
domains.append([("res_model", "=", model._name), ("res_id", "=", False)])
# Check record access in batch too
res_ids = [i for i in group["res_id"] if i] # Hack to remove None res_id
# Apply exists to skip records that do not exist. (e.g. a res.partner deleted
# by database).
model_records = model.browse(res_ids).exists()
related_ok = model_records._filter_access_rules_python(operation)
if not related_ok:
continue
domains.append(
[("res_model", "=", model._name), ("res_id", "in", related_ok.ids)]
)
result = inherited_access_domain + OR(domains)
return result
@api.model
def _get_access_groups_query(self, operation):
"""Return the query to select access groups."""
operation_check = {
"create": "AND dag.perm_inclusive_create",
"read": "",
"unlink": "AND dag.perm_inclusive_unlink",
"write": "AND dag.perm_inclusive_write",
}[operation]
select = """
SELECT
dir_group_rel.aid
FROM
dms_directory_complete_groups_rel AS dir_group_rel
INNER JOIN dms_access_group AS dag
ON dir_group_rel.gid = dag.id
INNER JOIN dms_access_group_users_rel AS users
ON users.gid = dag.id
WHERE
users.uid = %s {}
""".format(
operation_check
)
return (select, (self.env.uid,))
@api.model
def _get_domain_by_access_groups(self, operation):
"""Get domain for records accessible applying DMS access groups."""
result = [
(
"%s.storage_id_inherit_access_from_parent_record"
% self._directory_field,
"=",
False,
),
(
self._directory_field,
"inselect",
self._get_access_groups_query(operation),
),
]
return result
@api.model
def _get_permission_domain(self, operator, value, operation):
"""Abstract logic for searching computed permission fields."""
_self = self
# HACK ir.rule domain is always computed with sudo, so if this check is
# true, we can assume safely that you're checking permissions
if self.env.su and value == self.env.uid:
_self = self.sudo(False)
value = bool(value)
# Tricky one, to know if you want to search
# positive or negative access
positive = (operator not in NEGATIVE_TERM_OPERATORS) == bool(value)
if _self.env.su:
# You're SUPERUSER_ID
return TRUE_DOMAIN if positive else FALSE_DOMAIN
# Obtain and combine domains
result = OR(
[
_self._get_domain_by_access_groups(operation),
_self._get_domain_by_inheritance(operation),
]
)
if not positive:
result.insert(0, "!")
return result
@api.model
def _search_permission_create(self, operator, value):
return self._get_permission_domain(operator, value, "create")
@api.model
def _search_permission_read(self, operator, value):
return self._get_permission_domain(operator, value, "read")
@api.model
def _search_permission_unlink(self, operator, value):
return self._get_permission_domain(operator, value, "unlink")
@api.model
def _search_permission_write(self, operator, value):
return self._get_permission_domain(operator, value, "write")
def _filter_access_rules_python(self, operation):
# Only kept to not break inheritance; see next comment
result = super()._filter_access_rules_python(operation)
# HACK Always fall back to applying rules by SQL.
# Upstream `_filter_acccess_rules_python()` doesn't use computed fields
# search methods. Thus, it will take the `[('permission_{operation}',
# '=', user.id)]` rule literally. Obviously that will always fail
# because `self[f"permission_{operation}"]` will always be a `bool`,
# while `user.id` will always be an `int`.
result |= self._filter_access_rules(operation)
return result
@api.model_create_multi
def create(self, vals_list):
# Create as sudo to avoid testing creation permissions before DMS security
# groups are attached (otherwise nobody would be able to create)
res = super(DmsSecurityMixin, self.sudo()).create(vals_list)
# Need to flush now, so all groups are stored in DB and the SELECT used
# to check access works
res.flush_recordset()
# Go back to original sudo state and check we really had creation permission
res = res.sudo(self.env.su)
res.check_access_rights("create")
res.check_access_rule("create")
return res

View file

@ -0,0 +1,109 @@
# Copyright 2021-2025 Tecnativa - Víctor Martínez
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import api, models
from odoo.tools import ormcache
class IrAttachment(models.Model):
_inherit = "ir.attachment"
def _get_dms_directories(self, res_model, res_id):
domain = [
("res_model", "=", res_model),
("res_id", "=", res_id),
("storage_id.save_type", "=", "attachment"),
]
if self.env.context.get("attaching_to_record"):
domain += [("storage_id.include_message_attachments", "=", True)]
return self.env["dms.directory"].search(domain)
def _dms_directories_create(self):
items = self.sudo()._get_dms_directories(self.res_model, False)
for item in items:
model_item = self.env[self.res_model].browse(self.res_id)
ir_model_item = (
self.env["ir.model"].sudo().search([("model", "=", self.res_model)])
)
self.env["dms.directory"].sudo().with_context(check_name=False).create(
{
"name": model_item.display_name,
"model_id": ir_model_item.id,
"res_model": self.res_model,
"res_id": self.res_id,
"parent_id": item.id,
"storage_id": item.storage_id.id,
}
)
@ormcache("model")
def _dms_operations_from_model(self, model):
# Apply sudo to prevent ir.rule from being applied.
item = self.env["dms.storage"].sudo().search([("model_ids.model", "=", model)])
return bool(item)
def _dms_operations(self):
"""Perform the operation only if there is a storage with linked models.
The directory (dms.directory) linked to the record (if it does not exist)
and the file (dms.file) with the linked attachment would be created.
"""
for attachment in self:
if (
not attachment.res_model
or not attachment.res_id
or (
attachment.res_model
and not self._dms_operations_from_model(attachment.res_model)
)
):
continue
directories = attachment._get_dms_directories(
attachment.res_model, attachment.res_id
)
if not directories:
attachment._dms_directories_create()
# Get dms_directories again (with items previously created)
directories = attachment._get_dms_directories(
attachment.res_model, attachment.res_id
)
# Auto-create_files (if not exists)
for directory in directories:
dms_file_model = self.env["dms.file"].sudo()
dms_file = dms_file_model.search(
[
("attachment_id", "=", attachment.id),
("directory_id", "=", directory.id),
]
)
if not dms_file:
dms_file_model.create(
{
"name": attachment.name,
"directory_id": directory.id,
"attachment_id": attachment.id,
"res_model": attachment.res_model,
"res_id": attachment.res_id,
}
)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
if not self.env.context.get("dms_file"):
records._dms_operations()
return records
def write(self, vals):
res = super().write(vals)
if not self.env.context.get("dms_file") and self.env.context.get(
"attaching_to_record"
):
self._dms_operations()
return res
def unlink(self):
if not self.env.context.get("dms_file"):
self.env["dms.file"].search(
[("attachment_id", "in", self.ids)]
).with_context(dms_file=True).unlink()
return super().unlink()

View file

@ -0,0 +1,17 @@
# Copyright 2021 Tecnativa - Jairo Llopis
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from odoo import models
class MailThread(models.AbstractModel):
_inherit = "mail.thread"
def _message_post_process_attachments(
self, attachments, attachment_ids, message_data
):
"""Indicate to DMS that we're attaching a message to a record."""
_self = self.with_context(attaching_to_record=True)
return super(MailThread, _self)._message_post_process_attachments(
attachments, attachment_ids, message_data
)

View file

@ -0,0 +1,44 @@
# Copyright 2017-2019 MuK IT GmbH.
# Copyright 2020 Creu Blanca
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import os
from odoo import api, fields, models
from odoo.modules.module import get_resource_path
class Thumbnail(models.AbstractModel):
_name = "dms.mixins.thumbnail"
_inherit = "image.mixin"
_description = "DMS thumbnail and icon mixin"
icon_url = fields.Char(string="Icon URL", compute="_compute_icon_url")
def _get_icon_disk_path(self):
"""Obtain local disk path to record icon."""
folders = ["static", "icons"]
name = self._get_icon_placeholder_name()
path = get_resource_path("dms", *folders, name)
return path or get_resource_path("dms", *folders, "file_unknown.svg")
def _get_icon_placeholder_name(self):
return "folder.svg"
def _get_icon_url(self):
"""Obtain URL to record icon."""
local_path = self._get_icon_disk_path()
icon_name = os.path.basename(local_path)
return "/dms/static/icons/%s" % icon_name
@api.depends("image_128")
def _compute_icon_url(self):
"""Get icon static file URL."""
for one in self:
# Get URL to thumbnail or to the default icon by file extension
one.icon_url = (
"/web/image/{}/{}/image_128/128x128?crop=1".format(one._name, one.id)
if one.image_128
else one._get_icon_url()
)

View file

@ -0,0 +1,111 @@
# Copyright 2020 Creu Blanca
# Copyright 2017-2019 MuK IT GmbH
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class ResCompany(models.Model):
_inherit = "res.company"
# ----------------------------------------------------------
# Database
# ----------------------------------------------------------
documents_onboarding_state = fields.Selection(
selection=[
("not_done", "Not done"),
("just_done", "Just done"),
("done", "Done"),
("closed", "Closed"),
],
default="not_done",
)
documents_onboarding_storage_state = fields.Selection(
selection=[
("not_done", "Not done"),
("just_done", "Just done"),
("done", "Done"),
("closed", "Closed"),
],
default="not_done",
)
documents_onboarding_directory_state = fields.Selection(
selection=[
("not_done", "Not done"),
("just_done", "Just done"),
("done", "Done"),
("closed", "Closed"),
],
default="not_done",
)
documents_onboarding_file_state = fields.Selection(
selection=[
("not_done", "Not done"),
("just_done", "Just done"),
("done", "Done"),
("closed", "Closed"),
],
default="not_done",
)
# ----------------------------------------------------------
# Functions
# ----------------------------------------------------------
def get_and_update_documents_onboarding_state(self):
return self._get_and_update_onboarding_state(
"documents_onboarding_state", self.get_documents_steps_states_names()
)
def get_documents_steps_states_names(self):
return [
"documents_onboarding_storage_state",
"documents_onboarding_directory_state",
"documents_onboarding_file_state",
]
# ----------------------------------------------------------
# Actions
# ----------------------------------------------------------
@api.model
def action_open_documents_onboarding_storage(self):
return self.env.ref("dms.action_dms_storage_new").read()[0]
@api.model
def action_open_documents_onboarding_directory(self):
storage = self.env["dms.storage"].search([], order="create_date desc", limit=1)
action = self.env.ref("dms.action_dms_directory_new").read()[0]
action["context"] = {
**self.env.context,
**{
"default_is_root_directory": True,
"default_storage_id": storage and storage.id,
},
}
return action
@api.model
def action_open_documents_onboarding_file(self):
directory = self.env["dms.directory"].search(
[], order="create_date desc", limit=1
)
action = self.env.ref("dms.action_dms_file_new").read()[0]
action["context"] = {
**self.env.context,
**{"default_directory_id": directory and directory.id},
}
return action
@api.model
def action_close_documents_onboarding(self):
self.env.user.company_id.documents_onboarding_state = "closed"

View file

@ -0,0 +1,22 @@
# Copyright 2020 Creu Blanca
# Copyright 2017-2019 MuK IT GmbH
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
documents_binary_max_size = fields.Integer(
string="Size",
help="Defines the maximum upload size in MB. Default (25MB)",
config_parameter="dms.binary_max_size",
)
documents_forbidden_extensions = fields.Char(
string="Extensions",
help="Defines a list of forbidden file extensions. (Example: 'exe,msi')",
config_parameter="dms.forbidden_extensions",
)

View file

@ -0,0 +1,153 @@
# Copyright 2017-2019 MuK IT GmbH.
# Copyright 2020 Creu Blanca
# Copyright 2021 Tecnativa - Víctor Martínez
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
from odoo import _, api, fields, models
from odoo.exceptions import AccessError
_logger = logging.getLogger(__name__)
class Storage(models.Model):
_name = "dms.storage"
_description = "Storage"
# ----------------------------------------------------------
# Database
# ----------------------------------------------------------
name = fields.Char(required=True)
save_type = fields.Selection(
selection=[
("database", _("Database")),
("file", _("Filestore")),
("attachment", _("Attachment")),
],
default="database",
required=True,
help="""The save type is used to determine how a file is saved by the
system. If you change this setting, you can migrate existing files
manually by triggering the action.""",
)
company_id = fields.Many2one(
comodel_name="res.company",
string="Company",
default=lambda self: self.env.company,
help="If set, directories and files will only be available for "
"the selected company.",
)
is_hidden = fields.Boolean(
string="Storage is Hidden",
default=False,
help="Indicates if directories and files are hidden by default.",
)
root_directory_ids = fields.One2many(
comodel_name="dms.directory",
inverse_name="storage_id",
string="Root Directories",
auto_join=False,
readonly=False,
copy=False,
)
storage_directory_ids = fields.One2many(
comodel_name="dms.directory",
inverse_name="storage_id",
string="Directories",
auto_join=False,
readonly=True,
copy=False,
)
storage_file_ids = fields.One2many(
comodel_name="dms.file",
inverse_name="storage_id",
string="Files",
auto_join=False,
readonly=True,
copy=False,
)
count_storage_directories = fields.Integer(
compute="_compute_count_storage_directories", string="Count Directories"
)
count_storage_files = fields.Integer(
compute="_compute_count_storage_files", string="Count Files"
)
model_ids = fields.Many2many("ir.model", string="Linked Models")
inherit_access_from_parent_record = fields.Boolean(
string="Inherit permissions from related record",
default=False,
help="Indicate if directories and files access work only with "
"related model access (for example, if some directories are related "
"with any sale, only users with read access to these sale can acess)",
)
include_message_attachments = fields.Boolean(
string="Create files from message attachments",
default=False,
help="Indicate if directories and files auto-create in mail "
"composition process too",
)
model = fields.Char(search="_search_model", store=False)
def _search_model(self, operator, value):
allowed_items = self.env["ir.model"].sudo().search([("model", operator, value)])
return [("model_ids", "in", allowed_items.ids)]
@api.onchange("save_type")
def _onchange_save_type(self):
for record in self:
if record.save_type == "attachment":
record.inherit_access_from_parent_record = True
# ----------------------------------------------------------
# Actions
# ----------------------------------------------------------
def action_storage_migrate(self):
if self.save_type != "attachment":
if not self.env.user.has_group("dms.group_dms_manager"):
raise AccessError(_("Only managers can execute this action."))
files = self.env["dms.file"].with_context(active_test=False).sudo()
for record in self:
domain = [
("require_migration", "=", True),
("storage_id", "=", record.id),
]
files.search(domain).action_migrate()
def action_save_onboarding_storage_step(self):
self.env.user.company_id.set_onboarding_step_done(
"documents_onboarding_storage_state"
)
# ----------------------------------------------------------
# Read, View
# ----------------------------------------------------------
@api.depends("storage_directory_ids")
def _compute_count_storage_directories(self):
for record in self:
record.count_storage_directories = len(record.storage_directory_ids)
@api.depends("storage_file_ids")
def _compute_count_storage_files(self):
for record in self:
record.count_storage_files = len(record.storage_file_ids)
def write(self, values):
res = super().write(values)
if "model_ids" in values:
self.env["ir.attachment"].clear_caches()
return res

View file

@ -0,0 +1,59 @@
# Copyright 2020 RGB Consulting
# Copyright 2017-2019 MuK IT GmbH
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class Tag(models.Model):
_name = "dms.tag"
_description = "Document Tag"
name = fields.Char(required=True, translate=True)
active = fields.Boolean(
default=True,
help="The active field allows you " "to hide the tag without removing it.",
)
category_id = fields.Many2one(
comodel_name="dms.category",
context="{'dms_category_show_path': True}",
string="Category",
ondelete="set null",
)
color = fields.Integer(string="Color Index", default=10)
directory_ids = fields.Many2many(
comodel_name="dms.directory",
relation="dms_directory_tag_rel",
column1="tid",
column2="did",
string="Directories",
readonly=True,
)
file_ids = fields.Many2many(
comodel_name="dms.file",
relation="dms_file_tag_rel",
column1="tid",
column2="fid",
string="Files",
readonly=True,
)
count_directories = fields.Integer(compute="_compute_count_directories")
count_files = fields.Integer(compute="_compute_count_files")
_sql_constraints = [
("name_uniq", "unique (name, category_id)", "Tag name already exists!"),
]
@api.depends("directory_ids")
def _compute_count_directories(self):
for rec in self:
rec.count_directories = len(rec.directory_ids)
@api.depends("file_ids")
def _compute_count_files(self):
for rec in self:
rec.count_files = len(rec.file_ids)

View file

@ -0,0 +1,40 @@
To configure this module, you need to:
#. Go to *Documents -> Configuration -> Storages*.
#. Create a new document storage. You can choose between two options on `Save Type`:
* `Database`: Store the files on the database as a field
* `Attachment`: Store the files as attachments
#. Next create an administrative access group. Go to *Configuration -> Access Groups*.
* Create a new group, name it appropriately, and turn on all three permissions (Create, Write and Unlink - Read is implied and always enabled).
* Add any other top-level administrative users to the group if needed (your user should already be there).
* You can create other groups in here later for fine grained access control.
#. Afterwards go to *Documents -> Directories*.
#. Create a new directory, mark it as root and select the previously created setting.
* Select the *Groups* tab and add your administrative group created above.
#. On the Directory you can also add other access groups (created above) that will be able to:
* read
* create
* write
* delete
Migration
~~~~~~~~~
If you need to modify the storage Save Type you might want to migrate the file data.
In order to achieve it you need to:
#. Go to *Documents -> Configuration -> Storage* and select the storage you want to modify
#. Modify the save type
#. Press the button `Migrate files` if you want to migrate all the files at once
#. Press the button `Manual File Migration` in order to specify files one by one
You can check all the files that still needs to be migrated from all storages
and migrate them manually on *Documents -> Configuration -> Migration*
File Wizard Selection
~~~~~~~~~~~~~~~~~~~~~
There is an action called `action_dms_file_wizard_selector` to open a wizard to list files in kanban view.
This can be used (example `dms_attachment_link` module) to add a button in kanban view with the action we need.

View file

@ -0,0 +1,16 @@
* Mathias Markl <mathias.markl@mukit.at>
* Enric Tobella <etobella@creublanca.es>
* Antoni Romera
* Gelu Boros <gelu.boros@rgbconsulting.com>
* `Tecnativa <https://www.tecnativa.com>`_:
* Víctor Martínez
* Pedro M. Baeza
* Jairo Llopis
* `Elego <https://www.elegosoft.com>`_:
* Yu Weng <yweng@elegosoft.com>
* Philip Witte <phillip.witte@elegosoft.com>
* Khanh Bui <khanh.bui@mail.elegosoft.com>

View file

@ -0,0 +1,6 @@
The migration of this module from 15.0 to 16.0 was financially supported by `AgentERP <https://www.agenterp.com>`_
Some pictures are based on or inspired by:
* `Roundicons <https://www.flaticon.com/authors/roundicons>`_
* `Smashicons <https://www.flaticon.com/authors/smashicons>`_

View file

@ -0,0 +1,6 @@
DMS is a module for creating, managing and viewing document files directly
within Odoo.
This module is only the basis for an entire ecosystem of apps that extend and
seamlessly integrate with the document management system.
This module adds portal functionality for directories and files for allowed users, both portal or internal users. You can get as well a tokenized link from a directory or a file for sharing it with any anonymous user.

View file

@ -0,0 +1,9 @@
Preview
~~~~~~~
``mail_preview_base`` is required for DMS but it is recommended to install all
the other `mail_preview` modules from `social` OCA repository
in order to improve the preview of files.
``python-magic`` library is recommended to be installed for having whole support
to get proper file types and file preview.

View file

@ -0,0 +1,9 @@
- Files preview in portal
- Allow to download folder in portal and create zip file with all content
- Save in cache own_root directories and update in every create/write/unlink function
- Add a migration procedure for converting an storage to attachment one for populating existing records with attachments as folders
- Add a link from attachment view in chatter to linked documents
- If Inherit permissions from related record (the inherit_access_from_parent_record field from storage) is changed when directories already exist, inconsistencies may occur because groups defined in the directories and subdirectories will still exist, all groups in these directories should be removed before changing.
- Since portal users can read ``dms.storage`` records, if your module extends this model to another storage backend that needs using secrets, remember to forbid access to the secrets fields by other means. It would be nice to be able to remove that rule at some point.
- Searchpanel in files: Highlight items (shading) without records when filtering something (by name for example).
- Accessing the clipboard (for example copy share link of file/directory) is limited to secure connections. It also happens in any part of Odoo.

View file

@ -0,0 +1,8 @@
The best way to manage the documents is to switch to the Documents view.
Existing documents can be managed there and new documents can be created.
Portal functionality
~~~~~~~~~~~~~~~~~~~~
You can add any portal user to DMS access groups, and then allow that group in directories, so they will see in the portal such directories and their files.
Another possibility is to click on "Share" button inside a directory or a file for obtaining a tokenized link for single access to that resource, no matter if logged or not.

View file

@ -0,0 +1,27 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_dms_tag_user,dms_tag_user,model_dms_tag,group_dms_user,1,1,1,1
access_dms_category_user,dms_category_user,model_dms_category,group_dms_user,1,1,1,1
access_dms_storage_base_user,dms_storage_base_user,model_dms_storage,base.group_user,1,0,0,0
access_dms_storage_portal,dms_storage_portal,model_dms_storage,base.group_portal,1,0,0,0
access_dms_storage_user,dms_storage_user,model_dms_storage,group_dms_user,1,0,0,0
access_dms_storage_manager,dms_storage_manager,model_dms_storage,group_dms_manager,1,1,1,1
access_dms_directory_public,dms_directory_public,model_dms_directory,base.group_public,1,0,0,0
access_dms_directory_portal,dms_directory_portal,model_dms_directory,base.group_portal,1,0,0,0
access_dms_directory_base_user,dms_directory_base_user,model_dms_directory,base.group_user,1,0,0,0
access_dms_directory_user,dms_directory_user,model_dms_directory,group_dms_user,1,1,1,1
access_dms_file_public,dms_file_public,model_dms_file,base.group_public,1,0,0,0
access_dms_file_portal,dms_file_portal,model_dms_file,base.group_portal,1,0,0,0
access_dms_file_base_user,dms_file_base_user,model_dms_file,base.group_user,1,0,0,0
access_dms_file_user,dms_file_user,model_dms_file,group_dms_user,1,1,1,1
access_dms_access_group_public,access_dms_access_group_public,model_dms_access_group,base.group_public,1,0,0,0
access_dms_access_group_portal,access_dms_access_group_portal,model_dms_access_group,base.group_portal,1,0,0,0
access_security_access_groups_user,access_security_access_groups_user,model_dms_access_group,base.group_user,1,0,0,0
access_security_access_groups_dms_user,access_security_access_groups_dms_user,model_dms_access_group,group_dms_user,1,1,1,1
access_wizard_dms_file_move,access_wizard_dms_file_move,model_wizard_dms_file_move,group_dms_user,1,1,1,1
access_wizard_dms_share,access_wizard_dms_share,model_wizard_dms_share,group_dms_manager,1,1,1,0
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_dms_tag_user dms_tag_user model_dms_tag group_dms_user 1 1 1 1
3 access_dms_category_user dms_category_user model_dms_category group_dms_user 1 1 1 1
4 access_dms_storage_base_user dms_storage_base_user model_dms_storage base.group_user 1 0 0 0
5 access_dms_storage_portal dms_storage_portal model_dms_storage base.group_portal 1 0 0 0
6 access_dms_storage_user dms_storage_user model_dms_storage group_dms_user 1 0 0 0
7 access_dms_storage_manager dms_storage_manager model_dms_storage group_dms_manager 1 1 1 1
8 access_dms_directory_public dms_directory_public model_dms_directory base.group_public 1 0 0 0
9 access_dms_directory_portal dms_directory_portal model_dms_directory base.group_portal 1 0 0 0
10 access_dms_directory_base_user dms_directory_base_user model_dms_directory base.group_user 1 0 0 0
11 access_dms_directory_user dms_directory_user model_dms_directory group_dms_user 1 1 1 1
12 access_dms_file_public dms_file_public model_dms_file base.group_public 1 0 0 0
13 access_dms_file_portal dms_file_portal model_dms_file base.group_portal 1 0 0 0
14 access_dms_file_base_user dms_file_base_user model_dms_file base.group_user 1 0 0 0
15 access_dms_file_user dms_file_user model_dms_file group_dms_user 1 1 1 1
16 access_dms_access_group_public access_dms_access_group_public model_dms_access_group base.group_public 1 0 0 0
17 access_dms_access_group_portal access_dms_access_group_portal model_dms_access_group base.group_portal 1 0 0 0
18 access_security_access_groups_user access_security_access_groups_user model_dms_access_group base.group_user 1 0 0 0
19 access_security_access_groups_dms_user access_security_access_groups_dms_user model_dms_access_group group_dms_user 1 1 1 1
20 access_wizard_dms_file_move access_wizard_dms_file_move model_wizard_dms_file_move group_dms_user 1 1 1 1
21 access_wizard_dms_share access_wizard_dms_share model_wizard_dms_share group_dms_manager 1 1 1 0

View file

@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright 2017-2019 MuK IT GmbH
Copyright 2020 Creu Blanca
Copyright 2021 Tecnativa - Víctor Martínez
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<odoo>
<record id="category_dms_security" model="ir.module.category">
<field name="name">Documents</field>
</record>
<record id="group_dms_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="category_dms_security" />
<field name="implied_ids" eval="[(4, ref('base.group_user'))]" />
</record>
<record id="group_dms_manager" model="res.groups">
<field name="name">Manager</field>
<field name="implied_ids" eval="[(4, ref('group_dms_user'))]" />
<field name="category_id" ref="category_dms_security" />
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
/>
</record>
<record id="rule_multi_company_storage" model="ir.rule">
<field name="name">DMS Storage multi-company</field>
<field name="model_id" ref="model_dms_storage" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id','in',company_ids)]</field>
</record>
<record id="rule_multi_company_directory" model="ir.rule">
<field name="name">DMS Directory multi-company</field>
<field name="model_id" ref="model_dms_directory" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id','in',company_ids)]</field>
</record>
<record id="rule_multi_company_file" model="ir.rule">
<field name="name">File multi-company</field>
<field name="model_id" ref="model_dms_file" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id','in',company_ids)]</field>
</record>
<record id="rule_file_locked" model="ir.rule">
<field name="name">Locked files are only modified by locker user.</field>
<field name="model_id" ref="model_dms_file" />
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field
name="domain_force"
>['|', ('locked_by', '=', False), ('locked_by', '=', user.id)]</field>
</record>
<record id="rule_security_groups_user" model="ir.rule">
<field name="name">DMS users can only edit and delete their own groups.</field>
<field name="model_id" ref="model_dms_access_group" />
<field name="groups" eval="[(4, ref('group_dms_user'))]" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="domain_force">[('create_uid','=',user.id)]</field>
</record>
<record id="rule_security_groups_manager" model="ir.rule">
<field name="name">DMS Managers can edit and delete all groups.</field>
<field name="model_id" ref="model_dms_access_group" />
<field name="groups" eval="[(4, ref('group_dms_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="domain_force">[(1 ,'=', 1)]</field>
</record>
<!-- Forbid lower groups access to hidden storage -->
<record id="rule_forbid_hidden_storage" model="ir.rule">
<field name="name">Basic users cannot access hidden storage</field>
<field name="model_id" ref="model_dms_storage" />
<field
name="groups"
eval="[(4, ref('base.group_portal')), (4, ref('group_dms_user'))]"
/>
<field name="perm_read" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="domain_force">[('is_hidden', '=', False)]</field>
</record>
<record id="rule_allow_hidden_storage" model="ir.rule">
<field name="name">Managers can access hidden storage</field>
<field name="model_id" ref="model_dms_storage" />
<field name="groups" eval="[(4, ref('group_dms_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="domain_force">[('is_hidden', '=', True)]</field>
</record>
<!-- These rules leverage computed permission management -->
<record id="rule_directory_computed_create" model="ir.rule">
<field name="name">Apply computed create permissions.</field>
<field name="model_id" ref="model_dms_directory" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
<field name="domain_force">[('permission_create', '=', user.id)]</field>
</record>
<record id="rule_directory_computed_read" model="ir.rule">
<field name="name">Apply computed read permissions.</field>
<field name="model_id" ref="model_dms_directory" />
<field name="global" eval="True" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
<field name="domain_force">[('permission_read', '=', user.id)]</field>
</record>
<record id="rule_directory_computed_unlink" model="ir.rule">
<field name="name">Apply computed unlink permissions.</field>
<field name="model_id" ref="model_dms_directory" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="1" />
<field name="domain_force">[('permission_unlink', '=', user.id)]</field>
</record>
<record id="rule_directory_computed_write" model="ir.rule">
<field name="name">Apply computed write permissions.</field>
<field name="model_id" ref="model_dms_directory" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="0" />
<field name="domain_force">[('permission_write', '=', user.id)]</field>
</record>
<record id="rule_file_computed_create" model="ir.rule">
<field name="name">Apply computed create permissions.</field>
<field name="model_id" ref="model_dms_file" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
<field name="domain_force">[('permission_create', '=', user.id)]</field>
</record>
<record id="rule_file_computed_read" model="ir.rule">
<field name="name">Apply computed read permissions.</field>
<field name="model_id" ref="model_dms_file" />
<field name="global" eval="True" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
<field name="domain_force">[('permission_read', '=', user.id)]</field>
</record>
<record id="rule_file_computed_unlink" model="ir.rule">
<field name="name">Apply computed unlink permissions.</field>
<field name="model_id" ref="model_dms_file" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="1" />
<field name="domain_force">[('permission_unlink', '=', user.id)]</field>
</record>
<record id="rule_file_computed_write" model="ir.rule">
<field name="name">Apply computed write permissions.</field>
<field name="model_id" ref="model_dms_file" />
<field name="global" eval="True" />
<field name="perm_read" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="0" />
<field name="domain_force">[('permission_write', '=', user.id)]</field>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -0,0 +1,566 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Document Management System</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="document-management-system">
<h1 class="title">Document Management System</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:4ca37da84fb902307a08168a40a5048ba34c2af01b05c97a139cca5eb19b8301
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/dms/tree/16.0/dms"><img alt="OCA/dms" src="https://img.shields.io/badge/github-OCA%2Fdms-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/dms-16-0/dms-16-0-dms"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/dms&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>DMS is a module for creating, managing and viewing document files directly
within Odoo.
This module is only the basis for an entire ecosystem of apps that extend and
seamlessly integrate with the document management system.</p>
<p>This module adds portal functionality for directories and files for allowed users, both portal or internal users. You can get as well a tokenized link from a directory or a file for sharing it with any anonymous user.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#installation" id="toc-entry-1">Installation</a><ul>
<li><a class="reference internal" href="#preview" id="toc-entry-2">Preview</a></li>
</ul>
</li>
<li><a class="reference internal" href="#configuration" id="toc-entry-3">Configuration</a><ul>
<li><a class="reference internal" href="#migration" id="toc-entry-4">Migration</a></li>
<li><a class="reference internal" href="#file-wizard-selection" id="toc-entry-5">File Wizard Selection</a></li>
</ul>
</li>
<li><a class="reference internal" href="#usage" id="toc-entry-6">Usage</a><ul>
<li><a class="reference internal" href="#portal-functionality" id="toc-entry-7">Portal functionality</a></li>
</ul>
</li>
<li><a class="reference internal" href="#known-issues-roadmap" id="toc-entry-8">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-9">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-10">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-11">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-12">Contributors</a></li>
<li><a class="reference internal" href="#other-credits" id="toc-entry-13">Other credits</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-14">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="installation">
<h1><a class="toc-backref" href="#toc-entry-1">Installation</a></h1>
<div class="section" id="preview">
<h2><a class="toc-backref" href="#toc-entry-2">Preview</a></h2>
<p><tt class="docutils literal">mail_preview_base</tt> is required for DMS but it is recommended to install all
the other <cite>mail_preview</cite> modules from <cite>social</cite> OCA repository
in order to improve the preview of files.</p>
<p><tt class="docutils literal"><span class="pre">python-magic</span></tt> library is recommended to be installed for having whole support
to get proper file types and file preview.</p>
</div>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-3">Configuration</a></h1>
<p>To configure this module, you need to:</p>
<ol class="arabic simple">
<li>Go to <em>Documents -&gt; Configuration -&gt; Storages</em>.</li>
<li><dl class="first docutils">
<dt>Create a new document storage. You can choose between two options on <cite>Save Type</cite>:</dt>
<dd><ul class="first last">
<li><cite>Database</cite>: Store the files on the database as a field</li>
<li><cite>Attachment</cite>: Store the files as attachments</li>
</ul>
</dd>
</dl>
</li>
<li><dl class="first docutils">
<dt>Next create an administrative access group. Go to <em>Configuration -&gt; Access Groups</em>.</dt>
<dd><ul class="first last">
<li>Create a new group, name it appropriately, and turn on all three permissions (Create, Write and Unlink - Read is implied and always enabled).</li>
<li>Add any other top-level administrative users to the group if needed (your user should already be there).</li>
<li>You can create other groups in here later for fine grained access control.</li>
</ul>
</dd>
</dl>
</li>
<li>Afterwards go to <em>Documents -&gt; Directories</em>.</li>
<li><dl class="first docutils">
<dt>Create a new directory, mark it as root and select the previously created setting.</dt>
<dd><ul class="first last">
<li>Select the <em>Groups</em> tab and add your administrative group created above.</li>
</ul>
</dd>
</dl>
</li>
<li><dl class="first docutils">
<dt>On the Directory you can also add other access groups (created above) that will be able to:</dt>
<dd><ul class="first last">
<li>read</li>
<li>create</li>
<li>write</li>
<li>delete</li>
</ul>
</dd>
</dl>
</li>
</ol>
<div class="section" id="migration">
<h2><a class="toc-backref" href="#toc-entry-4">Migration</a></h2>
<p>If you need to modify the storage Save Type you might want to migrate the file data.
In order to achieve it you need to:</p>
<ol class="arabic simple">
<li>Go to <em>Documents -&gt; Configuration -&gt; Storage</em> and select the storage you want to modify</li>
<li>Modify the save type</li>
<li>Press the button <cite>Migrate files</cite> if you want to migrate all the files at once</li>
<li>Press the button <cite>Manual File Migration</cite> in order to specify files one by one</li>
</ol>
<p>You can check all the files that still needs to be migrated from all storages
and migrate them manually on <em>Documents -&gt; Configuration -&gt; Migration</em></p>
</div>
<div class="section" id="file-wizard-selection">
<h2><a class="toc-backref" href="#toc-entry-5">File Wizard Selection</a></h2>
<p>There is an action called <cite>action_dms_file_wizard_selector</cite> to open a wizard to list files in kanban view.
This can be used (example <cite>dms_attachment_link</cite> module) to add a button in kanban view with the action we need.</p>
</div>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-6">Usage</a></h1>
<p>The best way to manage the documents is to switch to the Documents view.
Existing documents can be managed there and new documents can be created.</p>
<div class="section" id="portal-functionality">
<h2><a class="toc-backref" href="#toc-entry-7">Portal functionality</a></h2>
<p>You can add any portal user to DMS access groups, and then allow that group in directories, so they will see in the portal such directories and their files.
Another possibility is to click on “Share” button inside a directory or a file for obtaining a tokenized link for single access to that resource, no matter if logged or not.</p>
</div>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#toc-entry-8">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Files preview in portal</li>
<li>Allow to download folder in portal and create zip file with all content</li>
<li>Save in cache own_root directories and update in every create/write/unlink function</li>
<li>Add a migration procedure for converting an storage to attachment one for populating existing records with attachments as folders</li>
<li>Add a link from attachment view in chatter to linked documents</li>
<li>If Inherit permissions from related record (the inherit_access_from_parent_record field from storage) is changed when directories already exist, inconsistencies may occur because groups defined in the directories and subdirectories will still exist, all groups in these directories should be removed before changing.</li>
<li>Since portal users can read <tt class="docutils literal">dms.storage</tt> records, if your module extends this model to another storage backend that needs using secrets, remember to forbid access to the secrets fields by other means. It would be nice to be able to remove that rule at some point.</li>
<li>Searchpanel in files: Highlight items (shading) without records when filtering something (by name for example).</li>
<li>Accessing the clipboard (for example copy share link of file/directory) is limited to secure connections. It also happens in any part of Odoo.</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-9">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/dms/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/dms/issues/new?body=module:%20dms%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-10">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-11">Authors</a></h2>
<ul class="simple">
<li>MuK IT</li>
<li>Tecnativa</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-12">Contributors</a></h2>
<ul class="simple">
<li>Mathias Markl &lt;<a class="reference external" href="mailto:mathias.markl&#64;mukit.at">mathias.markl&#64;mukit.at</a>&gt;</li>
<li>Enric Tobella &lt;<a class="reference external" href="mailto:etobella&#64;creublanca.es">etobella&#64;creublanca.es</a>&gt;</li>
<li>Antoni Romera</li>
<li>Gelu Boros &lt;<a class="reference external" href="mailto:gelu.boros&#64;rgbconsulting.com">gelu.boros&#64;rgbconsulting.com</a>&gt;</li>
<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:<ul>
<li>Víctor Martínez</li>
<li>Pedro M. Baeza</li>
<li>Jairo Llopis</li>
</ul>
</li>
<li><a class="reference external" href="https://www.elegosoft.com">Elego</a>:<ul>
<li>Yu Weng &lt;<a class="reference external" href="mailto:yweng&#64;elegosoft.com">yweng&#64;elegosoft.com</a>&gt;</li>
<li>Philip Witte &lt;<a class="reference external" href="mailto:phillip.witte&#64;elegosoft.com">phillip.witte&#64;elegosoft.com</a>&gt;</li>
<li>Khanh Bui &lt;<a class="reference external" href="mailto:khanh.bui&#64;mail.elegosoft.com">khanh.bui&#64;mail.elegosoft.com</a>&gt;</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="other-credits">
<h2><a class="toc-backref" href="#toc-entry-13">Other credits</a></h2>
<p>The migration of this module from 15.0 to 16.0 was financially supported by <a class="reference external" href="https://www.agenterp.com">AgentERP</a></p>
<p>Some pictures are based on or inspired by:</p>
<ul class="simple">
<li><a class="reference external" href="https://www.flaticon.com/authors/roundicons">Roundicons</a></li>
<li><a class="reference external" href="https://www.flaticon.com/authors/smashicons">Smashicons</a></li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-14">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/dms/tree/16.0/dms">OCA/dms</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_DIUxPpL1A4o6jIKxrY3KVZ5F1CHA2mKL"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_DIUxPpL1A4o6jIKxrY3KVZ5F1CHA2mKL)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(255,193,79)"/><path d=" M 128.095 203.746 L 126.023 197.594 L 115.236 197.594 L 113.186 203.746 L 106.644 203.746 L 117.759 173.887 L 123.46 173.887 L 134.637 203.746 L 128.095 203.746 L 128.095 203.746 Z M 120.609 181.434 L 116.897 192.61 L 124.362 192.61 L 120.609 181.434 L 120.609 181.434 Z M 143.968 173.887 L 143.968 203.746 L 137.815 203.746 L 137.815 173.887 L 143.968 173.887 L 143.968 173.887 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/><path d=" M 157.857 74.571 L 157.857 76.02 C 148.205 79.063 140.562 86.649 137.422 96.26 C 135.263 94.516 132.557 93.429 129.571 93.429 C 123.729 93.429 118.851 97.451 117.446 102.857 L 107.571 102.857 L 107.571 96.571 L 88.714 96.571 L 88.714 115.429 L 107.571 115.429 L 107.571 109.143 L 116.752 109.143 C 115.696 117.512 110.513 124.788 102.958 128.512 C 100.858 124.493 96.697 121.714 91.857 121.714 C 84.924 121.714 79.286 127.353 79.286 134.286 C 79.286 141.219 84.924 146.857 91.857 146.857 C 98.602 146.857 104.077 141.508 104.372 134.836 C 112.716 131.218 118.977 124.191 121.733 115.749 C 123.889 117.487 126.592 118.571 129.571 118.571 C 135.414 118.571 140.292 114.549 141.697 109.143 L 151.571 109.143 L 151.571 115.429 L 170.429 115.429 L 170.429 96.571 L 151.571 96.571 L 151.571 102.857 L 142.36 102.857 C 143.551 93.369 150.041 85.527 158.772 82.372 C 160.632 87.001 165.142 90.286 170.429 90.286 C 177.362 90.286 183 84.647 183 77.714 L 183 74.571 L 157.857 74.571 Z M 101.286 109.143 L 95 109.143 L 95 102.857 L 101.286 102.857 L 101.286 109.143 Z M 91.857 140.571 C 88.391 140.571 85.571 137.752 85.571 134.286 C 85.571 130.819 88.391 128 91.857 128 C 95.324 128 98.143 130.819 98.143 134.286 C 98.143 137.752 95.324 140.571 91.857 140.571 Z M 157.857 102.857 L 164.143 102.857 L 164.143 109.143 L 157.857 109.143 L 157.857 102.857 Z M 129.571 112.286 C 126.105 112.286 123.286 109.467 123.286 106 C 123.286 102.533 126.105 99.714 129.571 99.714 C 133.038 99.714 135.857 102.533 135.857 106 C 135.857 109.467 133.038 112.286 129.571 112.286 Z M 170.429 84 C 168.156 84 166.176 82.777 165.073 80.97 C 165.802 80.904 166.538 80.857 167.286 80.857 L 175.869 80.857 C 174.785 82.733 172.751 84 170.429 84 Z " fill="rgb(200,189,184)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_6nAp4GoljLPqc74aMxxhpy3ZhrUJqOMK"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_6nAp4GoljLPqc74aMxxhpy3ZhrUJqOMK)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(93,93,93)"/><g><path d=" M 112.936 91.207 C 111.707 89.978 109.721 89.978 108.492 91.207 L 89.635 110.064 C 88.406 111.293 88.406 113.279 89.635 114.508 L 108.492 133.365 C 109.105 133.978 109.91 134.286 110.714 134.286 C 111.519 134.286 112.323 133.978 112.936 133.365 C 114.165 132.136 114.165 130.15 112.936 128.921 L 96.301 112.286 L 112.936 95.651 C 114.165 94.422 114.165 92.435 112.936 91.207 Z " fill="rgb(93,93,93)"/><path d=" M 169.508 110.064 L 161.722 102.278 L 150.651 91.207 C 149.422 89.978 147.435 89.978 146.207 91.207 C 144.978 92.435 144.978 94.422 146.207 95.651 L 162.842 112.286 L 146.207 128.921 C 144.978 130.15 144.978 132.136 146.207 133.365 C 146.819 133.978 147.624 134.286 148.429 134.286 C 149.233 134.286 150.038 133.978 150.651 133.365 L 169.508 114.508 C 170.737 113.279 170.737 111.293 169.508 110.064 Z " fill="rgb(93,93,93)"/></g></g></g><path d=" M 122.595 203.746 L 120.523 197.594 L 109.736 197.594 L 107.686 203.746 L 101.144 203.746 L 112.259 173.887 L 117.96 173.887 L 129.137 203.746 L 122.595 203.746 L 122.595 203.746 Z M 115.109 181.434 L 111.397 192.61 L 118.862 192.61 L 115.109 181.434 L 115.109 181.434 Z M 143.964 194.559 L 143.964 173.887 L 150.116 173.887 L 150.116 194.559 L 150.116 194.559 Q 150.116 197.409 148.855 199.593 L 148.855 199.593 L 148.855 199.593 Q 147.594 201.777 145.297 202.967 L 145.297 202.967 L 145.297 202.967 Q 143 204.156 140.108 204.156 L 140.108 204.156 L 140.108 204.156 Q 135.371 204.156 132.726 201.747 L 132.726 201.747 L 132.726 201.747 Q 130.08 199.337 130.08 194.928 L 130.08 194.928 L 136.273 194.928 L 136.273 194.928 Q 136.273 197.122 137.196 198.168 L 137.196 198.168 L 137.196 198.168 Q 138.119 199.214 140.108 199.214 L 140.108 199.214 L 140.108 199.214 Q 141.872 199.214 142.918 198.004 L 142.918 198.004 L 142.918 198.004 Q 143.964 196.794 143.964 194.559 L 143.964 194.559 L 143.964 194.559 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/></g></svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_FLHZ43wzdKFmTHD21bIUvTuw3Pzi3Qlw"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_FLHZ43wzdKFmTHD21bIUvTuw3Pzi3Qlw)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(215,94,114)"/><path d=" M 114.595 203.746 L 112.523 197.594 L 101.736 197.594 L 99.686 203.746 L 93.144 203.746 L 104.259 173.887 L 109.96 173.887 L 121.137 203.746 L 114.595 203.746 L 114.595 203.746 Z M 107.109 181.434 L 103.397 192.61 L 110.862 192.61 L 107.109 181.434 L 107.109 181.434 Z M 128.232 173.887 L 134.959 196.343 L 141.727 173.887 L 148.576 173.887 L 138.179 203.746 L 131.76 203.746 L 121.403 173.887 L 128.232 173.887 L 128.232 173.887 Z M 157.928 173.887 L 157.928 203.746 L 151.775 203.746 L 151.775 173.887 L 157.928 173.887 L 157.928 173.887 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/><path d=" M 113.857 128 L 113.857 105.855 L 113.857 84 L 148.429 106 L 113.857 128 Z " fill="rgb(200,189,184)"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_QDq7UHuMAP1DndjZvSJ7oBHBPdwuwQhy"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_QDq7UHuMAP1DndjZvSJ7oBHBPdwuwQhy)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(128,140,155)"/></g></g><path d=" M 132.954 195.8 L 139.106 195.8 L 139.106 195.8 Q 138.758 200.619 135.548 203.388 L 135.548 203.388 L 135.548 203.388 Q 132.339 206.156 127.089 206.156 L 127.089 206.156 L 127.089 206.156 Q 121.347 206.156 118.055 202.291 L 118.055 202.291 L 118.055 202.291 Q 114.764 198.425 114.764 191.678 L 114.764 191.678 L 114.764 189.853 L 114.764 189.853 Q 114.764 185.546 116.281 182.265 L 116.281 182.265 L 116.281 182.265 Q 117.799 178.983 120.619 177.23 L 120.619 177.23 L 120.619 177.23 Q 123.438 175.477 127.171 175.477 L 127.171 175.477 L 127.171 175.477 Q 132.339 175.477 135.497 178.245 L 135.497 178.245 L 135.497 178.245 Q 138.655 181.014 139.147 186.018 L 139.147 186.018 L 132.995 186.018 L 132.995 186.018 Q 132.77 183.126 131.385 181.824 L 131.385 181.824 L 131.385 181.824 Q 130.001 180.521 127.171 180.521 L 127.171 180.521 L 127.171 180.521 Q 124.095 180.521 122.567 182.726 L 122.567 182.726 L 122.567 182.726 Q 121.039 184.931 120.998 189.565 L 120.998 189.565 L 120.998 191.821 L 120.998 191.821 Q 120.998 196.661 122.464 198.896 L 122.464 198.896 L 122.464 198.896 Q 123.931 201.132 127.089 201.132 L 127.089 201.132 L 127.089 201.132 Q 129.939 201.132 131.344 199.83 L 131.344 199.83 L 131.344 199.83 Q 132.749 198.527 132.954 195.8 L 132.954 195.8 L 132.954 195.8 Z " fill="rgb(255,255,255)"/><path d=" M 131.94 106.301 L 131.94 106.301 L 131.94 106.301 Q 127.32 106.301 124.86 109.121 L 124.86 109.121 L 124.86 109.121 Q 122.4 111.941 122.4 117.101 L 122.4 117.101 L 122.4 117.101 Q 122.4 122.321 125.13 125.141 L 125.13 125.141 L 125.13 125.141 Q 127.86 127.961 132.48 127.961 L 132.48 127.961 L 132.48 127.961 Q 134.94 127.961 136.65 127.301 L 136.65 127.301 L 136.65 127.301 Q 138.36 126.641 139.92 125.801 L 139.92 125.801 L 139.92 125.801 Q 140.94 126.641 141.51 127.811 L 141.51 127.811 L 141.51 127.811 Q 142.08 128.981 142.08 130.541 L 142.08 130.541 L 142.08 130.541 Q 142.08 133.001 139.35 134.711 L 139.35 134.711 L 139.35 134.711 Q 136.62 136.421 131.04 136.421 L 131.04 136.421 L 131.04 136.421 Q 127.02 136.421 123.48 135.281 L 123.48 135.281 L 123.48 135.281 Q 119.94 134.141 117.3 131.771 L 117.3 131.771 L 117.3 131.771 Q 114.66 129.401 113.13 125.771 L 113.13 125.771 L 113.13 125.771 Q 111.6 122.141 111.6 117.101 L 111.6 117.101 L 111.6 117.101 Q 111.6 112.421 113.07 108.851 L 113.07 108.851 L 113.07 108.851 Q 114.54 105.281 117.09 102.821 L 117.09 102.821 L 117.09 102.821 Q 119.64 100.361 123.06 99.101 L 123.06 99.101 L 123.06 99.101 Q 126.48 97.841 130.38 97.841 L 130.38 97.841 L 130.38 97.841 Q 135.9 97.841 138.93 99.641 L 138.93 99.641 L 138.93 99.641 Q 141.96 101.441 141.96 104.261 L 141.96 104.261 L 141.96 104.261 Q 141.96 105.821 141.18 106.961 L 141.18 106.961 L 141.18 106.961 Q 140.4 108.101 139.38 108.761 L 139.38 108.761 L 139.38 108.761 Q 137.82 107.741 136.05 107.021 L 136.05 107.021 L 136.05 107.021 Q 134.28 106.301 131.94 106.301 Z " fill="rgb(128,140,155)"/></g></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_TQmEGhsjtrLEyjf6zmhvf3bpAZU65XHf"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_TQmEGhsjtrLEyjf6zmhvf3bpAZU65XHf)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(93,93,93)"/><g><path d=" M 112.936 91.207 C 111.707 89.978 109.721 89.978 108.492 91.207 L 89.635 110.064 C 88.406 111.293 88.406 113.279 89.635 114.508 L 108.492 133.365 C 109.105 133.978 109.91 134.286 110.714 134.286 C 111.519 134.286 112.323 133.978 112.936 133.365 C 114.165 132.136 114.165 130.15 112.936 128.921 L 96.301 112.286 L 112.936 95.651 C 114.165 94.422 114.165 92.435 112.936 91.207 Z " fill="rgb(93,93,93)"/><path d=" M 169.508 110.064 L 161.722 102.278 L 150.651 91.207 C 149.422 89.978 147.435 89.978 146.207 91.207 C 144.978 92.435 144.978 94.422 146.207 95.651 L 162.842 112.286 L 146.207 128.921 C 144.978 130.15 144.978 132.136 146.207 133.365 C 146.819 133.978 147.624 134.286 148.429 134.286 C 149.233 134.286 150.038 133.978 150.651 133.365 L 169.508 114.508 C 170.737 113.279 170.737 111.293 169.508 110.064 Z " fill="rgb(93,93,93)"/></g></g></g><path d=" M 107.954 195.8 L 114.106 195.8 L 114.106 195.8 Q 113.758 200.619 110.548 203.388 L 110.548 203.388 L 110.548 203.388 Q 107.339 206.156 102.089 206.156 L 102.089 206.156 L 102.089 206.156 Q 96.347 206.156 93.055 202.291 L 93.055 202.291 L 93.055 202.291 Q 89.764 198.425 89.764 191.678 L 89.764 191.678 L 89.764 189.853 L 89.764 189.853 Q 89.764 185.546 91.281 182.265 L 91.281 182.265 L 91.281 182.265 Q 92.799 178.983 95.619 177.23 L 95.619 177.23 L 95.619 177.23 Q 98.438 175.477 102.171 175.477 L 102.171 175.477 L 102.171 175.477 Q 107.339 175.477 110.497 178.245 L 110.497 178.245 L 110.497 178.245 Q 113.655 181.014 114.147 186.018 L 114.147 186.018 L 107.995 186.018 L 107.995 186.018 Q 107.77 183.126 106.385 181.824 L 106.385 181.824 L 106.385 181.824 Q 105.001 180.521 102.171 180.521 L 102.171 180.521 L 102.171 180.521 Q 99.095 180.521 97.567 182.726 L 97.567 182.726 L 97.567 182.726 Q 96.039 184.931 95.998 189.565 L 95.998 189.565 L 95.998 191.821 L 95.998 191.821 Q 95.998 196.661 97.464 198.896 L 97.464 198.896 L 97.464 198.896 Q 98.931 201.132 102.089 201.132 L 102.089 201.132 L 102.089 201.132 Q 104.939 201.132 106.344 199.83 L 106.344 199.83 L 106.344 199.83 Q 107.749 198.527 107.954 195.8 L 107.954 195.8 L 107.954 195.8 Z M 129.754 205.746 L 118.146 205.746 L 118.146 175.887 L 128.605 175.887 L 128.605 175.887 Q 134.04 175.887 136.85 177.968 L 136.85 177.968 L 136.85 177.968 Q 139.659 180.05 139.659 184.069 L 139.659 184.069 L 139.659 184.069 Q 139.659 186.264 138.531 187.935 L 138.531 187.935 L 138.531 187.935 Q 137.403 189.606 135.394 190.386 L 135.394 190.386 L 135.394 190.386 Q 137.69 190.96 139.013 192.703 L 139.013 192.703 L 139.013 192.703 Q 140.336 194.446 140.336 196.969 L 140.336 196.969 L 140.336 196.969 Q 140.336 201.275 137.588 203.49 L 137.588 203.49 L 137.588 203.49 Q 134.84 205.705 129.754 205.746 L 129.754 205.746 L 129.754 205.746 Z M 129.938 192.744 L 124.299 192.744 L 124.299 200.804 L 129.569 200.804 L 129.569 200.804 Q 131.743 200.804 132.963 199.768 L 132.963 199.768 L 132.963 199.768 Q 134.184 198.732 134.184 196.907 L 134.184 196.907 L 134.184 196.907 Q 134.184 192.806 129.938 192.744 L 129.938 192.744 L 129.938 192.744 Z M 124.299 180.87 L 124.299 188.396 L 128.852 188.396 L 128.852 188.396 Q 133.507 188.314 133.507 184.685 L 133.507 184.685 L 133.507 184.685 Q 133.507 182.654 132.328 181.762 L 132.328 181.762 L 132.328 181.762 Q 131.148 180.87 128.605 180.87 L 128.605 180.87 L 124.299 180.87 L 124.299 180.87 Z M 151.103 175.887 L 151.103 200.804 L 164.166 200.804 L 164.166 205.746 L 144.95 205.746 L 144.95 175.887 L 151.103 175.887 L 151.103 175.887 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_AC8gC2Fy7eYKCxX4InFHVsjb6C7TwY0y"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_AC8gC2Fy7eYKCxX4InFHVsjb6C7TwY0y)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(233,99,96)"/><path d=" M 117 90.286 L 117 77.714 L 79.286 77.714 L 79.286 90.286 L 79.286 96.571 L 79.286 102.857 L 79.286 109.143 L 79.286 115.429 L 79.286 121.714 L 79.286 128 L 79.286 134.286 L 79.286 146.857 L 110.714 146.857 L 117 146.857 L 183 146.857 L 183 134.286 L 183 128 L 183 121.714 L 183 115.429 L 183 109.143 L 183 102.857 L 183 90.286 L 117 90.286 Z M 85.571 84 L 110.714 84 L 110.714 90.286 L 85.571 90.286 L 85.571 84 Z M 85.571 96.571 L 110.714 96.571 L 110.714 102.857 L 85.571 102.857 L 85.571 96.571 Z M 85.571 109.143 L 110.714 109.143 L 110.714 115.429 L 85.571 115.429 L 85.571 109.143 Z M 85.571 121.714 L 110.714 121.714 L 110.714 128 L 85.571 128 L 85.571 121.714 Z M 110.714 140.571 L 85.571 140.571 L 85.571 134.286 L 110.714 134.286 L 110.714 140.571 Z M 176.714 140.571 L 117 140.571 L 117 134.286 L 176.714 134.286 L 176.714 140.571 Z M 176.714 128 L 117 128 L 117 121.714 L 176.714 121.714 L 176.714 128 Z M 176.714 115.429 L 117 115.429 L 117 109.143 L 176.714 109.143 L 176.714 115.429 Z M 117 102.857 L 117 96.571 L 176.714 96.571 L 176.714 102.857 L 117 102.857 Z " fill="rgb(200,189,184)"/></g></g><path d=" M 99.915 205.746 L 90.666 205.746 L 90.666 175.887 L 99.854 175.887 L 99.854 175.887 Q 103.791 175.887 106.898 177.661 L 106.898 177.661 L 106.898 177.661 Q 110.005 179.435 111.748 182.706 L 111.748 182.706 L 111.748 182.706 Q 113.491 185.977 113.491 190.14 L 113.491 190.14 L 113.491 191.514 L 113.491 191.514 Q 113.491 195.677 111.779 198.917 L 111.779 198.917 L 111.779 198.917 Q 110.066 202.157 106.949 203.941 L 106.949 203.941 L 106.949 203.941 Q 103.832 205.726 99.915 205.746 L 99.915 205.746 L 99.915 205.746 Z M 99.854 180.87 L 96.818 180.87 L 96.818 200.804 L 99.792 200.804 L 99.792 200.804 Q 103.401 200.804 105.309 198.445 L 105.309 198.445 L 105.309 198.445 Q 107.216 196.087 107.257 191.698 L 107.257 191.698 L 107.257 190.119 L 107.257 190.119 Q 107.257 185.566 105.37 183.218 L 105.37 183.218 L 105.37 183.218 Q 103.483 180.87 99.854 180.87 L 99.854 180.87 L 99.854 180.87 Z M 129.569 205.746 L 117.962 205.746 L 117.962 175.887 L 128.421 175.887 L 128.421 175.887 Q 133.855 175.887 136.665 177.968 L 136.665 177.968 L 136.665 177.968 Q 139.475 180.05 139.475 184.069 L 139.475 184.069 L 139.475 184.069 Q 139.475 186.264 138.347 187.935 L 138.347 187.935 L 138.347 187.935 Q 137.219 189.606 135.209 190.386 L 135.209 190.386 L 135.209 190.386 Q 137.506 190.96 138.829 192.703 L 138.829 192.703 L 138.829 192.703 Q 140.151 194.446 140.151 196.969 L 140.151 196.969 L 140.151 196.969 Q 140.151 201.275 137.403 203.49 L 137.403 203.49 L 137.403 203.49 Q 134.655 205.705 129.569 205.746 L 129.569 205.746 L 129.569 205.746 Z M 129.754 192.744 L 124.114 192.744 L 124.114 200.804 L 129.385 200.804 L 129.385 200.804 Q 131.559 200.804 132.779 199.768 L 132.779 199.768 L 132.779 199.768 Q 133.999 198.732 133.999 196.907 L 133.999 196.907 L 133.999 196.907 Q 133.999 192.806 129.754 192.744 L 129.754 192.744 L 129.754 192.744 Z M 124.114 180.87 L 124.114 188.396 L 128.667 188.396 L 128.667 188.396 Q 133.322 188.314 133.322 184.685 L 133.322 184.685 L 133.322 184.685 Q 133.322 182.654 132.143 181.762 L 132.143 181.762 L 132.143 181.762 Q 130.964 180.87 128.421 180.87 L 128.421 180.87 L 124.114 180.87 L 124.114 180.87 Z M 162.73 188.581 L 162.73 193.544 L 150.918 193.544 L 150.918 205.746 L 144.766 205.746 L 144.766 175.887 L 164.207 175.887 L 164.207 180.87 L 150.918 180.87 L 150.918 188.581 L 162.73 188.581 L 162.73 188.581 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_JRbDI2Nk1iQLWnj5TvFr9peayMg5GuJ2"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_JRbDI2Nk1iQLWnj5TvFr9peayMg5GuJ2)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 38 L 65.027 38 C 62.487 38 60.429 40.059 60.429 44.053 L 60.429 210.857 C 60.429 211.941 62.487 214 65.027 214 L 190.973 214 C 193.513 214 195.571 211.941 195.571 210.857 L 195.571 78.788 C 195.571 76.601 195.279 75.897 194.764 75.378 L 158.193 38.808 C 157.675 38.292 156.971 38 156.239 38 Z " fill="rgb(233,233,224)"/><path d=" M 190.973 214 L 65.027 214 C 62.487 214 60.429 211.941 60.429 209.402 L 60.429 160.571 L 195.571 160.571 L 195.571 209.402 C 195.571 211.941 193.513 214 190.973 214 Z " fill="rgb(62,71,83)"/><path d=" M 157.857 38.475 L 157.857 75.714 L 195.097 75.714 L 157.857 38.475 Z " fill="rgb(217,215,202)"/></g></g><path d=" M 102.415 203.746 L 93.166 203.746 L 93.166 173.887 L 102.354 173.887 L 102.354 173.887 Q 106.291 173.887 109.398 175.661 L 109.398 175.661 L 109.398 175.661 Q 112.505 177.435 114.248 180.706 L 114.248 180.706 L 114.248 180.706 Q 115.991 183.977 115.991 188.14 L 115.991 188.14 L 115.991 189.514 L 115.991 189.514 Q 115.991 193.677 114.279 196.917 L 114.279 196.917 L 114.279 196.917 Q 112.566 200.157 109.449 201.941 L 109.449 201.941 L 109.449 201.941 Q 106.332 203.726 102.415 203.746 L 102.415 203.746 L 102.415 203.746 Z M 102.354 178.87 L 99.318 178.87 L 99.318 198.804 L 102.292 198.804 L 102.292 198.804 Q 105.901 198.804 107.809 196.445 L 107.809 196.445 L 107.809 196.445 Q 109.716 194.087 109.757 189.698 L 109.757 189.698 L 109.757 188.119 L 109.757 188.119 Q 109.757 183.566 107.87 181.218 L 107.87 181.218 L 107.87 181.218 Q 105.983 178.87 102.354 178.87 L 102.354 178.87 L 102.354 178.87 Z M 126.614 173.887 L 126.614 198.804 L 139.678 198.804 L 139.678 203.746 L 120.462 203.746 L 120.462 173.887 L 126.614 173.887 L 126.614 173.887 Z M 149.357 173.887 L 149.357 198.804 L 162.421 198.804 L 162.421 203.746 L 143.205 203.746 L 143.205 173.887 L 149.357 173.887 L 149.357 173.887 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/><g><g><g><g><path d=" M 128 98.25 C 122.625 98.25 118.25 102.625 118.25 108 C 118.25 113.375 122.625 117.75 128 117.75 C 133.375 117.75 137.75 113.375 137.75 108 C 137.75 102.625 133.375 98.25 128 98.25 Z " fill="rgb(200,189,184)"/><path d=" M 164.364 101.37 L 160.061 100.541 C 158.134 100.174 156.623 98.913 155.905 97.093 C 155.186 95.263 155.44 93.31 156.603 91.727 L 159.304 88.042 C 160.253 86.752 160.113 84.958 158.979 83.827 L 153.146 77.993 C 152.047 76.891 150.312 76.729 149.028 77.6 L 145.398 80.053 C 143.779 81.155 141.823 81.334 140.019 80.554 C 138.222 79.767 137.016 78.207 136.72 76.267 L 136.025 71.756 C 135.78 70.17 134.415 69 132.81 69 L 124.561 69 C 123.005 69 121.666 70.105 121.37 71.636 L 120.301 77.193 C 119.94 79.075 118.718 80.576 116.947 81.308 C 115.175 82.046 113.245 81.847 111.659 80.771 L 106.972 77.6 C 105.689 76.729 103.96 76.891 102.855 77.993 L 97.021 83.827 C 95.887 84.958 95.747 86.752 96.696 88.042 L 99.397 91.73 C 100.56 93.31 100.814 95.263 100.096 97.093 C 99.377 98.913 97.866 100.174 95.936 100.541 L 91.636 101.37 C 90.105 101.666 89 103.005 89 104.561 L 89 112.81 C 89 114.415 90.17 115.78 91.756 116.024 L 96.267 116.72 C 98.207 117.015 99.767 118.221 100.554 120.018 C 101.337 121.816 101.155 123.775 100.053 125.4 L 97.6 129.027 C 96.725 130.314 96.891 132.043 97.993 133.145 L 103.827 138.979 C 104.961 140.116 106.752 140.246 108.042 139.304 L 111.73 136.603 C 113.31 135.443 115.26 135.192 117.093 135.904 C 118.913 136.622 120.174 138.134 120.541 140.064 L 121.37 144.364 C 121.666 145.895 123.005 147 124.561 147 L 132.81 147 C 134.415 147 135.78 145.83 136.024 144.244 L 136.512 141.072 C 136.817 139.086 138.055 137.51 139.908 136.746 C 141.747 135.979 143.746 136.217 145.368 137.406 L 147.958 139.304 C 149.242 140.247 151.039 140.117 152.173 138.979 L 158.007 133.145 C 159.109 132.044 159.275 130.315 158.4 129.028 L 155.947 125.398 C 154.845 123.776 154.663 121.816 155.446 120.019 C 156.233 118.222 157.793 117.016 159.733 116.72 L 164.244 116.025 C 165.83 115.781 167 114.416 167 112.81 L 167 104.562 C 167 103.005 165.895 101.666 164.364 101.37 Z M 128 124.25 C 119.04 124.25 111.75 116.96 111.75 108 C 111.75 99.04 119.04 91.75 128 91.75 C 136.96 91.75 144.25 99.04 144.25 108 C 144.25 116.96 136.96 124.25 128 124.25 Z " fill="rgb(200,189,184)"/></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 256 256" width="256" height="256"><defs><clipPath id="_clipPath_D4Sj8YJrR0Fd21hr8GyZRcCPEVvEhAsA"><rect width="256" height="256"/></clipPath></defs><g clip-path="url(#_clipPath_D4Sj8YJrR0Fd21hr8GyZRcCPEVvEhAsA)"><rect width="256" height="256" style="fill:rgb(0,0,0)" fill-opacity="0"/><g><g><path d=" M 156.239 40 L 65.027 40 C 62.487 40 60.429 42.059 60.429 46.053 L 60.429 212.857 C 60.429 213.941 62.487 216 65.027 216 L 190.973 216 C 193.513 216 195.571 213.941 195.571 212.857 L 195.571 80.788 C 195.571 78.601 195.279 77.897 194.764 77.378 L 158.193 40.808 C 157.675 40.292 156.971 40 156.239 40 Z " fill="rgb(233,233,224)"/><path d=" M 157.857 40.475 L 157.857 77.714 L 195.097 77.714 L 157.857 40.475 Z " fill="rgb(217,215,202)"/><path d=" M 190.973 216 L 65.027 216 C 62.487 216 60.429 213.941 60.429 211.402 L 60.429 162.571 L 195.571 162.571 L 195.571 211.402 C 195.571 213.941 193.513 216 190.973 216 Z " fill="rgb(134,151,203)"/><rect x="79.286" y="80.857" width="47.143" height="47.347" transform="matrix(1,0,0,1,0,0)" fill="rgb(200,189,184)"/><rect x="148.429" y="84" width="6.286" height="22" transform="matrix(1,0,0,1,0,0)" fill="rgb(173,162,158)"/><rect x="148.429" y="131.143" width="6.286" height="22" transform="matrix(1,0,0,1,0,0)" fill="rgb(173,162,158)"/><path d=" M 167.286 134.286 L 135.857 134.286 L 135.857 102.857 L 167.286 102.857 L 167.286 134.286 Z M 142.143 128 L 161 128 L 161 109.143 L 142.143 109.143 L 142.143 128 Z " fill="rgb(173,162,158)"/><rect x="164.143" y="115.429" width="22" height="6.286" transform="matrix(1,0,0,1,0,0)" fill="rgb(173,162,158)"/><rect x="117" y="115.429" width="22" height="6.286" transform="matrix(1,0,0,1,0,0)" fill="rgb(173,162,158)"/></g></g><path d=" M 92.415 203.746 L 83.166 203.746 L 83.166 173.887 L 92.354 173.887 L 92.354 173.887 Q 96.291 173.887 99.398 175.661 L 99.398 175.661 L 99.398 175.661 Q 102.505 177.435 104.248 180.706 L 104.248 180.706 L 104.248 180.706 Q 105.991 183.977 105.991 188.14 L 105.991 188.14 L 105.991 189.514 L 105.991 189.514 Q 105.991 193.677 104.279 196.917 L 104.279 196.917 L 104.279 196.917 Q 102.566 200.157 99.449 201.941 L 99.449 201.941 L 99.449 201.941 Q 96.332 203.726 92.415 203.746 L 92.415 203.746 L 92.415 203.746 Z M 92.354 178.87 L 89.318 178.87 L 89.318 198.804 L 92.292 198.804 L 92.292 198.804 Q 95.901 198.804 97.809 196.445 L 97.809 196.445 L 97.809 196.445 Q 99.716 194.087 99.757 189.698 L 99.757 189.698 L 99.757 188.119 L 99.757 188.119 Q 99.757 183.566 97.87 181.218 L 97.87 181.218 L 97.87 181.218 Q 95.983 178.87 92.354 178.87 L 92.354 178.87 L 92.354 178.87 Z M 128.816 173.887 L 133.718 195.133 L 137.737 173.887 L 143.869 173.887 L 137.245 203.746 L 131.052 203.746 L 126.191 183.771 L 121.331 203.746 L 115.138 203.746 L 108.514 173.887 L 114.646 173.887 L 118.686 195.092 L 123.607 173.887 L 128.816 173.887 L 128.816 173.887 Z M 170.816 188.058 L 170.816 199.973 L 170.816 199.973 Q 169.155 201.962 166.12 203.059 L 166.12 203.059 L 166.12 203.059 Q 163.085 204.156 159.394 204.156 L 159.394 204.156 L 159.394 204.156 Q 155.518 204.156 152.595 202.464 L 152.595 202.464 L 152.595 202.464 Q 149.673 200.772 148.083 197.553 L 148.083 197.553 L 148.083 197.553 Q 146.494 194.333 146.453 189.985 L 146.453 189.985 L 146.453 187.955 L 146.453 187.955 Q 146.453 183.484 147.96 180.213 L 147.96 180.213 L 147.96 180.213 Q 149.468 176.942 152.308 175.209 L 152.308 175.209 L 152.308 175.209 Q 155.148 173.477 158.963 173.477 L 158.963 173.477 L 158.963 173.477 Q 164.274 173.477 167.269 176.009 L 167.269 176.009 L 167.269 176.009 Q 170.263 178.542 170.816 183.382 L 170.816 183.382 L 164.828 183.382 L 164.828 183.382 Q 164.418 180.818 163.013 179.629 L 163.013 179.629 L 163.013 179.629 Q 161.608 178.439 159.147 178.439 L 159.147 178.439 L 159.147 178.439 Q 156.01 178.439 154.369 180.798 L 154.369 180.798 L 154.369 180.798 Q 152.729 183.156 152.708 187.812 L 152.708 187.812 L 152.708 189.719 L 152.708 189.719 Q 152.708 194.415 154.492 196.814 L 154.492 196.814 L 154.492 196.814 Q 156.276 199.214 159.722 199.214 L 159.722 199.214 L 159.722 199.214 Q 163.188 199.214 164.664 197.737 L 164.664 197.737 L 164.664 192.59 L 159.065 192.59 L 159.065 188.058 L 170.816 188.058 L 170.816 188.058 Z " fill-rule="evenodd" fill="rgb(255,255,255)"/></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Some files were not shown because too many files have changed in this diff Show more