Compare commits
43 Commits
features/d
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25854a0a1c | ||
|
|
6006fc9b74 | ||
|
|
57baf76a50 | ||
|
|
8385ef13b0 | ||
|
|
dd1e4cb685 | ||
|
|
6dcfd1b861 | ||
|
|
d867614c1c | ||
|
|
6cc574e631 | ||
|
|
5b94d202dc | ||
|
|
3e7d3946f8 | ||
|
|
2cbb1bc9e1 | ||
|
|
f0505db3b7 | ||
|
|
39995764eb | ||
|
|
d0aad4cb35 | ||
|
|
7abf8a7fc0 | ||
|
|
fc0127c7ad | ||
|
|
bbef5fd570 | ||
|
|
7a492b28f3 | ||
|
|
abd720bf10 | ||
|
|
25c1391649 | ||
|
|
95c0be45fb | ||
|
|
944ddd682b | ||
|
|
c6bf3b00cd | ||
|
|
bd419dc943 | ||
|
|
70e2ca3334 | ||
|
|
5dd88ad690 | ||
|
|
ae57b97a1b | ||
|
|
cb15e40f70 | ||
|
|
01420c42dd | ||
|
|
20e3634f5a | ||
|
|
48fc36d54e | ||
|
|
7a29b2496b | ||
|
|
c0407652c0 | ||
|
|
5083a2d261 | ||
|
|
ba1ab5d4f1 | ||
|
|
17b7d6f25e | ||
|
|
ca1f674d78 | ||
|
|
93aeb8cffe | ||
|
|
8eeee5b5a4 | ||
|
|
6221187070 | ||
|
|
acdc7dca48 | ||
|
|
105eab0740 | ||
|
|
b91f7f685a |
@@ -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.
|
||||
|
||||
@@ -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
79
docs/releases/3.0.rst
Normal 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/
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
75
mayan/apps/checkouts/tests/test_api.py
Normal file
75
mayan/apps/checkouts/tests/test_api.py
Normal 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
|
||||
)
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,)
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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
122
mayan/apps/events/forms.py
Normal 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
|
||||
)
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
34
mayan/apps/events/managers.py
Normal file
34
mayan/apps/events/managers.py
Normal 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
|
||||
)
|
||||
30
mayan/apps/events/migrations/0002_eventsubscription.py
Normal file
30
mayan/apps/events/migrations/0002_eventsubscription.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
32
mayan/apps/events/migrations/0003_notification.py
Normal file
32
mayan/apps/events/migrations/0003_notification.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
40
mayan/apps/events/migrations/0004_auto_20170731_0423.py
Normal file
40
mayan/apps/events/migrations/0004_auto_20170731_0423.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
44
mayan/apps/events/migrations/0005_auto_20170731_0452.py
Normal file
44
mayan/apps/events/migrations/0005_auto_20170731_0452.py
Normal 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),
|
||||
]
|
||||
33
mayan/apps/events/migrations/0006_objecteventsubscription.py
Normal file
33
mayan/apps/events/migrations/0006_objecteventsubscription.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
19
mayan/apps/events/migrations/0007_auto_20170802_0823.py
Normal file
19
mayan/apps/events/migrations/0007_auto_20170802_0823.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
20
mayan/apps/metadata/migrations/0008_auto_20170904_1730.py
Normal file
20
mayan/apps/metadata/migrations/0008_auto_20170904_1730.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
|
||||
3
mayan/apps/quotas/__init__.py
Normal file
3
mayan/apps/quotas/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
default_app_config = 'quotas.apps.QuotasApp'
|
||||
24
mayan/apps/quotas/admin.py
Normal file
24
mayan/apps/quotas/admin.py
Normal 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
85
mayan/apps/quotas/apps.py
Normal 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,))
|
||||
81
mayan/apps/quotas/classes.py
Normal file
81
mayan/apps/quotas/classes.py
Normal 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
|
||||
)
|
||||
12
mayan/apps/quotas/exceptions.py
Normal file
12
mayan/apps/quotas/exceptions.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
class QuotaBaseException(Exception):
|
||||
"""
|
||||
Base exception for the quota app
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class QuotaExceeded(QuotaBaseException):
|
||||
pass
|
||||
50
mayan/apps/quotas/forms.py
Normal file
50
mayan/apps/quotas/forms.py
Normal 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
|
||||
13
mayan/apps/quotas/handlers.py
Normal file
13
mayan/apps/quotas/handlers.py
Normal 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)
|
||||
38
mayan/apps/quotas/links.py
Normal file
38
mayan/apps/quotas/links.py
Normal 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',
|
||||
)
|
||||
29
mayan/apps/quotas/migrations/0001_initial.py
Normal file
29
mayan/apps/quotas/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
20
mayan/apps/quotas/migrations/0002_quota_editable.py
Normal file
20
mayan/apps/quotas/migrations/0002_quota_editable.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
mayan/apps/quotas/migrations/__init__.py
Normal file
0
mayan/apps/quotas/migrations/__init__.py
Normal file
84
mayan/apps/quotas/models.py
Normal file
84
mayan/apps/quotas/models.py
Normal 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
|
||||
)
|
||||
20
mayan/apps/quotas/permissions.py
Normal file
20
mayan/apps/quotas/permissions.py
Normal 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')
|
||||
)
|
||||
211
mayan/apps/quotas/quota_backends.py
Normal file
211
mayan/apps/quotas/quota_backends.py
Normal 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
32
mayan/apps/quotas/urls.py
Normal 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
108
mayan/apps/quotas/views.py
Normal 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
|
||||
19
mayan/apps/rest_api/api_views.py
Normal file
19
mayan/apps/rest_api/api_views.py
Normal 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()
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
16
mayan/apps/rest_api/exceptions.py
Normal file
16
mayan/apps/rest_api/exceptions.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
9
mayan/apps/rest_api/serializers.py
Normal file
9
mayan/apps/rest_api/serializers.py
Normal 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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -242,18 +242,20 @@ class UploadInteractiveView(UploadBaseView):
|
||||
except Exception as exception:
|
||||
messages.error(self.request, exception)
|
||||
|
||||
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,
|
||||
label=label,
|
||||
language=forms['document_form'].cleaned_data.get('language'),
|
||||
metadata_dict_list=decode_metadata_from_url(self.request.GET),
|
||||
shared_uploaded_file_id=shared_uploaded_file.pk,
|
||||
source_id=self.source.pk,
|
||||
tag_ids=self.request.GET.getlist('tags'),
|
||||
user_id=user_id,
|
||||
))
|
||||
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,
|
||||
label=label,
|
||||
language=forms['document_form'].cleaned_data.get('language'),
|
||||
metadata_dict_list=decode_metadata_from_url(self.request.GET),
|
||||
shared_uploaded_file_id=shared_uploaded_file.pk,
|
||||
source_id=self.source.pk,
|
||||
tag_ids=self.request.GET.getlist('tags'),
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
requests==2.18.4
|
||||
|
||||
sh==1.12.11
|
||||
sh==1.12.14
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
Django==1.10.7
|
||||
Django==1.11.5
|
||||
-r base.txt
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
8
setup.py
8
setup.py
@@ -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
|
||||
requests==2.18.4
|
||||
sh==1.12.13
|
||||
""".split()
|
||||
|
||||
with open('README.rst') as f:
|
||||
|
||||
Reference in New Issue
Block a user