Compare commits

...

43 Commits

Author SHA1 Message Date
Roberto Rosario
25854a0a1c Merge branch 'cherry-pick-273f94e9' into 'development'
Merge branch 'fix-context' into 'development'

See merge request !22
2017-09-07 05:14:16 +00:00
Roberto Rosario
6006fc9b74 Merge branch 'fix-context' into 'master'
Fix error in context: it must be a dict

See merge request !15
2017-09-07 05:13:42 +00:00
Roberto Rosario
57baf76a50 Add information on installing flake8's git hook.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 01:08:01 -04:00
Roberto Rosario
8385ef13b0 Remove Vagrant section of the document. Anything related to
Vagrant has been move into its own repository at:
https://gitlab.com/mayan-edms/mayan-edms-vagrant

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 01:05:13 -04:00
Roberto Rosario
dd1e4cb685 Update event tests to use .id of an event instead of the .name.
The .id now also returns the namespace.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 01:01:11 -04:00
Roberto Rosario
6dcfd1b861 Ignore ProgramminError exception too while initializing the Quota model.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 01:00:31 -04:00
Roberto Rosario
d867614c1c Update requirements versions.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 00:52:42 -04:00
Roberto Rosario
6cc574e631 Fix method name typo.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 00:22:00 -04:00
Roberto Rosario
5b94d202dc Use get_object_list instead of get_queryset.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 00:13:28 -04:00
Roberto Rosario
3e7d3946f8 Don't error out when an object doesn't provide a get_absolute_url
method.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 00:12:17 -04:00
Roberto Rosario
2cbb1bc9e1 Add get_absolute_url method to document types.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 00:12:06 -04:00
Roberto Rosario
f0505db3b7 Update apps to use the new event type namespace class.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-07 00:11:25 -04:00
Roberto Rosario
39995764eb Merge remote-tracking branch 'origin/series/2.0' into development
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-09-06 23:09:54 -04:00
Roberto Rosario
d0aad4cb35 Merge branch 'feature/textfield_metadata#426' into 'development'
add migration for documentmetadata value to textfield

See merge request !20
2017-09-07 02:50:56 +00:00
Gustavo Selbach Teixeira
7abf8a7fc0 add migration for documentmetadata value to textfield 2017-09-06 08:45:11 -03:00
Roberto Rosario
fc0127c7ad Merge branch 'feature/textfield_metadata#426' into 'development'
Makes documentmetadata value a textfield

See merge request !19
2017-09-06 03:10:59 +00:00
Gustavo Selbach Teixeira
bbef5fd570 Makes documentmetadata value a textfield 2017-09-05 10:23:11 -03:00
Roberto Rosario
7a492b28f3 Merge branch 'feature/decode-base64-encoded-attachment-filename' into 'development'
Decode base64 encoded e-mail attachment filename.

See merge request !16
2017-08-15 15:58:08 +00:00
Cornelius Ludmann
abd720bf10 Test for base64 encoded e-mail attachment filename
Signed-off-by: Cornelius Ludmann <dev@cornelius-ludmann.de>
2017-08-02 14:35:11 +02:00
Cornelius Ludmann
25c1391649 Decode base64 encoded e-mail attachment filename.
According to RFC 2231 the filename of e-mail attachments can be encoded,
e.g. with base64 (for example, the Brother ADS2800W Scanner does this).

Here an example how it looks like:

Content-Disposition: attachment; filename="=?UTF-8?B?QlJXMjg1NjVBOEI5RkQyXzIwMTcwNTIzXzIzMDMzN18wMDAzMDMucGRm?="

Signed-off-by: Cornelius Ludmann <dev@cornelius-ludmann.de>
2017-08-02 13:28:43 +02:00
Roberto Rosario
95c0be45fb Remove the 'folders' app. GitLab issue #380.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:30:39 -04:00
Roberto Rosario
944ddd682b Add ACL list link with icon and use it for the document facet menu.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:27:25 -04:00
Roberto Rosario
c6bf3b00cd Fix mailing app permissions labels.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:24:36 -04:00
Roberto Rosario
bd419dc943 Add missing events app migration.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:23:42 -04:00
Roberto Rosario
70e2ca3334 Update changelog.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:22:50 -04:00
Roberto Rosario
5dd88ad690 Add ACLs link and ACLs permissions to the mailer profile model.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:20:44 -04:00
Roberto Rosario
ae57b97a1b Improve document creation via sources app to trigger
correct event.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:16:43 -04:00
Roberto Rosario
cb15e40f70 Improve mailer URL regex.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:16:04 -04:00
Roberto Rosario
01420c42dd Add support for quotas. GitLab issue #284.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:10:55 -04:00
Roberto Rosario
20e3634f5a Add support for dropdown menus for the item list view template.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:10:24 -04:00
Roberto Rosario
48fc36d54e Convert the document version list view to item view mode.
Add document version preview and thumbnail widgets.
Update the new version upload event have the version
as the target and the document as the action object.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-02 04:10:09 -04:00
Roberto Rosario
7a29b2496b Update release notes.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-01 01:20:44 -04:00
Roberto Rosario
c0407652c0 Add support for global and object event notification. GitLab issue #262.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-08-01 01:18:07 -04:00
Roberto Rosario
5083a2d261 Fix tests.
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-07-29 02:13:46 -04:00
Roberto Rosario
ba1ab5d4f1 Merge remote-tracking branch 'origin/master' into development
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-07-29 01:55:42 -04:00
Roberto Rosario
17b7d6f25e Merge remote-tracking branch 'origin/master' into development 2017-07-05 16:03:50 -04:00
Roberto Rosario
ca1f674d78 Merge remote-tracking branch 'origin/master' into merge_master
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-06-07 13:56:35 -04:00
Roberto Rosario
93aeb8cffe Merge remote-tracking branch 'origin/master' into merge_master
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-05-24 15:02:36 -04:00
Roberto Rosario
8eeee5b5a4 Update the required versions of Pillow, django-compressor, django-suit,
pyocr and sh.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-05-16 23:16:28 -04:00
Roberto Rosario
6221187070 Merge branch 'development' into feature/sane_source 2017-05-12 18:06:19 -04:00
Roberto Rosario
acdc7dca48 Convert the API URL system from an App based one
to a resource based one.

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-05-12 17:55:12 -04:00
Roberto Rosario
105eab0740 Add new document version list view permission. GitLab issue #379
Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-05-04 01:04:53 -04:00
Roberto Rosario
b91f7f685a Incorporate @Macrobb metadata widget and content visual changes.
GitLab issue #378

Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
2017-05-04 00:42:59 -04:00
99 changed files with 2709 additions and 337 deletions

View File

@@ -1,3 +1,8 @@
3.0 (2017-XX-XX)
================
- Add support for notifications. GitLab #262.
- Add quota support. GitLab #284.
2.7.2 (2017-09-06)
==================
- Fix new mailer creation view. GitLab issue #431.

View File

@@ -10,9 +10,9 @@ APP_LIST = (
'converter', 'django_gpg', 'document_comments', 'document_indexing',
'document_parsing', 'document_signatures', 'document_states', 'documents',
'dynamic_search', 'events', 'linking', 'lock_manager', 'mayan_statistics',
'mailer', 'metadata', 'mirroring', 'motd', 'navigation', 'ocr', 'permissions',
'rest_api', 'smart_settings', 'sources', 'storage', 'tags', 'task_manager',
'user_management'
'mailer', 'metadata', 'mirroring', 'motd', 'navigation', 'ocr',
'permissions', 'rest_api', 'smart_settings', 'sources', 'storage', 'tags',
'task_manager', 'user_management'
)
LANGUAGE_LIST = (

79
docs/releases/3.0.rst Normal file
View File

@@ -0,0 +1,79 @@
=============================
Mayan EDMS v3.0 release notes
=============================
Released: XX XX, 2017
What's new
==========
Other changes
-------------
- Resource based API endpoints
- Update required versions of Pillow to 4.1.1, django-compressor to 2.1.1, django-suit to 0.2.25, pyocr to 0.4.7, sh to 1.12.13
- Add notifications support.
Removals
--------
- None
Upgrading from a previous version
---------------------------------
If installed via PIP
~~~~~~~~~~~~~~~~~~~~
Type in the console::
$ pip install -U mayan-edms
the requirements will also be updated automatically.
If installed 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.
Manually upgrade/add the new requirements::
$ pip install --upgrade -r requirements.txt
Remove deprecated requirements::
$ pip uninstall -y -r removals.txt
Common steps
~~~~~~~~~~~~
Migrate existing database schema with::
$ mayan-edms.py performupgrade
Add new static media::
$ mayan-edms.py collectstatic --noinput
The upgrade procedure is now complete.
Backward incompatible changes
=============================
* None
Bugs fixed or issues closed
===========================
* `GitLab issue #366 <https://gitlab.com/mayan-edms/mayan-edms/issues/366>`_ Proofread documentation
* `GitLab issue #379 <https://gitlab.com/mayan-edms/mayan-edms/issues/379>`_ Add new document version list view permission.
* `GitLab issue #379 <https://gitlab.com/mayan-edms/mayan-edms/issues/379>`_ Add new document version list view permission.
.. _PyPI: https://pypi.python.org/pypi/mayan-edms/

View File

@@ -54,6 +54,12 @@ Whenever possible, but don't obsess over things like line length:
$ flake8 --ignore=E501,E128,E122 |less
To perform automatic PEP8 checks, install flake8's git hook using:
.. code-block:: bash
$ flake8 --install-hook git
Imports
~~~~~~~
@@ -245,47 +251,6 @@ Steps to deploy a development version
$ ./manage.py runserver
Setting up a development version using Vagrant
----------------------------------------------
Make sure you have Vagrant and a provider properly installed as per
https://docs.vagrantup.com/v2/installation/index.html
Start and provision a machine using:
.. code-block:: bash
$ vagrant up development
To launch a standalone development server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: bash
$ vagrant ssh
$ vagrant@vagrant-ubuntu-trusty-32:~$ cd ~/mayan-edms/
$ vagrant@vagrant-ubuntu-trusty-32:~$ source venv/bin/activate
$ vagrant@vagrant-ubuntu-trusty-32:~$ ./manage.py runserver 0.0.0.0:8000
To launch a development server with a celery worker and Redis as broker
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: bash
$ vagrant ssh
$ vagrant@vagrant-ubuntu-trusty-32:~$ cd ~/mayan-edms/
$ vagrant@vagrant-ubuntu-trusty-32:~$ source venv/bin/activate
$ vagrant@vagrant-ubuntu-trusty-32:~$ ./manage.py runserver 0.0.0.0:8000 --settings=mayan.settings.celery_redis
Then on a separate console launch a celery worker from the same provisioned Vagrant machine:
.. code-block:: bash
$ vagrant ssh
$ vagrant@vagrant-ubuntu-trusty-32:~$ cd ~/mayan-edms/
$ vagrant@vagrant-ubuntu-trusty-32:~$ source venv/bin/activate
$ vagrant@vagrant-ubuntu-trusty-32:~$ DJANGO_SETTINGS_MODULE='mayan.settings.celery_redis' celery -A mayan worker -l DEBUG -Q checkouts,mailing,uploads,converter,ocr,tools,indexing,metadata -Ofair -B
Contributing changes
--------------------
Once your have created and committed some new code or feature, submit a Pull

View File

@@ -92,8 +92,10 @@ class AccessControlListManager(models.Manager):
def filter_by_access(self, permission, user, queryset):
if user.is_superuser or user.is_staff:
logger.debug('Unfiltered queryset returned to user "%s" as superuser or staff',
user)
logger.debug(
'Unfiltered queryset returned to user "%s" as superuser '
'or staff', user
)
return queryset
try:

View File

@@ -28,19 +28,19 @@ urlpatterns = [
api_urls = [
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/$',
APIObjectACLListView.as_view(), name='accesscontrollist-list'
),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/$',
APIObjectACLView.as_view(), name='accesscontrollist-detail'
),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/$',
APIObjectACLPermissionListView.as_view(), name='accesscontrollist-permission-list'
),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/(?P<permission_pk>\d+)/$',
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_pk>\d+)/acls/(?P<pk>\d+)/permissions/(?P<permission_pk>\d+)/$',
APIObjectACLPermissionView.as_view(), name='accesscontrollist-permission-detail'
),
]

View File

@@ -5,6 +5,7 @@ import logging
from django.utils.translation import ugettext_lazy as _
from common import MayanAppConfig, menu_user
from navigation.classes import Separator, Text
from .links import link_logout, link_password_change
@@ -21,6 +22,6 @@ class AuthenticationApp(MayanAppConfig):
menu_user.bind_links(
links=(
link_password_change, link_logout
Separator(), link_password_change, link_logout
), position=99
)

View File

@@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events.classes import EventTypeNamespace
event_cabinets_add_document = Event(
name='cabinets_add_document',
label=_('Document added to cabinet')
namespace = EventTypeNamespace(name='cabinets', label=_('Cabinets'))
event_cabinets_add_document = namespace.add_event_type(
label=_('Document added to cabinet'),
name='add_document'
)
event_cabinets_remove_document = Event(
name='cabinets_remove_document',
label=_('Document removed from cabinet')
event_cabinets_remove_document = namespace.add_event_type(
label=_('Document removed from cabinet'),
name='remove_document'
)

View File

@@ -27,7 +27,7 @@ class CabinetsEventsTestCase(GenericDocumentTestCase):
self.assertEqual(Action.objects.last().target, self.document)
self.assertEqual(
Action.objects.last().verb,
event_cabinets_add_document.name
event_cabinets_add_document.id
)
def test_document_cabinet_remove_event(self):
@@ -38,5 +38,5 @@ class CabinetsEventsTestCase(GenericDocumentTestCase):
self.assertEqual(Action.objects.first().target, self.document)
self.assertEqual(
Action.objects.first().verb,
event_cabinets_remove_document.name
event_cabinets_remove_document.id
)

View File

@@ -9,13 +9,11 @@ from rest_framework import generics, status
from rest_framework.response import Response
from acls.models import AccessControlList
from documents.models import Document
from documents.permissions import permission_document_view
from .models import DocumentCheckout
from .permissions import (
permission_document_checkout, permission_document_checkin,
permission_document_checkin_override
permission_document_checkin, permission_document_checkin_override
)
from .serializers import (
DocumentCheckoutSerializer, NewDocumentCheckoutSerializer
@@ -48,12 +46,23 @@ class APICheckedoutDocumentListView(generics.ListCreateAPIView):
APICheckedoutDocumentListView, self
).get(request, *args, **kwargs)
def get_serializer_context(self):
"""
Extra context provided to the serializer class.
"""
return {
'format': self.format_kwarg,
'request': self.request,
'view': self
}
'''
def post(self, request, *args, **kwargs):
"""
Checkout a document.
"""
serializer = self.get_serializer(data=request.DATA, files=request.FILES)
serializer = self.get_serializer(data=request.data, files=request.file)
if serializer.is_valid():
document = get_object_or_404(
@@ -84,6 +93,7 @@ class APICheckedoutDocumentListView(generics.ListCreateAPIView):
return Response(status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
'''
class APICheckedoutDocumentView(generics.RetrieveDestroyAPIView):

View File

@@ -11,10 +11,15 @@ from django.utils.translation import ugettext_lazy as _
from acls import ModelPermission
from common import MayanAppConfig, menu_facet, menu_main, menu_sidebar
from common.dashboards import dashboard_main
from events import ModelEventType
from mayan.celery import app
from rest_api.classes import APIEndPoint
from .dashboard_widgets import widget_checkouts
from .events import (
event_document_auto_check_in, event_document_check_in,
event_document_check_out, event_document_forceful_check_in
)
from .handlers import check_new_version_creation
from .links import (
link_checkin_document, link_checkout_document, link_checkout_info,
@@ -51,7 +56,10 @@ class CheckoutsApp(MayanAppConfig):
Document.add_to_class(
'check_in',
lambda document, user=None: DocumentCheckout.objects.check_in_document(document, user)
lambda document,
user=None: DocumentCheckout.objects.check_in_document(
document, user
)
)
Document.add_to_class(
'checkout_info',
@@ -72,6 +80,13 @@ class CheckoutsApp(MayanAppConfig):
)
)
ModelEventType.register(
model=Document, event_types=(
event_document_auto_check_in, event_document_check_in,
event_document_check_out, event_document_forceful_check_in
)
)
ModelPermission.register(
model=Document, permissions=(
permission_document_checkout,

View File

@@ -2,19 +2,21 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events import EventTypeNamespace
event_document_auto_check_in = Event(
name='checkouts_document_auto_check_in',
namespace = EventTypeNamespace(name='checkouts', label=_('Checkouts'))
event_document_auto_check_in = namespace.add_event_type(
name='document_auto_check_in',
label=_('Document automatically checked in')
)
event_document_check_in = Event(
name='checkouts_document_check_in', label=_('Document checked in')
event_document_check_in = namespace.add_event_type(
name='document_check_in', label=_('Document checked in')
)
event_document_check_out = Event(
name='checkouts_document_check_out', label=_('Document checked out')
event_document_check_out = namespace.add_event_type(
name='document_check_out', label=_('Document checked out')
)
event_document_forceful_check_in = Event(
name='checkouts_document_forceful_check_in',
event_document_forceful_check_in = namespace.add_event_type(
name='document_forceful_check_in',
label=_('Document forcefully checked in')
)

View File

@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
class DocumentCheckoutManager(models.Manager):
def checkout_document(self, document, expiration_datetime, user, block_new_version=True):
self.create(
return self.create(
document=document, expiration_datetime=expiration_datetime,
user=user, block_new_version=block_new_version
)

View File

@@ -1,15 +1,19 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from acls.models import AccessControlList
from documents.models import Document
from documents.serializers import DocumentSerializer
from .models import DocumentCheckout
from .permissions import permission_document_checkout
class DocumentCheckoutSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
# Hide this import otherwise strange circular import error occur
from documents.serializers import DocumentSerializer
super(DocumentCheckoutSerializer, self).__init__(*args, **kwargs)
self.fields['document'] = DocumentSerializer()
@@ -17,7 +21,33 @@ class DocumentCheckoutSerializer(serializers.ModelSerializer):
model = DocumentCheckout
class NewDocumentCheckoutSerializer(serializers.Serializer):
document = serializers.IntegerField()
expiration_datetime = serializers.DateTimeField()
class NewDocumentCheckoutSerializer(serializers.ModelSerializer):
block_new_version = serializers.BooleanField()
document_pk = serializers.IntegerField(
help_text=_('Primary key of the document to be checked out.'),
write_only=True
)
expiration_datetime = serializers.DateTimeField()
class Meta:
fields = (
'block_new_version', 'document', 'document_pk',
'expiration_datetime', 'id'
)
model = DocumentCheckout
read_only_fields = ('document',)
write_only_fields = ('document_pk',)
def create(self, validated_data):
document = Document.objects.get(pk=validated_data.pop('document_pk'))
AccessControlList.objects.check_access(
permissions=permission_document_checkout,
user=self.context['request'].user, obj=document
)
validated_data['document'] = document
validated_data['user'] = self.context['request'].user
return super(NewDocumentCheckoutSerializer, self).create(
validated_data
)

View File

@@ -0,0 +1,75 @@
from __future__ import unicode_literals
import datetime
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from django.test import override_settings
from django.utils.encoding import force_text
from django.utils.timezone import now
from rest_framework.test import APITestCase
from documents.models import DocumentType
from documents.tests import TEST_DOCUMENT_TYPE_LABEL, TEST_SMALL_DOCUMENT_PATH
from user_management.tests.literals import (
TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME
)
from ..models import DocumentCheckout
@override_settings(OCR_AUTO_OCR=False)
class CheckoutAPITestCase(APITestCase):
def setUp(self):
super(CheckoutAPITestCase, self).setUp()
self.admin_user = get_user_model().objects.create_superuser(
username=TEST_ADMIN_USERNAME, email=TEST_ADMIN_EMAIL,
password=TEST_ADMIN_PASSWORD
)
self.client.login(
username=TEST_ADMIN_USERNAME, password=TEST_ADMIN_PASSWORD
)
self.document_type = DocumentType.objects.create(
label=TEST_DOCUMENT_TYPE_LABEL
)
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document = self.document_type.new_document(
file_object=file_object,
)
def tearDown(self):
self.document_type.delete()
super(CheckoutAPITestCase, self).tearDown()
def test_document_checkout_get_view(self):
expiration_datetime = now() + datetime.timedelta(days=1)
DocumentCheckout.objects.checkout_document(
document=self.document, expiration_datetime=expiration_datetime,
user=self.admin_user, block_new_version=True
)
response = self.client.get(reverse('rest_api:checkout-document-list'))
self.assertEqual(
response.data['results'][0]['document']['uuid'],
force_text(self.document.uuid)
)
def test_document_checkout_post_view(self):
response = self.client.post(
reverse('rest_api:checkout-document-list'), data={
'document_pk': self.document.pk,
'expiration_datetime': '2099-01-01T12:00'
}
)
self.assertEqual(response.status_code, 201)
self.assertEqual(
DocumentCheckout.objects.first().document, self.document
)

View File

@@ -26,11 +26,11 @@ urlpatterns = [
api_urls = [
url(
r'^documents/$', APICheckedoutDocumentListView.as_view(),
r'^checkouts/$', APICheckedoutDocumentListView.as_view(),
name='checkout-document-list'
),
url(
r'^documents/(?P<pk>[0-9]+)/$', APICheckedoutDocumentView.as_view(),
r'^checkouts/(?P<pk>[0-9]+)/$', APICheckedoutDocumentView.as_view(),
name='checkedout-document-view'
),
]

View File

@@ -67,8 +67,8 @@ class MayanAppConfig(apps.AppConfig):
except ImportError as exception:
if force_text(exception) not in ('No module named urls', 'No module named \'{}.urls\''.format(self.name)):
logger.error(
'Import time error when running AppConfig.ready(). Check '
'apps.py, urls.py, views.py, etc.'
'Import time error when running AppConfig.ready() of app '
'"%s".', self.name
)
raise exception
@@ -127,7 +127,6 @@ class CommonApp(MayanAppConfig):
Text(text=CommonApp.get_user_label_text), Separator(),
link_current_user_details, link_current_user_edit,
link_current_user_locale_profile_edit,
Separator()
)
)

View File

@@ -83,10 +83,9 @@ def render_subtemplate(context, template_name, template_context):
Renders the specified template with the mixed parent and
subtemplate contexts
"""
new_context = Context(context)
new_context = Context(context.flatten())
new_context.update(Context(template_context))
return get_template(template_name).render(new_context)
return get_template(template_name).render(new_context.flatten())
@register.simple_tag

View File

@@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
from django.apps import apps
from django.utils.translation import ugettext_lazy as _
@@ -6,9 +6,13 @@ from django.utils.translation import ugettext_lazy as _
from acls import ModelPermission
from common import MayanAppConfig, menu_facet, menu_object, menu_sidebar
from documents.search import document_page_search, document_search
from events import ModelEventType
from navigation import SourceColumn
from rest_api.classes import APIEndPoint
from .events import (
event_document_comment_create, event_document_comment_delete
)
from .links import (
link_comment_add, link_comment_delete, link_comments_for_document
)
@@ -36,6 +40,12 @@ class DocumentCommentsApp(MayanAppConfig):
Comment = self.get_model('Comment')
ModelEventType.register(
model=Document, event_types=(
event_document_comment_create, event_document_comment_delete
)
)
ModelPermission.register(
model=Document, permissions=(
permission_comment_create, permission_comment_delete,

View File

@@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events import EventTypeNamespace
event_document_comment_create = Event(
name='document_comment_create',
label=_('Document comment created')
namespace = EventTypeNamespace(
name='document_comments', label=_('Document comments')
)
event_document_comment_delete = Event(
name='document_comment_delete',
label=_('Document comment deleted')
event_document_comment_create = namespace.add_event_type(
name='create', label=_('Document comment created')
)
event_document_comment_delete = namespace.add_event_type(
name='delete', label=_('Document comment deleted')
)

View File

@@ -25,11 +25,11 @@ urlpatterns = [
api_urls = [
url(
r'^document/(?P<document_pk>[0-9]+)/comments/$',
r'^documents/(?P<document_pk>[0-9]+)/comments/$',
APICommentListView.as_view(), name='comment-list'
),
url(
r'^document/(?P<document_pk>[0-9]+)/comments/(?P<comment_pk>[0-9]+)/$',
r'^documents/(?P<document_pk>[0-9]+)/comments/(?P<comment_pk>[0-9]+)/$',
APICommentView.as_view(), name='comment-detail'
),
]

View File

@@ -72,12 +72,12 @@ urlpatterns = [
api_urls = [
url(
r'^index/node/(?P<pk>[0-9]+)/documents/$',
r'^indexes/node/(?P<pk>[0-9]+)/documents/$',
APIIndexNodeInstanceDocumentListView.as_view(),
name='index-node-documents'
),
url(
r'^index/template/(?P<pk>[0-9]+)/$', APIIndexTemplateView.as_view(),
r'^indexes/template/(?P<pk>[0-9]+)/$', APIIndexTemplateView.as_view(),
name='index-template-detail'
),
url(
@@ -85,12 +85,12 @@ api_urls = [
name='index-detail'
),
url(
r'^index/(?P<pk>[0-9]+)/template/$',
r'^indexes/(?P<pk>[0-9]+)/template/$',
APIIndexTemplateListView.as_view(), name='index-template-detail'
),
url(r'^indexes/$', APIIndexListView.as_view(), name='index-list'),
url(
r'^document/(?P<pk>[0-9]+)/indexes/$',
r'^documents/(?P<pk>[0-9]+)/indexes/$',
APIDocumentIndexListView.as_view(), name='document-index-list'
),
]

View File

@@ -2,13 +2,17 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events import EventTypeNamespace
event_parsing_document_version_submit = Event(
name='parsing_document_version_submit',
label=_('Document version submitted for parsing')
namespace = EventTypeNamespace(
name='parsing', label=_('Document parsing')
)
event_parsing_document_version_finish = Event(
name='parsing_document_version_finish',
label=_('Document version parsing finished')
event_parsing_document_version_submit = namespace.add_event_type(
label=_('Document version submitted for parsing'),
name='document_version_submit'
)
event_parsing_document_version_finish = namespace.add_event_type(
label=_('Document version parsing finished'),
name='document_version_finish'
)

View File

@@ -24,7 +24,7 @@ class DocumentParsingEventsTestCase(GenericDocumentTestCase):
)
self.assertEqual(
Action.objects.last().verb,
event_parsing_document_version_submit.name
event_parsing_document_version_submit.id
)
def test_document_version_finish_event(self):
@@ -35,5 +35,5 @@ class DocumentParsingEventsTestCase(GenericDocumentTestCase):
)
self.assertEqual(
Action.objects.first().verb,
event_parsing_document_version_finish.name
event_parsing_document_version_finish.id
)

View File

@@ -4,7 +4,7 @@ from django.apps import apps
from django.utils.translation import ugettext_lazy as _
from document_indexing.tasks import task_index_document
from events.classes import Event
from events.classes import EventType
def handler_index_document(sender, **kwargs):
@@ -42,8 +42,9 @@ def handler_trigger_transition(sender, **kwargs):
transition = list(set(trigger_transitions) & set(workflow_instance.get_transition_choices()))[0]
workflow_instance.do_transition(
comment=_('Event trigger: %s') % Event.get(name=action.verb).label,
transition=transition
comment=_('Event trigger: %s') % EventType.get(
name=action.verb
).label, transition=transition
)

View File

@@ -9,7 +9,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('events', '0001_initial'),
('events', '0005_auto_20170731_0452'),
('document_states', '0004_workflow_internal_name'),
]
@@ -17,8 +17,19 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='WorkflowTransitionTriggerEvent',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stored_event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trigger_events', to='events.EventType', verbose_name='Event type')),
(
'id', models.AutoField(
auto_created=True, primary_key=True, serialize=False,
verbose_name='ID'
)
),
(
'stored_event_type', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='trigger_events',
to='events.StoredEventType', verbose_name='Event type'
)
),
],
options={
'verbose_name': 'Workflow transition trigger event',
@@ -28,16 +39,28 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='workflowtransition',
name='trigger_time_period',
field=models.PositiveIntegerField(blank=True, help_text='Amount of time after which this transition will trigger on its own.', null=True, verbose_name='Trigger time period'),
field=models.PositiveIntegerField(
blank=True, help_text='Amount of time after which this '
'transition will trigger on its own.', null=True,
verbose_name='Trigger time period'
),
),
migrations.AddField(
model_name='workflowtransition',
name='trigger_time_unit',
field=models.CharField(blank=True, choices=[('days', 'Days'), ('hours', 'Hours'), ('minutes', 'Minutes')], max_length=8, null=True, verbose_name='Trigger time unit'),
field=models.CharField(
blank=True, choices=[
('days', 'Days'), ('hours', 'Hours'),
('minutes', 'Minutes')
], max_length=8, null=True, verbose_name='Trigger time unit'),
),
migrations.AddField(
model_name='workflowtransitiontriggerevent',
name='transition',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='document_states.WorkflowTransition', verbose_name='Transition'),
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='document_states.WorkflowTransition',
verbose_name='Transition'
),
),
]

View File

@@ -17,16 +17,28 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='workflowinstancelogentry',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'),
field=models.ForeignKey(
blank=True, null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL, verbose_name='User'
),
),
migrations.AlterField(
model_name='workflowtransitiontriggerevent',
name='event_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.EventType', verbose_name='Event type'),
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='events.StoredEventType', verbose_name='Event type'
),
),
migrations.AlterField(
model_name='workflowtransitiontriggerevent',
name='transition',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trigger_events', to='document_states.WorkflowTransition', verbose_name='Transition'),
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='trigger_events',
to='document_states.WorkflowTransition',
verbose_name='Transition'
),
),
]

View File

@@ -17,7 +17,7 @@ from django.utils.translation import ugettext_lazy as _
from acls.models import AccessControlList
from common.validators import validate_internal_name
from documents.models import Document, DocumentType
from events.models import EventType
from events.models import StoredEventType
from permissions import Permission
from .error_logs import error_log_state_actions
@@ -306,7 +306,8 @@ class WorkflowTransitionTriggerEvent(models.Model):
related_name='trigger_events', verbose_name=_('Transition')
)
event_type = models.ForeignKey(
EventType, on_delete=models.CASCADE, verbose_name=_('Event type')
StoredEventType, on_delete=models.CASCADE,
verbose_name=_('Event type')
)
class Meta:

View File

@@ -215,20 +215,20 @@ api_urls = [
APIWorkflowTransitionView.as_view(), name='workflowtransition-detail'
),
url(
r'^document/(?P<pk>[0-9]+)/workflows/$',
r'^documents/(?P<pk>[0-9]+)/workflows/$',
APIWorkflowInstanceListView.as_view(), name='workflowinstance-list'
),
url(
r'^document/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/$',
r'^documents/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/$',
APIWorkflowInstanceView.as_view(), name='workflowinstance-detail'
),
url(
r'^document/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/log_entries/$',
r'^documents/(?P<pk>[0-9]+)/workflows/(?P<workflow_pk>[0-9]+)/log_entries/$',
APIWorkflowInstanceLogEntryListView.as_view(),
name='workflowinstancelogentry-list'
),
url(
r'^document_type/(?P<pk>[0-9]+)/workflows/$',
r'^document_types/(?P<pk>[0-9]+)/workflows/$',
APIDocumentTypeWorkflowListView.as_view(),
name='documenttype-workflow-list'
),

View File

@@ -17,8 +17,8 @@ from common.views import (
)
from documents.models import Document
from documents.views import DocumentListView
from events.classes import Event
from events.models import EventType
from events.classes import EventType
from events.models import StoredEventType
from .classes import WorkflowAction
from .forms import (
@@ -675,7 +675,7 @@ class WorkflowStateListView(SingleObjectListView):
class SetupWorkflowTransitionTriggerEventListView(FormView):
form_class = WorkflowTransitionTriggerEventRelationshipFormSet
submodel = EventType
submodel = StoredEventType
def dispatch(self, *args, **kwargs):
messages.warning(
@@ -689,7 +689,7 @@ class SetupWorkflowTransitionTriggerEventListView(FormView):
user=self.request.user, obj=self.get_object().workflow
)
Event.refresh()
EventType.refresh()
return super(
SetupWorkflowTransitionTriggerEventListView, self
).dispatch(*args, **kwargs)
@@ -735,8 +735,10 @@ class SetupWorkflowTransitionTriggerEventListView(FormView):
initial = []
# Return the queryset by name from the sorted list of the class
event_type_ids = [event_type.name for event_type in Event.all()]
event_type_queryset = EventType.objects.filter(name__in=event_type_ids)
event_type_ids = [event_type.name for event_type in EventType.all()]
event_type_queryset = StoredEventType.objects.filter(
name__in=event_type_ids
)
for event_type in event_type_queryset:
initial.append({

View File

@@ -23,12 +23,16 @@ from converter.permissions import (
permission_transformation_delete, permission_transformation_edit,
permission_transformation_view,
)
from events.links import link_events_for_object
from events import ModelEventType
from events.links import (
link_events_for_object, link_object_event_types_user_subcriptions_list,
link_object_event_types_user_subcriptions_list_with_icon
)
from events.permissions import permission_events_view
from mayan.celery import app
from mayan_statistics.classes import StatisticNamespace, CharJSLine
from navigation import SourceColumn
from rest_api.classes import APIEndPoint
from rest_api.classes import APIEndPoint, APIResource
from rest_api.fields import DynamicSerializerField
from .dashboard_widgets import (
@@ -36,6 +40,12 @@ from .dashboard_widgets import (
widget_new_documents_this_month, widget_pages_per_month,
widget_total_documents
)
from .events import (
event_document_create, event_document_download,
event_document_properties_edit, event_document_type_change,
event_document_new_version, event_document_version_revert,
event_document_view
)
from .handlers import (
create_default_document_type, handler_scan_duplicates_for
)
@@ -107,6 +117,9 @@ class DocumentsApp(MayanAppConfig):
from actstream import registry
APIEndPoint(app=self, version_string='1')
APIResource(label=_('Document types'), name='document_types')
APIResource(label=_('Documents'), name='documents')
APIResource(label=_('Trashed documents'), name='trashed_documents')
DeletedDocument = self.get_model('DeletedDocument')
Document = self.get_model('Document')
@@ -141,6 +154,19 @@ class DocumentsApp(MayanAppConfig):
label=_('MIME type'), name='versions__mimetype', type_name='field'
)
ModelEventType.register(
model=DocumentType, event_types=(
event_document_create,
)
)
ModelEventType.register(
model=Document, event_types=(
event_document_download, event_document_properties_edit,
event_document_type_change, event_document_new_version,
event_document_version_revert, event_document_view
)
)
ModelPermission.register(
model=Document, permissions=(
permission_acl_edit, permission_acl_view,
@@ -376,7 +402,8 @@ class DocumentsApp(MayanAppConfig):
menu_object.bind_links(
links=(
link_document_type_edit, link_document_type_filename_list,
link_acl_list, link_document_type_delete
link_acl_list, link_object_event_types_user_subcriptions_list,
link_document_type_delete
), sources=(DocumentType,)
)
menu_object.bind_links(
@@ -433,8 +460,11 @@ class DocumentsApp(MayanAppConfig):
links=(link_document_properties,), sources=(Document,), position=2
)
menu_facet.bind_links(
links=(link_events_for_object, link_document_version_list,),
sources=(Document,), position=2
links=(
link_events_for_object,
link_object_event_types_user_subcriptions_list_with_icon,
link_document_version_list,
), sources=(Document,), position=2
)
menu_facet.bind_links(links=(link_document_pages,), sources=(Document,))
menu_facet.bind_links(
@@ -444,7 +474,8 @@ class DocumentsApp(MayanAppConfig):
# Document actions
menu_object.bind_links(
links=(
link_document_version_revert, link_document_version_download
link_document_version_view, link_document_version_revert,
link_document_version_download
),
sources=(DocumentVersion,)
)

View File

@@ -2,29 +2,28 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events import EventTypeNamespace
event_document_create = Event(
name='documents_document_create', label=_('Document created')
namespace = EventTypeNamespace(name='documents', label=_('Documents'))
event_document_create = namespace.add_event_type(
name='document_create', label=_('Document created')
)
event_document_download = Event(
name='documents_document_download',
label=_('Document downloaded')
event_document_download = namespace.add_event_type(
name='document_download', label=_('Document downloaded')
)
event_document_properties_edit = Event(
name='documents_document_edit', label=_('Document properties edited')
event_document_properties_edit = namespace.add_event_type(
name='document_edit', label=_('Document properties edited')
)
event_document_type_change = Event(
name='documents_document_type_change', label=_('Document type changed')
event_document_type_change = namespace.add_event_type(
name='document_type_change', label=_('Document type changed')
)
event_document_new_version = Event(
name='documents_document_new_version', label=_('New version uploaded')
event_document_new_version = namespace.add_event_type(
name='document_new_version', label=_('New version uploaded')
)
event_document_version_revert = Event(
name='documents_document_version_revert',
label=_('Document version reverted')
event_document_version_revert = namespace.add_event_type(
name='document_version_revert', label=_('Document version reverted')
)
event_document_view = Event(
name='documents_document_view',
label=_('Document viewed')
event_document_view = namespace.add_event_type(
name='document_view', label=_('Document viewed')
)

View File

@@ -100,6 +100,11 @@ class DocumentType(models.Model):
return super(DocumentType, self).delete(*args, **kwargs)
def get_absolute_url(self):
return reverse(
'documents:document_type_document_list', args=(self.pk,)
)
def natural_key(self):
return (self.label,)
@@ -222,9 +227,13 @@ class Document(models.Model):
if new_document:
if user:
self.add_as_recent_document_for_user(user)
event_document_create.commit(actor=user, target=self)
event_document_create.commit(
actor=user, target=self, action_object=self.document_type
)
else:
event_document_create.commit(target=self)
event_document_create.commit(
target=self, action_object=self.document_type
)
else:
if _commit_events:
event_document_properties_edit.commit(actor=user, target=self)

View File

@@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _
from smart_settings import Namespace
LANGUAGE_CHOICES = [
(i.iso639_3_code, i.name) for i in list(pycountry.languages)
(i.alpha_3, i.name) for i in list(pycountry.languages)
]
namespace = Namespace(name='documents', label=_('Documents'))

View File

@@ -64,7 +64,7 @@ class DocumentEventsTestCase(GenericDocumentViewTestCase):
event = Action.objects.any(obj=self.document).first()
self.assertEqual(event.verb, event_document_download.name)
self.assertEqual(event.verb, event_document_download.id)
self.assertEqual(event.target, self.document)
self.assertEqual(event.actor, self.user)
@@ -98,6 +98,6 @@ class DocumentEventsTestCase(GenericDocumentViewTestCase):
event = Action.objects.any(obj=self.document).first()
self.assertEqual(event.verb, event_document_view.name)
self.assertEqual(event.verb, event_document_view.id)
self.assertEqual(event.target, self.document)
self.assertEqual(event.actor, self.user)

View File

@@ -916,6 +916,73 @@ class DocumentVersionTestCase(GenericDocumentViewTestCase):
self.assertEqual(self.document.versions.count(), 1)
class DocumentVersionTestCase(GenericDocumentViewTestCase):
def setUp(self):
super(DocumentVersionTestCase, self).setUp()
self.login_user()
def test_document_version_list_no_permission(self):
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document.new_version(
comment=TEST_VERSION_COMMENT, file_object=file_object
)
response = self.get(
'documents:document_version_list', args=(self.document.pk,)
)
self.assertEqual(response.status_code, 403)
def test_document_version_list_with_permission(self):
self.grant_permission(permission=permission_document_version_view)
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document.new_version(
comment=TEST_VERSION_COMMENT, file_object=file_object
)
response = self.get(
'documents:document_version_list', args=(self.document.pk,)
)
self.assertContains(response, TEST_VERSION_COMMENT, status_code=200)
def test_document_version_revert_no_permission(self):
first_version = self.document.latest_version
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document.new_version(
file_object=file_object
)
response = self.post(
'documents:document_version_revert', args=(first_version.pk,)
)
self.assertEqual(response.status_code, 403)
self.assertEqual(self.document.versions.count(), 2)
def test_document_version_revert_with_permission(self):
first_version = self.document.latest_version
with open(TEST_SMALL_DOCUMENT_PATH) as file_object:
self.document.new_version(
file_object=file_object
)
self.grant_permission(permission=permission_document_version_revert)
response = self.post(
'documents:document_version_revert', args=(first_version.pk,),
follow=True
)
self.assertContains(response, 'reverted', status_code=200)
self.assertEqual(self.document.versions.count(), 1)
class DeletedDocumentTestCase(GenericDocumentViewTestCase):
def setUp(self):
super(DeletedDocumentTestCase, self).setUp()

View File

@@ -35,7 +35,7 @@ api_urls = [
name='search-view'
),
url(
r'^advanced/(?P<search_model>[\.\w]+)/$', APIAdvancedSearchView.as_view(),
r'^search/advanced/(?P<search_model>[\.\w]+)/$', APIAdvancedSearchView.as_view(),
name='advanced-search-view'
),
]

View File

@@ -1,5 +1,5 @@
from __future__ import unicode_literals
from .classes import Event # NOQA
from .classes import EventTypeNamespace, ModelEventType # NOQA
default_app_config = 'events.apps.EventsApp'

View File

@@ -2,9 +2,19 @@ from __future__ import unicode_literals
from django.contrib import admin
from .models import EventType
from .models import EventSubscription, Notification, StoredEventType
@admin.register(EventType)
class EventTypeAdmin(admin.ModelAdmin):
@admin.register(EventSubscription)
class EventSubscriptionAdmin(admin.ModelAdmin):
list_display = ('user', 'stored_event_type')
@admin.register(StoredEventType)
class StoredEventTypeAdmin(admin.ModelAdmin):
readonly_fields = ('name', '__str__')
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ('user', 'action', 'read')

View File

@@ -10,9 +10,13 @@ from rest_framework import generics
from acls.models import AccessControlList
from rest_api.permissions import MayanPermission
from .classes import Event
from .classes import EventType, EventTypeNamespace
from .models import Notification
from .permissions import permission_events_view
from .serializers import EventSerializer, EventTypeSerializer
from .serializers import (
EventSerializer, EventTypeSerializer, EventTypeNamespaceSerializer,
NotificationSerializer
)
class APIObjectEventListView(generics.ListAPIView):
@@ -46,13 +50,72 @@ class APIObjectEventListView(generics.ListAPIView):
return any_stream(obj)
class APIEventTypeNamespaceDetailView(generics.RetrieveAPIView):
"""
Returns the details of an event type namespace.
"""
serializer_class = EventTypeNamespaceSerializer
def get_object(self):
try:
return EventTypeNamespace.get(name=self.kwargs['name'])
except KeyError:
raise Http404
class APIEventTypeNamespaceListView(generics.ListAPIView):
"""
Returns a list of all the available event type namespaces.
"""
serializer_class = EventTypeNamespaceSerializer
queryset = EventTypeNamespace.all()
def get_serializer_context(self):
return {
'format': self.format_kwarg,
'request': self.request,
'view': self
}
class APIEventTypeNamespaceEventTypeListView(generics.ListAPIView):
"""
Returns a list of all the available event types from a namespaces.
"""
serializer_class = EventTypeSerializer
def get_queryset(self):
try:
return EventTypeNamespace.get(
name=self.kwargs['name']
).get_event_types()
except KeyError:
raise Http404
def get_serializer_context(self):
return {
'format': self.format_kwarg,
'request': self.request,
'view': self
}
class APIEventTypeListView(generics.ListAPIView):
"""
Returns a list of all the available event types.
"""
serializer_class = EventTypeSerializer
queryset = sorted(Event.all(), key=lambda event: event.name)
queryset = EventType.all()
def get_serializer_context(self):
return {
'format': self.format_kwarg,
'request': self.request,
'view': self
}
class APIEventListView(generics.ListAPIView):
@@ -64,3 +127,20 @@ class APIEventListView(generics.ListAPIView):
permission_classes = (MayanPermission,)
queryset = Action.objects.all()
serializer_class = EventSerializer
def get_serializer_context(self):
return {
'format': self.format_kwarg,
'request': self.request,
'view': self
}
class APINotificationListView(generics.ListAPIView):
"""
Return a list of notifications for the current user.
"""
serializer_class = NotificationSerializer
def get_queryset(self):
return Notification.objects.filter(user=self.request.user)

View File

@@ -3,11 +3,19 @@ from __future__ import unicode_literals
from django.apps import apps
from django.utils.translation import ugettext_lazy as _
from common import MayanAppConfig, menu_tools
from common import (
MayanAppConfig, menu_main, menu_object, menu_secondary, menu_tools,
menu_user
)
from common.widgets import two_state_template
from navigation import SourceColumn
from rest_api.classes import APIEndPoint
from .links import link_events_list
from .links import (
link_events_list, link_event_types_subscriptions_list,
link_notification_mark_read, link_notification_mark_read_all,
link_user_notifications_list,
)
from .licenses import * # NOQA
from .widgets import event_object_link, event_type_link
@@ -28,6 +36,8 @@ class EventsApp(MayanAppConfig):
def ready(self):
super(EventsApp, self).ready()
Action = apps.get_model(app_label='actstream', model_name='Action')
Notification = self.get_model(model_name='Notification')
StoredEventType = self.get_model(model_name='StoredEventType')
APIEndPoint(app=self, version_string='1')
@@ -39,7 +49,7 @@ class EventsApp(MayanAppConfig):
func=lambda context: event_actor(context['object'])
)
SourceColumn(
source=Action, label=_('Verb'),
source=Action, label=_('Event'),
func=lambda context: event_type_link(context['object'])
)
SourceColumn(
@@ -49,4 +59,44 @@ class EventsApp(MayanAppConfig):
)
)
SourceColumn(
source=StoredEventType, label=_('Namespace'), attribute='namespace'
)
SourceColumn(
source=StoredEventType, label=_('Label'), attribute='label'
)
SourceColumn(
source=Notification, label=_('Timestamp'),
attribute='action.timestamp'
)
SourceColumn(
source=Notification, label=_('Actor'), attribute='action.actor'
)
SourceColumn(
source=Notification, label=_('Event'),
func=lambda context: event_type_link(context['object'].action)
)
SourceColumn(
source=Notification, label=_('Target'),
func=lambda context: event_object_link(context['object'].action)
)
SourceColumn(
source=Notification, label=_('Seen'),
func=lambda context: two_state_template(
state=context['object'].read
)
)
menu_main.bind_links(
links=(link_user_notifications_list,), position=99
)
menu_object.bind_links(
links=(link_notification_mark_read,), sources=(Notification,)
)
menu_secondary.bind_links(
links=(link_notification_mark_read_all,),
sources=('events:user_notifications_list',)
)
menu_tools.bind_links(links=(link_events_list,))
menu_user.bind_links(links=(link_event_types_subscriptions_list,))

View File

@@ -1,18 +1,57 @@
from __future__ import unicode_literals
import logging
from django.apps import apps
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.utils.encoding import force_text, python_2_unicode_compatible
from actstream import action
from .permissions import permission_events_view
class Event(object):
logger = logging.getLogger(__name__)
@python_2_unicode_compatible
class EventTypeNamespace(object):
_registry = {}
@classmethod
def all(cls):
return Event.sort(event_type_list=cls._registry.values())
return sorted(cls._registry.values())
@classmethod
def get(cls, name):
return cls._registry[name]
def __init__(self, name, label):
self.name = name
self.label = label
self.event_types = []
self.__class__._registry[name] = self
def __str__(self):
return force_text(self.label)
def add_event_type(self, name, label):
event_type = EventType(namespace=self, name=name, label=label)
self.event_types.append(event_type)
return event_type
def get_event_types(self):
return EventType.sort(event_type_list=self.event_types)
@python_2_unicode_compatible
class EventType(object):
_registry = {}
@classmethod
def all(cls):
# Return sorted permisions by namespace.name
return EventType.sort(event_type_list=cls._registry.values())
@classmethod
def get(cls, name):
@@ -20,51 +59,174 @@ class Event(object):
return cls._registry[name]
except KeyError:
raise KeyError(
_('Unknown or obsolete event type: {0}'.format(name))
'Unknown or obsolete event type: {0}'.format(name)
)
@classmethod
def get_label(cls, name):
try:
return cls.get(name=name).label
except KeyError as exception:
return force_text(exception)
def __init__(self, namespace, name, label):
self.namespace = namespace
self.name = name
self.label = label
self.stored_event_type = None
self.__class__._registry[self.id] = self
def __str__(self):
return force_text('{}: {}'.format(self.namespace.label, self.label))
@property
def id(self):
return '%s.%s' % (self.namespace.name, self.name)
@classmethod
def refresh(cls):
for event_type in cls.all():
event_type.get_type()
event_type.get_stored_event_type()
def get_stored_event_type(self):
if not self.stored_event_type:
StoredEventType = apps.get_model('events', 'StoredEventType')
self.stored_event_type, created = StoredEventType.objects.get_or_create(
name=self.id
)
return self.stored_event_type
@staticmethod
def sort(event_type_list):
return sorted(
event_type_list, key=lambda x: x.label
event_type_list, key=lambda x: (x.namespace.label, x.label)
)
def __init__(self, name, label):
self.name = name
self.label = label
self.event_type = None
self.__class__._registry[name] = self
def get_type(self):
if not self.event_type:
EventType = apps.get_model('events', 'EventType')
self.event_type, created = EventType.objects.get_or_create(
name=self.name
)
return self.event_type
def commit(self, actor=None, action_object=None, target=None):
if not self.event_type:
EventType = apps.get_model('events', 'EventType')
self.event_type, created = EventType.objects.get_or_create(
name=self.name
AccessControlList = apps.get_model(
app_label='acls', model_name='AccessControlList'
)
Action = apps.get_model(
app_label='actstream', model_name='Action'
)
ContentType = apps.get_model(
app_label='contenttypes', model_name='ContentType'
)
Notification = apps.get_model(
app_label='events', model_name='Notification'
)
action.send(
actor or target, actor=actor, verb=self.name,
results = action.send(
actor or target, actor=actor, verb=self.id,
action_object=action_object, target=target
)
for handler, result in results:
if isinstance(result, Action):
for user in get_user_model().objects.all():
notification = None
if user.event_subscriptions.filter(stored_event_type__name=result.verb).exists():
if result.target:
try:
AccessControlList.objects.check_access(
permissions=permission_events_view,
user=user, obj=result.target
)
except PermissionDenied:
pass
else:
notification = Notification.objects.create(action=result, user=user)
else:
notification = Notification.objects.create(action=result, user=user)
if result.target:
content_type = ContentType.objects.get_for_model(model=result.target)
relationship = user.object_subscriptions.filter(
content_type=content_type,
object_id=result.target.pk,
stored_event_type__name=result.verb
)
if relationship.exists():
try:
AccessControlList.objects.check_access(
permissions=permission_events_view,
user=user, obj=result.target
)
except PermissionDenied:
pass
else:
notification = Notification.objects.create(action=result, user=user)
if not notification and result.action_object:
content_type = ContentType.objects.get_for_model(model=result.action_object)
relationship = user.object_subscriptions.filter(
content_type=content_type,
object_id=result.action_object.pk,
stored_event_type__name=result.verb
)
if relationship.exists():
try:
AccessControlList.objects.check_access(
permissions=permission_events_view,
user=user, obj=result.action_object
)
except PermissionDenied:
pass
else:
notification = Notification.objects.create(action=result, user=user)
class ModelEventType(object):
"""
Class to allow matching a model to a specific set of events.
"""
_registry = {}
_proxies = {}
_inheritances = {}
@classmethod
def register(cls, model, event_types):
cls._registry.setdefault(model, [])
for event_type in event_types:
cls._registry[model].append(event_type)
@classmethod
def get_for_class(cls, klass):
return cls._registry.get(klass, ())
@classmethod
def get_for_instance(cls, instance):
StoredEventType = apps.get_model(
app_label='events', model_name='StoredEventType'
)
events = []
class_events = cls._registry.get(type(instance))
if class_events:
events.extend(class_events)
proxy = cls._proxies.get(type(instance))
if proxy:
events.extend(cls._registry.get(proxy))
pks = [
event.id for event in set(events)
]
return EventType.sort(
event_type_list=StoredEventType.objects.filter(name__in=pks)
)
@classmethod
def get_inheritance(cls, model):
return cls._inheritances[model]
@classmethod
def register_proxy(cls, source, model):
cls._proxies[model] = source
@classmethod
def register_inheritance(cls, model, related):
cls._inheritances[model] = related

122
mayan/apps/events/forms.py Normal file
View File

@@ -0,0 +1,122 @@
from __future__ import unicode_literals
from django import forms
from django.forms.formsets import formset_factory
from django.utils.translation import ugettext_lazy as _
from .models import EventSubscription, ObjectEventSubscription
class EventTypeUserRelationshipForm(forms.Form):
namespace = forms.CharField(
label=_('Namespace'), required=False,
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
label = forms.CharField(
label=_('Label'), required=False,
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
subscription = forms.ChoiceField(
label=_('Subscription'),
widget=forms.RadioSelect(), choices=(
('none', _('No')),
('subscribed', _('Subscribed')),
)
)
def __init__(self, *args, **kwargs):
super(EventTypeUserRelationshipForm, self).__init__(
*args, **kwargs
)
self.fields['namespace'].initial = self.initial['stored_event_type'].namespace
self.fields['label'].initial = self.initial['stored_event_type'].label
subscription = EventSubscription.objects.get_for(
stored_event_type=self.initial['stored_event_type'],
user=self.initial['user'],
)
if subscription.exists():
self.fields['subscription'].initial = 'subscribed'
else:
self.fields['subscription'].initial = 'none'
def save(self):
subscription = EventSubscription.objects.get_for(
stored_event_type=self.initial['stored_event_type'],
user=self.initial['user'],
)
if self.cleaned_data['subscription'] == 'none':
subscription.delete()
elif self.cleaned_data['subscription'] == 'subscribed':
if not subscription.exists():
EventSubscription.objects.create_for(
stored_event_type=self.initial['stored_event_type'],
user=self.initial['user']
)
EventTypeUserRelationshipFormSet = formset_factory(
EventTypeUserRelationshipForm, extra=0
)
class ObjectEventTypeUserRelationshipForm(forms.Form):
namespace = forms.CharField(
label=_('Namespace'), required=False,
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
label = forms.CharField(
label=_('Label'), required=False,
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
subscription = forms.ChoiceField(
label=_('Subscription'),
widget=forms.RadioSelect(), choices=(
('none', _('No')),
('subscribed', _('Subscribed')),
)
)
def __init__(self, *args, **kwargs):
super(ObjectEventTypeUserRelationshipForm, self).__init__(
*args, **kwargs
)
self.fields['namespace'].initial = self.initial['stored_event_type'].namespace
self.fields['label'].initial = self.initial['stored_event_type'].label
subscription = ObjectEventSubscription.objects.get_for(
obj=self.initial['object'],
stored_event_type=self.initial['stored_event_type'],
user=self.initial['user'],
)
if subscription.exists():
self.fields['subscription'].initial = 'subscribed'
else:
self.fields['subscription'].initial = 'none'
def save(self):
subscription = ObjectEventSubscription.objects.get_for(
obj=self.initial['object'],
stored_event_type=self.initial['stored_event_type'],
user=self.initial['user'],
)
if self.cleaned_data['subscription'] == 'none':
subscription.delete()
elif self.cleaned_data['subscription'] == 'subscribed':
if not subscription.exists():
ObjectEventSubscription.objects.create_for(
obj=self.initial['object'],
stored_event_type=self.initial['stored_event_type'],
user=self.initial['user']
)
ObjectEventTypeUserRelationshipFormSet = formset_factory(
ObjectEventTypeUserRelationshipForm, extra=0
)

View File

@@ -26,12 +26,44 @@ def get_kwargs_factory(variable_name):
return get_kwargs
def get_notification_count(context):
return context['request'].user.notifications.filter(read=False).count()
link_events_list = Link(
icon='fa fa-list-ol', permissions=(permission_events_view,),
text=_('Events'), view='events:events_list'
)
link_events_details = Link(
text=_('Events'), view='events:events_list'
)
link_events_for_object = Link(
icon='fa fa-list-ol', permissions=(permission_events_view,),
text=_('Events'), view='events:events_for_object',
kwargs=get_kwargs_factory('resolved_object')
)
link_event_types_subscriptions_list = Link(
icon='fa fa-list-ol', text=_('Event subscriptions'),
view='events:event_types_user_subcriptions_list'
)
link_notification_mark_read = Link(
args='object.pk', text=_('Mark as seen'),
view='events:notification_mark_read'
)
link_notification_mark_read_all = Link(
text=_('Mark all as seen'), view='events:notification_mark_read_all'
)
link_object_event_types_user_subcriptions_list = Link(
kwargs=get_kwargs_factory('resolved_object'),
permissions=(permission_events_view,), text=_('Subscriptions'),
view='events:object_event_types_user_subcriptions_list',
)
link_object_event_types_user_subcriptions_list_with_icon = Link(
kwargs=get_kwargs_factory('resolved_object'), icon='fa fa-rss',
permissions=(permission_events_view,), text=_('Subscriptions'),
view='events:object_event_types_user_subcriptions_list',
)
link_user_notifications_list = Link(
icon='fa fa-bell', text=get_notification_count,
view='events:user_notifications_list'
)

View File

@@ -0,0 +1,34 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.models import ContentType
from django.db import models
class EventSubscriptionManager(models.Manager):
def create_for(self, stored_event_type, user):
return self.create(
stored_event_type=stored_event_type, user=user
)
def get_for(self, stored_event_type, user):
return self.filter(
stored_event_type=stored_event_type, user=user
)
class ObjectEventSubscriptionManager(models.Manager):
def create_for(self, obj, stored_event_type, user):
content_type = ContentType.objects.get_for_model(model=obj)
return self.create(
content_type=content_type, object_id=obj.pk,
stored_event_type=stored_event_type, user=user
)
def get_for(self, obj, stored_event_type, user):
content_type = ContentType.objects.get_for_model(model=obj)
return self.filter(
content_type=content_type, object_id=obj.pk,
stored_event_type=stored_event_type, user=user
)

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-29 07:04
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('events', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='EventSubscription',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='events.EventType', verbose_name='Event type')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Event subscription',
'verbose_name_plural': 'Event subscriptions',
},
),
]

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-29 07:23
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('actstream', '0002_remove_action_data'),
('events', '0002_eventsubscription'),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read', models.BooleanField(default=False, verbose_name='Read')),
('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='actstream.Action', verbose_name='Action')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
},
),
]

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-31 04:23
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('events', '0003_notification'),
]
operations = [
migrations.RenameModel(
old_name='EventType',
new_name='StoredEventType',
),
migrations.AlterModelOptions(
name='storedeventtype',
options={'verbose_name': 'Stored event type', 'verbose_name_plural': 'Stored event types'},
),
migrations.RemoveField(
model_name='eventsubscription',
name='event_type',
),
migrations.AddField(
model_name='eventsubscription',
name='stored_event_type',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='event_subscriptions', to='events.StoredEventType', verbose_name='Event type'),
preserve_default=False,
),
migrations.AlterField(
model_name='eventsubscription',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
]

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-31 04:52
from __future__ import unicode_literals
import re
from django.db import migrations
def update_event_types_names(apps, schema_editor):
Action = apps.get_model('actstream', 'Action')
StoredEventType = apps.get_model('events', 'StoredEventType')
known_namespaces = {
'documents_': 'documents.',
'checkouts_': 'checkouts.',
'document_comment_': 'document_comments.',
}
pattern = re.compile('|'.join(known_namespaces.keys()))
for event_type in StoredEventType.objects.all():
event_type.name = pattern.sub(
lambda x: known_namespaces[x.group()], event_type.name
)
event_type.save()
for action in Action.objects.all():
action.verb = pattern.sub(
lambda x: known_namespaces[x.group()], action.verb
)
action.save()
class Migration(migrations.Migration):
dependencies = [
('events', '0004_auto_20170731_0423'),
('actstream', '0001_initial'),
]
operations = [
migrations.RunPython(update_event_types_names),
]

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-31 06:40
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('events', '0005_auto_20170731_0452'),
]
operations = [
migrations.CreateModel(
name='ObjectEventSubscription',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('stored_event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='object_subscriptions', to='events.StoredEventType', verbose_name='Event type')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='object_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Object event subscription',
'verbose_name_plural': 'Object event subscriptions',
},
),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-08-02 08:23
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0006_objecteventsubscription'),
]
operations = [
migrations.AlterModelOptions(
name='notification',
options={'ordering': ('-action__timestamp',), 'verbose_name': 'Notification', 'verbose_name_plural': 'Notifications'},
),
]

View File

@@ -1,28 +1,111 @@
from __future__ import unicode_literals
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.encoding import force_text, python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from .classes import Event
from actstream.models import Action
from .classes import EventType
from .managers import (
EventSubscriptionManager, ObjectEventSubscriptionManager
)
@python_2_unicode_compatible
class EventType(models.Model):
class StoredEventType(models.Model):
name = models.CharField(
max_length=64, unique=True, verbose_name=_('Name')
)
class Meta:
verbose_name = _('Event type')
verbose_name_plural = _('Event types')
verbose_name = _('Stored event type')
verbose_name_plural = _('Stored event types')
def __str__(self):
return self.get_class().label
return force_text(self.get_class())
def get_class(self):
return Event.get(name=self.name)
return EventType.get(name=self.name)
@property
def label(self):
return self.get_class().label
@property
def namespace(self):
return self.get_class().namespace
@python_2_unicode_compatible
class EventSubscription(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE,
related_name='event_subscriptions', verbose_name=_('User')
)
stored_event_type = models.ForeignKey(
StoredEventType, on_delete=models.CASCADE,
related_name='event_subscriptions', verbose_name=_('Event type')
)
objects = EventSubscriptionManager()
class Meta:
verbose_name = _('Event subscription')
verbose_name_plural = _('Event subscriptions')
def __str__(self):
return force_text(self.stored_event_type)
@python_2_unicode_compatible
class Notification(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE,
related_name='notifications', verbose_name=_('User')
)
action = models.ForeignKey(
Action, on_delete=models.CASCADE, related_name='notifications',
verbose_name=_('Action')
)
read = models.BooleanField(default=False, verbose_name=_('Read'))
class Meta:
ordering = ('-action__timestamp',)
verbose_name = _('Notification')
verbose_name_plural = _('Notifications')
def __str__(self):
return force_text(self.action)
@python_2_unicode_compatible
class ObjectEventSubscription(models.Model):
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE,
)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey(
ct_field='content_type',
fk_field='object_id',
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE,
related_name='object_subscriptions', verbose_name=_('User')
)
stored_event_type = models.ForeignKey(
StoredEventType, on_delete=models.CASCADE,
related_name='object_subscriptions', verbose_name=_('Event type')
)
objects = ObjectEventSubscriptionManager()
class Meta:
verbose_name = _('Object event subscription')
verbose_name_plural = _('Object event subscriptions')
def __str__(self):
return force_text(self.stored_event_type)

View File

@@ -4,30 +4,59 @@ from django.utils.six import string_types
from actstream.models import Action
from rest_framework import serializers
from rest_framework.reverse import reverse
from common.serializers import ContentTypeSerializer
from rest_api.fields import DynamicSerializerField
from user_management.serializers import UserSerializer
from .classes import Event
from .models import EventType
from .classes import EventType
from .models import Notification, StoredEventType
class EventTypeNamespaceSerializer(serializers.Serializer):
label = serializers.CharField()
name = serializers.CharField()
url = serializers.SerializerMethodField()
event_types_url = serializers.HyperlinkedIdentityField(
lookup_field='name',
view_name='rest_api:event-type-namespace-event-type-list',
)
def get_url(self, instance):
return reverse(
'rest_api:event-type-namespace-detail', args=(
instance.name,
), request=self.context['request'], format=self.context['format']
)
class EventTypeSerializer(serializers.Serializer):
label = serializers.CharField()
name = serializers.CharField()
id = serializers.CharField()
event_type_namespace_url = serializers.SerializerMethodField()
def get_event_type_namespace_url(self, instance):
return reverse(
'rest_api:event-type-namespace-detail', args=(
instance.namespace.name,
), request=self.context['request'], format=self.context['format']
)
def to_representation(self, instance):
if isinstance(instance, Event):
if isinstance(instance, EventType):
return super(EventTypeSerializer, self).to_representation(
instance
)
elif isinstance(instance, EventType):
elif isinstance(instance, StoredEventType):
return super(EventTypeSerializer, self).to_representation(
instance.get_class()
)
elif isinstance(instance, string_types):
return super(EventTypeSerializer, self).to_representation(
Event.get(name=instance)
EventType.get(name=instance)
)
@@ -43,3 +72,12 @@ class EventSerializer(serializers.ModelSerializer):
'action_object_content_type', 'action_object_object_id'
)
model = Action
class NotificationSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
action = EventSerializer(read_only=True)
class Meta:
fields = ('action', 'read', 'user')
model = Notification

View File

@@ -3,9 +3,15 @@ from __future__ import unicode_literals
from django.conf.urls import url
from .api_views import (
APIEventListView, APIEventTypeListView, APIObjectEventListView
APIEventListView, APIEventTypeListView, APIEventTypeNamespaceDetailView,
APIEventTypeNamespaceEventTypeListView, APIEventTypeNamespaceListView,
APINotificationListView, APIObjectEventListView
)
from .views import (
EventListView, EventTypeSubscriptionListView, NotificationListView,
NotificationMarkRead, NotificationMarkReadAll, ObjectEventListView,
ObjectEventTypeSubscriptionListView, VerbEventListView
)
from .views import EventListView, ObjectEventListView, VerbEventListView
urlpatterns = [
url(r'^all/$', EventListView.as_view(), name='events_list'),
@@ -14,16 +20,60 @@ urlpatterns = [
ObjectEventListView.as_view(), name='events_for_object'
),
url(
r'^by_verb/(?P<verb>[\w\-]+)/$', VerbEventListView.as_view(),
r'^by_verb/(?P<verb>[\w\-\.]+)/$', VerbEventListView.as_view(),
name='events_by_verb'
),
url(
r'^notifications/(?P<pk>\d+)/mark_read/$',
NotificationMarkRead.as_view(), name='notification_mark_read'
),
url(
r'^notifications/all/mark_read/$',
NotificationMarkReadAll.as_view(), name='notification_mark_read_all'
),
url(
r'^user/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_id>\d+)/subscriptions/$',
ObjectEventTypeSubscriptionListView.as_view(),
name='object_event_types_user_subcriptions_list'
),
url(
r'^user/event_types/subscriptions/$',
EventTypeSubscriptionListView.as_view(),
name='event_types_user_subcriptions_list'
),
url(
r'^user/notifications/$',
NotificationListView.as_view(),
name='user_notifications_list'
),
]
api_urls = [
url(r'^types/$', APIEventTypeListView.as_view(), name='event-type-list'),
url(
r'^event_type_namespaces/(?P<name>[-\w]+)/$',
APIEventTypeNamespaceDetailView.as_view(),
name='event-type-namespace-detail'
),
url(
r'^event_type_namespaces/(?P<name>[-\w]+)/event_types/$',
APIEventTypeNamespaceEventTypeListView.as_view(),
name='event-type-namespace-event-type-list'
),
url(
r'^event_type_namespaces/$', APIEventTypeNamespaceListView.as_view(),
name='event-type-namespace-list'
),
url(
r'^event_types/$', APIEventTypeListView.as_view(),
name='event-type-list'
),
url(r'^events/$', APIEventListView.as_view(), name='event-list'),
url(
r'^object/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_id>\d+)/events/$',
r'^notifications/$', APINotificationListView.as_view(),
name='notification-list'
),
url(
r'^objects/(?P<app_label>[-\w]+)/(?P<model>[-\w]+)/(?P<object_id>\d+)/events/$',
APIObjectEventListView.as_view(), name='object-event-list'
),
]

View File

@@ -1,17 +1,24 @@
from __future__ import absolute_import, unicode_literals
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from actstream.models import Action, any_stream
from acls.models import AccessControlList
from common.generics import FormView, SimpleView
from common.utils import encapsulate
from common.views import SingleObjectListView
from .classes import Event
from .classes import EventType, ModelEventType
from .forms import (
EventTypeUserRelationshipFormSet, ObjectEventTypeUserRelationshipFormSet
)
from .models import StoredEventType
from .permissions import permission_events_view
from .widgets import event_object_link
@@ -37,6 +44,96 @@ class EventListView(SingleObjectListView):
return Action.objects.all()
class EventTypeSubscriptionListView(FormView):
form_class = EventTypeUserRelationshipFormSet
main_model = 'user'
submodel = StoredEventType
def dispatch(self, *args, **kwargs):
EventType.refresh()
return super(EventTypeSubscriptionListView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
try:
for instance in form:
instance.save()
except Exception as exception:
messages.error(
self.request,
_('Error updating event subscription; %s') % exception
)
else:
messages.success(
self.request, _('Event subscriptions updated successfully')
)
return super(
EventTypeSubscriptionListView, self
).form_valid(form=form)
def get_extra_context(self):
return {
'form_display_mode_table': True,
'object': self.get_object(),
'title': _(
'Event subscriptions'
) % self.get_object()
}
def get_initial(self):
obj = self.get_object()
initial = []
for element in self.get_queryset():
initial.append({
'user': obj,
'main_model': self.main_model,
'stored_event_type': element,
})
return initial
def get_object(self):
return self.request.user
def get_object_list(self):
# Return the queryset by name from the sorted list of the class
event_type_ids = [event_type.id for event_type in EventType.all()]
return self.submodel.objects.filter(name__in=event_type_ids)
def get_post_action_redirect(self):
return reverse('common:current_user_details')
class NotificationListView(SingleObjectListView):
def get_extra_context(self):
return {
'hide_object': True,
'object': self.request.user,
'title': _('Notifications'),
}
def get_object_list(self):
return self.request.user.notifications.all()
class NotificationMarkRead(SimpleView):
def dispatch(self, *args, **kwargs):
self.get_queryset().filter(pk=self.kwargs['pk']).update(read=True)
return HttpResponseRedirect(reverse('events:user_notifications_list'))
def get_object_list(self):
return self.request.user.notifications.all()
class NotificationMarkReadAll(SimpleView):
def dispatch(self, *args, **kwargs):
self.get_queryset().update(read=True)
return HttpResponseRedirect(reverse('events:user_notifications_list'))
def get_object_list(self):
return self.request.user.notifications.all()
class ObjectEventListView(EventListView):
view_permissions = None
@@ -73,6 +170,76 @@ class ObjectEventListView(EventListView):
return any_stream(self.content_object)
class ObjectEventTypeSubscriptionListView(FormView):
form_class = ObjectEventTypeUserRelationshipFormSet
def dispatch(self, *args, **kwargs):
EventType.refresh()
return super(ObjectEventTypeSubscriptionListView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
try:
for instance in form:
instance.save()
except Exception as exception:
messages.error(
self.request,
_('Error updating object event subscription; %s') % exception
)
else:
messages.success(
self.request, _('Object event subscriptions updated successfully')
)
return super(
ObjectEventTypeSubscriptionListView, self
).form_valid(form=form)
def get_object(self):
object_content_type = get_object_or_404(
ContentType, app_label=self.kwargs['app_label'],
model=self.kwargs['model']
)
try:
content_object = object_content_type.get_object_for_this_type(
pk=self.kwargs['object_id']
)
except object_content_type.model_class().DoesNotExist:
raise Http404
AccessControlList.objects.check_access(
permissions=permission_events_view, user=self.request.user,
obj=content_object
)
return content_object
def get_extra_context(self):
return {
'form_display_mode_table': True,
'object': self.get_object(),
'title': _(
'Event subscriptions for: %s'
) % self.get_object()
}
def get_initial(self):
obj = self.get_object()
initial = []
for element in self.get_object_list():
initial.append({
'user': self.request.user,
'object': obj,
'stored_event_type': element,
})
return initial
def get_object_list(self):
return ModelEventType.get_for_instance(instance=self.get_object())
class VerbEventListView(SingleObjectListView):
def get_extra_context(self):
return {
@@ -87,7 +254,7 @@ class VerbEventListView(SingleObjectListView):
'hide_object': True,
'title': _(
'Events of type: %s'
) % Event.get_label(self.kwargs['verb']),
) % EventType.get(name=self.kwargs['verb']),
}
def get_object_list(self):

View File

@@ -1,23 +1,28 @@
from __future__ import unicode_literals
from django.urls import reverse
from django.utils.encoding import force_text
from django.utils.safestring import mark_safe
from .classes import Event
from .classes import EventType
def event_object_link(entry, attribute='target'):
label = ''
url = '#'
obj_type = ''
obj = getattr(entry, attribute)
if obj:
obj_type = '{}: '.format(obj._meta.verbose_name)
else:
obj_type = ''
if hasattr(obj, 'get_absolute_url'):
url = obj.get_absolute_url()
label = force_text(obj)
return mark_safe(
'<a href="%(url)s">%(obj_type)s%(label)s</a>' % {
'url': obj.get_absolute_url() if obj else '#',
'label': obj or '', 'obj_type': obj_type
'url': url, 'label': label, 'obj_type': obj_type
}
)
@@ -26,6 +31,6 @@ def event_type_link(entry):
return mark_safe(
'<a href="%(url)s">%(label)s</a>' % {
'url': reverse('events:events_by_verb', kwargs={'verb': entry.verb}),
'label': Event.get_label(entry.verb)
'label': EventType.get(name=entry.verb)
}
)

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-09-04 17:30
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('metadata', '0007_auto_20150918_0800'),
]
operations = [
migrations.AlterField(
model_name='documentmetadata',
name='value',
field=models.TextField(blank=True, db_index=True, null=True, verbose_name='Value'),
),
]

View File

@@ -157,8 +157,8 @@ class DocumentMetadata(models.Model):
metadata_type = models.ForeignKey(
MetadataType, on_delete=models.CASCADE, verbose_name=_('Type')
)
value = models.CharField(
blank=True, db_index=True, max_length=255, null=True,
value = models.TextField(
blank=True, db_index=True, null=True,
verbose_name=_('Value')
)

View File

@@ -190,6 +190,25 @@ class MetadataTestCase(MetadataTypeMixin, BaseTestCase):
self.metadata_type.save()
self.metadata_type.validate_value(document_type=None, value='test1')
def test_long_value_for_metadata(self):
"""
asserts that DocumentMetadata.value can store more than 255 chars
"""
very_long_string = (
'Mayan EDMS is a Free Open Source Electronic '
'Document Management System, coded in the Python language '
'using the Django web application framework and released '
'under the Apache 2.0 License. It provides an electronic '
'vault or repository for electronic documents.'
) # 258 chars
document_metadata = DocumentMetadata(
document=self.document, metadata_type=self.metadata_type,
value=very_long_string
)
document_metadata.full_clean()
document_metadata.save()
self.assertEqual(very_long_string, document_metadata.value)
def test_add_new_metadata_type_on_document_type_change(self):
"""
When switching document types, add the required metadata of the new

View File

@@ -1,4 +1,5 @@
from __future__ import unicode_literals
from django.utils.html import format_html_join
from django.utils.html import format_html_join

View File

@@ -3,6 +3,8 @@ from __future__ import absolute_import, unicode_literals
from rest_framework import generics, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from documents.models import Document, DocumentPage, DocumentVersion
from rest_api.permissions import MayanPermission
@@ -26,10 +28,6 @@ class APIDocumentOCRView(generics.GenericAPIView):
Submit a document for OCR.
---
omit_serializer: true
parameters:
- name: pk
paramType: path
type: number
responseMessages:
- code: 202
message: Accepted
@@ -40,12 +38,19 @@ class APIDocumentOCRView(generics.GenericAPIView):
class APIDocumentVersionOCRView(generics.GenericAPIView):
lookup_url_kwarg = 'version_pk'
mayan_object_permissions = {
'POST': (permission_ocr_document,)
}
permission_classes = (MayanPermission,)
queryset = DocumentVersion.objects.all()
def get_document(self):
return get_object_or_404(Document, pk=self.kwargs['document_pk'])
def get_queryset(self):
return self.get_document().versions.all()
def get_serializer_class(self):
return None
@@ -54,10 +59,6 @@ class APIDocumentVersionOCRView(generics.GenericAPIView):
Submit a document version for OCR.
---
omit_serializer: true
parameters:
- name: pk
paramType: path
type: number
responseMessages:
- code: 202
message: Accepted
@@ -70,20 +71,25 @@ class APIDocumentVersionOCRView(generics.GenericAPIView):
class APIDocumentPageOCRContentView(generics.RetrieveAPIView):
"""
Returns the OCR content of the selected document page.
---
GET:
parameters:
- name: pk
paramType: path
type: number
"""
lookup_url_kwarg = 'page_pk'
mayan_object_permissions = {
'GET': (permission_ocr_content_view,),
}
permission_classes = (MayanPermission,)
serializer_class = DocumentPageOCRContentSerializer
queryset = DocumentPage.objects.all()
def get_document(self):
return get_object_or_404(Document, pk=self.kwargs['document_pk'])
def get_document_version(self):
return get_object_or_404(
self.get_document().versions.all(), pk=self.kwargs['version_pk']
)
def get_queryset(self):
return self.get_document_version().pages.all()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()

View File

@@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events.classes import EventTypeNamespace
event_ocr_document_version_submit = Event(
name='ocr_document_version_submit',
label=_('Document version submitted for OCR')
namespace = EventTypeNamespace(name='ocr', label=_('OCR'))
event_ocr_document_version_submit = namespace.add_event_type(
label=_('Document version submitted for OCR'),
name='document_version_submit'
)
event_ocr_document_version_finish = Event(
name='ocr_document_version_finish',
label=_('Document version OCR finished')
event_ocr_document_version_finish = namespace.add_event_type(
label=_('Document version OCR finished'),
name='document_version_finish'
)

View File

@@ -63,7 +63,9 @@ class OCRAPITestCase(BaseAPITestCase):
response = self.client.post(
reverse(
'rest_api:document-version-ocr-submit-view',
args=(self.document.latest_version.pk,)
args=(
self.document.pk, self.document.latest_version.pk,
)
)
)
@@ -77,7 +79,10 @@ class OCRAPITestCase(BaseAPITestCase):
response = self.client.get(
reverse(
'rest_api:document-page-content-view',
args=(self.document.latest_version.pages.first().pk,)
args=(
self.document.pk, self.document.latest_version.pk,
self.document.latest_version.pages.first().pk,
)
),
)

View File

@@ -19,7 +19,7 @@ class OCREventsTestCase(GenericDocumentTestCase):
)
self.assertEqual(
Action.objects.last().verb,
event_ocr_document_version_submit.name
event_ocr_document_version_submit.id
)
def test_document_version_finish_event(self):
@@ -31,5 +31,5 @@ class OCREventsTestCase(GenericDocumentTestCase):
)
self.assertEqual(
Action.objects.first().verb,
event_ocr_document_version_finish.name
event_ocr_document_version_finish.id
)

View File

@@ -47,16 +47,16 @@ urlpatterns = [
api_urls = [
url(
r'^document/(?P<pk>\d+)/submit/$', APIDocumentOCRView.as_view(),
r'^documents/(?P<pk>\d+)/ocr/$', APIDocumentOCRView.as_view(),
name='document-ocr-submit-view'
),
url(
r'^document_version/(?P<pk>\d+)/submit/$',
r'^documents/(?P<document_pk>\d+)/versions/(?P<version_pk>\d+)/ocr/$',
APIDocumentVersionOCRView.as_view(),
name='document-version-ocr-submit-view'
),
url(
r'^page/(?P<pk>\d+)/content/$',
r'^documents/(?P<document_pk>\d+)/versions/(?P<version_pk>\d+)/pages/(?P<page_pk>\d+)/ocr/$',
APIDocumentPageOCRContentView.as_view(),
name='document-page-content-view'
),

View File

@@ -0,0 +1,3 @@
from __future__ import unicode_literals
default_app_config = 'quotas.apps.QuotasApp'

View File

@@ -0,0 +1,24 @@
from __future__ import unicode_literals
from django.contrib import admin
from .models import Quota
@admin.register(Quota)
class QuotaAdmin(admin.ModelAdmin):
list_display = (
'backend_path', 'backend_data', 'enabled', 'editable',
)
def has_change_permission(self, request, obj=None):
if obj:
return obj.editable
else:
return True
def has_delete_permission(self, request, obj=None):
if obj:
return obj.editable
else:
return False

85
mayan/apps/quotas/apps.py Normal file
View File

@@ -0,0 +1,85 @@
from __future__ import unicode_literals
from django.db.utils import OperationalError, ProgrammingError
from django.utils.translation import ugettext_lazy as _
from acls import ModelPermission
from acls.links import link_acl_list
from acls.permissions import permission_acl_edit, permission_acl_view
from common import MayanAppConfig, menu_object, menu_secondary, menu_setup
from common.widgets import two_state_template
from navigation import SourceColumn
from .classes import QuotaBackend
from .links import (
link_quota_create, link_quota_delete, link_quota_edit, link_quota_list,
link_quota_setup
)
from .permissions import (
permission_quota_delete, permission_quota_edit, permission_quota_view
)
class QuotasApp(MayanAppConfig):
name = 'quotas'
verbose_name = _('Quotas')
def ready(self, *args, **kwargs):
super(QuotasApp, self).ready(*args, **kwargs)
Quota = self.get_model('Quota')
QuotaBackend.initialize()
try:
for quota in Quota.objects.all():
quota.update_receiver()
except (OperationalError, ProgrammingError):
# Ignore errors during migration
pass
ModelPermission.register(
model=Quota, permissions=(
permission_acl_edit, permission_acl_view,
permission_quota_delete, permission_quota_edit,
permission_quota_view
)
)
SourceColumn(
source=Quota, label=_('Backend'), attribute='backend_label'
)
SourceColumn(
source=Quota, label=_('Display'), attribute='backend_display'
)
SourceColumn(
source=Quota, label=_('Usage'), attribute='backend_usage'
)
SourceColumn(
source=Quota, label=_('Enabled?'),
func=lambda context: two_state_template(
context['object'].enabled
)
)
SourceColumn(
source=Quota, label=_('Editable?'),
func=lambda context: two_state_template(
context['object'].editable
)
)
menu_object.bind_links(
links=(
link_quota_edit, link_acl_list, link_quota_delete,
), sources=(Quota,)
)
menu_secondary.bind_links(
links=(
link_quota_list, link_quota_create,
), sources=(
Quota, 'quotas:quota_backend_selection', 'quotas:quota_create',
'quotas:quota_list',
)
)
menu_setup.bind_links(links=(link_quota_setup,))

View File

@@ -0,0 +1,81 @@
from __future__ import unicode_literals
from importlib import import_module
import logging
from django.apps import apps
from django.utils import six
from django.utils.encoding import force_text
logger = logging.getLogger(__name__)
__ALL__ = ('QuotaBackend',)
class QuotaBackendMetaclass(type):
_registry = {}
def __new__(mcs, name, bases, attrs):
new_class = super(QuotaBackendMetaclass, mcs).__new__(
mcs, name, bases, attrs
)
if not new_class.__module__ == 'quotas.classes':
mcs._registry[
'{}.{}'.format(new_class.__module__, name)
] = new_class
new_class.id = '{}.{}'.format(new_class.__module__, name)
return new_class
class QuotaBackendBase(object):
"""
Base class for the mailing backends. This class is mainly a wrapper
for other Django backends that adds a few metadata to specify the
fields it needs to be instanciated at runtime.
The fields attribute is a list of dictionaries with the format:
{
'name': '' # Field internal name
'label': '' # Label to show to users
'class': '' # Field class to use. Field classes are Python dot
paths to Django's form fields.
'initial': '' # Field initial value
'default': '' # Default value.
}
"""
fields = ()
class QuotaBackend(six.with_metaclass(QuotaBackendMetaclass, QuotaBackendBase)):
@classmethod
def get(cls, name):
return cls._registry[name]
@classmethod
def get_all(cls):
return sorted(
cls._registry.values(), key=lambda x: x.label
)
@classmethod
def as_choices(cls):
return [
(
backend.id, backend.label
) for backend in QuotaBackend.get_all()
]
@staticmethod
def initialize():
for app in apps.get_app_configs():
try:
import_module('{}.quota_backends'.format(app.name))
except ImportError as exception:
if force_text(exception) != 'No module named quota_backends':
logger.error(
'Error importing %s quota_backends.py file; %s',
app.name, exception
)

View File

@@ -0,0 +1,12 @@
from __future__ import unicode_literals
class QuotaBaseException(Exception):
"""
Base exception for the quota app
"""
pass
class QuotaExceeded(QuotaBaseException):
pass

View File

@@ -0,0 +1,50 @@
from __future__ import unicode_literals
import json
from django import forms
from django.utils.translation import ugettext_lazy as _
from common.forms import DynamicModelForm
from .classes import QuotaBackend
from .models import Quota
class QuotaBackendSelectionForm(forms.Form):
backend = forms.ChoiceField(choices=(), label=_('Backend'))
def __init__(self, *args, **kwargs):
super(QuotaBackendSelectionForm, self).__init__(*args, **kwargs)
self.fields['backend'].choices = QuotaBackend.as_choices()
class QuotaDynamicForm(DynamicModelForm):
class Meta:
fields = ('enabled', 'backend_data')
model = Quota
widgets = {'backend_data': forms.widgets.HiddenInput}
def __init__(self, *args, **kwargs):
result = super(QuotaDynamicForm, self).__init__(*args, **kwargs)
if self.instance.backend_data:
for key, value in json.loads(self.instance.backend_data).items():
self.fields[key].initial = value
return result
def clean(self):
data = super(QuotaDynamicForm, self).clean()
# Consolidate the dynamic fields into a single JSON field called
# 'backend_data'.
backend_data = {}
for field in self.schema['fields']:
backend_data[field['name']] = data.pop(
field['name'], field.get('default', None)
)
data['backend_data'] = json.dumps(backend_data)
return data

View File

@@ -0,0 +1,13 @@
from __future__ import unicode_literals
from django.apps import apps
def handler_process_signal(sender, **kwargs):
Quota = apps.get_model(app_label='quotas', model_name='Quota')
for quota in Quota.objects.filter(enabled=True):
backend_instance = quota.get_backend_instance()
if backend_instance.sender == sender and kwargs['signal'].__class__ == backend_instance.signal.__class__:
backend_instance.process(**kwargs)

View File

@@ -0,0 +1,38 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from navigation import Link
from .permissions import (
permission_quota_create, permission_quota_delete,
permission_quota_edit, permission_quota_view,
)
def is_not_editable(context):
return not context['object'].editable
link_quota_create = Link(
icon='fa fa-envelope', permissions=(permission_quota_create,),
text=_('Quota create'), view='quotas:quota_backend_selection',
)
link_quota_delete = Link(
args='resolved_object.pk', conditional_disable=is_not_editable,
permissions=(permission_quota_delete,), tags='dangerous', text=_('Delete'),
view='quotas:quota_delete',
)
link_quota_edit = Link(
args='object.pk', conditional_disable=is_not_editable,
permissions=(permission_quota_edit,), text=_('Edit'),
view='quotas:quota_edit',
)
link_quota_list = Link(
icon='fa fa-envelope', permissions=(permission_quota_view,),
text=_('Quotas list'), view='quotas:quota_list',
)
link_quota_setup = Link(
icon='fa fa-dashboard', permissions=(permission_quota_view,),
text=_('Quotas'), view='quotas:quota_list',
)

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-08-01 06:26
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Quota',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('backend_path', models.CharField(help_text='The dotted Python path to the backend class.', max_length=255, verbose_name='Backend path')),
('backend_data', models.TextField(blank=True, verbose_name='Backend data')),
('enabled', models.BooleanField(default=True, verbose_name='Enabled')),
],
options={
'verbose_name': 'Quota',
'verbose_name_plural': 'Quotas',
},
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-08-01 07:57
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('quotas', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='quota',
name='editable',
field=models.BooleanField(default=True, editable=False, verbose_name='Editable'),
),
]

View File

View File

@@ -0,0 +1,84 @@
from __future__ import unicode_literals
import json
import logging
from django.db import models
from django.utils.encoding import force_text
from django.utils.module_loading import import_string
from django.utils.translation import ugettext_lazy as _
from .handlers import handler_process_signal
logger = logging.getLogger(__name__)
class Quota(models.Model):
backend_path = models.CharField(
max_length=255,
help_text=_('The dotted Python path to the backend class.'),
verbose_name=_('Backend path')
)
backend_data = models.TextField(
blank=True, verbose_name=_('Backend data')
)
enabled = models.BooleanField(default=True, verbose_name=_('Enabled'))
editable = models.BooleanField(
default=True, editable=False, verbose_name=_('Editable')
)
class Meta:
verbose_name = _('Quota')
verbose_name_plural = _('Quotas')
def __str__(self):
return force_text(self.backend_label())
def save(self, *args, **kwargs):
result = super(Quota, self).save(*args, **kwargs)
self.update_receiver()
return result
def backend_display(self):
return self.get_backend_instance().display()
def backend_label(self):
return self.get_backend_instance().label
def backend_usage(self):
return self.get_backend_instance().usage()
def dispatch_uid(self):
return 'quote_{}'.format(self.pk)
def dumps(self, data):
self.backend_data = json.dumps(data)
self.save()
def get_backend_class(self):
return import_string(self.backend_path)
def get_backend_instance(self):
return self.get_backend_class()(**self.loads())
def loads(self):
return json.loads(self.backend_data)
def update_receiver(self):
backend_instance = self.get_backend_instance()
if self.enabled:
backend_instance.signal.disconnect(
dispatch_uid=self.dispatch_uid(),
sender=backend_instance.sender
)
backend_instance.signal.connect(
handler_process_signal,
dispatch_uid=self.dispatch_uid(),
sender=backend_instance.sender
)
else:
backend_instance.signal.disconnect(
dispatch_uid=self.dispatch_uid(),
sender=backend_instance.sender
)

View File

@@ -0,0 +1,20 @@
from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from permissions import PermissionNamespace
namespace = PermissionNamespace('quotas', _('Quotas'))
permission_quota_create = namespace.add_permission(
name='quota_create', label=_('Create a quota')
)
permission_quota_delete = namespace.add_permission(
name='quota_delete', label=_('Delete a quota')
)
permission_quota_edit = namespace.add_permission(
name='quota_edit', label=_('Edit a quota')
)
permission_quota_view = namespace.add_permission(
name='quota_view', label=_('View a quota')
)

View File

@@ -0,0 +1,211 @@
from __future__ import unicode_literals
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import pre_save
from django.template.defaultfilters import filesizeformat
from django.utils.translation import ugettext_lazy as _
from actstream.models import actor_stream
from documents.events import event_document_create, event_document_new_version
from documents.models import Document, DocumentVersion
from .classes import QuotaBackend
from .exceptions import QuotaExceeded
__all__ = ('DocumentStorageQuota', 'DocumentCountQuota',)
class DocumentCountQuota(QuotaBackend):
fields = (
{
'name': 'documents_limit', 'label': _('Documents limit'),
'class': 'django.forms.IntegerField',
'help_text': _('Maximum number of documents')
},
)
label = _('Document count')
sender = Document
signal = pre_save
def __init__(self, documents_limit):
self.documents_limit = documents_limit
def _allowed(self):
return self.documents_limit
def _usage(self, **kwargs):
return Document.passthrough.all().count()
def display(self):
return _(
'Maximum document count: %(total_documents)s'
) % {
'total_documents': self._allowed(),
}
def process(self, **kwargs):
if self._usage() > self._allowed():
raise QuotaExceeded('Document count exceeded')
def usage(self):
return _('%(usage)s out of %(allowed)s') % {
'usage': self._usage(),
'allowed': self._allowed()
}
class DocumentStorageQuota(QuotaBackend):
fields = (
{
'name': 'storage_size', 'label': _('Storage size'),
'class': 'django.forms.FloatField',
'help_text': _('Total storage usage in megabytes (MB)')
},
)
label = _('Document storage')
sender = Document
signal = pre_save
def __init__(self, storage_size):
self.storage_size = storage_size
def _allowed(self):
return self.storage_size * 1024 * 1024
def _usage(self, **kwargs):
total_usage = 0
for document_version in DocumentVersion.objects.all():
if document_version.exists():
total_usage += document_version.file.size
return total_usage
def display(self):
return _(
'Maximum storage usage: %(formatted_file_size)s (%(raw_file_size)s MB)'
) % {
'formatted_file_size': filesizeformat(self._allowed()),
'raw_file_size': self.storage_size
}
def process(self, **kwargs):
if self._usage() > self.storage_size * 1024 * 1024:
raise QuotaExceeded('Storage usage exceeded')
def usage(self):
return _('%(usage)s out of %(allowed)s') % {
'usage': filesizeformat(self._usage()),
'allowed': filesizeformat(self._allowed())
}
class UserDocumentCountQuota(QuotaBackend):
fields = (
{
'name': 'username', 'label': _('Username'),
'class': 'django.forms.CharField', 'kwargs': {
'max_length': 255
}, 'help_text': _(
'Username of the user to which the quota will be applied'
)
},
{
'name': 'documents_limit', 'label': _('Documents limit'),
'class': 'django.forms.IntegerField',
'help_text': _('Maximum number of documents')
},
)
label = _('User document count')
sender = Document
signal = pre_save
def __init__(self, documents_limit, username):
self.documents_limit = documents_limit
self.username = username
def _allowed(self):
return self.documents_limit
def _usage(self, **kwargs):
user = get_user_model().objects.get(username=self.username)
return actor_stream(user).filter(verb=event_document_create.id).count()
def display(self):
user = get_user_model().objects.get(username=self.username)
return _(
'Maximum document count: %(total_documents)s, for user: %(user)s'
) % {
'total_documents': self._allowed(),
'user': user.get_full_name() or user
}
def process(self, **kwargs):
if self._usage() > self._allowed():
raise QuotaExceeded('Document count exceeded')
def usage(self):
return _('%(usage)s out of %(allowed)s') % {
'usage': self._usage(),
'allowed': self._allowed()
}
###
class UserDocumentStorageQuota(QuotaBackend):
fields = (
{
'name': 'username', 'label': _('Username'),
'class': 'django.forms.CharField', 'kwargs': {
'max_length': 255
}, 'help_text': _(
'Username of the user to which the quota will be applied'
)
},
{
'name': 'storage_size', 'label': _('Storage size'),
'class': 'django.forms.FloatField',
'help_text': _('Total storage usage in megabytes (MB)')
},
)
label = _('User document storage')
sender = Document
signal = pre_save
def __init__(self, storage_size, username):
self.storage_size = storage_size
self.username = username
def _allowed(self):
return self.storage_size * 1024 * 1024
def _usage(self, **kwargs):
total_usage = 0
user = get_user_model().objects.get(username=self.username)
content_type = ContentType.objects.get_for_model(model=user)
for document_version in DocumentVersion.objects.filter(target_actions__actor_object_id=1, target_actions__actor_content_type=content_type, target_actions__verb=event_document_new_version.id):
if document_version.exists():
total_usage += document_version.file.size
return total_usage
def display(self):
user = get_user_model().objects.get(username=self.username)
return _(
'Maximum storage usage: %(formatted_file_size)s (%(raw_file_size)s MB), for user %(user)s'
) % {
'formatted_file_size': filesizeformat(self._allowed()),
'raw_file_size': self.storage_size,
'user': user.get_full_name() or user
}
def process(self, **kwargs):
if self._usage() > self._allowed():
raise QuotaExceeded('Document count exceeded')
def usage(self):
return _('%(usage)s out of %(allowed)s') % {
'usage': filesizeformat(self._usage()),
'allowed': filesizeformat(self._allowed())
}

32
mayan/apps/quotas/urls.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import unicode_literals
from django.conf.urls import url
from .views import (
QuotaBackendSelectionView, QuotaCreateView, QuotaDeleteView,
QuotaEditView, QuotaListView
)
urlpatterns = [
url(
r'^quotas/backend/selection/$',
QuotaBackendSelectionView.as_view(),
name='quota_backend_selection'
),
url(
r'^quotas/(?P<class_path>[a-zA-Z0-9_.]+)/create/$',
QuotaCreateView.as_view(), name='quota_create'
),
url(
r'^quotas/(?P<pk>\d+)/delete/$', QuotaDeleteView.as_view(),
name='quota_delete'
),
url(
r'^quotas/(?P<pk>\d+)/edit/$', QuotaEditView.as_view(),
name='quota_edit'
),
url(
r'^quotas/$', QuotaListView.as_view(),
name='quota_list'
),
]

108
mayan/apps/quotas/views.py Normal file
View File

@@ -0,0 +1,108 @@
from __future__ import absolute_import, unicode_literals
from django.http import Http404, HttpResponseRedirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext_lazy as _
from common.generics import (
FormView, SingleObjectDeleteView, SingleObjectDynamicFormCreateView,
SingleObjectDynamicFormEditView, SingleObjectListView
)
from .classes import QuotaBackend
from .forms import QuotaBackendSelectionForm, QuotaDynamicForm
from .models import Quota
from .permissions import (
permission_quota_create, permission_quota_delete,
permission_quota_edit, permission_quota_view
)
class QuotaBackendSelectionView(FormView):
extra_context = {
'title': _('New quota backend selection'),
}
form_class = QuotaBackendSelectionForm
view_permission = permission_quota_create
def form_valid(self, form):
backend = form.cleaned_data['backend']
return HttpResponseRedirect(
reverse('quotas:quota_create', args=(backend,),)
)
class QuotaCreateView(SingleObjectDynamicFormCreateView):
form_class = QuotaDynamicForm
post_action_redirect = reverse_lazy('quotas:quota_list')
view_permission = permission_quota_create
def get_backend(self):
try:
return QuotaBackend.get(name=self.kwargs['class_path'])
except KeyError:
raise Http404(
'{} class not found'.format(self.kwargs['class_path'])
)
def get_extra_context(self):
return {
'title': _(
'Create a "%s" quota'
) % self.get_backend().label,
}
def get_form_schema(self):
return {
'fields': self.get_backend().fields,
'widgets': getattr(self.get_backend(), 'widgets', {})
}
def get_instance_extra_data(self):
return {'backend_path': self.kwargs['class_path']}
class QuotaDeleteView(SingleObjectDeleteView):
object_permission = permission_quota_delete
post_action_redirect = reverse_lazy('quotas:quota_list')
def get_extra_context(self):
return {
'title': _('Delete quota: %s') % self.get_object(),
}
def get_queryset(self):
return Quota.objects.filter(editable=True)
class QuotaEditView(SingleObjectDynamicFormEditView):
form_class = QuotaDynamicForm
object_permission = permission_quota_edit
def form_valid(self, form):
return super(QuotaEditView, self).form_valid(form)
def get_extra_context(self):
return {
'title': _('Edit quota: %s') % self.get_object(),
}
def get_form_schema(self):
return {
'fields': self.get_object().get_backend_class().fields,
'widgets': getattr(
self.get_object().get_backend_class(), 'widgets', {}
)
}
def get_queryset(self):
return Quota.objects.filter(editable=True)
class QuotaListView(SingleObjectListView):
extra_context = {
'hide_object': True,
'title': _('Quotas'),
}
model = Quota
object_permission = permission_quota_view

View File

@@ -0,0 +1,19 @@
from __future__ import unicode_literals
from rest_framework import generics
from rest_api.filters import MayanObjectPermissionsFilter
from rest_api.permissions import MayanPermission
from .classes import APIResource
from .serializers import APIResourceSerializer
class APIResourceTypeListView(generics.ListAPIView):
"""
Returns a list of all the available API resources.
"""
serializer_class = APIResourceSerializer
def get_queryset(self):
return APIResource.all()

View File

@@ -5,10 +5,34 @@ from django.conf import settings
from django.utils.encoding import force_text, python_2_unicode_compatible
from django.utils.module_loading import import_string
from .exceptions import APIResourcePatternError
class APIResource(object):
_registry = {}
@classmethod
def all(cls):
return cls._registry.values()
@classmethod
def get(cls, name):
return cls._registry[name]
def __unicode__(self):
return unicode(self.name)
def __init__(self, name, label, description=None):
self.label = label
self.name = name
self.description = description
self.__class__._registry[self.name] = self
@python_2_unicode_compatible
class APIEndPoint(object):
_registry = {}
_patterns = []
@classmethod
def get_all(cls):
@@ -48,6 +72,12 @@ class APIEndPoint(object):
def register_urls(self, urlpatterns):
from .urls import urlpatterns as app_urls
app_urls += [
url(r'^%s/' % (self.name or self.app.name), include(urlpatterns)),
]
for url in urlpatterns:
if url.regex.pattern not in self.__class__._patterns:
app_urls.append(url)
self.__class__._patterns.append(url.regex.pattern)
else:
raise APIResourcePatternError(
'App "{}" tried to register API URL pattern "{}", which '
'already exists'.format(self.app.label, url.regex.pattern)
)

View File

@@ -0,0 +1,16 @@
from __future__ import unicode_literals
class APIError(Exception):
"""
Base exception for the API app
"""
pass
class APIResourcePatternError(APIError):
"""
Raised when an app tries to override an existing URL regular expression
pattern
"""
pass

View File

@@ -24,7 +24,10 @@ class DynamicSerializerField(serializers.ReadOnlyField):
for klass, serializer_class in self.serializers.items():
if isinstance(value, klass):
return serializer_class(
context={'request': self.context['request']}
context={
'format': self.context['format'],
'request': self.context['request']
}
).to_representation(instance=value)
return _('Unable to find serializer class for: %s') % value

View File

@@ -0,0 +1,9 @@
from __future__ import unicode_literals
from rest_framework import serializers
class APIResourceSerializer(serializers.Serializer):
description = serializers.CharField()
label = serializers.CharField()
name = serializers.CharField()

View File

@@ -2,15 +2,18 @@ from __future__ import unicode_literals
from django.conf.urls import url
from .views import APIBase, APIAppView, BrowseableObtainAuthToken
from .api_views import APIResourceTypeListView
from .views import APIBase, BrowseableObtainAuthToken
urlpatterns = [
]
urlpatterns = []
api_urls = [
url(r'^$', APIBase.as_view(), name='api_root'),
url(r'^api/(?P<path>.*)/?$', APIAppView.as_view(), name='api_app'),
url(
r'^resources/$', APIResourceTypeListView.as_view(),
name='resource-list'
),
url(
r'^auth/token/obtain/$', BrowseableObtainAuthToken.as_view(),
name='auth_token_obtain'

View File

@@ -13,13 +13,6 @@ class APIBase(SwaggerResourcesView):
renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer)
class APIAppView(SwaggerApiView):
"""
Entry points of the selected app.
"""
renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer)
class BrowseableObtainAuthToken(ObtainAuthToken):
"""
Obtain an API authentication token.

View File

@@ -124,7 +124,7 @@ class Source(models.Model):
logger.critical(
'Unexpected exception while trying to create version for '
'new document "%s" from source "%s"; %s',
label or file_object.name, self, exception
label or file_object.name, self, exception, exc_info=True
)
document.delete(to_trash=False)
raise
@@ -601,6 +601,12 @@ class EmailBaseModel(IntervalBaseModel):
if raw_filename:
filename = collapse_rfc2231_value(raw_filename)
# Decode base64 encoded filename
# https://stackoverflow.com/a/21859258/1364435
if decode_header(filename)[0][1] is not None:
filename = str(decode_header(filename)[0][0]).decode(decode_header(filename)[0][1])
else:
filename = _('attachment-%i') % counter
counter += 1

View File

@@ -14,7 +14,7 @@ from documents.tests import (
)
from ..literals import SOURCE_UNCOMPRESS_CHOICE_Y
from ..models import WatchFolderSource, WebFormSource
from ..models import WatchFolderSource, WebFormSource, EmailBaseModel
@override_settings(OCR_AUTO_OCR=False)
@@ -117,3 +117,55 @@ class CompressedUploadsTestCase(BaseTestCase):
'label', flat=True
)
)
test_email = """From: noreply@example.com
To: test@example.com
Subject: Scan to E-mail Server Job
Date: Tue, 23 May 2017 23:03:37 +0200
Message-Id: <00000001.465619c9.1.00@BRN30055CCF4D76>
Mime-Version: 1.0
Content-Type: multipart/mixed;
boundary="RS1tYWlsIENsaWVudA=="
X-Mailer: E-mail Client
This is multipart message.
--RS1tYWlsIENsaWVudA==
Content-Type: text/plain; charset=iso-8859-1
Content-Transfer-Encoding: quoted-printable
Sending device cannot receive e-mail replies.
--RS1tYWlsIENsaWVudA==
Content-Type: text/plain
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="=?UTF-8?B?QW1wZWxtw6RubmNoZW4udHh0?="
SGFsbG8gQW1wZWxtw6RubmNoZW4hCg==
--RS1tYWlsIENsaWVudA==--"""
class SourceStub():
subject_metadata_type = None
from_metadata_type = None
metadata_attachment_name = None
document_type = None
uncompress = None
store_body = False
label = ""
def handle_upload(self, file_object, description=None, document_type=None, expand=False, label=None, language=None,
metadata_dict_list=None, metadata_dictionary=None, tag_ids=None, user=None):
self.label = label
class EmailFilenameDecodingTestCase(BaseTestCase):
"""
Test decoding of base64 encoded e-mail attachment filename.
"""
def test_decode_email_encoded_filename(self):
source_stub = SourceStub()
EmailBaseModel.process_message(source_stub, test_email)
self.assertEqual(source_stub.label, u'Ampelm\xe4nnchen.txt')

View File

@@ -242,7 +242,8 @@ class UploadInteractiveView(UploadBaseView):
except Exception as exception:
messages.error(self.request, exception)
task_source_handle_upload.apply_async(kwargs=dict(
task_source_handle_upload.apply_async(
kwargs=dict(
description=forms['document_form'].cleaned_data.get('description'),
document_type_id=self.document_type.pk,
expand=expand,
@@ -253,7 +254,8 @@ class UploadInteractiveView(UploadBaseView):
source_id=self.source.pk,
tag_ids=self.request.GET.getlist('tags'),
user_id=user_id,
))
)
)
messages.success(
self.request,
_(

View File

@@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from events.classes import Event
from events.classes import EventTypeNamespace
event_tag_attach = Event(
name='tag_attach',
label=_('Tag attached to document')
namespace = EventTypeNamespace(name='tag', label=_('Tags'))
event_tag_attach = namespace.add_event_type(
label=_('Tag attached to document'),
name='attach'
)
event_tag_remove = Event(
name='tag_remove',
label=_('Tag removed from document')
event_tag_remove = namespace.add_event_type(
label=_('Tag removed from document'),
name='remove'
)

View File

@@ -89,9 +89,6 @@ INSTALLED_APPS = (
'document_states',
'documents',
'events',
# Disable the folders app by default
# Will be removed in the next version
# 'folders',
'linking',
'mailer',
'mayan_statistics',
@@ -99,6 +96,7 @@ INSTALLED_APPS = (
'mirroring',
'motd',
'ocr',
'quotas',
'rest_api',
'sources',
'storage',

View File

@@ -1,44 +1,44 @@
Pillow==4.2.0
Pillow==4.2.1
PyYAML==3.12
celery==3.1.24
cssmin==0.2.0
django-activity-stream==0.6.3
django-activity-stream==0.6.4
django-autoadmin==1.1.1
django-celery==3.2.1
django-colorful==1.2
django-compressor==2.1
django-cors-headers==1.2.2
django-compressor==2.2
django-cors-headers==2.1.0
django-downloadview==1.9
django-formtools==2.0
django-pure-pagination==0.3.0
django-mathfilters==0.4.0
django-model-utils==2.6.1
django-mptt>=0.8.7
django-qsstats-magic==0.7.2
django-model-utils==3.0.0
django-mptt==0.8.7
django-qsstats-magic==1.0.0
django-rest-swagger==0.3.10
django-stronghold==0.2.8
django-stronghold==0.2.9
django-suit==0.2.25
django-widget-tweaks==1.4.1
djangorestframework==3.3.2
djangorestframework-recursive==0.1.1
furl==1.0.0
furl==1.0.1
fusepy==2.0.4
graphviz==0.8
mock==2.0.0
pycountry==1.20
pycountry==17.5.14
PyPDF2==1.26.0
pyocr==0.4.5
python-dateutil==2.5.3
python-gnupg==0.3.9
pyocr==0.4.7
python-dateutil==2.6.1
python-gnupg==0.4.1
python-magic==0.4.13
pytz==2016.7
pytz==2017.2
requests==2.18.4
sh==1.12.11
sh==1.12.14

View File

@@ -1,2 +1,2 @@
Django==1.10.7
Django==1.11.5
-r base.txt

View File

@@ -2,14 +2,14 @@
Werkzeug==0.12.2
django-debug-toolbar==1.8
django-extensions==1.7.9
django-extensions==1.9.0
django-rosetta==0.7.13
flake8==3.3.0
flake8==3.4.1
ipython==5.4.1
safety==1.4.0
safety==1.5.1
transifex-client==0.12.4

View File

@@ -1,4 +1,4 @@
Sphinx==1.5.3
Sphinx==1.6.3
sphinx-autobuild==0.6.0
sphinx-autobuild==0.7.1
sphinxcontrib-blockdiag==1.5.5

View File

@@ -1,6 +1,6 @@
codecov==2.0.9
coverage==4.4.1
coveralls==1.1
coveralls==1.2.0
django-test-without-migrations==0.6
tox==2.7.0
psutil==5.2.2
tox==2.8.1
psutil==5.3.0

View File

@@ -65,7 +65,7 @@ django-activity-stream==0.6.3
django-autoadmin==1.1.1
django-celery==3.2.1
django-colorful==1.2
django-compressor==2.1
django-compressor==2.1.1
django-cors-headers==1.2.2
django-downloadview==1.9
django-formtools==2.0
@@ -86,13 +86,13 @@ graphviz==0.8
mock==2.0.0
pycountry==1.20
PyPDF2==1.26.0
pyocr==0.4.5
pyocr==0.4.7
python-dateutil==2.5.3
python-gnupg==0.3.9
python-magic==0.4.13
pytz==2016.7
requests==2.18.4
sh==1.12.11
sh==1.12.13
""".split()
with open('README.rst') as f: