Compare commits

...

64 Commits

Author SHA1 Message Date
Roberto Rosario
754b84b4d7 Merge branch 'versions/minor' into features/workflow_context 2019-07-05 21:38:00 -04:00
Roberto Rosario
300bdbfc8a Tweak setup buttom border and tag shadows
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-05 21:34:20 -04:00
Roberto Rosario
a0331e0236 Add support for icon shadows
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-05 21:26:45 -04:00
Roberto Rosario
572690e2bc Finish workflow context implementation
Improve workflow instance detail view.
Add workflow transition field widget support.
Fix workflow transition field required support.
Update tests.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-01 09:55:58 -04:00
Roberto Rosario
303e34299a Add a JSON and YAML validator to the common app
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-01 09:41:45 -04:00
Roberto Rosario
c628de9ede Improve appearance of the object error list view
Add icon to the object error list link.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-01 09:41:06 -04:00
Roberto Rosario
e73be6bbab Don't error out if the settings are set to blank
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-01 01:12:31 -04:00
Roberto Rosario
c9fd8b02e3 Add field type selection
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-07-01 01:12:02 -04:00
Roberto Rosario
e1a63064dc Proof of concept of the workflow instance context
Add support for workflow instance JSON context.
Add support for two step workflow transition.
Add support for dynamic form creation for transition execution.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-30 09:51:22 -04:00
Roberto Rosario
42db8255d1 Merge branch 'versions/minor' into features/workflow_context
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-29 20:35:25 -04:00
Roberto Rosario
14d45cbe90 Use polylines for the edge splines
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-28 15:48:44 -04:00
Roberto Rosario
75be11bc96 Hightlight initial state
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-28 15:35:33 -04:00
Roberto Rosario
ebf29d0eed Add actions to workflow preview
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-28 15:35:27 -04:00
Roberto Rosario
a391d27b44 Add transition form comment help text
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-28 14:33:37 -04:00
Roberto Rosario
753c9b8b4b Merge branch 'versions/minor' into features/workflow_context 2019-06-28 14:08:58 -04:00
Roberto Rosario
744bfefa5c Add workflow email action template support
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 12:10:31 -04:00
Roberto Rosario
850fb16c8c Add automatic execution test
Add test for automatic email action execution on document upload.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 11:51:21 -04:00
Roberto Rosario
72ba805fbb Add test case database connection check
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 11:35:58 -04:00
Roberto Rosario
3d7b40f029 Add email action tests
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 09:54:48 -04:00
Roberto Rosario
2039a9f13b Merge branch 'clients/bc' into features/workflow_email_action 2019-06-27 08:45:27 -04:00
Roberto Rosario
bb8f12dd7a Update CHANGES file
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 08:40:43 -04:00
Roberto Rosario
40ab1f3665 [FIX] Remove tag create document registration
Make no sense to have the tag create event register to existing tags.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 08:39:48 -04:00
Roberto Rosario
fdef757fd0 Add redactions app JavaScript dependencies
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 08:22:53 -04:00
Roberto Rosario
3608ee1141 Remove included cropper.js files
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 08:17:50 -04:00
Roberto Rosario
7fb3d61dff [Fix] Change to relative imports
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 07:03:55 -04:00
Roberto Rosario
e9aa11673b Initial commit of the workflow mail action
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-27 07:03:31 -04:00
Roberto Rosario
03a7aa5daf Add missing migrations
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 15:04:30 -04:00
Roberto Rosario
755f20c5c4 Fix importer logging
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 14:20:00 -04:00
Roberto Rosario
64772e2e90 Update changes file
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 14:18:29 -04:00
Roberto Rosario
75a4a426e0 Remove duplicated trashed document preview
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 14:18:11 -04:00
Roberto Rosario
42a7ebeea2 Finish redactions app
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 14:16:11 -04:00
Roberto Rosario
3d22f48555 Add draw box by percentage
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 00:13:20 -04:00
Roberto Rosario
488e048d8f Remove old remarks and add redirect
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 00:12:41 -04:00
Roberto Rosario
2f82559a5c Add verbose name for the Redaction model
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 00:12:08 -04:00
Roberto Rosario
7d5b7b9fc4 Fix static media folder
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-26 00:11:52 -04:00
Roberto Rosario
7aa68b8bbf Initial commit of the redactions app
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 12:13:49 -04:00
Roberto Rosario
aecde926f2 Fix varaible typo
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 12:08:25 -04:00
Roberto Rosario
6b95628e56 Add rectangle drawing transformation
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 10:23:30 -04:00
Roberto Rosario
56a1b97b46 Update changes file
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 09:17:01 -04:00
Roberto Rosario
34a5a54c8b Add sortable index instance label column
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 09:15:52 -04:00
Roberto Rosario
0c17ab3f8a Improve source column exclusion
Improve for model subclasses in partial querysets.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 09:08:02 -04:00
Roberto Rosario
c967a25f82 Support exclusions from source columns
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-25 00:16:29 -04:00
Roberto Rosario
7562588c42 Fix typo
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 23:55:02 -04:00
Roberto Rosario
a1a706b7b9 Add link to sort individual indexes
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 23:50:01 -04:00
Roberto Rosario
d623cb2df5 Sort function
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 23:04:49 -04:00
Roberto Rosario
488ddcf1e1 Rename CHANGES file
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 23:04:07 -04:00
Roberto Rosario
3d39893f17 Add columns to show document count per workflow
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 23:03:06 -04:00
Roberto Rosario
3694839d97 Use Select2 for the document type selection form
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 19:29:12 -04:00
Roberto Rosario
cce27aceca Allow client builds
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 16:42:59 -04:00
Roberto Rosario
c73d251370 Generate metadata by name not label
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 16:32:14 -04:00
Roberto Rosario
091f0d1cfd Generate new metadata when label is ambiguous
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-24 16:20:56 -04:00
Roberto Rosario
d2affdcf21 Merge branch 'feature/document_importer' into nightly
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-21 17:39:19 -04:00
Roberto Rosario
885d430b98 Merge branch 'versions/minor' into nightly 2019-06-21 17:38:08 -04:00
Roberto Rosario
39eabe1c54 Associate metadata to all types
Previously metadata types were associated to documents types
if the metadata type was newly created.

Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-21 17:37:00 -04:00
Roberto Rosario
f6ad579829 Merge branch 'versions/minor' into nightly
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-21 12:05:19 -04:00
Roberto Rosario
6fc9e46882 Merge branch 'versions/minor' into feature/document_importer 2019-06-21 11:53:09 -04:00
Roberto Rosario
2d326a679d Merge branch 'master' into feature/document_importer 2019-06-21 11:53:03 -04:00
Roberto Rosario
aa8c2db446 Merge branch 'master' into feature/document_importer
Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
2019-06-21 00:06:49 -04:00
Roberto Rosario
925b55d76d Support ignoring certain rows
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2019-06-20 10:12:53 -04:00
Roberto Rosario
5808d3653d Add support for ignoring import errors
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2019-06-20 10:05:24 -04:00
Roberto Rosario
bc072f7b7e Add column mapping support
Add support for specifying metadata columns.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2019-06-19 17:47:32 -04:00
Roberto Rosario
b3d59eee39 Add MVP of the importer app
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2019-06-19 16:02:00 -04:00
Roberto Rosario
7d379a52af Add a reusable task to upload documents
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2019-06-19 16:00:59 -04:00
Roberto Rosario
499ab1f3e7 Allow disabling the random primary key test mixin
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2019-06-19 15:59:15 -04:00
79 changed files with 2874 additions and 268 deletions

View File

@@ -63,6 +63,7 @@ job_docker_nightly:
only: only:
- nightly - nightly
- staging - staging
- /^clients\/.+$/
job_documentation_build: job_documentation_build:
stage: build_documentation stage: build_documentation
@@ -160,6 +161,7 @@ job_push_python:
- releases/python - releases/python
- staging - staging
- nightly - nightly
- /^clients\/.+$/
test-mysql: test-mysql:
<<: *test_base <<: *test_base

14
CHANGES_BC.rst Normal file
View File

@@ -0,0 +1,14 @@
- Use Select2 widget for the document type selection form.
- Update source column matching to be additive and not exclusive.
- Add two columns to show the number of documents per workflow and
workflow state.
- Sort module.
- Add link to sort individual indexes.
- Support exclusions from source columns.
- Improve source column exclusion. Improve for model subclasses in partial querysets.
- Add sortable index instance label column.
- Add rectangle drawing transformation.
- Redactions app.
- Remove duplicated trashed document preview.
- Add label to trashed date and time document source column.
- Tag created event fix.

View File

@@ -1,3 +1,7 @@
3.3 (2019-XX-XX)
================
* Add support for icon shadows.
3.2.5 (2019-07-05) 3.2.5 (2019-07-05)
================== ==================
* Don't error out if the EXTRA_APPS or the DISABLED_APPS settings * Don't error out if the EXTRA_APPS or the DISABLED_APPS settings
@@ -46,6 +50,8 @@
================== ==================
* Add support for disabling the random primary key * Add support for disabling the random primary key
test mixin. test mixin.
* Add a reusable task to upload documents.
* Add MVP of the importer app.
* Fix mailing profile log columns mappings. * Fix mailing profile log columns mappings.
GitLab issue #626. Thanks to Jesaja Everling (@jeverling) GitLab issue #626. Thanks to Jesaja Everling (@jeverling)
for the report. for the report.

View File

@@ -19,7 +19,6 @@ Changes
GitLab issue #625. Thanks to Jesaja Everling (@jeverling) GitLab issue #625. Thanks to Jesaja Everling (@jeverling)
for the report and the research. for the report and the research.
Removals Removals
-------- --------

101
docs/releases/3.3.rst Normal file
View File

@@ -0,0 +1,101 @@
Version 3.3
===========
Released: XX XX, 2019
Changes
-------
- Add support for icon shadows.
Removals
--------
- None
Upgrading from a previous version
---------------------------------
If installed via Python's PIP
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Remove deprecated requirements::
$ curl https://gitlab.com/mayan-edms/mayan-edms/raw/master/removals.txt | pip uninstall -r /dev/stdin
Type in the console::
$ pip install mayan-edms==3.3
the requirements will also be updated automatically.
Using Git
^^^^^^^^^
If you installed Mayan EDMS by cloning the Git repository issue the commands::
$ git reset --hard HEAD
$ git pull
otherwise download the compressed archived and uncompress it overriding the
existing installation.
Remove deprecated requirements::
$ pip uninstall -y -r removals.txt
Next upgrade/add the new requirements::
$ pip install --upgrade -r requirements.txt
Common steps
^^^^^^^^^^^^
Perform these steps after updating the code from either step above.
Make a backup of your supervisord file::
sudo cp /etc/supervisor/conf.d/mayan.conf /etc/supervisor/conf.d/mayan.conf.bck
Update the supervisord configuration file. Replace the environment
variables values show here with your respective settings. This step will refresh
the supervisord configuration file with the new queues and the latest
recommended layout::
sudo MAYAN_DATABASE_ENGINE=django.db.backends.postgresql MAYAN_DATABASE_NAME=mayan \
MAYAN_DATABASE_PASSWORD=mayanuserpass MAYAN_DATABASE_USER=mayan \
MAYAN_DATABASE_HOST=127.0.0.1 MAYAN_MEDIA_ROOT=/opt/mayan-edms/media \
/opt/mayan-edms/bin/mayan-edms.py platformtemplate supervisord > /etc/supervisor/conf.d/mayan.conf
Edit the supervisord configuration file and update any setting the template
generator missed::
sudo vi /etc/supervisor/conf.d/mayan.conf
Migrate existing database schema with::
$ mayan-edms.py performupgrade
Add new static media::
$ mayan-edms.py preparestatic --noinput
The upgrade procedure is now complete.
Backward incompatible changes
-----------------------------
- None
Bugs fixed or issues closed
---------------------------
- :gitlab-issue:`XX`
.. _PyPI: https://pypi.python.org/pypi/mayan-edms/

View File

@@ -20,6 +20,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
3.3
3.2.5 3.2.5
3.2.4 3.2.4
3.2.3 3.2.3

View File

@@ -4,6 +4,7 @@ from django.template.loader import get_template
class IconDriver(object): class IconDriver(object):
context = {}
_registry = {} _registry = {}
@classmethod @classmethod
@@ -14,6 +15,17 @@ class IconDriver(object):
def register(cls, driver_class): def register(cls, driver_class):
cls._registry[driver_class.name] = driver_class cls._registry[driver_class.name] = driver_class
def get_context(self):
return self.context
def render(self, extra_context=None):
context = self.get_context()
if extra_context:
context.update(extra_context)
return get_template(template_name=self.template_name).render(
context=context
)
class FontAwesomeDriver(IconDriver): class FontAwesomeDriver(IconDriver):
name = 'fontawesome' name = 'fontawesome'
@@ -22,10 +34,8 @@ class FontAwesomeDriver(IconDriver):
def __init__(self, symbol): def __init__(self, symbol):
self.symbol = symbol self.symbol = symbol
def render(self): def get_context(self):
return get_template(template_name=self.template_name).render( return {'symbol': self.symbol}
context={'symbol': self.symbol}
)
class FontAwesomeDualDriver(IconDriver): class FontAwesomeDualDriver(IconDriver):
@@ -36,9 +46,8 @@ class FontAwesomeDualDriver(IconDriver):
self.primary_symbol = primary_symbol self.primary_symbol = primary_symbol
self.secondary_symbol = secondary_symbol self.secondary_symbol = secondary_symbol
def render(self): def get_context(self):
return get_template(template_name=self.template_name).render( return {
context={
'data': ( 'data': (
{ {
'class': 'fas fa-circle', 'class': 'fas fa-circle',
@@ -52,7 +61,6 @@ class FontAwesomeDualDriver(IconDriver):
}, },
) )
} }
)
class FontAwesomeCSSDriver(IconDriver): class FontAwesomeCSSDriver(IconDriver):
@@ -62,10 +70,8 @@ class FontAwesomeCSSDriver(IconDriver):
def __init__(self, css_classes): def __init__(self, css_classes):
self.css_classes = css_classes self.css_classes = css_classes
def render(self): def get_context(self):
return get_template(template_name=self.template_name).render( return {'css_classes': self.css_classes}
context={'css_classes': self.css_classes}
)
class FontAwesomeMasksDriver(IconDriver): class FontAwesomeMasksDriver(IconDriver):
@@ -75,23 +81,23 @@ class FontAwesomeMasksDriver(IconDriver):
def __init__(self, data): def __init__(self, data):
self.data = data self.data = data
def render(self): def get_context(self):
return get_template(template_name=self.template_name).render( return {'data': self.data}
context={'data': self.data}
)
class FontAwesomeLayersDriver(IconDriver): class FontAwesomeLayersDriver(IconDriver):
name = 'fontawesome-layers' name = 'fontawesome-layers'
template_name = 'appearance/icons/font_awesome_layers.html' template_name = 'appearance/icons/font_awesome_layers.html'
def __init__(self, data): def __init__(self, data, shadow_class=None):
self.data = data self.data = data
self.shadow_class = shadow_class
def render(self): def get_context(self):
return get_template(template_name=self.template_name).render( return {
context={'data': self.data} 'data': self.data,
) 'shadow_class': self.shadow_class,
}
class Icon(object): class Icon(object):

View File

@@ -70,7 +70,8 @@ img.lazy-load-carousel {
} }
.label-tag { .label-tag {
text-shadow: 0px 0px 2px #000 text-shadow: 0px 0px 2px #000;
box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.5);
} }
.fancybox-nav span { .fancybox-nav span {
@@ -88,21 +89,23 @@ hr {
} }
.btn-block { .btn-block {
border-top: 2px solid rgba(255, 255, 255, 0.7);
border-left: 2px solid rgba(255, 255, 255, 0.7);
border-right: 2px solid rgba(0, 0, 0, 0.7);
border-bottom: 2px solid rgba(0, 0, 0, 0.7);
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5);
margin-bottom: 15px; margin-bottom: 15px;
white-space: normal;
min-height: 120px; min-height: 120px;
padding-top: 20px;
padding-bottom: 1px; padding-bottom: 1px;
padding-top: 20px;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
white-space: normal;
} }
.btn-block .fa { .btn-block .fa {
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3); text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
} }
.btn-block {
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
}
.radio ul li { .radio ul li {
list-style-type:none; list-style-type:none;
} }

View File

@@ -1,4 +1,7 @@
<span class="fa-layers fa-fw" style="margin-right: 7px;"> <span class="fa-layers fa-fw" style="margin-right: 7px;">
{% if enable_shadow %}
<i class="{{ shadow_class }}" data-fa-transform="right-1 down-2" style="color:rgba(0, 0, 0, 0.3); stroke: rgba(255, 255, 255, 0.3); stroke-width: 20;"></i>
{% endif %}
{% for entry in data %} {% for entry in data %}
<i class="{{ entry.class }}" data-fa-transform="{{ entry.transform }}" data-fa-mask="{{ entry.mask }}"></i> <i class="{{ entry.class }}" data-fa-transform="{{ entry.transform }}" data-fa-mask="{{ entry.mask }}"></i>
{% endfor %} {% endfor %}

View File

@@ -1 +1,8 @@
{% if enable_shadow %}
<span class="fa-layers fa-fw" >
<i class="fa fa-{{ symbol }}" data-fa-transform="right-1 down-2" style="color:rgba(0, 0, 0, 0.3);stroke: rgba(255, 255, 255, 0.3); stroke-width: 20;"></i>
<i class="fa fa-{{ symbol }}"></i>
</span>
{% else %}
<i class="fa fa-{{ symbol }}" style="padding-right: 5px; width: auto;"></i> <i class="fa fa-{{ symbol }}" style="padding-right: 5px; width: auto;"></i>
{% endif %}

View File

@@ -7,6 +7,11 @@ from django.utils.translation import ugettext_lazy as _
register = Library() register = Library()
@register.simple_tag
def appearance_icon_render(icon_class, enable_shadow=False):
return icon_class.render(extra_context={'enable_shadow': enable_shadow})
@register.filter @register.filter
def get_choice_value(field): def get_choice_value(field):
try: try:

View File

@@ -35,8 +35,11 @@ icon_menu_about = Icon(
icon_menu_user = Icon( icon_menu_user = Icon(
driver_name='fontawesome', symbol='user-circle' driver_name='fontawesome', symbol='user-circle'
) )
icon_object_error_list_with_icon = Icon( icon_object_errors = Icon(
driver_name='fontawesome', symbol='lock' driver_name='fontawesome', symbol='exclamation-triangle'
)
icon_object_error_list = Icon(
driver_name='fontawesome', symbol='exclamation-triangle'
) )
icon_ok = Icon( icon_ok = Icon(
driver_name='fontawesome', symbol='check' driver_name='fontawesome', symbol='check'

View File

@@ -8,8 +8,8 @@ from mayan.apps.navigation.classes import Link
from .icons import ( from .icons import (
icon_about, icon_current_user_locale_profile_details, icon_about, icon_current_user_locale_profile_details,
icon_current_user_locale_profile_edit, icon_documentation, icon_current_user_locale_profile_edit, icon_documentation,
icon_forum, icon_license, icon_object_error_list_with_icon, icon_forum, icon_license, icon_setup, icon_source_code, icon_support,
icon_setup, icon_source_code, icon_support, icon_tools icon_tools
) )
from .permissions_runtime import permission_error_log_view from .permissions_runtime import permission_error_log_view
@@ -51,6 +51,7 @@ link_documentation = Link(
) )
link_object_error_list = Link( link_object_error_list = Link(
kwargs=get_kwargs_factory('resolved_object'), kwargs=get_kwargs_factory('resolved_object'),
icon_class_path='mayan.apps.common.icons.icon_object_error_list',
permissions=(permission_error_log_view,), text=_('Errors'), permissions=(permission_error_log_view,), text=_('Errors'),
view='common:object_error_list', view='common:object_error_list',
) )
@@ -59,12 +60,6 @@ link_object_error_list_clear = Link(
permissions=(permission_error_log_view,), text=_('Clear all'), permissions=(permission_error_log_view,), text=_('Clear all'),
view='common:object_error_list_clear', view='common:object_error_list_clear',
) )
link_object_error_list_with_icon = Link(
kwargs=get_kwargs_factory('resolved_object'),
icon_class=icon_object_error_list_with_icon,
permissions=(permission_error_log_view,), text=_('Errors'),
view='common:error_list',
)
link_forum = Link( link_forum = Link(
icon_class=icon_forum, tags='new_window', text=_('Forum'), icon_class=icon_forum, tags='new_window', text=_('Forum'),
url='https://forum.mayan-edms.com' url='https://forum.mayan-edms.com'

View File

@@ -1,9 +1,18 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import json
import re import re
import yaml
try:
from yaml import CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeLoader
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.utils import six from django.utils import six
from django.utils.deconstruct import deconstructible
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -23,6 +32,54 @@ def _lazy_re_compile(regex, flags=0):
return SimpleLazyObject(_compile) return SimpleLazyObject(_compile)
@deconstructible
class JSONValidator(object):
"""
Validates that the input is JSON compliant.
"""
def __call__(self, value):
value = value.strip()
try:
json.loads(stream=value)
except ValueError:
raise ValidationError(
_('Enter a valid JSON value.'),
code='invalid'
)
def __eq__(self, other):
return (
isinstance(other, JSONValidator)
)
def __ne__(self, other):
return not (self == other)
@deconstructible
class YAMLValidator(object):
"""
Validates that the input is YAML compliant.
"""
def __call__(self, value):
value = value.strip()
try:
yaml.load(stream=value, Loader=SafeLoader)
except yaml.error.YAMLError:
raise ValidationError(
_('Enter a valid YAML value.'),
code='invalid'
)
def __eq__(self, other):
return (
isinstance(other, YAMLValidator)
)
def __ne__(self, other):
return not (self == other)
internal_name_re = _lazy_re_compile(r'^[a-zA-Z0-9_]+\Z') internal_name_re = _lazy_re_compile(r'^[a-zA-Z0-9_]+\Z')
validate_internal_name = RegexValidator( validate_internal_name = RegexValidator(
internal_name_re, _( internal_name_re, _(

View File

@@ -21,7 +21,7 @@ from .forms import (
from .generics import ( from .generics import (
ConfirmView, SingleObjectEditView, SingleObjectListView, SimpleView ConfirmView, SingleObjectEditView, SingleObjectListView, SimpleView
) )
from .icons import icon_setup from .icons import icon_object_errors, icon_setup
from .menus import menu_tools, menu_setup from .menus import menu_tools, menu_setup
from .permissions_runtime import permission_error_log_view from .permissions_runtime import permission_error_log_view
from .settings import setting_home_view from .settings import setting_home_view
@@ -155,6 +155,14 @@ class ObjectErrorLogEntryListView(SingleObjectListView):
{'name': _('Result'), 'attribute': 'result'}, {'name': _('Result'), 'attribute': 'result'},
), ),
'hide_object': True, 'hide_object': True,
'no_results_icon': icon_object_errors,
'no_results_text': _(
'This view displays the error log of different object. '
'An empty list is a good thing.'
),
'no_results_title': _(
'There are no error log entries'
),
'object': self.get_object(), 'object': self.get_object(),
'title': _('Error log entries for: %s' % self.get_object()), 'title': _('Error log entries for: %s' % self.get_object()),
} }

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-06-26 19:04
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('converter', '0013_auto_20180823_2353'),
]
operations = [
migrations.AlterField(
model_name='transformation',
name='name',
field=models.CharField(choices=[('crop', 'Crop: left, top, right, bottom'), ('draw_rectangle', 'Draw rectangle: left, top, right, bottom, fillcolor, outlinecolor, outlinewidth'), ('draw_rectangle_percent', 'Draw rectangle (percents coordinates): left, top, right, bottom, fillcolor, outlinecolor, outlinewidth'), ('flip', 'Flip'), ('gaussianblur', 'Gaussian blur: radius'), ('lineart', 'Line art'), ('mirror', 'Mirror'), ('resize', 'Resize: width, height'), ('rotate', 'Rotate: degrees, fillcolor'), ('rotate180', 'Rotate 180 degrees'), ('rotate270', 'Rotate 270 degrees'), ('rotate90', 'Rotate 90 degrees'), ('unsharpmask', 'Unsharp masking: radius, percent, threshold'), ('zoom', 'Zoom: percent')], max_length=128, verbose_name='Name'),
),
]

View File

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
import hashlib import hashlib
import logging import logging
from PIL import Image, ImageColor, ImageFilter from PIL import Image, ImageColor, ImageDraw, ImageFilter
from django.utils.translation import string_concat, ugettext_lazy as _ from django.utils.translation import string_concat, ugettext_lazy as _
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
@@ -151,6 +151,214 @@ class TransformationCrop(BaseTransformation):
return self.image.crop((left, top, right, bottom)) return self.image.crop((left, top, right, bottom))
class TransformationDrawRectangle(BaseTransformation):
arguments = (
'left', 'top', 'right', 'bottom', 'fillcolor', 'outlinecolor',
'outlinewidth'
)
label = _('Draw rectangle')
name = 'draw_rectangle'
def execute_on(self, *args, **kwargs):
super(TransformationDrawRectangle, self).execute_on(*args, **kwargs)
try:
left = int(self.left or '0')
except ValueError:
left = 0
try:
top = int(self.top or '0')
except ValueError:
top = 0
try:
right = int(self.right or '0')
except ValueError:
right = 0
try:
bottom = int(self.bottom or '0')
except ValueError:
bottom = 0
if left < 0:
left = 0
if left > self.image.size[0] - 1:
left = self.image.size[0] - 1
if top < 0:
top = 0
if top > self.image.size[1] - 1:
top = self.image.size[1] - 1
if right < 0:
right = 0
if right > self.image.size[0] - 1:
right = self.image.size[0] - 1
if bottom < 0:
bottom = 0
if bottom > self.image.size[1] - 1:
bottom = self.image.size[1] - 1
# Invert right value
# Pillow uses left, top, right, bottom to define a viewport
# of real coordinates
# We invert the right and bottom to define a viewport
# that can crop from the right and bottom borders without
# having to know the real dimensions of an image
right = self.image.size[0] - right
bottom = self.image.size[1] - bottom
if left > right:
left = right - 1
if top > bottom:
top = bottom - 1
logger.debug(
'left: %f, top: %f, right: %f, bottom: %f', left, top, right,
bottom
)
fillcolor_value = getattr(self, 'fillcolor', None)
if fillcolor_value:
fill_color = ImageColor.getrgb(fillcolor_value)
else:
fill_color = 0
outlinecolor_value = getattr(self, 'outlinecolor', None)
if outlinecolor_value:
outline_color = ImageColor.getrgb(outlinecolor_value)
else:
outline_color = None
outlinewidth_value = getattr(self, 'outlinewidth', None)
if outlinewidth_value:
outline_width = int(outlinewidth_value)
else:
outline_width = 0
draw = ImageDraw.Draw(self.image)
draw.rectangle(
(left, top, right, bottom), fill=fill_color, outline=outline_color,
width=outline_width
)
return self.image
class TransformationDrawRectanglePercent(BaseTransformation):
arguments = (
'left', 'top', 'right', 'bottom', 'fillcolor', 'outlinecolor',
'outlinewidth'
)
label = _('Draw rectangle (percents coordinates)')
name = 'draw_rectangle_percent'
def execute_on(self, *args, **kwargs):
super(TransformationDrawRectanglePercent, self).execute_on(*args, **kwargs)
try:
left = float(self.left or '0')
except ValueError:
left = 0
try:
top = float(self.top or '0')
except ValueError:
top = 0
try:
right = float(self.right or '0')
except ValueError:
right = 0
try:
bottom = float(self.bottom or '0')
except ValueError:
bottom = 0
if left < 0:
left = 0
if left > 100:
left = 100
if top < 0:
top = 0
if top > 100:
top = 100
if right < 0:
right = 0
if right > 100:
right = 100
if bottom < 0:
bottom = 0
if bottom > 100:
bottom = 100
#if left > right:
# left, right = right, left
#if top > bottom:
# top, bottom = bottom, top
logger.debug(
'left: %f, top: %f, right: %f, bottom: %f', left, top, right,
bottom
)
fillcolor_value = getattr(self, 'fillcolor', None)
if fillcolor_value:
fill_color = ImageColor.getrgb(fillcolor_value)
else:
fill_color = 0
outlinecolor_value = getattr(self, 'outlinecolor', None)
if outlinecolor_value:
outline_color = ImageColor.getrgb(outlinecolor_value)
else:
outline_color = None
outlinewidth_value = getattr(self, 'outlinewidth', None)
if outlinewidth_value:
outline_width = int(outlinewidth_value)
else:
outline_width = 0
left = left / 100.0 * self.image.size[0]
top = top / 100.0 * self.image.size[1]
# Invert right value
# Pillow uses left, top, right, bottom to define a viewport
# of real coordinates
# We invert the right and bottom to define a viewport
# that can crop from the right and bottom borders without
# having to know the real dimensions of an image
right = self.image.size[0] - (right / 100.0 * self.image.size[0])
bottom = self.image.size[1] - (bottom / 100.0 * self.image.size[1])
draw = ImageDraw.Draw(self.image)
draw.rectangle(
(left, top, right, bottom), fill=fill_color, outline=outline_color,
width=outline_width
)
return self.image
class TransformationFlip(BaseTransformation): class TransformationFlip(BaseTransformation):
arguments = () arguments = ()
label = _('Flip') label = _('Flip')
@@ -316,6 +524,8 @@ class TransformationZoom(BaseTransformation):
BaseTransformation.register(transformation=TransformationCrop) BaseTransformation.register(transformation=TransformationCrop)
BaseTransformation.register(transformation=TransformationDrawRectangle)
BaseTransformation.register(transformation=TransformationDrawRectanglePercent)
BaseTransformation.register(transformation=TransformationFlip) BaseTransformation.register(transformation=TransformationFlip)
BaseTransformation.register(transformation=TransformationGaussianBlur) BaseTransformation.register(transformation=TransformationGaussianBlur)
BaseTransformation.register(transformation=TransformationLineArt) BaseTransformation.register(transformation=TransformationLineArt)

View File

@@ -31,9 +31,10 @@ from .html_widgets import (
) )
from .links import ( from .links import (
link_document_index_instance_list, link_document_type_index_templates, link_document_index_instance_list, link_document_type_index_templates,
link_index_instance_menu, link_index_template_setup, link_index_instance_menu, link_index_instance_rebuild,
link_index_template_create, link_index_template_document_types, link_index_template_setup, link_index_template_create,
link_index_template_delete, link_index_template_edit, link_index_template_list, link_index_template_document_types, link_index_template_delete,
link_index_template_edit, link_index_template_list,
link_index_template_node_tree_view, link_index_instances_rebuild, link_index_template_node_tree_view, link_index_instances_rebuild,
link_index_template_node_create, link_index_template_node_delete, link_index_template_node_create, link_index_template_node_delete,
link_index_template_node_edit link_index_template_node_edit
@@ -101,15 +102,20 @@ class DocumentIndexingApp(MayanAppConfig):
) )
SourceColumn( SourceColumn(
attribute='label', is_identifier=True, is_sortable=True, attribute='label', exclude=(IndexInstance,), is_identifier=True,
is_sortable=True, source=Index
)
SourceColumn(
attribute='label', is_object_absolute_url=True, is_identifier=True,
is_sortable=True, source=IndexInstance
)
SourceColumn(
attribute='slug', exclude=(IndexInstance,), is_sortable=True,
source=Index source=Index
) )
SourceColumn( SourceColumn(
attribute='slug', is_sortable=True, source=Index attribute='enabled', exclude=(IndexInstance,), is_sortable=True,
) source=Index, widget=TwoStateWidget
SourceColumn(
attribute='enabled', is_sortable=True, source=Index,
widget=TwoStateWidget
) )
SourceColumn( SourceColumn(
@@ -192,6 +198,7 @@ class DocumentIndexingApp(MayanAppConfig):
menu_object.bind_links( menu_object.bind_links(
links=( links=(
link_index_template_delete, link_index_template_edit, link_index_template_delete, link_index_template_edit,
link_index_instance_rebuild
), sources=(Index,) ), sources=(Index,)
) )
menu_object.bind_links( menu_object.bind_links(

View File

@@ -49,6 +49,12 @@ link_index_instances_rebuild = Link(
), ),
text=_('Rebuild indexes'), view='indexing:rebuild_index_instances' text=_('Rebuild indexes'), view='indexing:rebuild_index_instances'
) )
link_index_instance_rebuild = Link(
args='resolved_object.pk',
icon_class_path='mayan.apps.document_indexing.icons.icon_index_instances_rebuild',
permissions=(permission_document_indexing_rebuild,),
text=_('Rebuild index'), view='indexing:index_setup_rebuild'
)
link_index_template_setup = Link( link_index_template_setup = Link(
condition=get_cascade_condition( condition=get_cascade_condition(

View File

@@ -50,3 +50,10 @@ class IndexViewTestMixin(object):
'label': TEST_INDEX_LABEL_EDITED, 'slug': TEST_INDEX_SLUG 'label': TEST_INDEX_LABEL_EDITED, 'slug': TEST_INDEX_SLUG
} }
) )
def _request_test_index_rebuild_view(self):
return self.post(
viewname='indexing:index_setup_rebuild', kwargs={
'pk': self.test_index.pk
}
)

View File

@@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals
from mayan.apps.documents.tests import GenericDocumentViewTestCase from mayan.apps.documents.tests import GenericDocumentViewTestCase
from ..models import Index from ..models import Index, IndexInstanceNode
from ..permissions import ( from ..permissions import (
permission_document_indexing_create, permission_document_indexing_delete, permission_document_indexing_create, permission_document_indexing_delete,
permission_document_indexing_edit, permission_document_indexing_edit,
@@ -10,7 +10,10 @@ from ..permissions import (
permission_document_indexing_rebuild permission_document_indexing_rebuild
) )
from .literals import TEST_INDEX_LABEL, TEST_INDEX_LABEL_EDITED from .literals import (
TEST_INDEX_LABEL, TEST_INDEX_LABEL_EDITED,
TEST_INDEX_TEMPLATE_DOCUMENT_LABEL_EXPRESSION
)
from .mixins import IndexTestMixin, IndexViewTestMixin from .mixins import IndexTestMixin, IndexViewTestMixin
@@ -100,27 +103,27 @@ class IndexViewTestCase(IndexTestMixin, IndexViewTestMixin, GenericDocumentViewT
) )
self.assertContains(response, text=TEST_INDEX_LABEL, status_code=200) self.assertContains(response, text=TEST_INDEX_LABEL, status_code=200)
def _request_index_rebuild_get_view(self): def _request_indexes_rebuild_get_view(self):
return self.get( return self.get(
viewname='indexing:rebuild_index_instances', viewname='indexing:rebuild_index_instances',
) )
def _request_index_rebuild_post_view(self): def _request_indexes_rebuild_post_view(self):
return self.post( return self.post(
viewname='indexing:rebuild_index_instances', data={ viewname='indexing:rebuild_index_instances', data={
'index_templates': self.test_index.pk 'index_templates': self.test_index.pk
} }
) )
def test_index_rebuild_no_permission(self): def test_indexes_rebuild_no_permission(self):
self._create_test_index(rebuild=False) self._create_test_index(rebuild=False)
response = self._request_index_rebuild_get_view() response = self._request_indexes_rebuild_get_view()
self.assertNotContains( self.assertNotContains(
response=response, text=self.test_index.label, status_code=200 response=response, text=self.test_index.label, status_code=200
) )
response = self._request_index_rebuild_post_view() response = self._request_indexes_rebuild_post_view()
# No error since we just don't see the index # No error since we just don't see the index
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -128,7 +131,7 @@ class IndexViewTestCase(IndexTestMixin, IndexViewTestMixin, GenericDocumentViewT
self.test_index.instance_root.get_children_count(), 0 self.test_index.instance_root.get_children_count(), 0
) )
def test_index_rebuild_with_access(self): def test_indexes_rebuild_with_access(self):
self._create_test_index(rebuild=False) self._create_test_index(rebuild=False)
self.grant_access( self.grant_access(
@@ -136,13 +139,46 @@ class IndexViewTestCase(IndexTestMixin, IndexViewTestMixin, GenericDocumentViewT
permission=permission_document_indexing_rebuild permission=permission_document_indexing_rebuild
) )
response = self._request_index_rebuild_get_view() response = self._request_indexes_rebuild_get_view()
self.assertContains( self.assertContains(
response=response, text=self.test_index.label, status_code=200 response=response, text=self.test_index.label, status_code=200
) )
response = self._request_index_rebuild_post_view() response = self._request_indexes_rebuild_post_view()
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# An instance root exists # An instance root exists
self.assertTrue(self.test_index.instance_root.pk) self.assertTrue(self.test_index.instance_root.pk)
def test_index_rebuild_view_no_permission(self):
self._create_test_index()
self.test_index.node_templates.create(
parent=self.test_index.template_root,
expression=TEST_INDEX_TEMPLATE_DOCUMENT_LABEL_EXPRESSION,
link_documents=True
)
response = self._request_test_index_rebuild_view()
self.assertEqual(response.status_code, 404)
self.assertEqual(IndexInstanceNode.objects.count(), 0)
def test_index_rebuild_view_with_access(self):
self._create_test_index()
self.test_index.node_templates.create(
parent=self.test_index.template_root,
expression=TEST_INDEX_TEMPLATE_DOCUMENT_LABEL_EXPRESSION,
link_documents=True
)
self.grant_access(
obj=self.test_index,
permission=permission_document_indexing_rebuild
)
response = self._request_test_index_rebuild_view()
self.assertEqual(response.status_code, 302)
self.assertNotEqual(IndexInstanceNode.objects.count(), 0)

View File

@@ -11,8 +11,8 @@ from .views import (
DocumentIndexNodeListView, DocumentTypeIndexesView, IndexInstanceNodeView, DocumentIndexNodeListView, DocumentTypeIndexesView, IndexInstanceNodeView,
IndexListView, IndexesRebuildView, SetupIndexDocumentTypesView, IndexListView, IndexesRebuildView, SetupIndexDocumentTypesView,
SetupIndexCreateView, SetupIndexDeleteView, SetupIndexEditView, SetupIndexCreateView, SetupIndexDeleteView, SetupIndexEditView,
SetupIndexListView, SetupIndexTreeTemplateListView, TemplateNodeCreateView, SetupIndexListView, SetupIndexRebuildView, SetupIndexTreeTemplateListView,
TemplateNodeDeleteView, TemplateNodeEditView TemplateNodeCreateView, TemplateNodeDeleteView, TemplateNodeEditView
) )
urlpatterns = [ urlpatterns = [
@@ -46,6 +46,10 @@ urlpatterns = [
view=SetupIndexDocumentTypesView.as_view(), view=SetupIndexDocumentTypesView.as_view(),
name='index_setup_document_types' name='index_setup_document_types'
), ),
url(
regex=r'^setup/index/(?P<pk>\d+)/rebuild/$',
view=SetupIndexRebuildView.as_view(), name='index_setup_rebuild'
),
url( url(
regex=r'^setup/template/node/(?P<pk>\d+)/create/child/$', regex=r'^setup/template/node/(?P<pk>\d+)/create/child/$',
view=TemplateNodeCreateView.as_view(), name='template_node_create' view=TemplateNodeCreateView.as_view(), name='template_node_create'

View File

@@ -9,8 +9,8 @@ from django.utils.translation import ugettext_lazy as _, ungettext
from mayan.apps.acls.models import AccessControlList from mayan.apps.acls.models import AccessControlList
from mayan.apps.common.generics import ( from mayan.apps.common.generics import (
AddRemoveView, FormView, SingleObjectCreateView, SingleObjectDeleteView, AddRemoveView, ConfirmView, FormView, SingleObjectCreateView,
SingleObjectEditView, SingleObjectListView SingleObjectDeleteView, SingleObjectEditView, SingleObjectListView
) )
from mayan.apps.documents.events import event_document_type_edited from mayan.apps.documents.events import event_document_type_edited
from mayan.apps.documents.models import Document, DocumentType from mayan.apps.documents.models import Document, DocumentType
@@ -32,7 +32,7 @@ from .permissions import (
permission_document_indexing_create, permission_document_indexing_delete, permission_document_indexing_create, permission_document_indexing_delete,
permission_document_indexing_edit, permission_document_indexing_edit,
permission_document_indexing_instance_view, permission_document_indexing_instance_view,
permission_document_indexing_view permission_document_indexing_rebuild, permission_document_indexing_view
) )
from .tasks import task_rebuild_index from .tasks import task_rebuild_index
@@ -150,6 +150,36 @@ class SetupIndexListView(SingleObjectListView):
} }
class SetupIndexRebuildView(ConfirmView):
post_action_redirect = reverse_lazy(
viewname='indexing:index_setup_list'
)
def get_extra_context(self):
return {
'object': self.get_object(),
'title': _('Rebuild index: %s') % self.get_object()
}
def get_object(self):
return get_object_or_404(klass=self.get_queryset(), pk=self.kwargs['pk'])
def get_queryset(self):
return AccessControlList.objects.restrict_queryset(
permission=permission_document_indexing_rebuild,
queryset=Index.objects.all(), user=self.request.user
)
def view_action(self):
task_rebuild_index.apply_async(
kwargs=dict(index_id=self.get_object().pk)
)
messages.success(
message='Index queued for rebuild.', request=self.request
)
class SetupIndexDocumentTypesView(AddRemoveView): class SetupIndexDocumentTypesView(AddRemoveView):
main_object_method_add = 'document_types_add' main_object_method_add = 'document_types_add'
main_object_method_remove = 'document_types_remove' main_object_method_remove = 'document_types_remove'
@@ -279,6 +309,7 @@ class IndexListView(SingleObjectListView):
def get_extra_context(self): def get_extra_context(self):
return { return {
'hide_links': True, 'hide_links': True,
'hide_object': True,
'no_results_icon': icon_index, 'no_results_icon': icon_index,
'no_results_main_link': link_index_template_create.resolve( 'no_results_main_link': link_index_template_create.resolve(
context=RequestContext(request=self.request) context=RequestContext(request=self.request)

View File

@@ -27,6 +27,7 @@ from .dependencies import * # NOQA
from .handlers import ( from .handlers import (
handler_index_document, handler_launch_workflow, handler_trigger_transition handler_index_document, handler_launch_workflow, handler_trigger_transition
) )
from .html_widgets import widget_transition_events, WorkflowLogExtraDataWidget
from .links import ( from .links import (
link_document_workflow_instance_list, link_setup_document_type_workflows, link_document_workflow_instance_list, link_setup_document_type_workflows,
link_setup_workflow_document_types, link_setup_workflow_create, link_setup_workflow_document_types, link_setup_workflow_create,
@@ -40,6 +41,10 @@ from .links import (
link_setup_workflow_state_edit, link_setup_workflow_transitions, link_setup_workflow_state_edit, link_setup_workflow_transitions,
link_setup_workflow_transition_create, link_setup_workflow_transition_create,
link_setup_workflow_transition_delete, link_setup_workflow_transition_edit, link_setup_workflow_transition_delete, link_setup_workflow_transition_edit,
link_setup_workflow_transition_field_create,
link_setup_workflow_transition_field_delete,
link_setup_workflow_transition_field_edit,
link_setup_workflow_transition_field_list,
link_tool_launch_all_workflows, link_workflow_instance_detail, link_tool_launch_all_workflows, link_workflow_instance_detail,
link_workflow_instance_transition, link_workflow_runtime_proxy_document_list, link_workflow_instance_transition, link_workflow_runtime_proxy_document_list,
link_workflow_runtime_proxy_list, link_workflow_preview, link_workflow_runtime_proxy_list, link_workflow_preview,
@@ -50,7 +55,6 @@ from .permissions import (
permission_workflow_delete, permission_workflow_edit, permission_workflow_delete, permission_workflow_edit,
permission_workflow_transition, permission_workflow_view permission_workflow_transition, permission_workflow_view
) )
from .widgets import widget_transition_events
class DocumentStatesApp(MayanAppConfig): class DocumentStatesApp(MayanAppConfig):
@@ -86,6 +90,7 @@ class DocumentStatesApp(MayanAppConfig):
WorkflowStateAction = self.get_model('WorkflowStateAction') WorkflowStateAction = self.get_model('WorkflowStateAction')
WorkflowStateRuntimeProxy = self.get_model('WorkflowStateRuntimeProxy') WorkflowStateRuntimeProxy = self.get_model('WorkflowStateRuntimeProxy')
WorkflowTransition = self.get_model('WorkflowTransition') WorkflowTransition = self.get_model('WorkflowTransition')
WorkflowTransitionField = self.get_model('WorkflowTransitionField')
WorkflowTransitionTriggerEvent = self.get_model( WorkflowTransitionTriggerEvent = self.get_model(
'WorkflowTransitionTriggerEvent' 'WorkflowTransitionTriggerEvent'
) )
@@ -152,6 +157,9 @@ class DocumentStatesApp(MayanAppConfig):
ModelPermission.register_inheritance( ModelPermission.register_inheritance(
model=WorkflowTransition, related='workflow', model=WorkflowTransition, related='workflow',
) )
ModelPermission.register_inheritance(
model=WorkflowTransitionField, related='transition',
)
ModelPermission.register_inheritance( ModelPermission.register_inheritance(
model=WorkflowTransitionTriggerEvent, model=WorkflowTransitionTriggerEvent,
related='transition__workflow', related='transition__workflow',
@@ -161,7 +169,8 @@ class DocumentStatesApp(MayanAppConfig):
attribute='label', is_sortable=True, source=Workflow attribute='label', is_sortable=True, source=Workflow
) )
SourceColumn( SourceColumn(
attribute='internal_name', is_sortable=True, source=Workflow attribute='internal_name', exclude=(WorkflowRuntimeProxy,),
is_sortable=True, source=Workflow
) )
SourceColumn( SourceColumn(
attribute='get_initial_state', empty_value=_('None'), attribute='get_initial_state', empty_value=_('None'),
@@ -197,18 +206,31 @@ class DocumentStatesApp(MayanAppConfig):
SourceColumn( SourceColumn(
source=WorkflowInstanceLogEntry, label=_('Date and time'), source=WorkflowInstanceLogEntry, label=_('Date and time'),
attribute='datetime' attribute='datetime', is_sortable=True
) )
SourceColumn( SourceColumn(
source=WorkflowInstanceLogEntry, label=_('User'), attribute='user' source=WorkflowInstanceLogEntry, attribute='user', is_sortable=True
) )
SourceColumn( SourceColumn(
source=WorkflowInstanceLogEntry, label=_('Transition'), source=WorkflowInstanceLogEntry,
attribute='transition' attribute='transition__origin_state', is_sortable=True
) )
SourceColumn( SourceColumn(
source=WorkflowInstanceLogEntry, label=_('Comment'), source=WorkflowInstanceLogEntry,
attribute='comment' attribute='transition', is_sortable=True
)
SourceColumn(
source=WorkflowInstanceLogEntry,
attribute='transition__destination_state', is_sortable=True
)
SourceColumn(
source=WorkflowInstanceLogEntry,
attribute='comment', is_sortable=True
)
SourceColumn(
source=WorkflowInstanceLogEntry,
attribute='get_extra_data', label=_('Additional details'),
widget=WorkflowLogExtraDataWidget
) )
SourceColumn( SourceColumn(
@@ -256,6 +278,43 @@ class DocumentStatesApp(MayanAppConfig):
) )
) )
SourceColumn(
attribute='name', is_identifier=True, is_sortable=True,
source=WorkflowTransitionField
)
SourceColumn(
attribute='label', is_sortable=True, source=WorkflowTransitionField
)
SourceColumn(
attribute='get_field_type_display', label=_('Type'),
source=WorkflowTransitionField
)
SourceColumn(
attribute='required', is_sortable=True,
source=WorkflowTransitionField, widget=TwoStateWidget
)
SourceColumn(
attribute='get_widget_display', label=_('Widget'),
is_sortable=False, source=WorkflowTransitionField
)
SourceColumn(
attribute='widget_kwargs', is_sortable=True,
source=WorkflowTransitionField
)
SourceColumn(
source=WorkflowRuntimeProxy, label=_('Documents'),
func=lambda context: context['object'].get_document_count(
user=context['request'].user
), order=99
)
SourceColumn(
source=WorkflowStateRuntimeProxy, label=_('Documents'),
func=lambda context: context['object'].get_document_count(
user=context['request'].user
), order=99
)
menu_facet.bind_links( menu_facet.bind_links(
links=(link_document_workflow_instance_list,), sources=(Document,) links=(link_document_workflow_instance_list,), sources=(Document,)
) )
@@ -291,10 +350,18 @@ class DocumentStatesApp(MayanAppConfig):
menu_object.bind_links( menu_object.bind_links(
links=( links=(
link_setup_workflow_transition_edit, link_setup_workflow_transition_edit,
link_workflow_transition_events, link_acl_list, link_workflow_transition_events,
link_setup_workflow_transition_field_list,
link_acl_list,
link_setup_workflow_transition_delete link_setup_workflow_transition_delete
), sources=(WorkflowTransition,) ), sources=(WorkflowTransition,)
) )
menu_object.bind_links(
links=(
link_setup_workflow_transition_field_delete,
link_setup_workflow_transition_field_edit
), sources=(WorkflowTransitionField,)
)
menu_object.bind_links( menu_object.bind_links(
links=( links=(
link_workflow_instance_detail, link_workflow_instance_detail,
@@ -328,6 +395,12 @@ class DocumentStatesApp(MayanAppConfig):
'document_states:setup_workflow_list' 'document_states:setup_workflow_list'
) )
) )
menu_secondary.bind_links(
links=(link_setup_workflow_transition_field_create,),
sources=(
WorkflowTransition,
)
)
menu_secondary.bind_links( menu_secondary.bind_links(
links=(link_workflow_runtime_proxy_list,), links=(link_workflow_runtime_proxy_list,),
sources=( sources=(

View File

@@ -165,26 +165,19 @@ WorkflowTransitionTriggerEventRelationshipFormSet = formset_factory(
) )
class WorkflowInstanceTransitionForm(forms.Form): class WorkflowInstanceTransitionSelectForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
user = kwargs.pop('user') user = kwargs.pop('user')
workflow_instance = kwargs.pop('workflow_instance') workflow_instance = kwargs.pop('workflow_instance')
super(WorkflowInstanceTransitionForm, self).__init__(*args, **kwargs) super(WorkflowInstanceTransitionSelectForm, self).__init__(*args, **kwargs)
self.fields[ self.fields[
'transition' 'transition'
].queryset = workflow_instance.get_transition_choices(_user=user) ].queryset = workflow_instance.get_transition_choices(_user=user)
transition = forms.ModelChoiceField( transition = forms.ModelChoiceField(
help_text=_('Select a transition to execute in the next step.'),
label=_('Transition'), queryset=WorkflowTransition.objects.none() label=_('Transition'), queryset=WorkflowTransition.objects.none()
) )
comment = forms.CharField(
help_text=_('Optional comment to attach to the transition.'),
label=_('Comment'), required=False, widget=forms.widgets.Textarea(
attrs={
'rows': 3
}
)
)
class WorkflowPreviewForm(forms.Form): class WorkflowPreviewForm(forms.Form):

View File

@@ -0,0 +1,39 @@
from __future__ import unicode_literals
from django import forms
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.html import format_html_join, mark_safe
def widget_transition_events(transition):
return format_html_join(
sep='\n', format_string='<div class="">{}</div>', args_generator=(
(
transition_trigger.event_type.label,
) for transition_trigger in transition.trigger_events.all()
)
)
def widget_workflow_diagram(workflow):
return mark_safe(
'<img class="img-responsive" src="{}" style="margin:auto;">'.format(
reverse(
viewname='document_states:workflow_image', kwargs={
'pk': workflow.pk
}
)
)
)
class WorkflowLogExtraDataWidget(object):
template_name = 'document_states/extra_data.html'
def render(self, name=None, value=None):
return render_to_string(
template_name=self.template_name, context={
'value': value
}
)

View File

@@ -3,7 +3,6 @@ from __future__ import absolute_import, unicode_literals
from mayan.apps.appearance.classes import Icon from mayan.apps.appearance.classes import Icon
from mayan.apps.documents.icons import icon_document_type from mayan.apps.documents.icons import icon_document_type
icon_workflow = Icon(driver_name='fontawesome', symbol='sitemap') icon_workflow = Icon(driver_name='fontawesome', symbol='sitemap')
icon_document_type_workflow_list = icon_workflow icon_document_type_workflow_list = icon_workflow
@@ -26,7 +25,9 @@ icon_workflow_list = Icon(driver_name='fontawesome', symbol='sitemap')
icon_workflow_preview = Icon(driver_name='fontawesome', symbol='eye') icon_workflow_preview = Icon(driver_name='fontawesome', symbol='eye')
icon_workflow_instance_detail = Icon(driver_name='fontawesome', symbol='sitemap') icon_workflow_instance_detail = Icon(
driver_name='fontawesome', symbol='sitemap'
)
icon_workflow_instance_transition = Icon( icon_workflow_instance_transition = Icon(
driver_name='fontawesome', symbol='arrows-alt-h' driver_name='fontawesome', symbol='arrows-alt-h'
) )
@@ -58,8 +59,12 @@ icon_workflow_state_delete = Icon(driver_name='fontawesome', symbol='times')
icon_workflow_state_edit = Icon(driver_name='fontawesome', symbol='pencil-alt') icon_workflow_state_edit = Icon(driver_name='fontawesome', symbol='pencil-alt')
icon_workflow_state_action = Icon(driver_name='fontawesome', symbol='code') icon_workflow_state_action = Icon(driver_name='fontawesome', symbol='code')
icon_workflow_state_action_delete = Icon(driver_name='fontawesome', symbol='times') icon_workflow_state_action_delete = Icon(
icon_workflow_state_action_edit = Icon(driver_name='fontawesome', symbol='pencil-alt') driver_name='fontawesome', symbol='times'
)
icon_workflow_state_action_edit = Icon(
driver_name='fontawesome', symbol='pencil-alt'
)
icon_workflow_state_action_selection = Icon( icon_workflow_state_action_selection = Icon(
driver_name='fontawesome-dual', primary_symbol='code', driver_name='fontawesome-dual', primary_symbol='code',
secondary_symbol='plus' secondary_symbol='plus'
@@ -72,10 +77,28 @@ icon_workflow_transition_create = Icon(
driver_name='fontawesome-dual', primary_symbol='arrows-alt-h', driver_name='fontawesome-dual', primary_symbol='arrows-alt-h',
secondary_symbol='plus' secondary_symbol='plus'
) )
icon_workflow_transition_delete = Icon(driver_name='fontawesome', symbol='times') icon_workflow_transition_delete = Icon(
driver_name='fontawesome', symbol='times'
)
icon_workflow_transition_edit = Icon( icon_workflow_transition_edit = Icon(
driver_name='fontawesome', symbol='pencil-alt' driver_name='fontawesome', symbol='pencil-alt'
) )
icon_workflow_transition_field = Icon(driver_name='fontawesome', symbol='table')
icon_workflow_transition_field_delete = Icon(
driver_name='fontawesome', symbol='times'
)
icon_workflow_transition_field_edit = Icon(
driver_name='fontawesome', symbol='pencil-alt'
)
icon_workflow_transition_field_create = Icon(
driver_name='fontawesome-dual', primary_symbol='table',
secondary_symbol='plus'
)
icon_workflow_transition_field_list = Icon(
driver_name='fontawesome', symbol='table'
)
icon_workflow_transition_triggers = Icon( icon_workflow_transition_triggers = Icon(
driver_name='fontawesome', symbol='bolt' driver_name='fontawesome', symbol='bolt'
) )

View File

@@ -129,6 +129,35 @@ link_workflow_transition_events = Link(
text=_('Transition triggers'), text=_('Transition triggers'),
view='document_states:setup_workflow_transition_events' view='document_states:setup_workflow_transition_events'
) )
# Workflow transition fields
link_setup_workflow_transition_field_create = Link(
args='resolved_object.pk',
icon_class_path='mayan.apps.document_states.icons.icon_workflow_transition_field',
permissions=(permission_workflow_edit,), text=_('Create field'),
view='document_states:setup_workflow_transition_field_create',
)
link_setup_workflow_transition_field_delete = Link(
args='resolved_object.pk',
icon_class_path='mayan.apps.document_states.icons.icon_workflow_transition_field_delete',
permissions=(permission_workflow_edit,),
tags='dangerous', text=_('Delete'),
view='document_states:setup_workflow_transition_field_delete',
)
link_setup_workflow_transition_field_edit = Link(
args='resolved_object.pk',
icon_class_path='mayan.apps.document_states.icons.icon_workflow_transition_field_edit',
permissions=(permission_workflow_edit,),
text=_('Edit'), view='document_states:setup_workflow_transition_field_edit',
)
link_setup_workflow_transition_field_list = Link(
args='resolved_object.pk',
icon_class_path='mayan.apps.document_states.icons.icon_workflow_transition_field_list',
permissions=(permission_workflow_edit,),
text=_('Fields'),
view='document_states:setup_workflow_transition_field_list',
)
link_workflow_preview = Link( link_workflow_preview = Link(
args='resolved_object.pk', args='resolved_object.pk',
icon_class_path='mayan.apps.document_states.icons.icon_workflow_preview', icon_class_path='mayan.apps.document_states.icons.icon_workflow_preview',
@@ -159,7 +188,7 @@ link_workflow_instance_transition = Link(
args='resolved_object.pk', args='resolved_object.pk',
icon_class_path='mayan.apps.document_states.icons.icon_workflow_instance_transition', icon_class_path='mayan.apps.document_states.icons.icon_workflow_instance_transition',
text=_('Transition'), text=_('Transition'),
view='document_states:workflow_instance_transition', view='document_states:workflow_instance_transition_selection',
) )
# Runtime proxies # Runtime proxies

View File

@@ -2,6 +2,27 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
FIELD_TYPE_CHOICE_CHAR = 1
FIELD_TYPE_CHOICE_INTEGER = 2
FIELD_TYPE_CHOICES = (
(FIELD_TYPE_CHOICE_CHAR, _('Character')),
(FIELD_TYPE_CHOICE_INTEGER, _('Number (Integer)')),
)
FIELD_TYPE_MAPPING = {
FIELD_TYPE_CHOICE_CHAR: 'django.forms.CharField',
FIELD_TYPE_CHOICE_INTEGER: 'django.forms.IntegerField',
}
WIDGET_CLASS_TEXTAREA = 1
WIDGET_CLASS_CHOICES = (
(WIDGET_CLASS_TEXTAREA, _('Text area')),
)
WIDGET_CLASS_MAPPING = {
WIDGET_CLASS_TEXTAREA: 'django.forms.widgets.Textarea',
}
WORKFLOW_ACTION_ON_ENTRY = 1 WORKFLOW_ACTION_ON_ENTRY = 1
WORKFLOW_ACTION_ON_EXIT = 2 WORKFLOW_ACTION_ON_EXIT = 2

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-07-01 04:54
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('document_states', '0013_auto_20190423_0810'),
]
operations = [
migrations.CreateModel(
name='WorkflowTransitionField',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('field_type', models.PositiveIntegerField(choices=[(1, 'Character'), (2, 'Number (Integer)')], verbose_name='Type')),
('name', models.CharField(help_text='The name that will be used to identify this field in other parts of the workflow system.', max_length=128, verbose_name='Internal name')),
('label', models.CharField(help_text='The field name that will be shown on the user interface.', max_length=128, verbose_name='Label')),
('help_text', models.TextField(blank=True, help_text='An optional message that will help users better understand the purpose of the field and data to provide.', verbose_name='Help text')),
('required', models.BooleanField(default=False, help_text='Whether this fields needs to be filled out or not to proceed.', verbose_name='Required')),
('transition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='document_states.WorkflowTransition', verbose_name='Transition')),
],
options={
'verbose_name': 'Workflow transition trigger event',
'verbose_name_plural': 'Workflow transitions trigger events',
},
),
migrations.AddField(
model_name='workflowinstance',
name='context',
field=models.TextField(blank=True, verbose_name='Backend data'),
),
migrations.AddField(
model_name='workflowinstancelogentry',
name='extra_data',
field=models.TextField(blank=True, verbose_name='Extra data'),
),
migrations.AlterUniqueTogether(
name='workflowtransitionfield',
unique_together=set([('transition', 'name')]),
),
]

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-07-01 13:11
from __future__ import unicode_literals
from django.db import migrations, models
import mayan.apps.common.validators
class Migration(migrations.Migration):
dependencies = [
('document_states', '0014_auto_20190701_0454'),
]
operations = [
migrations.AddField(
model_name='workflowtransitionfield',
name='widget',
field=models.PositiveIntegerField(blank=True, choices=[(1, 'Text area')], help_text='An optional class to change the default presentation of the field.', null=True, verbose_name='Widget class'),
),
migrations.AddField(
model_name='workflowtransitionfield',
name='widget_kwargs',
field=models.TextField(blank=True, help_text='A group of keyword arguments to customize the widget. Use YAML format.', validators=[mayan.apps.common.validators.YAMLValidator()], verbose_name='Widget keyword arguments'),
),
migrations.AlterField(
model_name='workflowinstance',
name='context',
field=models.TextField(blank=True, verbose_name='Context'),
),
]

View File

@@ -4,6 +4,11 @@ import json
import logging import logging
from graphviz import Digraph from graphviz import Digraph
import yaml
try:
from yaml import CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeLoader
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@@ -15,15 +20,16 @@ from django.utils.module_loading import import_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from mayan.apps.acls.models import AccessControlList from mayan.apps.acls.models import AccessControlList
from mayan.apps.common.validators import validate_internal_name from mayan.apps.common.validators import YAMLValidator, validate_internal_name
from mayan.apps.documents.models import Document, DocumentType from mayan.apps.documents.models import Document, DocumentType
from mayan.apps.documents.permissions import permission_document_view
from mayan.apps.events.models import StoredEventType from mayan.apps.events.models import StoredEventType
from .error_logs import error_log_state_actions from .error_logs import error_log_state_actions
from .events import event_workflow_created, event_workflow_edited from .events import event_workflow_created, event_workflow_edited
from .literals import ( from .literals import (
WORKFLOW_ACTION_WHEN_CHOICES, WORKFLOW_ACTION_ON_ENTRY, FIELD_TYPE_CHOICES, WIDGET_CLASS_CHOICES, WORKFLOW_ACTION_WHEN_CHOICES,
WORKFLOW_ACTION_ON_EXIT WORKFLOW_ACTION_ON_ENTRY, WORKFLOW_ACTION_ON_EXIT
) )
from .managers import WorkflowManager from .managers import WorkflowManager
from .permissions import permission_workflow_transition from .permissions import permission_workflow_transition
@@ -256,8 +262,8 @@ class WorkflowState(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Solve issue #557 "Break workflows with invalid input" # Solve issue #557 "Break workflows with invalid input"
# without using a migration. # without using a migration.
# Remove blank=True, remove this, and create a migration in the next # TODO: Remove blank=True, remove this, and create a migration in the
# minor version. # next minor version.
try: try:
self.completion = int(self.completion) self.completion = int(self.completion)
@@ -362,6 +368,61 @@ class WorkflowTransition(models.Model):
return self.label return self.label
@python_2_unicode_compatible
class WorkflowTransitionField(models.Model):
transition = models.ForeignKey(
on_delete=models.CASCADE, related_name='fields',
to=WorkflowTransition, verbose_name=_('Transition')
)
field_type = models.PositiveIntegerField(
choices=FIELD_TYPE_CHOICES, verbose_name=_('Type')
)
name = models.CharField(
help_text=_(
'The name that will be used to identify this field in other parts '
'of the workflow system.'
), max_length=128, verbose_name=_('Internal name')
)
label = models.CharField(
help_text=_(
'The field name that will be shown on the user interface.'
), max_length=128, verbose_name=_('Label'))
help_text = models.TextField(
blank=True, help_text=_(
'An optional message that will help users better understand the '
'purpose of the field and data to provide.'
), verbose_name=_('Help text')
)
required = models.BooleanField(
default=False, help_text=_(
'Whether this fields needs to be filled out or not to proceed.'
), verbose_name=_('Required')
)
widget = models.PositiveIntegerField(
blank=True, choices=WIDGET_CLASS_CHOICES, help_text=_(
'An optional class to change the default presentation of the field.'
), null=True, verbose_name=_('Widget class')
)
widget_kwargs = models.TextField(
blank=True, help_text=_(
'A group of keyword arguments to customize the widget. '
'Use YAML format.'
), validators=[YAMLValidator()],
verbose_name=_('Widget keyword arguments')
)
class Meta:
unique_together = ('transition', 'name')
verbose_name = _('Workflow transition trigger event')
verbose_name_plural = _('Workflow transitions trigger events')
def __str__(self):
return self.label
def get_widget_kwargs(self):
return yaml.load(stream=self.widget_kwargs, Loader=SafeLoader)
@python_2_unicode_compatible @python_2_unicode_compatible
class WorkflowTransitionTriggerEvent(models.Model): class WorkflowTransitionTriggerEvent(models.Model):
transition = models.ForeignKey( transition = models.ForeignKey(
@@ -391,6 +452,9 @@ class WorkflowInstance(models.Model):
on_delete=models.CASCADE, related_name='workflows', to=Document, on_delete=models.CASCADE, related_name='workflows', to=Document,
verbose_name=_('Document') verbose_name=_('Document')
) )
context = models.TextField(
blank=True, verbose_name=_('Context')
)
class Meta: class Meta:
ordering = ('workflow',) ordering = ('workflow',)
@@ -401,16 +465,31 @@ class WorkflowInstance(models.Model):
def __str__(self): def __str__(self):
return force_text(self.workflow) return force_text(self.workflow)
def do_transition(self, transition, user=None, comment=None): def do_transition(self, transition, extra_data=None, user=None, comment=None):
with transaction.atomic():
try: try:
if transition in self.get_current_state().origin_transitions.all(): if transition in self.get_current_state().origin_transitions.all():
if extra_data:
context = self.loads()
context.update(extra_data)
self.dumps(context=context)
self.log_entries.create( self.log_entries.create(
comment=comment or '', transition=transition, user=user comment=comment or '',
extra_data=json.dumps(extra_data or {}),
transition=transition, user=user
) )
except AttributeError: except AttributeError:
# No initial state has been set for this workflow # No initial state has been set for this workflow
pass pass
def dumps(self, context):
"""
Serialize the context data.
"""
self.context = json.dumps(context)
self.save()
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse(
viewname='document_states:workflow_instance_detail', kwargs={ viewname='document_states:workflow_instance_detail', kwargs={
@@ -419,10 +498,12 @@ class WorkflowInstance(models.Model):
) )
def get_context(self): def get_context(self):
return { context = {
'document': self.document, 'workflow': self.workflow, 'document': self.document, 'workflow': self.workflow,
'workflow_instance': self, 'workflow_instance': self,
} }
context['workflow_instance_context'] = self.loads()
return context
def get_current_state(self): def get_current_state(self):
""" """
@@ -488,6 +569,12 @@ class WorkflowInstance(models.Model):
""" """
return WorkflowTransition.objects.none() return WorkflowTransition.objects.none()
def loads(self):
"""
Deserialize the context data.
"""
return json.loads(self.context or '{}')
@python_2_unicode_compatible @python_2_unicode_compatible
class WorkflowInstanceLogEntry(models.Model): class WorkflowInstanceLogEntry(models.Model):
@@ -514,6 +601,7 @@ class WorkflowInstanceLogEntry(models.Model):
to=settings.AUTH_USER_MODEL, verbose_name=_('User') to=settings.AUTH_USER_MODEL, verbose_name=_('User')
) )
comment = models.TextField(blank=True, verbose_name=_('Comment')) comment = models.TextField(blank=True, verbose_name=_('Comment'))
extra_data = models.TextField(blank=True, verbose_name=_('Extra data'))
class Meta: class Meta:
ordering = ('datetime',) ordering = ('datetime',)
@@ -527,7 +615,21 @@ class WorkflowInstanceLogEntry(models.Model):
if self.transition not in self.workflow_instance.get_transition_choices(_user=self.user): if self.transition not in self.workflow_instance.get_transition_choices(_user=self.user):
raise ValidationError(_('Not a valid transition choice.')) raise ValidationError(_('Not a valid transition choice.'))
def get_extra_data(self):
result = {}
for key, value in self.loads().items():
result[self.transition.fields.get(name=key).label] = value
return result
def loads(self):
"""
Deserialize the context data.
"""
return json.loads(self.extra_data or '{}')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
with transaction.atomic():
result = super(WorkflowInstanceLogEntry, self).save(*args, **kwargs) result = super(WorkflowInstanceLogEntry, self).save(*args, **kwargs)
context = self.workflow_instance.get_context() context = self.workflow_instance.get_context()
context.update( context.update(
@@ -561,9 +663,30 @@ class WorkflowRuntimeProxy(Workflow):
verbose_name = _('Workflow runtime proxy') verbose_name = _('Workflow runtime proxy')
verbose_name_plural = _('Workflow runtime proxies') verbose_name_plural = _('Workflow runtime proxies')
def get_document_count(self, user):
"""
Return the numeric count of documents executing this workflow.
The count is filtered by access.
"""
return AccessControlList.objects.restrict_queryset(
permission=permission_document_view,
queryset=Document.objects.filter(workflows__workflow=self),
user=user
).count()
class WorkflowStateRuntimeProxy(WorkflowState): class WorkflowStateRuntimeProxy(WorkflowState):
class Meta: class Meta:
proxy = True proxy = True
verbose_name = _('Workflow state runtime proxy') verbose_name = _('Workflow state runtime proxy')
verbose_name_plural = _('Workflow state runtime proxies') verbose_name_plural = _('Workflow state runtime proxies')
def get_document_count(self, user):
"""
Return the numeric count of documents at this workflow state.
The count is filtered by access.
"""
return AccessControlList.objects.restrict_queryset(
permission=permission_document_view, queryset=self.get_documents(),
user=user
).count()

View File

@@ -0,0 +1,7 @@
{% if value %}
<ul>
{% for key, value in value.items %}
<li>{{ key }}: {{ value }}</li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -152,9 +152,10 @@ class WorkflowTransitionViewTestMixin(object):
def _request_test_workflow_transition(self): def _request_test_workflow_transition(self):
return self.post( return self.post(
viewname='document_states:workflow_instance_transition', viewname='document_states:workflow_instance_transition_execute',
kwargs={'pk': self.test_workflow_instance.pk}, data={ kwargs={
'transition': self.test_workflow_transition.pk, 'workflow_instance_pk': self.test_workflow_instance.pk,
'workflow_transition_pk': self.test_workflow_transition.pk,
} }
) )

View File

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
from mayan.apps.common.tests import GenericViewTestCase from mayan.apps.common.tests import GenericViewTestCase
from mayan.apps.documents.tests import GenericDocumentViewTestCase from mayan.apps.documents.tests import GenericDocumentViewTestCase
from ..literals import FIELD_TYPE_CHOICE_CHAR
from ..models import WorkflowTransition from ..models import WorkflowTransition
from ..permissions import ( from ..permissions import (
permission_workflow_edit, permission_workflow_view, permission_workflow_edit, permission_workflow_view,
@@ -16,6 +17,11 @@ from .mixins import (
WorkflowTestMixin, WorkflowViewTestMixin, WorkflowTransitionViewTestMixin WorkflowTestMixin, WorkflowViewTestMixin, WorkflowTransitionViewTestMixin
) )
TEST_WORKFLOW_TRANSITION_FIELD_NAME = 'test_workflow_transition_field'
TEST_WORKFLOW_TRANSITION_FIELD_LABEL = 'test workflow transition field'
TEST_WORKFLOW_TRANSITION_FIELD_HELP_TEXT = 'test workflow transition field help test'
TEST_WORKFLOW_TRANSITION_FIELD_TYPE = FIELD_TYPE_CHOICE_CHAR
class WorkflowTransitionViewTestCase( class WorkflowTransitionViewTestCase(
WorkflowTestMixin, WorkflowViewTestMixin, WorkflowTransitionViewTestMixin, WorkflowTestMixin, WorkflowViewTestMixin, WorkflowTransitionViewTestMixin,
@@ -160,7 +166,7 @@ class WorkflowTransitionDocumentViewTestCase(
permission. permission.
""" """
response = self._request_test_workflow_transition() response = self._request_test_workflow_transition()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 404)
# Workflow should remain in the same initial state # Workflow should remain in the same initial state
self.assertEqual( self.assertEqual(
@@ -232,3 +238,125 @@ class WorkflowTransitionEventViewTestCase(
response = self._request_test_workflow_transition_event_list_view() response = self._request_test_workflow_transition_event_list_view()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class WorkflowTransitionFieldViewTestCase(
WorkflowTestMixin, WorkflowTransitionViewTestMixin, GenericViewTestCase
):
def setUp(self):
super(WorkflowTransitionFieldViewTestCase, self).setUp()
self._create_test_workflow()
self._create_test_workflow_states()
self._create_test_workflow_transition()
def _create_test_workflow_transition_field(self):
self.test_workflow_transition_field = self.test_workflow_transition.fields.create(
field_type=TEST_WORKFLOW_TRANSITION_FIELD_TYPE,
name=TEST_WORKFLOW_TRANSITION_FIELD_NAME,
label=TEST_WORKFLOW_TRANSITION_FIELD_LABEL,
help_text=TEST_WORKFLOW_TRANSITION_FIELD_HELP_TEXT
)
def _request_test_workflow_transition_field_list_view(self):
return self.get(
viewname='document_states:setup_workflow_transition_field_list',
kwargs={'pk': self.test_workflow_transition.pk}
)
def test_workflow_transition_field_list_view_no_permission(self):
self._create_test_workflow_transition_field()
response = self._request_test_workflow_transition_field_list_view()
self.assertNotContains(
response=response,
text=self.test_workflow_transition_field.label,
status_code=404
)
def test_workflow_transition_field_list_view_with_access(self):
self._create_test_workflow_transition_field()
self.grant_access(
obj=self.test_workflow, permission=permission_workflow_edit
)
response = self._request_test_workflow_transition_field_list_view()
self.assertContains(
response=response,
text=self.test_workflow_transition_field.label,
status_code=200
)
def _request_workflow_transition_field_create_view(self):
return self.post(
viewname='document_states:setup_workflow_transition_field_create',
kwargs={'pk': self.test_workflow_transition.pk},
data={
'field_type': TEST_WORKFLOW_TRANSITION_FIELD_TYPE,
'name': TEST_WORKFLOW_TRANSITION_FIELD_NAME,
'label': TEST_WORKFLOW_TRANSITION_FIELD_LABEL,
'help_text': TEST_WORKFLOW_TRANSITION_FIELD_HELP_TEXT
}
)
def test_workflow_transition_field_create_view_no_permission(self):
workflow_transition_field_count = self.test_workflow_transition.fields.count()
response = self._request_workflow_transition_field_create_view()
self.assertEqual(response.status_code, 404)
self.assertEqual(
self.test_workflow_transition.fields.count(),
workflow_transition_field_count
)
def test_workflow_transition_field_create_view_with_access(self):
workflow_transition_field_count = self.test_workflow_transition.fields.count()
self.grant_access(
obj=self.test_workflow, permission=permission_workflow_edit
)
response = self._request_workflow_transition_field_create_view()
self.assertEqual(response.status_code, 302)
self.assertEqual(
self.test_workflow_transition.fields.count(),
workflow_transition_field_count + 1
)
def _request_workflow_transition_field_delete_view(self):
return self.post(
viewname='document_states:setup_workflow_transition_field_delete',
kwargs={'pk': self.test_workflow_transition_field.pk},
)
def test_workflow_transition_field_delete_view_no_permission(self):
self._create_test_workflow_transition_field()
workflow_transition_field_count = self.test_workflow_transition.fields.count()
response = self._request_workflow_transition_field_delete_view()
self.assertEqual(response.status_code, 404)
self.assertEqual(
self.test_workflow_transition.fields.count(),
workflow_transition_field_count
)
def test_workflow_transition_field_delete_view_with_access(self):
self._create_test_workflow_transition_field()
workflow_transition_field_count = self.test_workflow_transition.fields.count()
self.grant_access(
obj=self.test_workflow, permission=permission_workflow_edit
)
response = self._request_workflow_transition_field_delete_view()
self.assertEqual(response.status_code, 302)
self.assertEqual(
self.test_workflow_transition.fields.count(),
workflow_transition_field_count - 1
)

View File

@@ -22,19 +22,86 @@ from .views import (
SetupWorkflowTransitionEditView, SetupWorkflowTransitionEditView,
SetupWorkflowTransitionTriggerEventListView, ToolLaunchAllWorkflows, SetupWorkflowTransitionTriggerEventListView, ToolLaunchAllWorkflows,
WorkflowDocumentListView, WorkflowInstanceDetailView, WorkflowDocumentListView, WorkflowInstanceDetailView,
WorkflowImageView, WorkflowInstanceTransitionView, WorkflowListView, WorkflowImageView, WorkflowInstanceTransitionExecuteView,
WorkflowInstanceTransitionSelectView, WorkflowListView,
WorkflowPreviewView, WorkflowStateDocumentListView, WorkflowStateListView, WorkflowPreviewView, WorkflowStateDocumentListView, WorkflowStateListView,
) )
from .views.workflow_views import SetupDocumentTypeWorkflowsView from .views.workflow_views import (
SetupDocumentTypeWorkflowsView, SetupWorkflowTransitionFieldCreateView,
SetupWorkflowTransitionFieldDeleteView,
SetupWorkflowTransitionFieldEditView, SetupWorkflowTransitionFieldListView
)
urlpatterns_workflows = [ urlpatterns_workflows = [
url( url(
regex=r'^document_type/(?P<pk>\d+)/workflows/$', regex=r'^setup/workflows/$', view=SetupWorkflowListView.as_view(),
name='setup_workflow_list'
),
url(
regex=r'^setup/workflows/create/$', view=SetupWorkflowCreateView.as_view(),
name='setup_workflow_create'
),
url(
regex=r'^setup/workflows/(?P<pk>\d+)/delete/$',
view=SetupWorkflowDeleteView.as_view(), name='setup_workflow_delete'
),
url(
regex=r'^setup/workflows/(?P<pk>\d+)/edit/$',
view=SetupWorkflowEditView.as_view(), name='setup_workflow_edit'
),
url(
regex=r'^setup/document_types/(?P<pk>\d+)/workflows/$',
view=SetupDocumentTypeWorkflowsView.as_view(), view=SetupDocumentTypeWorkflowsView.as_view(),
name='document_type_workflows' name='document_type_workflows'
), ),
] ]
urlpatterns_workflow_states = [
url(
regex=r'^setup/workflow/(?P<pk>\d+)/states/$',
view=SetupWorkflowStateListView.as_view(),
name='setup_workflow_state_list'
),
url(
regex=r'^setup/workflow/(?P<pk>\d+)/states/create/$',
view=SetupWorkflowStateCreateView.as_view(),
name='setup_workflow_state_create'
),
url(
regex=r'^setup/workflow/state/(?P<pk>\d+)/delete/$',
view=SetupWorkflowStateDeleteView.as_view(),
name='setup_workflow_state_delete'
),
url(
regex=r'^setup/workflow/state/(?P<pk>\d+)/edit/$',
view=SetupWorkflowStateEditView.as_view(),
name='setup_workflow_state_edit'
),
]
urlpatterns_workflow_transition_fields = [
url(
regex=r'^setup/workflows/transitions/(?P<pk>\d+)/fields/create/$',
view=SetupWorkflowTransitionFieldCreateView.as_view(),
name='setup_workflow_transition_field_create'
),
url(
regex=r'^setup/workflows/transitions/(?P<pk>\d+)/fields/$',
view=SetupWorkflowTransitionFieldListView.as_view(),
name='setup_workflow_transition_field_list'
),
url(
regex=r'^setup/workflows/transitions/fields/(?P<pk>\d+)/delete/$',
view=SetupWorkflowTransitionFieldDeleteView.as_view(),
name='setup_workflow_transition_field_delete'
),
url(
regex=r'^setup/workflows/transitions/fields/(?P<pk>\d+)/edit/$',
view=SetupWorkflowTransitionFieldEditView.as_view(),
name='setup_workflow_transition_field_edit'
),
]
urlpatterns = [ urlpatterns = [
url( url(
regex=r'^document/(?P<pk>\d+)/workflows/$', regex=r'^document/(?P<pk>\d+)/workflows/$',
@@ -47,25 +114,14 @@ urlpatterns = [
name='workflow_instance_detail' name='workflow_instance_detail'
), ),
url( url(
regex=r'^document/workflows/(?P<pk>\d+)/transition/$', regex=r'^document/workflows/(?P<pk>\d+)/transitions/select/$',
view=WorkflowInstanceTransitionView.as_view(), view=WorkflowInstanceTransitionSelectView.as_view(),
name='workflow_instance_transition' name='workflow_instance_transition_selection'
), ),
url( url(
regex=r'^setup/all/$', view=SetupWorkflowListView.as_view(), regex=r'^document/workflows/(?P<workflow_instance_pk>\d+)/transitions/(?P<workflow_transition_pk>\d+)/execute/$',
name='setup_workflow_list' view=WorkflowInstanceTransitionExecuteView.as_view(),
), name='workflow_instance_transition_execute'
url(
regex=r'^setup/create/$', view=SetupWorkflowCreateView.as_view(),
name='setup_workflow_create'
),
url(
regex=r'^setup/workflow/(?P<pk>\d+)/edit/$',
view=SetupWorkflowEditView.as_view(), name='setup_workflow_edit'
),
url(
regex=r'^setup/workflow/(?P<pk>\d+)/delete/$',
view=SetupWorkflowDeleteView.as_view(), name='setup_workflow_delete'
), ),
url( url(
regex=r'^setup/workflow/(?P<pk>\d+)/documents/$', regex=r'^setup/workflow/(?P<pk>\d+)/documents/$',
@@ -77,16 +133,6 @@ urlpatterns = [
view=SetupWorkflowDocumentTypesView.as_view(), view=SetupWorkflowDocumentTypesView.as_view(),
name='setup_workflow_document_types' name='setup_workflow_document_types'
), ),
url(
regex=r'^setup/workflow/(?P<pk>\d+)/states/$',
view=SetupWorkflowStateListView.as_view(),
name='setup_workflow_state_list'
),
url(
regex=r'^setup/workflow/(?P<pk>\d+)/states/create/$',
view=SetupWorkflowStateCreateView.as_view(),
name='setup_workflow_state_create'
),
url( url(
regex=r'^setup/workflow/(?P<pk>\d+)/transitions/$', regex=r'^setup/workflow/(?P<pk>\d+)/transitions/$',
view=SetupWorkflowTransitionListView.as_view(), view=SetupWorkflowTransitionListView.as_view(),
@@ -98,20 +144,10 @@ urlpatterns = [
name='setup_workflow_transition_create' name='setup_workflow_transition_create'
), ),
url( url(
regex=r'^setup/workflow/(?P<pk>\d+)/transitions/events/$', regex=r'^setup/workflow/transitions/(?P<pk>\d+)/events/$',
view=SetupWorkflowTransitionTriggerEventListView.as_view(), view=SetupWorkflowTransitionTriggerEventListView.as_view(),
name='setup_workflow_transition_events' name='setup_workflow_transition_events'
), ),
url(
regex=r'^setup/workflow/state/(?P<pk>\d+)/delete/$',
view=SetupWorkflowStateDeleteView.as_view(),
name='setup_workflow_state_delete'
),
url(
regex=r'^setup/workflow/state/(?P<pk>\d+)/edit/$',
view=SetupWorkflowStateEditView.as_view(),
name='setup_workflow_state_edit'
),
url( url(
regex=r'^setup/workflow/state/(?P<pk>\d+)/actions/$', regex=r'^setup/workflow/state/(?P<pk>\d+)/actions/$',
view=SetupWorkflowStateActionListView.as_view(), view=SetupWorkflowStateActionListView.as_view(),
@@ -184,6 +220,8 @@ urlpatterns = [
), ),
] ]
urlpatterns.extend(urlpatterns_workflows) urlpatterns.extend(urlpatterns_workflows)
urlpatterns.extend(urlpatterns_workflow_states)
urlpatterns.extend(urlpatterns_workflow_transition_fields)
api_urls = [ api_urls = [
url( url(

View File

@@ -4,21 +4,26 @@ from django.contrib import messages
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.template import RequestContext from django.template import RequestContext
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from mayan.apps.acls.models import AccessControlList from mayan.apps.acls.models import AccessControlList
from mayan.apps.common.forms import DynamicForm
from mayan.apps.common.generics import FormView, SingleObjectListView from mayan.apps.common.generics import FormView, SingleObjectListView
from mayan.apps.common.mixins import ExternalObjectMixin
from mayan.apps.documents.models import Document from mayan.apps.documents.models import Document
from ..forms import WorkflowInstanceTransitionForm from ..forms import WorkflowInstanceTransitionSelectForm
from ..icons import icon_workflow_instance_detail, icon_workflow_list from ..icons import icon_workflow_instance_detail, icon_workflow_list
from ..links import link_workflow_instance_transition from ..links import link_workflow_instance_transition
from ..literals import FIELD_TYPE_MAPPING, WIDGET_CLASS_MAPPING
from ..models import WorkflowInstance from ..models import WorkflowInstance
from ..permissions import permission_workflow_view from ..permissions import permission_workflow_view
__all__ = ( __all__ = (
'DocumentWorkflowInstanceListView', 'WorkflowInstanceDetailView', 'DocumentWorkflowInstanceListView', 'WorkflowInstanceDetailView',
'WorkflowInstanceTransitionView' 'WorkflowInstanceTransitionSelectView',
'WorkflowInstanceTransitionExecuteView'
) )
@@ -100,14 +105,17 @@ class WorkflowInstanceDetailView(SingleObjectListView):
return get_object_or_404(klass=WorkflowInstance, pk=self.kwargs['pk']) return get_object_or_404(klass=WorkflowInstance, pk=self.kwargs['pk'])
class WorkflowInstanceTransitionView(FormView): class WorkflowInstanceTransitionExecuteView(FormView):
form_class = WorkflowInstanceTransitionForm form_class = DynamicForm
template_name = 'appearance/generic_form.html' template_name = 'appearance/generic_form.html'
def form_valid(self, form): def form_valid(self, form):
form_data = form.cleaned_data
comment = form_data.pop('comment')
self.get_workflow_instance().do_transition( self.get_workflow_instance().do_transition(
comment=form.cleaned_data['comment'], comment=comment, extra_data=form_data,
transition=form.cleaned_data['transition'], user=self.request.user transition=self.get_workflow_transition(), user=self.request.user,
) )
messages.success( messages.success(
self.request, _( self.request, _(
@@ -122,19 +130,99 @@ class WorkflowInstanceTransitionView(FormView):
'object': self.get_workflow_instance().document, 'object': self.get_workflow_instance().document,
'submit_label': _('Submit'), 'submit_label': _('Submit'),
'title': _( 'title': _(
'Do transition for workflow: %s' 'Execute transition "%(transition)s" for workflow: %(workflow)s'
) % self.get_workflow_instance(), ) % {
'transition': self.get_workflow_transition(),
'workflow': self.get_workflow_instance(),
},
'workflow_instance': self.get_workflow_instance(), 'workflow_instance': self.get_workflow_instance(),
} }
def get_form_extra_kwargs(self): def get_form_extra_kwargs(self):
return { schema = {
'user': self.request.user, 'fields': {
'workflow_instance': self.get_workflow_instance() 'comment': {
'label': _('Comment'),
'class': 'django.forms.CharField', 'kwargs': {
'help_text': _(
'Optional comment to attach to the transition.'
),
'required': False,
} }
}
},
'widgets': {
'comment': {
'class': 'django.forms.widgets.Textarea',
'kwargs': {
'attrs': {
'rows': 3
}
}
}
}
}
for field in self.get_workflow_transition().fields.all():
schema['fields'][field.name] = {
'class': FIELD_TYPE_MAPPING[field.field_type],
'help_text': field.help_text,
'label': field.label,
'required': field.required,
}
if field.widget:
schema['widgets'][field.name] = {
'class': WIDGET_CLASS_MAPPING[field.widget],
'kwargs': field.get_widget_kwargs()
}
return {'schema': schema}
def get_success_url(self): def get_success_url(self):
return self.get_workflow_instance().get_absolute_url() return self.get_workflow_instance().get_absolute_url()
def get_workflow_instance(self): def get_workflow_instance(self):
return get_object_or_404(klass=WorkflowInstance, pk=self.kwargs['pk']) return get_object_or_404(
klass=WorkflowInstance, pk=self.kwargs['workflow_instance_pk']
)
def get_workflow_transition(self):
return get_object_or_404(
klass=self.get_workflow_instance().get_transition_choices(
_user=self.request.user
), pk=self.kwargs['workflow_transition_pk']
)
class WorkflowInstanceTransitionSelectView(ExternalObjectMixin, FormView):
external_object_class = WorkflowInstance
form_class = WorkflowInstanceTransitionSelectForm
template_name = 'appearance/generic_form.html'
def form_valid(self, form):
return HttpResponseRedirect(
redirect_to=reverse(
viewname='document_states:workflow_instance_transition_execute',
kwargs={
'workflow_instance_pk': self.external_object.pk,
'workflow_transition_pk': form.cleaned_data['transition'].pk
}
)
)
def get_extra_context(self):
return {
'navigation_object_list': ('object', 'workflow_instance'),
'object': self.external_object.document,
'submit_label': _('Select'),
'title': _(
'Select transition for workflow: %s'
) % self.external_object,
'workflow_instance': self.external_object,
}
def get_form_extra_kwargs(self):
return {
'user': self.request.user,
'workflow_instance': self.external_object
}

View File

@@ -31,15 +31,17 @@ from ..forms import (
) )
from ..icons import ( from ..icons import (
icon_workflow_list, icon_workflow_state, icon_workflow_state_action, icon_workflow_list, icon_workflow_state, icon_workflow_state_action,
icon_workflow_transition icon_workflow_transition, icon_workflow_transition_field
) )
from ..links import ( from ..links import (
link_setup_workflow_create, link_setup_workflow_state_create, link_setup_workflow_create, link_setup_workflow_state_create,
link_setup_workflow_state_action_selection, link_setup_workflow_state_action_selection,
link_setup_workflow_transition_create link_setup_workflow_transition_create,
link_setup_workflow_transition_field_create,
) )
from ..models import ( from ..models import (
Workflow, WorkflowState, WorkflowStateAction, WorkflowTransition Workflow, WorkflowState, WorkflowStateAction, WorkflowTransition,
WorkflowTransitionField
) )
from ..permissions import ( from ..permissions import (
permission_workflow_create, permission_workflow_delete, permission_workflow_create, permission_workflow_delete,
@@ -732,6 +734,124 @@ class SetupWorkflowTransitionTriggerEventListView(ExternalObjectMixin, FormView)
) )
# Transition fields
class SetupWorkflowTransitionFieldCreateView(ExternalObjectMixin, SingleObjectCreateView):
external_object_class = WorkflowTransition
external_object_permission = permission_workflow_edit
fields = (
'name', 'label', 'field_type', 'help_text', 'required', 'widget',
'widget_kwargs'
)
def get_extra_context(self):
return {
'navigation_object_list': ('transition', 'workflow'),
'transition': self.external_object,
'title': _(
'Create a field for workflow transition: %s'
) % self.external_object,
'workflow': self.external_object.workflow
}
def get_instance_extra_data(self):
return {
'transition': self.external_object,
}
def get_queryset(self):
return self.external_object.fields.all()
def get_post_action_redirect(self):
return reverse(
viewname='document_states:setup_workflow_transition_field_list',
kwargs={'pk': self.external_object.pk}
)
class SetupWorkflowTransitionFieldDeleteView(SingleObjectDeleteView):
model = WorkflowTransitionField
object_permission = permission_workflow_edit
def get_extra_context(self):
return {
'navigation_object_list': (
'object', 'workflow_transition', 'workflow'
),
'object': self.object,
'title': _('Delete workflow transition field: %s') % self.object,
'workflow': self.object.transition.workflow,
'workflow_transition': self.object.transition,
}
def get_post_action_redirect(self):
return reverse(
viewname='document_states:setup_workflow_transition_field_list',
kwargs={'pk': self.object.transition.pk}
)
class SetupWorkflowTransitionFieldEditView(SingleObjectEditView):
fields = (
'name', 'label', 'field_type', 'help_text', 'required', 'widget',
'widget_kwargs'
)
model = WorkflowTransitionField
object_permission = permission_workflow_edit
def get_extra_context(self):
return {
'navigation_object_list': (
'object', 'workflow_transition', 'workflow'
),
'object': self.object,
'title': _('Edit workflow transition field: %s') % self.object,
'workflow': self.object.transition.workflow,
'workflow_transition': self.object.transition,
}
def get_post_action_redirect(self):
return reverse(
viewname='document_states:setup_workflow_transition_field_list',
kwargs={'pk': self.object.transition.pk}
)
class SetupWorkflowTransitionFieldListView(ExternalObjectMixin, SingleObjectListView):
external_object_class = WorkflowTransition
external_object_permission = permission_workflow_edit
def get_extra_context(self):
return {
'hide_object': True,
'navigation_object_list': ('object', 'workflow'),
'no_results_icon': icon_workflow_transition_field,
'no_results_main_link': link_setup_workflow_transition_field_create.resolve(
context=RequestContext(
request=self.request, dict_={
'object': self.external_object
}
)
),
'no_results_text': _(
'Workflow transition fields allow adding data to the '
'workflow\'s context. This additional context data can then '
'be used by other elements of the workflow system like the '
'workflow state actions.'
),
'no_results_title': _(
'There are no fields for this workflow transition'
),
'object': self.external_object,
'title': _(
'Fields for workflow transition: %s'
) % self.external_object,
'workflow': self.external_object.workflow,
}
def get_source_queryset(self):
return self.external_object.fields.all()
class ToolLaunchAllWorkflows(ConfirmView): class ToolLaunchAllWorkflows(ConfirmView):
extra_context = { extra_context = {
'title': _('Launch all workflows?'), 'title': _('Launch all workflows?'),

View File

@@ -311,15 +311,8 @@ class DocumentsApp(MayanAppConfig):
source=DeletedDocument source=DeletedDocument
) )
SourceColumn( SourceColumn(
func=lambda context: document_page_thumbnail_widget.render( attribute='deleted_date_time', include_label=True, order=99,
instance=context['object'] source=DeletedDocument
), label=_('Thumbnail'), source=DeletedDocument
)
SourceColumn(
attribute='document_type', is_sortable=True, source=DeletedDocument
)
SourceColumn(
attribute='deleted_date_time', source=DeletedDocument
) )
# DocumentVersion # DocumentVersion

View File

@@ -41,7 +41,7 @@ class DocumentTypeFilteredSelectForm(forms.Form):
self.fields['document_type'] = field_class( self.fields['document_type'] = field_class(
help_text=help_text, label=_('Document type'), help_text=help_text, label=_('Document type'),
queryset=queryset, required=True, queryset=queryset, required=True,
widget=widget_class(attrs={'size': 10}), **extra_kwargs widget=widget_class(attrs={'class': 'select2', 'size': 10}), **extra_kwargs
) )

View File

@@ -7,7 +7,7 @@ icon_document_type = Icon(
driver_name='fontawesome-layers', data=[ driver_name='fontawesome-layers', data=[
{'class': 'fas fa-circle', 'transform': 'shrink-12 up-2'}, {'class': 'fas fa-circle', 'transform': 'shrink-12 up-2'},
{'class': 'fas fa-cog', 'transform': 'shrink-6 up-2', 'mask': 'fas fa-torah'} {'class': 'fas fa-cog', 'transform': 'shrink-6 up-2', 'mask': 'fas fa-torah'}
] ], shadow_class='fas fa-torah'
) )
icon_menu_documents = Icon(driver_name='fontawesome', symbol='book') icon_menu_documents = Icon(driver_name='fontawesome', symbol='book')

View File

@@ -32,6 +32,7 @@ DEFAULT_DOCUMENT_TYPE_LABEL = _('Default')
DOCUMENT_IMAGE_TASK_TIMEOUT = 120 DOCUMENT_IMAGE_TASK_TIMEOUT = 120
STUB_EXPIRATION_INTERVAL = 60 * 60 * 24 # 24 hours STUB_EXPIRATION_INTERVAL = 60 * 60 * 24 # 24 hours
UPDATE_PAGE_COUNT_RETRY_DELAY = 10 UPDATE_PAGE_COUNT_RETRY_DELAY = 10
UPLOAD_NEW_DOCUMENT_RETRY_DELAY = 10
UPLOAD_NEW_VERSION_RETRY_DELAY = 10 UPLOAD_NEW_VERSION_RETRY_DELAY = 10
PAGE_RANGE_ALL = 'all' PAGE_RANGE_ALL = 'all'

View File

@@ -82,3 +82,7 @@ queue_uploads.add_task_type(
dotted_path='mayan.apps.documents.tasks.task_scan_duplicates_for', dotted_path='mayan.apps.documents.tasks.task_scan_duplicates_for',
label=_('Scan document duplicates') label=_('Scan document duplicates')
) )
queue_uploads.add_task_type(
dotted_path='mayan.apps.documents.tasks.task_upload_new_document',
label=_('Upload new document')
)

View File

@@ -9,7 +9,8 @@ from django.db import OperationalError
from mayan.celery import app from mayan.celery import app
from .literals import ( from .literals import (
UPDATE_PAGE_COUNT_RETRY_DELAY, UPLOAD_NEW_VERSION_RETRY_DELAY UPDATE_PAGE_COUNT_RETRY_DELAY, UPLOAD_NEW_DOCUMENT_RETRY_DELAY,
UPLOAD_NEW_VERSION_RETRY_DELAY
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -127,6 +128,60 @@ def task_update_page_count(self, version_id):
raise self.retry(exc=exception) raise self.retry(exc=exception)
@app.task(bind=True, default_retry_delay=UPLOAD_NEW_DOCUMENT_RETRY_DELAY, ignore_result=True)
def task_upload_new_document(self, document_type_id, shared_uploaded_file_id):
DocumentType = apps.get_model(
app_label='documents', model_name='DocumentType'
)
SharedUploadedFile = apps.get_model(
app_label='common', model_name='SharedUploadedFile'
)
try:
document_type = DocumentType.objects.get(pk=document_type_id)
shared_file = SharedUploadedFile.objects.get(
pk=shared_uploaded_file_id
)
except OperationalError as exception:
logger.warning(
'Operational error during attempt to retrieve shared data for '
'new document of type: %s; %s. Retrying.', document_type, exception
)
raise self.retry(exc=exception)
try:
with shared_file.open() as file_object:
document_type.new_document(file_object=file_object)
except OperationalError as exception:
logger.warning(
'Operational error during attempt to create new document '
'of type: %s; %s. Retrying.', document_type, exception
)
raise self.retry(exc=exception)
except Exception as exception:
# This except and else block emulate a finally:
logger.error(
'Unexpected error during attempt to create new document '
'of type: %s; %s', document_type, exception
)
try:
shared_file.delete()
except OperationalError as exception:
logger.warning(
'Operational error during attempt to delete shared '
'file: %s; %s.', shared_file, exception
)
else:
try:
shared_file.delete()
except OperationalError as exception:
logger.warning(
'Operational error during attempt to delete shared '
'file: %s; %s.', shared_file, exception
)
@app.task(bind=True, default_retry_delay=UPLOAD_NEW_VERSION_RETRY_DELAY, ignore_result=True) @app.task(bind=True, default_retry_delay=UPLOAD_NEW_VERSION_RETRY_DELAY, ignore_result=True)
def task_upload_new_version(self, document_id, shared_uploaded_file_id, user_id, comment=None): def task_upload_new_version(self, document_id, shared_uploaded_file_id, user_id, comment=None):
SharedUploadedFile = apps.get_model( SharedUploadedFile = apps.get_model(

View File

@@ -0,0 +1,3 @@
from __future__ import unicode_literals
default_app_config = 'mayan.apps.importer.apps.ImporterApp'

View File

@@ -0,0 +1,17 @@
from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from mayan.apps.common.apps import MayanAppConfig
class ImporterApp(MayanAppConfig):
app_namespace = 'importer'
app_url = 'importer'
has_rest_api = False
has_tests = True
name = 'mayan.apps.importer'
verbose_name = _('Importer')
def ready(self):
super(ImporterApp, self).ready()

View File

@@ -0,0 +1,150 @@
from __future__ import unicode_literals
import csv
import time
from django.apps import apps
from django.core import management
from django.core.files import File
from ...tasks import task_upload_new_document
class Command(management.BaseCommand):
help = 'Import documents from a CSV file.'
def add_arguments(self, parser):
parser.add_argument(
'--document_type_column',
action='store', dest='document_type_column', default=0,
help='Column that contains the document type labels. Column '
'numbers start at 0.',
type=int
)
parser.add_argument(
'--document_path_column',
action='store', dest='document_path_column', default=1,
help='Column that contains the path to the document files. Column '
'numbers start at 0.',
type=int
)
parser.add_argument(
'--ignore_errors',
action='store_true', dest='ignore_errors', default=False,
help='Don\'t stop the import process on common errors like '
'incorrect file paths.',
)
parser.add_argument(
'--ignore_rows',
action='store', dest='ignore_rows', default='',
help='Ignore a set of rows. Row numbers must be separated by commas.'
)
parser.add_argument(
'--metadata_pairs_column',
action='store', dest='metadata_pairs_column',
help='Column that contains metadata name and values for the '
'documents. Use the form: <label column>:<value column>. Example: '
'2:5. Separate multiple pairs with commas. Example: 2:5,7:10',
)
parser.add_argument('filelist', nargs='?', help='File list')
def handle(self, *args, **options):
time_start = time.time()
time_last_display = time_start
document_types = {}
uploaded_count = 0
row_count = 0
rows_to_ignore = []
for entry in options['ignore_rows'].split(','):
if entry:
rows_to_ignore.append(int(entry))
DocumentType = apps.get_model(
app_label='documents', model_name='DocumentType'
)
SharedUploadedFile = apps.get_model(
app_label='common', model_name='SharedUploadedFile'
)
if not options['filelist']:
self.stderr.write('Must specify a CSV file path.')
exit(1)
else:
with open(options['filelist']) as csv_datafile:
csv_reader = csv.reader(csv_datafile)
for row in csv_reader:
# Increase row count here even though start index is 0
# purpose is to avoid losing row number increments on
# exceptions
row_count = row_count + 1
if row_count - 1 not in rows_to_ignore:
try:
with open(row[options['document_path_column']]) as file_object:
document_type_label = row[options['document_type_column']]
if document_type_label not in document_types:
self.stdout.write(
'New document type: {}. Creating and caching.'.format(
document_type_label
)
)
document_type, created = DocumentType.objects.get_or_create(
label=document_type_label
)
document_types[document_type_label] = document_type
else:
document_type = document_types[document_type_label]
shared_uploaded_file = SharedUploadedFile.objects.create(
file=File(file_object)
)
extra_data = {}
if options['metadata_pairs_column']:
extra_data['metadata_pairs'] = []
for pair in options['metadata_pairs_column'].split(','):
name, value = pair.split(':')
extra_data['metadata_pairs'].append(
{
'name': row[int(name)],
'value': row[int(value)]
}
)
task_upload_new_document.apply_async(
kwargs=dict(
document_type_id=document_type.pk,
shared_uploaded_file_id=shared_uploaded_file.pk,
extra_data=extra_data
)
)
uploaded_count = uploaded_count + 1
if (time.time() - time_last_display) > 1:
time_last_display = time.time()
self.stdout.write(
'Time: {}s, Files copied and queued: {}, files processed per second: {}'.format(
int(time.time() - time_start),
uploaded_count,
uploaded_count / (time.time() - time_start)
)
)
except (IOError, OSError) as exception:
if not options['ignore_errors']:
raise
else:
self.stderr.write(
'Error processing row: {}; {}.'.format(
row_count - 1, exception
)
)
self.stdout.write(
'Total files copied and queues: {}'.format(uploaded_count)
)
self.stdout.write(
'Total time: {}'.format(time.time() - time_start)
)

View File

@@ -0,0 +1,10 @@
from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from mayan.apps.documents.queues import queue_uploads
queue_uploads.add_task_type(
dotted_path='mayan.apps.importer.tasks.task_upload_new_document',
label=_('Import new document')
)

View File

@@ -0,0 +1,93 @@
from __future__ import unicode_literals
import logging
from django.apps import apps
from django.db import OperationalError
from django.utils.text import slugify
from mayan.celery import app
from mayan.apps.documents.literals import UPLOAD_NEW_DOCUMENT_RETRY_DELAY
logger = logging.getLogger(__name__)
@app.task(bind=True, default_retry_delay=UPLOAD_NEW_DOCUMENT_RETRY_DELAY, ignore_result=True)
def task_upload_new_document(self, document_type_id, shared_uploaded_file_id, extra_data=None):
DocumentType = apps.get_model(
app_label='documents', model_name='DocumentType'
)
MetadataType = apps.get_model(
app_label='metadata', model_name='MetadataType'
)
SharedUploadedFile = apps.get_model(
app_label='common', model_name='SharedUploadedFile'
)
try:
document_type = DocumentType.objects.get(pk=document_type_id)
shared_file = SharedUploadedFile.objects.get(
pk=shared_uploaded_file_id
)
except OperationalError as exception:
logger.warning(
'Operational error during attempt to retrieve shared data for '
'new document of type ID: %d; %s. Retrying.', document_type_id,
exception
)
raise self.retry(exc=exception)
try:
with shared_file.open() as file_object:
new_document = document_type.new_document(file_object=file_object)
except OperationalError as exception:
logger.warning(
'Operational error during attempt to create new document '
'of type: %s; %s. Retrying.', document_type, exception
)
raise self.retry(exc=exception)
except Exception as exception:
# This except and else block emulate a finally:
logger.error(
'Unexpected error during attempt to create new document '
'of type: %s; %s', document_type, exception
)
try:
shared_file.delete()
except OperationalError as exception:
logger.warning(
'Operational error during attempt to delete shared '
'file: %s; %s.', shared_file, exception
)
else:
if extra_data:
for pair in extra_data.get('metadata_pairs', []):
name = slugify(pair['name']).replace('-', '_')
logger.debug(
'Metadata pair (label, name, value): %s, %s, %s',
pair['name'], name, pair['value']
)
metadata_type, created = MetadataType.objects.get_or_create(
name=name, defaults={'label': pair['name']}
)
if not new_document.document_type.metadata.filter(metadata_type=metadata_type).exists():
logger.debug('Metadata type created')
new_document.document_type.metadata.create(
metadata_type=metadata_type, required=False
)
new_document.metadata.create(
metadata_type=metadata_type, value=pair['value']
)
try:
shared_file.delete()
except OperationalError as exception:
logger.warning(
'Operational error during attempt to delete shared '
'file: %s; %s.', shared_file, exception
)

View File

View File

@@ -0,0 +1,120 @@
from __future__ import unicode_literals
import csv
from django.core import management
from django.utils.encoding import force_bytes
from mayan.apps.documents.models import DocumentType, Document
from mayan.apps.documents.tests import GenericDocumentTestCase
from mayan.apps.documents.tests.literals import TEST_SMALL_DOCUMENT_PATH
from mayan.apps.storage.utils import fs_cleanup, mkstemp
class ImportManagementCommandTestCase(GenericDocumentTestCase):
auto_generate_test_csv_file = True
auto_upload_document = False
random_primary_key_enable = False
test_import_count = 1
def setUp(self):
super(ImportManagementCommandTestCase, self).setUp()
if self.auto_generate_test_csv_file:
self._create_test_csv_file()
def tearDown(self):
self._destroy_test_csv_file()
super(ImportManagementCommandTestCase, self).tearDown()
def _create_test_csv_file(self):
self.test_csv_file_descriptor, self.test_csv_path = mkstemp()
print('Test CSV file: {}'.format(self.test_csv_path))
with open(self.test_csv_path, mode='wb') as csvfile:
filewriter = csv.writer(
csvfile, delimiter=force_bytes(','), quotechar=force_bytes('"'),
quoting=csv.QUOTE_MINIMAL
)
print(
'Generating test CSV for {} documents'.format(
self.test_import_count
)
)
for times in range(self.test_import_count):
filewriter.writerow(
[
self.test_document_type.label, TEST_SMALL_DOCUMENT_PATH,
'column 2', 'column 3', 'column 4', 'column 5',
'part #', 'value',
]
)
filewriter.writerow(
[
self.test_document_type.label, TEST_SMALL_DOCUMENT_PATH,
'column 2', 'column 3', 'column 4', 'column 5',
'part#', 'value',
]
)
def _destroy_test_csv_file(self):
fs_cleanup(
filename=self.test_csv_path,
file_descriptor=self.test_csv_file_descriptor
)
def test_import_csv_read(self):
self.test_document_type.delete()
management.call_command('import', self.test_csv_path)
self.assertTrue(DocumentType.objects.count() > 0)
self.assertTrue(Document.objects.count() > 0)
def test_import_document_type_column_mapping(self):
self.test_document_type.delete()
management.call_command(
'import', self.test_csv_path, '--document_type_column', '2'
)
self.assertTrue(DocumentType.objects.first().label == 'column 2')
self.assertTrue(Document.objects.count() > 0)
def test_import_document_path_column_mapping(self):
self.test_document_type.delete()
with self.assertRaises(IOError):
management.call_command(
'import', self.test_csv_path, '--document_path_column', '2'
)
def test_import_metadata_column_mapping(self):
self.test_document_type.delete()
management.call_command(
'import', self.test_csv_path, '--metadata_pairs_column', '2:3,4:5',
)
self.assertTrue(DocumentType.objects.count() > 0)
self.assertTrue(Document.objects.count() > 0)
self.assertTrue(Document.objects.first().metadata.count() > 0)
self.assertEqual(
Document.objects.first().metadata.get(
metadata_type__name='column_2'
).value, 'column 3'
)
def test_import_ambiguous_metadata(self):
self.auto_generate_test_csv_file = False
self.test_import_count = 2
self.test_document_type.delete()
management.call_command(
'import', self.test_csv_path, '--metadata_pairs_column', '6:7',
)
self.assertTrue(DocumentType.objects.count() > 0)
self.assertTrue(Document.objects.count() > 0)
self.assertTrue(Document.objects.first().metadata.count() > 0)
self.assertEqual(
Document.objects.first().metadata.get(
metadata_type__name='part'
).value, 'value'
)

View File

@@ -1,8 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
TEST_BODY_HTML = '<strong>test body</strong>'
TEST_EMAIL_ADDRESS = 'test@example.com' TEST_EMAIL_ADDRESS = 'test@example.com'
TEST_EMAIL_BODY = 'test body'
TEST_EMAIL_BODY_HTML = '<strong>test body</strong>'
TEST_EMAIL_FROM_ADDRESS = 'from.test@example.com' TEST_EMAIL_FROM_ADDRESS = 'from.test@example.com'
TEST_EMAIL_SUBJECT = 'test subject'
TEST_RECIPIENTS_MULTIPLE_COMMA = 'test@example.com,test2@example.com' TEST_RECIPIENTS_MULTIPLE_COMMA = 'test@example.com,test2@example.com'
TEST_RECIPIENTS_MULTIPLE_COMMA_RESULT = [ TEST_RECIPIENTS_MULTIPLE_COMMA_RESULT = [
'test@example.com', 'test2@example.com' 'test@example.com', 'test2@example.com'

View File

@@ -0,0 +1,180 @@
from __future__ import unicode_literals
import json
from django.core import mail
from mayan.apps.common.tests import GenericViewTestCase
from mayan.apps.documents.tests.mixins import DocumentTestMixin
from mayan.apps.document_states.literals import WORKFLOW_ACTION_ON_ENTRY
from mayan.apps.document_states.tests.mixins import WorkflowTestMixin
from mayan.apps.document_states.tests.test_actions import ActionTestCase
from mayan.apps.metadata.tests.mixins import MetadataTypeTestMixin
from ..permissions import permission_user_mailer_use
from ..workflow_actions import EmailAction
from .literals import (
TEST_EMAIL_ADDRESS, TEST_EMAIL_BODY, TEST_EMAIL_FROM_ADDRESS,
TEST_EMAIL_SUBJECT
)
from .mixins import MailerTestMixin
class EmailActionTestCase(MailerTestMixin, WorkflowTestMixin, ActionTestCase):
def test_email_action_literal_text(self):
self._create_test_user_mailer()
action = EmailAction(
form_data={
'mailing_profile': self.test_user_mailer.pk,
'recipient': TEST_EMAIL_ADDRESS,
'subject': TEST_EMAIL_SUBJECT,
'body': TEST_EMAIL_BODY,
}
)
action.execute(context={'document': self.test_document})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS)
self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS])
def test_email_action_workflow_execute(self):
self._create_test_workflow()
self._create_test_workflow_state()
self._create_test_user_mailer()
self.test_workflow_state.actions.create(
action_data=json.dumps(
{
'mailing_profile': self.test_user_mailer.pk,
'recipient': TEST_EMAIL_ADDRESS,
'subject': TEST_EMAIL_SUBJECT,
'body': TEST_EMAIL_BODY,
}
),
action_path='mayan.apps.mailer.workflow_actions.EmailAction',
label='test email action', when=WORKFLOW_ACTION_ON_ENTRY,
)
self.test_workflow_state.initial = True
self.test_workflow_state.save()
self.test_workflow.document_types.add(self.test_document_type)
self.upload_document()
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS)
self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS])
class EmailActionTemplateTestCase(MetadataTypeTestMixin, MailerTestMixin, WorkflowTestMixin, ActionTestCase):
def test_email_action_recipient_template(self):
self._create_test_metadata_type()
self.test_document_type.metadata.create(metadata_type=self.test_metadata_type)
self.test_document.metadata.create(metadata_type=self.test_metadata_type, value=TEST_EMAIL_ADDRESS)
self._create_test_user_mailer()
action = EmailAction(
form_data={
'mailing_profile': self.test_user_mailer.pk,
'recipient': '{{{{ document.metadata_value_of.{} }}}}'.format(self.test_metadata_type.name),
'subject': TEST_EMAIL_SUBJECT,
'body': '',
}
)
action.execute(context={'document': self.test_document})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS)
self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS])
def test_email_action_subject_template(self):
self._create_test_metadata_type()
self.test_document_type.metadata.create(metadata_type=self.test_metadata_type)
self.test_document.metadata.create(metadata_type=self.test_metadata_type, value=TEST_EMAIL_SUBJECT)
self._create_test_user_mailer()
action = EmailAction(
form_data={
'mailing_profile': self.test_user_mailer.pk,
'recipient': TEST_EMAIL_ADDRESS,
'subject': '{{{{ document.metadata_value_of.{} }}}}'.format(self.test_metadata_type.name),
'body': '',
}
)
action.execute(context={'document': self.test_document})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS)
self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS])
def test_email_action_body_template(self):
self._create_test_metadata_type()
self.test_document_type.metadata.create(metadata_type=self.test_metadata_type)
self.test_document.metadata.create(metadata_type=self.test_metadata_type, value=TEST_EMAIL_BODY)
self._create_test_user_mailer()
action = EmailAction(
form_data={
'mailing_profile': self.test_user_mailer.pk,
'recipient': TEST_EMAIL_ADDRESS,
'subject': TEST_EMAIL_SUBJECT,
'body': '{{{{ document.metadata_value_of.{} }}}}'.format(self.test_metadata_type.name),
}
)
action.execute(context={'document': self.test_document})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS)
self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS])
self.assertEqual(mail.outbox[0].body, TEST_EMAIL_BODY)
class EmailActionViewTestCase(DocumentTestMixin, MailerTestMixin, WorkflowTestMixin, GenericViewTestCase):
auto_upload_document = False
def test_email_action_create_get_view(self):
self._create_test_workflow()
self._create_test_workflow_state()
self._create_test_user_mailer()
response = self.get(
viewname='document_states:setup_workflow_state_action_create',
kwargs={
'pk': self.test_workflow_state.pk,
'class_path': 'mayan.apps.mailer.workflow_actions.EmailAction',
}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.test_workflow_state.actions.count(), 0)
def _request_email_action_create_post_view(self):
return self.post(
viewname='document_states:setup_workflow_state_action_create',
kwargs={
'pk': self.test_workflow_state.pk,
'class_path': 'mayan.apps.mailer.workflow_actions.EmailAction',
}, data={
'when': WORKFLOW_ACTION_ON_ENTRY,
'label': 'test email action',
'mailing_profile': self.test_user_mailer.pk,
'recipient': TEST_EMAIL_ADDRESS,
'subject': TEST_EMAIL_SUBJECT,
'body': TEST_EMAIL_BODY,
}
)
def test_email_action_create_post_view(self):
self._create_test_workflow()
self._create_test_workflow_state()
self._create_test_user_mailer()
self.grant_access(
obj=self.test_user_mailer, permission=permission_user_mailer_use
)
response = self._request_email_action_create_post_view()
self.assertEqual(response.status_code, 302)
self.assertEqual(self.test_workflow_state.actions.count(), 1)

View File

@@ -5,7 +5,7 @@ from django.core import mail
from mayan.apps.documents.tests.test_models import GenericDocumentTestCase from mayan.apps.documents.tests.test_models import GenericDocumentTestCase
from .literals import ( from .literals import (
TEST_BODY_HTML, TEST_EMAIL_ADDRESS, TEST_EMAIL_FROM_ADDRESS, TEST_EMAIL_BODY_HTML, TEST_EMAIL_ADDRESS, TEST_EMAIL_FROM_ADDRESS,
TEST_RECIPIENTS_MULTIPLE_COMMA, TEST_RECIPIENTS_MULTIPLE_COMMA_RESULT, TEST_RECIPIENTS_MULTIPLE_COMMA, TEST_RECIPIENTS_MULTIPLE_COMMA_RESULT,
TEST_RECIPIENTS_MULTIPLE_SEMICOLON, TEST_RECIPIENTS_MULTIPLE_SEMICOLON,
TEST_RECIPIENTS_MULTIPLE_SEMICOLON_RESULT, TEST_RECIPIENTS_MULTIPLE_MIXED, TEST_RECIPIENTS_MULTIPLE_SEMICOLON_RESULT, TEST_RECIPIENTS_MULTIPLE_MIXED,
@@ -25,17 +25,22 @@ class ModelTestCase(MailerTestMixin, GenericDocumentTestCase):
def test_send_simple_with_html(self): def test_send_simple_with_html(self):
self._create_test_user_mailer() self._create_test_user_mailer()
self.test_user_mailer.send(to=TEST_EMAIL_ADDRESS, body=TEST_BODY_HTML) self.test_user_mailer.send(
to=TEST_EMAIL_ADDRESS, body=TEST_EMAIL_BODY_HTML
)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS) self.assertEqual(mail.outbox[0].from_email, TEST_EMAIL_FROM_ADDRESS)
self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS]) self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS])
self.assertEqual(mail.outbox[0].alternatives[0][0], TEST_BODY_HTML) self.assertEqual(
mail.outbox[0].alternatives[0][0], TEST_EMAIL_BODY_HTML
)
def test_send_attachment(self): def test_send_attachment(self):
self._create_test_user_mailer() self._create_test_user_mailer()
self.test_user_mailer.send_document( self.test_user_mailer.send_document(
to=TEST_EMAIL_ADDRESS, document=self.test_document, as_attachment=True to=TEST_EMAIL_ADDRESS, document=self.test_document,
as_attachment=True
) )
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)

View File

@@ -0,0 +1,124 @@
from __future__ import absolute_import, unicode_literals
import logging
from django.template import Template, Context
from django.utils.translation import ugettext_lazy as _
from mayan.apps.acls.models import AccessControlList
from mayan.apps.document_states.classes import WorkflowAction
from mayan.apps.document_states.exceptions import WorkflowStateActionError
from .models import UserMailer
from .permissions import permission_user_mailer_use
__all__ = ('EmailAction',)
logger = logging.getLogger(__name__)
class EmailAction(WorkflowAction):
fields = {
'mailing_profile': {
'label': _('Mailing profile'),
'class': 'django.forms.ModelChoiceField', 'kwargs': {
'help_text': _('Mailing profile to use when sending the email.'),
'queryset': UserMailer.objects.none(), 'required': True
}
},
'recipient': {
'label': _('Recipient'),
'class': 'django.forms.CharField', 'kwargs': {
'help_text': _(
'Email address of the recipient. Can be multiple addresses '
'separated by comma or semicolon. A template can be used '
'to reference properties of the document.'
),
'required': True
}
},
'subject': {
'label': _('Subject'),
'class': 'django.forms.CharField', 'kwargs': {
'help_text': _(
'Subject of the email. Can be a string or a template.'
),
'required': True
}
},
'body': {
'label': _('Body'),
'class': 'django.forms.CharField', 'kwargs': {
'help_text': _(
'Body of the email to send. Can be a string or a template.'
),
'required': True
}
},
}
field_order = ('mailing_profile', 'recipient', 'subject', 'body')
label = _('Send email')
widgets = {
'body': {
'class': 'django.forms.widgets.Textarea', 'kwargs': {}
}
}
permission = permission_user_mailer_use
def execute(self, context):
try:
recipient = Template(self.form_data['recipient']).render(
context=Context(context)
)
except Exception as exception:
raise WorkflowStateActionError(
_('Recipient template error: %s') % exception
)
else:
logger.debug('Recipient result: %s', recipient)
try:
subject = Template(self.form_data['subject']).render(
context=Context(context)
)
except Exception as exception:
raise WorkflowStateActionError(
_('Subject template error: %s') % exception
)
else:
logger.debug('Subject result: %s', subject)
try:
body = Template(self.form_data['body']).render(
context=Context(context)
)
except Exception as exception:
raise WorkflowStateActionError(
_('Body template error: %s') % exception
)
else:
logger.debug('Body result: %s', body)
user_mailer = self.get_user_mailer()
user_mailer.send(
to=recipient, subject=subject, body=body,
)
def get_form_schema(self, request):
user = request.user
logger.debug('user: %s', user)
queryset = AccessControlList.objects.restrict_queryset(
permission=self.permission, queryset=UserMailer.objects.all(),
user=user
)
self.fields['mailing_profile']['kwargs']['queryset'] = queryset
return {
'field_order': self.field_order,
'fields': self.fields,
'widgets': self.widgets
}
def get_user_mailer(self):
return UserMailer.objects.get(pk=self.form_data['mailing_profile'])

View File

@@ -577,44 +577,65 @@ class SourceColumn(object):
@classmethod @classmethod
def get_for_source(cls, context, source, exclude_identifier=False, only_identifier=False): def get_for_source(cls, context, source, exclude_identifier=False, only_identifier=False):
columns = []
source_classes = set()
if hasattr(source, '_meta'):
source_classes.add(source._meta.model)
else:
source_classes.add(source)
try: try:
result = cls._registry[source] columns.extend(cls._registry[source])
except KeyError: except KeyError:
pass
try: try:
# Might be an instance, try its class # Might be an instance, try its class
result = cls._registry[source.__class__] columns.extend(cls._registry[source.__class__])
except KeyError: except KeyError:
try: try:
# Might be a subclass, try its root class # Might be a subclass, try its root class
result = cls._registry[source.__class__.__mro__[-2]] columns.extend(cls._registry[source.__class__.__mro__[-2]])
except KeyError: except KeyError:
pass
try: try:
# Might be an inherited class insance, try its source class # Might be an inherited class instance, try its source class
result = cls._registry[source.source_ptr.__class__] columns.extend(cls._registry[source.source_ptr.__class__])
except (KeyError, AttributeError): except (KeyError, AttributeError):
pass
try: try:
# Try it as a queryset # Try it as a queryset
result = cls._registry[source.model] columns.extend(cls._registry[source.model])
except AttributeError: except AttributeError:
pass
try: try:
# Special case for queryset items produced from # Special case for queryset items produced from
# .defer() or .only() optimizations # .defer() or .only() optimizations
result = cls._registry[list(source._meta.parents.items())[0][0]] result = cls._registry[list(source._meta.parents.items())[0][0]]
except (AttributeError, KeyError, IndexError): except (AttributeError, KeyError, IndexError):
result = () pass
except TypeError: else:
# unhashable type: list # Second level special case for model subclasses from
result = () # .defer and .only querysets
# Examples: Workflow runtime proxy and index instances in 3.2.x
for column in result:
if not source_classes.intersection(set(column.exclude)):
columns.append(column)
result = SourceColumn.sort(columns=result) columns = SourceColumn.sort(columns=columns)
if exclude_identifier: if exclude_identifier:
result = [item for item in result if not item.is_identifier] columns = [column for column in columns if not column.is_identifier]
else: else:
if only_identifier: if only_identifier:
for item in result: for column in columns:
if item.is_identifier: if column.is_identifier:
return item return column
return None return None
final_result = [] final_result = []
@@ -632,20 +653,20 @@ class SourceColumn(object):
logger.warning( logger.warning(
'No request variable, aborting request resolution' 'No request variable, aborting request resolution'
) )
return result return final_result
current_view_name = get_current_view_name(request=request) current_view_name = get_current_view_name(request=request)
for item in result: for column in columns:
if item.views: if column.views:
if current_view_name in item.views: if current_view_name in column.views:
final_result.append(item) final_result.append(column)
else: else:
final_result.append(item) final_result.append(column)
return final_result return final_result
def __init__( def __init__(
self, source, attribute=None, empty_value=None, func=None, self, source, attribute=None, empty_value=None, exclude=None, func=None,
include_label=False, is_attribute_absolute_url=False, include_label=False, is_attribute_absolute_url=False,
is_object_absolute_url=False, is_identifier=False, is_sortable=False, is_object_absolute_url=False, is_identifier=False, is_sortable=False,
kwargs=None, label=None, order=None, sort_field=None, views=None, kwargs=None, label=None, order=None, sort_field=None, views=None,
@@ -655,6 +676,7 @@ class SourceColumn(object):
self._label = label self._label = label
self.attribute = attribute self.attribute = attribute
self.empty_value = empty_value self.empty_value = empty_value
self.exclude = exclude or ()
self.func = func self.func = func
self.is_attribute_absolute_url = is_attribute_absolute_url self.is_attribute_absolute_url = is_attribute_absolute_url
self.is_object_absolute_url = is_object_absolute_url self.is_object_absolute_url = is_object_absolute_url

View File

@@ -1,6 +1,8 @@
{% load appearance_tags %}
<div class="{% if div_class %}{{ div_class }}{% else %}col-xs-12 col-sm-6 col-md-4 col-lg-3{% endif %}"> <div class="{% if div_class %}{{ div_class }}{% else %}col-xs-12 col-sm-6 col-md-4 col-lg-3{% endif %}">
<a class="btn btn-default btn-lg btn-block {% if 'new_window' in link.tags %}new_window{% endif %}" href="{{ link.url }}"> <a class="btn btn-primary btn-lg btn-block {% if 'new_window' in link.tags %}new_window{% endif %}" href="{{ link.url }}" style="">
{% if link.icon_class %}<span style="font-size: 200%;">{{ link.icon_class.render }}</span>{% endif %} {% if link.icon_class %}<span style="font-size: 200%;">{% appearance_icon_render link.icon_class enable_shadow=True %}</span>{% endif %}
<br> <br>
{{ link.text }} {{ link.text }}
</a> </a>

View File

@@ -113,12 +113,6 @@ def navigation_source_column_get_absolute_url(source_column, obj):
return source_column.get_absolute_url(obj=obj) return source_column.get_absolute_url(obj=obj)
@register.simple_tag(takes_context=True)
def resolve_link(context, link):
# This can be used to resolve links or menus too
return link.resolve(context=context)
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def navigation_source_column_resolve(context, column): def navigation_source_column_resolve(context, column):
if column: if column:
@@ -126,3 +120,9 @@ def navigation_source_column_resolve(context, column):
return result return result
else: else:
return '' return ''
@register.simple_tag(takes_context=True)
def resolve_link(context, link):
# This can be used to resolve links or menus too
return link.resolve(context=context)

View File

@@ -0,0 +1,3 @@
from __future__ import unicode_literals
default_app_config = 'mayan.apps.redactions.apps.RedactionsApp'

View File

@@ -0,0 +1,56 @@
from __future__ import unicode_literals
import logging
from django.apps import apps
from django.utils.translation import ugettext_lazy as _
from mayan.apps.common.apps import MayanAppConfig
from mayan.apps.common.menus import (
menu_list_facet, menu_object, menu_secondary,
)
from .dependencies import * # NOQA
from .links import (
link_redaction_create, link_redaction_delete, link_redaction_edit,
link_redaction_list
)
logger = logging.getLogger(__name__)
class RedactionsApp(MayanAppConfig):
app_namespace = 'redactions'
app_url = 'redactions'
has_rest_api = False
has_tests = False
name = 'mayan.apps.redactions'
verbose_name = _('Redactions')
def ready(self):
super(RedactionsApp, self).ready()
DocumentPage = apps.get_model(
app_label='documents', model_name='DocumentPage'
)
Redaction = self.get_model(model_name='Redaction')
menu_list_facet.bind_links(
links=(
link_redaction_list,
), sources=(DocumentPage,)
)
menu_object.bind_links(
links=(link_redaction_delete, link_redaction_edit,),
sources=(Redaction,)
)
menu_secondary.bind_links(
links=(link_redaction_create,), sources=(Redaction,)
)
menu_secondary.bind_links(
links=(link_redaction_create,),
sources=(
'redactions:redaction_create',
'redactions:redaction_list'
)
)

View File

@@ -0,0 +1,28 @@
'''
from __future__ import unicode_literals
import logging
from PIL import Image
from converter import converter_class
logger = logging.getLogger(__name__)
class OCRBackendBase(object):
def execute(self, file_object, language=None, process_barcodes=True, process_text=True, transformations=None):
self.language = language
self.process_barcodes = process_barcodes
self.process_text = process_text
if not transformations:
transformations = []
self.converter = converter_class(file_object=file_object)
for transformation in transformations:
self.converter.transform(transformation=transformation)
self.image = Image.open(self.converter.get_page())
'''

View File

@@ -0,0 +1,15 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from mayan.apps.dependencies.classes import (
GoogleFontDependency, JavaScriptDependency
)
JavaScriptDependency(
label=_('JavaScript image cropper'), module=__name__, name='cropperjs',
version_string='=1.4.1'
)
JavaScriptDependency(
module=__name__, name='jquery-cropper', version_string='=1.0.0'
)

View File

@@ -0,0 +1,16 @@
'''from __future__ import unicode_literals
class OCRError(Exception):
"""
Raised by the OCR backend
"""
pass
class UnpaperError(Exception):
"""
Raised by unpaper
"""
pass
'''

View File

@@ -0,0 +1,15 @@
from __future__ import unicode_literals
from django import forms
from django.utils.translation import ugettext_lazy as _
from .models import Redaction
class RedactionCoordinatesForm(forms.ModelForm):
class Meta:
fields = ('arguments',)
model = Redaction
widgets = {
'arguments': forms.widgets.Textarea(attrs={'class': 'hidden'}),
}

View File

@@ -0,0 +1,11 @@
from __future__ import absolute_import, unicode_literals
from mayan.apps.appearance.classes import Icon
icon_redaction_create = Icon(
driver_name='fontawesome-dual', primary_symbol='highlighter',
secondary_symbol='plus'
)
icon_redaction_delete = Icon(driver_name='fontawesome', symbol='times')
icon_redaction_edit = Icon(driver_name='fontawesome', symbol='pencil-alt')
icon_redactions = Icon(driver_name='fontawesome', symbol='highlighter')

View File

@@ -0,0 +1,32 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from mayan.apps.navigation.classes import Link
from .permissions import (
permission_redaction_create, permission_redaction_delete,
permission_redaction_edit, permission_redaction_view
)
link_redaction_create = Link(
icon_class_path='mayan.apps.redactions.icons.icon_redaction_create',
permissions=(permission_redaction_create,), text=_('Create redaction'),
view='redactions:redaction_create', args='resolved_object.id'
)
link_redaction_delete = Link(
icon_class_path='mayan.apps.redactions.icons.icon_redaction_delete',
permissions=(permission_redaction_delete,), tags='dangerous',
text=_('Delete'), view='redactions:redaction_delete',
args='resolved_object.id'
)
link_redaction_edit = Link(
icon_class_path='mayan.apps.redactions.icons.icon_redaction_edit',
permissions=(permission_redaction_edit,), text=_('Edit'),
view='redactions:redaction_edit', args='resolved_object.id'
)
link_redaction_list = Link(
icon_class_path='mayan.apps.redactions.icons.icon_redactions',
permissions=(permission_redaction_view,), text=_('Redactions'),
view='redactions:redaction_list', args='resolved_object.id'
)

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-06-26 19:04
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = [
('converter', '0014_auto_20190626_1904'),
]
operations = [
migrations.CreateModel(
name='Redaction',
fields=[
],
options={
'verbose_name': 'Redaction',
'proxy': True,
'verbose_name_plural': 'Redactions',
'indexes': [],
},
bases=('converter.transformation',),
),
]

View File

@@ -0,0 +1,12 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from mayan.apps.converter.models import Transformation
class Redaction(Transformation):
class Meta:
proxy = True
verbose_name = _('Redaction')
verbose_name_plural = _('Redactions')

View File

@@ -0,0 +1,20 @@
from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from mayan.apps.permissions import PermissionNamespace
namespace = PermissionNamespace(label=_('Redactions'), name='redactions')
permission_redaction_create = namespace.add_permission(
label=_('Create new redactions'), name='redaction_create'
)
permission_redaction_delete = namespace.add_permission(
label=_('Delete redactions'), name='redaction_delete'
)
permission_redaction_edit = namespace.add_permission(
label=_('Edit redactions'), name='redaction_edit'
)
permission_redaction_view = namespace.add_permission(
label=_('View existing redactions'), name='redaction_view'
)

View File

@@ -0,0 +1,5 @@
from rest_framework import serializers
class DocumentVersionOCRSerializer(serializers.Serializer):
document_version_id = serializers.IntegerField()

View File

@@ -0,0 +1,113 @@
{% extends 'appearance/base.html' %}
{% load i18n %}
{% load static %}
{% load common_tags %}
{% block title %}{% blocktrans with object as object %}Redaction coordinates for: {{ object }}{% endblocktrans %}{% endblock title %}
{% block stylesheets %}
<link href="{% static 'redactions/node_modules/cropperjs/dist/cropper.css' %}" rel="stylesheet">
<style>
.cropper-main {
width: 100%;
}
.cropper-main img {
max-width: 100%;
}
</style>
{% endblock %}
{% block content %}
<div class="cropper-main">
<img src="{{ document_page.get_api_image_url }}">
</div>
<br>
{% with '' as title %}
{% include 'appearance/generic_form_subtemplate.html' %}
{% endwith %}
{% endblock content %}
{% block javascript %}
<script>
var crop_left, crop_top, crop_right, crop_bottom;
var pic_real_width, pic_real_height;
var canvasData;
var containerData;
var $image = $('.cropper-main img');
var cropperInstance;
var defaultArguments = {
left: 10,
top: 10,
right: 10,
bottom: 10,
fillcolor: '#000000',
}
var initialArguments = JSON.parse($('#id_arguments').text() || JSON.stringify(defaultArguments));
var callbackCrop = function (data) {
var crop_left = (data.detail.x / pic_real_width * 100).toFixed(2);
var crop_top = (data.detail.y / pic_real_height * 100).toFixed(2);
var crop_right = (100.001 - (data.detail.x + data.detail.width) / pic_real_width * 100).toFixed(2);
var crop_bottom = (100.001 - (data.detail.y + data.detail.height) / pic_real_height * 100).toFixed(2);
var arguments = {
'left': parseFloat(crop_left),
'top': parseFloat(crop_top),
'right': parseFloat(crop_right),
'bottom': parseFloat(crop_bottom),
'fillcolor': '#000000',
}
$('#id_arguments').text(JSON.stringify(arguments));
}
jQuery(document).ready(function() {
$('.help-block').hide();
$('label').hide();
});
$.getScript("{% static 'redactions/node_modules/cropperjs/dist/cropper.js' %}")
.done(function (script, textStatus) {
$.getScript("{% static 'redactions/node_modules/jquery-cropper/dist/jquery-cropper.js' %}")
.done(function (script, textStatus) {
jQuery(document).ready(function () {
// Create DOM new image to get the real
// (unscaled) image size
$('<img/>')
.attr('src', $image.attr('src'))
.on('load', function () {
pic_real_width = this.width;
pic_real_height = this.height;
});
cropperInstance = $image.cropper({
crop: callbackCrop,
mouseWheelZoom: false,
movable: false,
//preview: '.cropper-preview',
ready: function () {
canvasData = $image.cropper('getCanvasData');
containerData = $image.cropper('getContainerData');
$image.cropper('setCropBoxData', {
left: initialArguments.left / 100.0 * canvasData.width + canvasData.left,
top: initialArguments.top / 100.0 * canvasData.height + canvasData.top,
width: (100.0 - initialArguments.right - initialArguments.left) / 100.0 * canvasData.width,
height: (100.0 - initialArguments.bottom - initialArguments.top) / 100.0 * canvasData.height,
});
},
rotatable: false,
touchDragZoom: false,
viewMode: 1,
zoomable: false,
});
})
})
});
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
from __future__ import unicode_literals
from django.conf.urls import url
from .views import (
RedactionCreateView, RedactionDeleteView, RedactionEditView,
RedactionListView,
)
urlpatterns = [
url(
regex=r'^document_pages/(?P<pk>\d+)/redactions/create/$',
view=RedactionCreateView.as_view(), name='redaction_create'
),
url(
regex=r'^document_pages/(?P<pk>\d+)/redactions/$',
view=RedactionListView.as_view(), name='redaction_list'
),
url(
regex=r'^redactions/(?P<pk>\d+)/delete/$',
view=RedactionDeleteView.as_view(), name='redaction_delete'
),
url(
regex=r'^redactions/(?P<pk>\d+)/edit/$',
view=RedactionEditView.as_view(), name='redaction_edit'
),
]
api_urls = []

View File

@@ -0,0 +1,147 @@
from __future__ import absolute_import, unicode_literals
import logging
from django.core.urlresolvers import reverse
from django.template import RequestContext
from django.utils.translation import ugettext_lazy as _
from mayan.apps.common.generics import (
SingleObjectCreateView, SingleObjectDeleteView, SingleObjectEditView,
SingleObjectListView
)
from mayan.apps.common.mixins import ExternalObjectMixin
from mayan.apps.converter.transformations import TransformationDrawRectanglePercent
from mayan.apps.documents.models import DocumentPage
from .forms import RedactionCoordinatesForm
from .icons import icon_redactions
from .links import link_redaction_create
from .models import Redaction
from .permissions import (
permission_redaction_create, permission_redaction_delete,
permission_redaction_edit, permission_redaction_view
)
logger = logging.getLogger(__name__)
class RedactionCreateView(ExternalObjectMixin, SingleObjectCreateView):
external_object_class = DocumentPage
external_object_pk_url_kwarg = 'pk'
form_class = RedactionCoordinatesForm
model = Redaction
object_permission = permission_redaction_create
template_name = 'redactions/cropper.html'
def form_valid(self, form):
instance = form.save(commit=False)
instance.content_object = self.external_object
instance.name = TransformationDrawRectanglePercent.name
instance.save()
return super(RedactionCreateView, self).form_valid(form)
def get_extra_context(self, **kwargs):
context = {
'document_page': self.external_object,
'redaction': self.object,
'title': _('Create redaction for: %s') % self.external_object
}
return context
def get_post_action_redirect(self):
return reverse(
viewname='redactions:redaction_list', kwargs={
'pk': self.external_object.pk
}
)
class RedactionDeleteView(SingleObjectDeleteView):
model = Redaction
object_permission = permission_redaction_delete
def get_post_action_redirect(self):
return reverse(
viewname='redactions:redaction_list', kwargs={
'pk': self.object.content_object.pk
}
)
def get_extra_context(self):
return {
'content_object': self.object.content_object,
'navigation_object_list': ('content_object', 'redaction'),
'previous': reverse(
viewname='redactions:redaction_list', kwargs={
'pk': self.object.content_object.pk
}
),
'redaction': self.object,
'title': _(
'Delete refaction for: %(content_object)s?'
) % {
'content_object': self.object.content_object
},
}
class RedactionEditView(SingleObjectEditView):
form_class = RedactionCoordinatesForm
model = Redaction
object_permission = permission_redaction_edit
template_name = 'redactions/cropper.html'
def get_extra_context(self, **kwargs):
context = {
'document_page': self.object.content_object,
'navigation_object_list': ['document_page', 'redaction'],
'redaction': self.object,
'title': _('Edit redaction: %s') % self.object
}
return context
def get_post_action_redirect(self):
return reverse(
viewname='redactions:redaction_list', kwargs={
'pk': self.object.content_object.pk
}
)
class RedactionListView(ExternalObjectMixin, SingleObjectListView):
external_object_class = DocumentPage
object_permission = permission_redaction_view
external_object_pk_url_kwarg = 'pk'
def dispatch(self, request, *args, **kwargs):
return super(RedactionListView, self).dispatch(
request, *args, **kwargs
)
def get_extra_context(self):
return {
'hide_object': True,
'object': self.external_object,
'no_results_icon': icon_redactions,
'no_results_main_link': link_redaction_create.resolve(
context=RequestContext(
request=self.request, dict_={
'object': self.external_object
}
)
),
'no_results_text': _(
'Redactions allow removing access to confidential and '
'sensitive information without having to modify the document.'
),
'no_results_title': _('No existing redactions'),
'title': _('Redactions for: %s') % self.external_object,
}
def get_source_queryset(self):
return Redaction.objects.get_for_object(
obj=self.external_object
).filter(name__startswith='draw')

Binary file not shown.

View File

@@ -61,7 +61,7 @@ class Tag(models.Model):
def get_document_count(self, user): def get_document_count(self, user):
""" """
Return the numeric count of documents that have this tag attached. Return the numeric count of documents that have this tag attached.
The count if filtered by access. The count is filtered by access.
""" """
queryset = AccessControlList.objects.restrict_queryset( queryset = AccessControlList.objects.restrict_queryset(
permission=permission_document_view, queryset=self.documents, permission=permission_document_view, queryset=self.documents,

View File

@@ -120,11 +120,13 @@ INSTALLED_APPS = (
'mayan.apps.document_states', 'mayan.apps.document_states',
'mayan.apps.documents', 'mayan.apps.documents',
'mayan.apps.file_metadata', 'mayan.apps.file_metadata',
'mayan.apps.importer',
'mayan.apps.linking', 'mayan.apps.linking',
'mayan.apps.mailer', 'mayan.apps.mailer',
'mayan.apps.mayan_statistics', 'mayan.apps.mayan_statistics',
'mayan.apps.metadata', 'mayan.apps.metadata',
'mayan.apps.mirroring', 'mayan.apps.mirroring',
'mayan.apps.redactions',
'mayan.apps.ocr', 'mayan.apps.ocr',
'mayan.apps.sources', 'mayan.apps.sources',
'mayan.apps.storage', 'mayan.apps.storage',