├── django_project ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── generate_notifications.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── signals.py ├── managers.py ├── urls.py ├── mixins.py ├── filters.py ├── admin.py ├── handlers.py ├── tests.py ├── fixtures │ └── initial_data.json ├── models.py ├── serializers.py └── views.py ├── example_project ├── __init__.py ├── wsgi.py ├── urls.py └── settings.py ├── follow ├── templatetags │ ├── __init__.py │ └── follow_tags.py ├── __init__.py ├── registry.py ├── admin.py ├── signals.py ├── templates │ └── follow │ │ └── form.html ├── urls.py ├── views.py ├── utils.py ├── models.py └── tests.py ├── manage.py ├── requirements.txt ├── .gitignore ├── README.md ├── Dockerfile ├── setup.py └── LICENSE /django_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /follow/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_project/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_project/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_project/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /follow/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.6.1' 2 | -------------------------------------------------------------------------------- /follow/registry.py: -------------------------------------------------------------------------------- 1 | 2 | registry = [] 3 | model_map = {} 4 | -------------------------------------------------------------------------------- /follow/admin.py: -------------------------------------------------------------------------------- 1 | from follow.models import Follow 2 | from django.contrib import admin 3 | 4 | admin.site.register(Follow) -------------------------------------------------------------------------------- /follow/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch.dispatcher import Signal 2 | 3 | followed = Signal(providing_args=["user", "target", "instance"]) 4 | unfollowed = Signal(providing_args=["user", "target", "instance"]) 5 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /follow/templates/follow/form.html: -------------------------------------------------------------------------------- 1 | {% load follow_tags %} 2 |
3 | {% csrf_token %} 4 | {% if request.user|is_following:object %} 5 | 6 | {% else %} 7 | 8 | {% endif %} 9 |
-------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.9.4 2 | django-autoslug==1.9.3 3 | django-cors-headers==1.1.0 4 | django-filter==0.12.0 5 | django-model-utils==2.3.1 6 | django-notifications-hq==1.0 7 | django-reversion==1.10.1 8 | django-smart-selects 9 | djangorestframework==3.3.2 10 | drf-extensions==0.2.8 11 | jsonfield==1.0.3 12 | pytz==2015.7 13 | six==1.10.0 14 | wheel==0.24.0 15 | -------------------------------------------------------------------------------- /follow/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url, include 2 | 3 | import follow.views 4 | urlpatterns = [ 5 | url(r'^toggle/(?P[^\/]+)/(?P[^\/]+)/(?P\d+)/$', follow.views.toggle, name='toggle'), 6 | url(r'^toggle/(?P[^\/]+)/(?P[^\/]+)/(?P\d+)/$', follow.views.toggle, name='follow'), 7 | url(r'^toggle/(?P[^\/]+)/(?P[^\/]+)/(?P\d+)/$', follow.views.toggle, name='unfollow'), 8 | ] 9 | -------------------------------------------------------------------------------- /example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | *~ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | _build 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | __pycache__ 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-project 2 | ============== 3 | 4 | Project management with tasks, milestones, follow and activity-stream 5 | 6 | 7 | git clone https://github.com/peragro/django-project.git 8 | virtualenv env 9 | source env/bin/activate 10 | cd django_project 11 | pip install -r requirements.txt 12 | python manage.py migrate --run-syncdb 13 | Set the superuser for django using python manage.py createsuperuser 14 | python manage.py loaddata django_project/fixtures/initial_data.json 15 | python manage.py runserver 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | gcc \ 5 | gettext \ 6 | mysql-client libmysqlclient-dev \ 7 | postgresql-client libpq-dev \ 8 | sqlite3 \ 9 | --no-install-recommends && rm -rf /var/lib/apt/lists/* 10 | 11 | RUN mkdir -p /usr/src/app 12 | WORKDIR /usr/src/app 13 | 14 | COPY requirements.txt /usr/src/app/ 15 | RUN pip install --no-cache-dir -r requirements.txt 16 | 17 | COPY . /usr/src/app 18 | 19 | RUN python manage.py migrate 20 | 21 | EXPOSE 8000 22 | CMD ["python", "-u", "manage.py", "runserver", "0.0.0.0:8000"] -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | config = { 4 | 'description': 'Django app for project management', 5 | 'author': 'sueastside', 6 | 'url': 'https://github.com/sueastside/django-project', 7 | 'download_url': 'https://github.com/sueastside/django-project', 8 | 'author_email': 'No, thanks', 9 | 'version': '0.1', 10 | 'test_suite': 'tests.suite', 11 | 'install_requires': [], 12 | 'test_requires': [], 13 | 'packages': find_packages(exclude=['example_project']), 14 | 'scripts': [], 15 | 'name': 'django-project', 16 | } 17 | 18 | setup(**config) 19 | -------------------------------------------------------------------------------- /django_project/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | workflow_task_new = django.dispatch.Signal(providing_args=["instance"]) 4 | 5 | workflow_task_transition = django.dispatch.Signal(providing_args=["instance", "transition", "old_state", "new_state"]) 6 | 7 | workflow_task_resolved = django.dispatch.Signal(providing_args=["instance", "transition", "old_state", "new_state"]) 8 | 9 | 10 | follow = django.dispatch.Signal(providing_args=["follower", "followee"]) 11 | 12 | unfollow = django.dispatch.Signal(providing_args=["follower", "followee"]) 13 | 14 | 15 | commented = django.dispatch.Signal(providing_args=["instance", "comment"]) 16 | -------------------------------------------------------------------------------- /django_project/management/commands/generate_notifications.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from django.contrib.auth.models import User, Group 4 | 5 | from notifications import notify 6 | from notifications.models import Notification 7 | 8 | from django_project import signals 9 | from django_project.models import Task, Project 10 | from follow.models import Follow 11 | 12 | class Command(BaseCommand): 13 | args = '' 14 | help = 'Closes the specified poll for voting' 15 | 16 | def handle(self, *args, **options): 17 | Notification.objects.all().delete() 18 | for obj in list(Task.objects.all()) + list(Project.objects.all()) + list(User.objects.all()) + list(Group.objects.all()): 19 | for follow in Follow.objects.get_follows(obj): 20 | print(follow.user, follow.target) 21 | print('-'*70) 22 | 23 | signals.follow.send(Command, follower=follow.user, followee=obj) 24 | -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | from django.contrib import admin 4 | admin.autodiscover() 5 | 6 | import notifications.urls 7 | 8 | 9 | from django_project.urls import router 10 | 11 | import follow.views 12 | import rest_framework.urls 13 | import rest_framework.authtoken.views 14 | 15 | urlpatterns = [ 16 | url(r'^', include(router.urls)), 17 | 18 | url(r'^api-auth/', include(rest_framework.urls, namespace='rest_framework')), 19 | url(r'^api-token-auth/', rest_framework.authtoken.views.obtain_auth_token), 20 | 21 | url(r'^admin/', include(admin.site.urls)), 22 | 23 | ] 24 | 25 | urlpatterns += [ 26 | url('^inbox/notifications/', include(notifications.urls, namespace='notifications')), 27 | url(r'^toggle/(?P[^\/]+)/(?P[^\/]+)/(?P\d+)/$', follow.views.toggle, name='toggle'), 28 | url(r'^toggle/(?P[^\/]+)/(?P[^\/]+)/(?P\d+)/$', follow.views.toggle, name='follow'), 29 | url(r'^toggle/(?P[^\/]+)/(?P[^\/]+)/(?P\d+)/$', follow.views.toggle, name='unfollow'), 30 | url(r'^', include('django_project.urls')), 31 | 32 | ] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, sueastside 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /django_project/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.utils.encoding import force_text 4 | 5 | class CommentManager(models.Manager): 6 | 7 | def for_model(self, model): 8 | """ 9 | QuerySet for all comments for a particular model (either an instance or 10 | a class). 11 | """ 12 | ct = ContentType.objects.get_for_model(model) 13 | qs = self.get_query_set().filter(content_type=ct) 14 | if isinstance(model, models.Model): 15 | qs = qs.filter(object_pk=force_text(model._get_pk_val())) 16 | return qs 17 | 18 | 19 | 20 | from django.db.models.fields.related import ManyToManyField 21 | from django.contrib.contenttypes.fields import GenericRelation 22 | 23 | class ObjectTaskMixin(models.Model): 24 | _object_tasks = GenericRelation('ObjectTask') 25 | 26 | class Meta: 27 | abstract = True 28 | 29 | @property 30 | def tasks(self): 31 | from django_project.models import Task 32 | return Task.objects.filter(objecttask_tasks__content_type=self._content_type(), objecttask_tasks__object_id=self._object_pk()) 33 | 34 | def _content_type(self): 35 | return ContentType.objects.get_for_model(self) 36 | 37 | def _object_pk(self): 38 | return force_text(self._get_pk_val()) 39 | 40 | def _filter(self, model): 41 | return self._object_tasks 42 | #return model.objects.filter(content_type=self._content_type(), object_pk=self._object_pk()) 43 | 44 | def add_task(self, task): 45 | from django_project.models import ObjectTask 46 | if self._filter(ObjectTask).filter(task=task).count() == 0: 47 | ot = ObjectTask(task=task, content_object=self) 48 | ot.save() 49 | 50 | def remove_task(self, task): 51 | from django_project.models import ObjectTask 52 | self._filter(ObjectTask).filter(task=task).delete() 53 | 54 | def tasks_for_author(self, user): 55 | return self.tasks.filter(author=user) 56 | -------------------------------------------------------------------------------- /follow/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.apps import apps 3 | from django.http import HttpResponse, HttpResponseRedirect, \ 4 | HttpResponseServerError, HttpResponseBadRequest 5 | from follow.utils import follow as _follow, unfollow as _unfollow, toggle as _toggle 6 | 7 | def check(func): 8 | """ 9 | Check the permissions, http method and login state. 10 | """ 11 | def iCheck(request, *args, **kwargs): 12 | if not request.method == "POST": 13 | return HttpResponseBadRequest("Must be POST request.") 14 | follow = func(request, *args, **kwargs) 15 | if request.is_ajax(): 16 | return HttpResponse('ok') 17 | try: 18 | if 'next' in request.GET: 19 | return HttpResponseRedirect(request.GET.get('next')) 20 | if 'next' in request.POST: 21 | return HttpResponseRedirect(request.POST.get('next')) 22 | return HttpResponseRedirect(follow.target.get_absolute_url()) 23 | except (AttributeError, TypeError): 24 | if 'HTTP_REFERER' in request.META: 25 | return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/')) 26 | if follow: 27 | return HttpResponseServerError('"%s" object of type ``%s`` has no method ``get_absolute_url()``.' % ( 28 | unicode(follow.target), follow.target.__class__)) 29 | return HttpResponseServerError('No follow object and `next` parameter found.') 30 | return iCheck 31 | 32 | @login_required 33 | @check 34 | def follow(request, app, model, id): 35 | model = apps.get_model(app, model) 36 | obj = model.objects.get(pk=id) 37 | return _follow(request.user, obj) 38 | 39 | @login_required 40 | @check 41 | def unfollow(request, app, model, id): 42 | model = apps.get_model(app, model) 43 | obj = model.objects.get(pk=id) 44 | return _unfollow(request.user, obj) 45 | 46 | 47 | @login_required 48 | @check 49 | def toggle(request, app, model, id): 50 | model = apps.get_model(app, model) 51 | obj = model.objects.get(pk=id) 52 | return _toggle(request.user, obj) 53 | -------------------------------------------------------------------------------- /follow/templatetags/follow_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.core.urlresolvers import reverse 3 | from follow.models import Follow 4 | from follow import utils 5 | import re 6 | 7 | register = template.Library() 8 | 9 | @register.tag 10 | def follow_url(parser, token): 11 | """ 12 | Returns either a link to follow or to unfollow. 13 | 14 | Usage:: 15 | 16 | {% follow_url object %} 17 | {% follow_url object user %} 18 | 19 | """ 20 | bits = token.split_contents() 21 | return FollowLinkNode(*bits[1:]) 22 | 23 | class FollowLinkNode(template.Node): 24 | def __init__(self, obj, user=None): 25 | self.obj = template.Variable(obj) 26 | self.user = user 27 | 28 | def render(self, context): 29 | obj = self.obj.resolve(context) 30 | 31 | if not self.user: 32 | try: 33 | user = context['request'].user 34 | except KeyError: 35 | raise template.TemplateSyntaxError('There is no request object in the template context.') 36 | else: 37 | user = template.Variable(self.user).resolve(context) 38 | 39 | return utils.follow_url(user, obj) 40 | 41 | 42 | @register.filter 43 | def is_following(user, obj): 44 | """ 45 | Returns `True` in case `user` is following `obj`, else `False` 46 | """ 47 | return Follow.objects.is_following(user, obj) 48 | 49 | 50 | @register.tag 51 | def follow_form(parser, token): 52 | """ 53 | Renders the following form. This can optionally take a path to a custom 54 | template. 55 | 56 | Usage:: 57 | 58 | {% follow_form object %} 59 | {% follow_form object "app/follow_form.html" %} 60 | 61 | """ 62 | bits = token.split_contents() 63 | return FollowFormNode(*bits[1:]) 64 | 65 | class FollowFormNode(template.Node): 66 | def __init__(self, obj, tpl=None): 67 | self.obj = template.Variable(obj) 68 | self.template = tpl[1:-1] if tpl else 'follow/form.html' 69 | 70 | def render(self, context): 71 | ctx = {'object': self.obj.resolve(context)} 72 | return template.loader.render_to_string(self.template, ctx, 73 | context_instance=context) 74 | -------------------------------------------------------------------------------- /follow/utils.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.db.models.fields.related import ManyToManyField, ForeignKey 3 | from follow.models import Follow 4 | from follow.registry import registry, model_map 5 | 6 | def get_followers_for_object(instance): 7 | return Follow.objects.get_follows(instance) 8 | 9 | def register(model, field_name=None, related_name=None, lookup_method_name='get_follows'): 10 | """ 11 | This registers any model class to be follow-able. 12 | 13 | """ 14 | if model in registry: 15 | return 16 | 17 | registry.append(model) 18 | 19 | if not field_name: 20 | field_name = 'target_%s' % model._meta.model_name 21 | 22 | if not related_name: 23 | related_name = 'follow_%s' % model._meta.model_name 24 | 25 | field = ForeignKey(model, related_name=related_name, null=True, 26 | blank=True, db_index=True) 27 | 28 | field.contribute_to_class(Follow, field_name) 29 | setattr(model, lookup_method_name, get_followers_for_object) 30 | model_map[model] = [related_name, field_name] 31 | 32 | def follow(user, obj): 33 | """ Make a user follow an object """ 34 | follow, created = Follow.objects.get_or_create(user, obj) 35 | return follow 36 | 37 | def unfollow(user, obj): 38 | """ Make a user unfollow an object """ 39 | try: 40 | follow = Follow.objects.get_follows(obj).get(user=user) 41 | follow.delete() 42 | return follow 43 | except Follow.DoesNotExist: 44 | pass 45 | 46 | def toggle(user, obj): 47 | """ Toggles a follow status. Useful function if you don't want to perform follow 48 | checks but just toggle it on / off. """ 49 | if Follow.objects.is_following(user, obj): 50 | return unfollow(user, obj) 51 | return follow(user, obj) 52 | 53 | 54 | def follow_link(object): 55 | return reverse('follow', args=[object._meta.app_label, object._meta.object_name.lower(), object.pk]) 56 | 57 | def unfollow_link(object): 58 | return reverse('unfollow', args=[object._meta.app_label, object._meta.object_name.lower(), object.pk]) 59 | 60 | def toggle_link(object): 61 | return reverse('toggle', args=[object._meta.app_label, object._meta.object_name.lower(), object.pk]) 62 | 63 | def follow_url(user, obj): 64 | """ Returns the right follow/unfollow url """ 65 | return toggle_link(obj) 66 | -------------------------------------------------------------------------------- /django_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url, include 2 | from django_project import views 3 | 4 | from rest_framework_extensions.routers import ExtendedDefaultRouter 5 | 6 | router = ExtendedDefaultRouter() 7 | projects_router = router.register(r'projects', views.ProjectViewSet) 8 | router.register(r'components', views.ComponentViewSet) 9 | tasks_router = router.register(r'tasks', views.TaskViewSet) 10 | users_router = router.register(r'users', views.UserViewSet) 11 | milestones_router = router.register(r'milestones', views.MilestoneModelViewSet) 12 | router.register(r'groups', views.GroupViewSet) 13 | router.register(r'comments', views.CommentModelViewSet) 14 | router.register(r'notifications', views.NotificationModelViewSet) 15 | 16 | router.register(r'tasktypes', views.TaskTypeViewSet) 17 | router.register(r'priorities', views.PriorityViewSet) 18 | router.register(r'statuses', views.StatusViewSet) 19 | 20 | projects_router.register(r'members', views.UserViewSet, base_name='projects-member', parents_query_lookups=['membership']) 21 | projects_router.register(r'milestones', views.MilestoneModelViewSet, base_name='projects-milestone', parents_query_lookups=['project']) 22 | projects_router.register(r'components', views.ComponentViewSet, base_name='projects-component', parents_query_lookups=['project']) 23 | projects_router.register(r'tasks', views.TaskViewSet, base_name='projects-task', parents_query_lookups=['project']) 24 | projects_router.register(r'tasktypes', views.TaskTypeViewSet, base_name='projects-tasktype', parents_query_lookups=['project']) 25 | projects_router.register(r'priorities', views.PriorityViewSet, base_name='projects-priority', parents_query_lookups=['project']) 26 | projects_router.register(r'statuses', views.StatusViewSet, base_name='projects-status', parents_query_lookups=['project']) 27 | 28 | milestones_router.register(r'tasks', views.TaskViewSet, base_name='milestones-task', parents_query_lookups=['milestone']) 29 | 30 | users_router.register(r'tasks', views.TaskViewSet, base_name='users-task', parents_query_lookups=['owner']) 31 | users_router.register(r'projects', views.ProjectViewSet, base_name='users-project', parents_query_lookups=['members']) 32 | 33 | #tasks_router = routers.NestedSimpleRouter(router, r'tasks', lookup='CommentModelViewSet__content_object') 34 | tasks_router.register(r'comments', views.nested_viewset_with_genericfk(views.TaskViewSet, views.CommentModelViewSet), base_name='tasks-comment', parents_query_lookups=['object_pk']) 35 | 36 | urlpatterns = [ 37 | url(r'^user/$', views.CurrentUserDetail.as_view()), 38 | 39 | #url(r'^', include(router.urls)), 40 | 41 | url(r'^chaining/', include('smart_selects.urls')), 42 | ] 43 | -------------------------------------------------------------------------------- /django_project/mixins.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django.db import transaction 4 | from reversion import revisions as reversion 5 | 6 | from follow.models import Follow 7 | 8 | from django_project import signals 9 | 10 | class ProjectMixin(object): 11 | def save(self, *args, **kwargs): 12 | ret = super(ProjectMixin, self).save(*args, **kwargs) 13 | #Author of the project is always following! 14 | Follow.objects.get_or_create(self.author, self) 15 | return ret 16 | 17 | 18 | class TaskMixin(object): 19 | def versions(self): 20 | #version_list = reversion.get_for_object(self) 21 | version_list = reversion.get_unique_for_object(self) 22 | return version_list 23 | 24 | def nr_of_versions(self): 25 | version_list = reversion.get_unique_for_object(self) 26 | return len(version_list) 27 | 28 | def save_revision(self, user, comment, *args, **kwargs): 29 | with transaction.atomic(), reversion.create_revision(): 30 | self.save() 31 | reversion.set_user(user) 32 | reversion.set_comment(comment) 33 | 34 | 35 | def save(self, *args, **kwargs): 36 | from django_project.models import Task, Transition 37 | 38 | exists = self.id is not None 39 | if exists: 40 | old_task = Task.objects.get(pk=self.id) 41 | old_state = old_task.status 42 | 43 | ret = super(TaskMixin, self).save(*args, **kwargs) 44 | 45 | if exists: 46 | new_state = self.status 47 | # Only signal if the states belong to the same project(else assume saving from admin) 48 | if new_state.project == old_state.project: 49 | #print('TaskMixin::save 1', old_state, new_state) 50 | transition = Transition.objects.get(source=old_state, destination=new_state) 51 | print('TaskMixin::save 2', transition) 52 | 53 | if new_state.is_resolved: 54 | signals.workflow_task_resolved.send(sender=Task, instance=self, transition=transition, old_state=old_state, new_state=new_state) 55 | else: 56 | signals.workflow_task_transition.send(sender=Task, instance=self, transition=transition, old_state=old_state, new_state=new_state) 57 | else: 58 | signals.workflow_task_new.send(sender=Task, instance=self) 59 | 60 | return ret 61 | 62 | 63 | class CommentMixin(object): 64 | def save(self, *args, **kwargs): 65 | from django_project.models import Comment 66 | 67 | exists = self.id is not None 68 | 69 | ret = super(CommentMixin, self).save(*args, **kwargs) 70 | 71 | if not exists: 72 | signals.commented.send(sender=Comment, instance=self.content_object, comment=self) 73 | return ret 74 | -------------------------------------------------------------------------------- /django_project/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | 3 | from django_project import models 4 | 5 | 6 | from datetime import timedelta 7 | from django.utils.timezone import now 8 | from django_filters.filters import _truncate 9 | from django_filters.filters import Filter 10 | class ExtendedDateRangeFilter(Filter): 11 | def __init__(self, *args, **kwargs): 12 | super(ExtendedDateRangeFilter, self).__init__(*args, **kwargs) 13 | 14 | def filter(self, qs, value): 15 | try: 16 | value = int(value) 17 | except (ValueError, TypeError): 18 | value = 0 19 | if value >= 0: 20 | return qs.filter(**{ 21 | '%s__gte' % self.name: _truncate(now() + timedelta(days=1)), 22 | '%s__lt' % self.name: _truncate(now() + timedelta(days=value)), 23 | }) 24 | else: 25 | return qs.filter(**{ 26 | '%s__gte' % self.name: _truncate(now() - timedelta(days=-value)), 27 | '%s__lt' % self.name: _truncate(now() + timedelta(days=1)), 28 | }) 29 | 30 | 31 | class TaskFilter(django_filters.FilterSet): 32 | #http://stackoverflow.com/questions/10873249/django-filter-lookup-type-documentation 33 | owner = django_filters.CharFilter(name="owner__username", lookup_type="icontains") 34 | author = django_filters.CharFilter(name="author__username", lookup_type="icontains") 35 | deadline = ExtendedDateRangeFilter() 36 | class Meta: 37 | model = models.Task 38 | fields = ['owner', 'author', 'status', 'component', 'milestone'] 39 | order_by = ( 40 | ('status__order', 'Status'), 41 | ('priority__order', 'Priority'), 42 | ('-status__order', 'Status'), 43 | ('-priority__order', 'Priority'), 44 | ('deadline', 'Dead line'), 45 | ('-deadline', 'Dead line'), 46 | ) 47 | 48 | class ProjectFilter(django_filters.FilterSet): 49 | 50 | class Meta: 51 | model = models.Project 52 | fields = ['name', 'author', 'created_at', 'modified_at'] 53 | order_by = ( 54 | ('name', 'Name'), 55 | ('-name', 'Name'), 56 | ('created_at', 'Created at'), 57 | ('-created_at', 'Created at'), 58 | ('modified_at', 'Modified at'), 59 | ('-modified_at', 'Modified at'), 60 | ) 61 | 62 | class CommentFilter(django_filters.FilterSet): 63 | class Meta: 64 | model = models.Comment 65 | order_by = ( 66 | ('submit_date', 'Date'), 67 | ('-submit_date', 'Date'), 68 | ) 69 | 70 | class MilestoneFilter(django_filters.FilterSet): 71 | deadline = ExtendedDateRangeFilter() 72 | class Meta: 73 | model = models.Milestone 74 | -------------------------------------------------------------------------------- /example_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_project project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.6/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.6/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = '#pn6kye3mj&qicb=c=8!tm*x5nt)(-##0)1l9ya$@iai#i@wzi' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | ALLOWED_HOSTS = [] 26 | 27 | 28 | # Application definition 29 | 30 | INSTALLED_APPS = ( 31 | 'django.contrib.admin', 32 | 'django.contrib.auth', 33 | 'django.contrib.contenttypes', 34 | 'django.contrib.sessions', 35 | 'django.contrib.messages', 36 | 'django.contrib.staticfiles', 37 | 'reversion', 38 | 'notifications', 39 | 'follow', 40 | 'corsheaders', 41 | 'rest_framework', 42 | 'rest_framework.authtoken', 43 | 'django_project', 44 | 'smart_selects', 45 | ) 46 | 47 | MIDDLEWARE_CLASSES = ( 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ) 55 | 56 | ROOT_URLCONF = 'example_project.urls' 57 | 58 | WSGI_APPLICATION = 'example_project.wsgi.application' 59 | 60 | 61 | # Database 62 | # https://docs.djangoproject.com/en/1.6/ref/settings/#databases 63 | 64 | DATABASES = { 65 | 'default': { 66 | 'ENGINE': 'django.db.backends.sqlite3', 67 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 68 | } 69 | } 70 | 71 | # Internationalization 72 | # https://docs.djangoproject.com/en/1.6/topics/i18n/ 73 | 74 | LANGUAGE_CODE = 'en-us' 75 | 76 | TIME_ZONE = 'UTC' 77 | 78 | USE_I18N = True 79 | 80 | USE_L10N = True 81 | 82 | USE_TZ = True 83 | 84 | 85 | # Static files (CSS, JavaScript, Images) 86 | # https://docs.djangoproject.com/en/1.6/howto/static-files/ 87 | 88 | STATIC_URL = '/static/' 89 | 90 | CORS_ORIGIN_ALLOW_ALL = True 91 | CORS_ALLOW_CREDENTIALS = True 92 | 93 | TEMPLATES = [ 94 | { 95 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 96 | 'DIRS': [ 97 | # insert your TEMPLATE_DIRS here 98 | ], 99 | 'APP_DIRS': True, 100 | 'OPTIONS': { 101 | 'context_processors': [ 102 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 103 | # list if you haven't customized them: 104 | 'django.contrib.auth.context_processors.auth', 105 | 'django.template.context_processors.debug', 106 | 'django.template.context_processors.i18n', 107 | 'django.template.context_processors.media', 108 | 'django.template.context_processors.static', 109 | 'django.template.context_processors.tz', 110 | 'django.contrib.messages.context_processors.messages', 111 | ], 112 | 'debug': True, 113 | }, 114 | }, 115 | ] 116 | -------------------------------------------------------------------------------- /django_project/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import ugettext as _ 3 | 4 | from reversion.admin import VersionAdmin 5 | 6 | from django_project.models import Component 7 | from django_project.models import Membership 8 | from django_project.models import Milestone 9 | from django_project.models import Priority 10 | from django_project.models import Project 11 | from django_project.models import Task 12 | from django_project.models import TaskType 13 | from django_project.models import ObjectTask 14 | 15 | from django_project.models import Status 16 | from django_project.models import Transition 17 | 18 | from django_project.models import Comment 19 | 20 | 21 | class StatusAdmin(admin.ModelAdmin): 22 | list_display = ( 'id', 'name', 'order', 'is_resolved', 'project') 23 | list_display_links = ('id',) 24 | list_editable = ( 'name', 'order', 'is_resolved', ) 25 | list_filter = ('project',) 26 | #readonly_fields = ['project'] 27 | 28 | 29 | class TaskAdmin(VersionAdmin): 30 | list_display = ( 'project', 'milestone', 'component', 'id', 'summary', 31 | 'created_at', 'author', 'owner', 'status', 'priority', 'type', 'nr_of_versions') 32 | list_display_links = ('summary',) 33 | list_filter = ('project',) 34 | date_hierarchy = 'created_at' 35 | save_on_top = True 36 | search_fields = ['id', 'summary', 'description'] 37 | 38 | fieldsets = ( 39 | (_("Task detail"), { 40 | 'fields': ( 41 | 'summary', 42 | ('project', 'component'), 43 | 'description', 44 | 'status', 45 | 'priority', 46 | 'type', 47 | 'owner', 48 | 'milestone', 49 | 'deadline', 50 | ) 51 | }), 52 | (_("Author & editor"), { 53 | #'classes': ['collapsed collapse-toggle'], 54 | 'fields': ( 55 | ('author', ), 56 | ), 57 | }), 58 | ) 59 | 60 | # This option would be used from Django 1.2 61 | #readonly_fields = ('author_ip', 'editor_ip') 62 | 63 | 64 | class OrderedDictModelAdmin(admin.ModelAdmin): 65 | list_display = ( 'id', 'name', 'order', 'description' ) 66 | list_display_links = ( 'id', 'name' ) 67 | list_editable = ( 'order', ) 68 | 69 | class MilestoneInline(admin.TabularInline): 70 | model = Milestone 71 | extra = 1 72 | 73 | class TaskTypeInline(admin.TabularInline): 74 | model = TaskType 75 | extra = 1 76 | 77 | class ComponentInline(admin.TabularInline): 78 | model = Component 79 | extra = 1 80 | 81 | class StatusInline(admin.TabularInline): 82 | model = Status 83 | extra = 1 84 | 85 | class PriorityInline(admin.TabularInline): 86 | model = Priority 87 | extra = 1 88 | 89 | 90 | class ComponentAdmin(admin.ModelAdmin): 91 | list_display = 'project', 'name', 'description' 92 | list_display_links = 'name', 93 | list_filter = 'project', 94 | search_fields = ['project', 'name'] 95 | 96 | 97 | class TaskTypeAdmin(admin.ModelAdmin): 98 | list_display = ['id', 'project', 'name', 'order'] 99 | list_display_links = ['name'] 100 | list_filter = ['project'] 101 | search_field = ['name', 'project'] 102 | 103 | 104 | admin.site.register(Transition) 105 | admin.site.register(Status, StatusAdmin) 106 | 107 | admin.site.register(Priority, OrderedDictModelAdmin) 108 | admin.site.register(TaskType, TaskTypeAdmin) 109 | admin.site.register(Task, TaskAdmin) 110 | admin.site.register(ObjectTask) 111 | admin.site.register(Project) 112 | admin.site.register(Membership) 113 | admin.site.register(Component) 114 | admin.site.register(Milestone) 115 | 116 | admin.site.register(Comment) 117 | -------------------------------------------------------------------------------- /django_project/handlers.py: -------------------------------------------------------------------------------- 1 | from notifications.signals import notify 2 | from follow.models import Follow 3 | 4 | from django_project import signals 5 | 6 | #Monkey patching the Follow clean function 7 | from django.core.exceptions import ValidationError 8 | def clean(instance): 9 | ''' 10 | Only allow a single target to be selected on follow! 11 | ''' 12 | fields = [field.name for field in instance._meta.fields if not field.name in ['id', 'user', 'datetime']] 13 | values = filter(lambda a: a != None, [getattr(instance, field_name) for field_name in fields]) 14 | if len(values) != 1: 15 | raise ValidationError('You should only set a single target!') 16 | Follow.clean = clean 17 | def __unicode__(instance): 18 | return str(instance.user)+' is following '+str(instance.target._meta.model_name)+' '+str(instance.target) 19 | Follow.__unicode__ = __unicode__ 20 | 21 | 22 | def follow_handler(follower, followee, **kwargs): 23 | """ 24 | """ 25 | notify.send(follower, 26 | recipient=follower, 27 | actor=follower, 28 | verb='started following', 29 | action_object=followee, 30 | description='', 31 | target=getattr(followee, 'project', None)) 32 | 33 | def unfollow_handler(follower, followee, **kwargs): 34 | """ 35 | """ 36 | notify.send(follower, 37 | recipient=follower, 38 | actor=follower, 39 | verb='stopped following', 40 | action_object=followee, 41 | description='', 42 | target=getattr(followee, 'project', None)) 43 | 44 | # connect the signal 45 | signals.follow.connect(follow_handler, dispatch_uid='django_project.handlers.follow') 46 | signals.unfollow.connect(unfollow_handler, dispatch_uid='django_project.handlers.unfollow') 47 | 48 | 49 | def workflow_task_handler_creator(verb): 50 | """ 51 | """ 52 | print('REG workflow_task_handler_creator::handler', verb) 53 | def handler(instance, *args, **kwargs): 54 | print('workflow_task_handler_creator::handler', verb) 55 | follow_obj = instance.project if verb=='created' else instance 56 | for follow in Follow.objects.get_follows(follow_obj): 57 | notify.send(instance.author, 58 | recipient=follow.user, 59 | actor=instance.author, 60 | verb=verb, 61 | action_object=instance, 62 | description='', 63 | target=instance.project) 64 | if not hasattr(workflow_task_handler_creator, 'instances'): 65 | workflow_task_handler_creator.instances = [] 66 | workflow_task_handler_creator.instances.append(handler) 67 | return handler 68 | 69 | def handler(instance, transition, old_state, new_state, **kwargs): 70 | print('workflow_task_handler_creator::handler22') 71 | 72 | # connect the signal 73 | signals.workflow_task_new.connect(workflow_task_handler_creator('created'), dispatch_uid='django_project.handlers.workflow_task_new') 74 | signals.workflow_task_transition.connect(workflow_task_handler_creator('updated'), dispatch_uid='django_project.handlers.workflow_task_transition') 75 | signals.workflow_task_resolved.connect(workflow_task_handler_creator('resolved'), dispatch_uid='django_project.handlers.workflow_task_resolved') 76 | 77 | 78 | def commented_handler(instance, comment, **kwargs): 79 | for follow in Follow.objects.get_follows(instance): 80 | notify.send(instance.author, 81 | recipient=follow.user, 82 | actor=instance.author, 83 | verb='commented', 84 | action_object=comment, 85 | description=comment.comment[:50]+'...', 86 | target=instance) 87 | 88 | from django.contrib.contenttypes.models import ContentType 89 | from django.contrib.admin.models import LogEntry, ADDITION 90 | from django.utils.encoding import force_unicode 91 | LogEntry.objects.log_action( 92 | user_id = instance.author.pk, 93 | content_type_id = ContentType.objects.get_for_model(comment).pk, 94 | object_id = comment.pk, 95 | object_repr = force_unicode(comment), 96 | action_flag = ADDITION 97 | ) 98 | 99 | # connect the signal 100 | signals.commented.connect(commented_handler, dispatch_uid='django_project.handlers.commented_handler') 101 | -------------------------------------------------------------------------------- /follow/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, AnonymousUser 2 | from django.db import models 3 | from django.db.models.query import QuerySet 4 | from django.db.models.signals import post_save, post_delete 5 | from follow.registry import model_map 6 | from follow.signals import followed, unfollowed 7 | import inspect 8 | 9 | class FollowManager(models.Manager): 10 | def fname(self, model_or_obj_or_qs): 11 | """ 12 | Return the field name on the :class:`Follow` model for ``model_or_obj_or_qs``. 13 | """ 14 | if isinstance(model_or_obj_or_qs, QuerySet): 15 | _, fname = model_map[model_or_obj_or_qs.model] 16 | else: 17 | cls = model_or_obj_or_qs if inspect.isclass(model_or_obj_or_qs) else model_or_obj_or_qs.__class__ 18 | _, fname = model_map[cls] 19 | return fname 20 | 21 | def create(self, user, obj, **kwargs): 22 | """ 23 | Create a new follow link between a user and an object 24 | of a registered model type. 25 | 26 | """ 27 | follow = Follow(user=user) 28 | follow.target = obj 29 | follow.save() 30 | return follow 31 | 32 | def get_or_create(self, user, obj, **kwargs): 33 | """ 34 | Almost the same as `FollowManager.objects.create` - behaves the same 35 | as the normal `get_or_create` methods in django though. 36 | 37 | Returns a tuple with the `Follow` and either `True` or `False` 38 | 39 | """ 40 | if not self.is_following(user, obj): 41 | return self.create(user, obj, **kwargs), True 42 | return self.get_follows(obj).get(user=user), False 43 | 44 | def is_following(self, user, obj): 45 | """ Returns `True` or `False` """ 46 | if isinstance(user, AnonymousUser): 47 | return False 48 | return 0 < self.get_follows(obj).filter(user=user).count() 49 | 50 | def get_follows(self, model_or_obj_or_qs): 51 | """ 52 | Returns all the followers of a model, an object or a queryset. 53 | """ 54 | fname = self.fname(model_or_obj_or_qs) 55 | 56 | if isinstance(model_or_obj_or_qs, QuerySet): 57 | return self.filter(**{'%s__in' % fname: model_or_obj_or_qs}) 58 | 59 | if inspect.isclass(model_or_obj_or_qs): 60 | return self.exclude(**{fname:None}) 61 | 62 | return self.filter(**{fname:model_or_obj_or_qs}) 63 | 64 | class Follow(models.Model): 65 | """ 66 | This model allows a user to follow any kind of object. The followed 67 | object is accessible through `Follow.target`. 68 | """ 69 | user = models.ForeignKey(User, related_name='following') 70 | 71 | datetime = models.DateTimeField(auto_now_add=True) 72 | 73 | objects = FollowManager() 74 | 75 | def __unicode__(self): 76 | return u'%s' % self.target 77 | 78 | def _get_target(self): 79 | for Model, (_, fname) in model_map.iteritems(): 80 | try: 81 | if hasattr(self, fname) and getattr(self, fname): 82 | return getattr(self, fname) 83 | except Model.DoesNotExist: 84 | # In case the target was deleted in the previous transaction 85 | # it's already gone from the db and this throws DoesNotExist. 86 | return None 87 | 88 | def _set_target(self, obj): 89 | for _, fname in model_map.values(): 90 | setattr(self, fname, None) 91 | if obj is None: 92 | return 93 | _, fname = model_map[obj.__class__] 94 | setattr(self, fname, obj) 95 | 96 | target = property(fget=_get_target, fset=_set_target) 97 | 98 | def follow_dispatch(sender, instance, created=False, **kwargs): 99 | if created: 100 | followed.send(instance.target.__class__, user=instance.user, target=instance.target, instance=instance) 101 | 102 | def unfollow_dispatch(sender, instance, **kwargs): 103 | # FIXME: When deleting out of the admin, django *leaves* the transaction 104 | # management after the user is deleted and then starts deleting all the 105 | # associated objects. This breaks the unfollow signal. Looking up 106 | # `instance.user` will throw a `DoesNotExist` exception. The offending 107 | # code is in django/db/models/deletion.py#70 108 | # At least that's what the error report looks like and I'm a bit short 109 | # on time to investigate properly. 110 | # Unfollow handlers should be aware that both target and user can be `None` 111 | try: 112 | user = instance.user 113 | except User.DoesNotExist: 114 | user = None 115 | 116 | unfollowed.send(instance.target.__class__, user=user, target=instance.target, instance=instance) 117 | 118 | 119 | post_save.connect(follow_dispatch, dispatch_uid='follow.follow_dispatch', sender=Follow) 120 | post_delete.connect(unfollow_dispatch, dispatch_uid='follow.unfollow_dispatch', sender=Follow) 121 | -------------------------------------------------------------------------------- /django_project/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, AnonymousUser, Group 2 | from django.core.urlresolvers import reverse 3 | from django.test import TestCase 4 | 5 | from django_project.models import Project, Task, Status, Transition, Component, Priority, TaskType 6 | 7 | from django_project import signals 8 | 9 | 10 | from django.conf import settings 11 | 12 | from django.db import models 13 | from django_project.managers import ObjectTaskMixin 14 | 15 | class Asset(ObjectTaskMixin): 16 | title = models.CharField(max_length=128) 17 | 18 | def __unicode__(self): 19 | return self.title 20 | 21 | class ProjectTest(TestCase): 22 | initiated = False 23 | 24 | def setUp(self): 25 | self.author = User.objects.create(username='Test User') 26 | self.project = Project.objects.create(name='Test Project', author=self.author) 27 | 28 | self.priority = Priority.objects.create(project=self.project, order=1, name='minor') 29 | self.component = Component.objects.create(project=self.project, name='Animation') 30 | self.type = TaskType.objects.create(project=self.project, order=1, name='Task') 31 | 32 | self.state_new = Status.objects.create(project=self.project, order=1, name='new', is_initial=True) 33 | self.state_pro = Status.objects.create(project=self.project, order=2, name='in progress', is_initial=True) 34 | self.state_pub = Status.objects.create(project=self.project, order=3, name='published', is_resolved=True) 35 | 36 | self.transition1 = Transition.objects.create(source=self.state_new, destination=self.state_pro) 37 | self.transition2 = Transition.objects.create(source=self.state_pro, destination=self.state_pro) 38 | self.transition3 = Transition.objects.create(source=self.state_pro, destination=self.state_pub) 39 | 40 | self.task = self.create_task('summary', 'description') 41 | 42 | 43 | def create_task(self, summary, description): 44 | task = Task.objects.create(project=self.project, author=self.author, summary=summary, description=description, 45 | priority=self.priority, 46 | component=self.component, 47 | type=self.type, 48 | status=self.state_new) 49 | return task 50 | 51 | 52 | def test_signals(self): 53 | Handler = type('Handler', (object,), { 54 | 'inc': lambda self: setattr(self, 'i', getattr(self, 'i') + 1), 55 | 'i': 0 56 | }) 57 | transition_handler_c = Handler() 58 | resolved_handler_c = Handler() 59 | new_handler_c = Handler() 60 | 61 | def transition_handler(signal, sender, instance, transition, old_state, new_state): 62 | self.assertEqual(sender, Task) 63 | self.assertEqual(instance, self.task) 64 | self.assertEqual(transition_handler.transition, transition) 65 | transition_handler_c.inc() 66 | 67 | def resolved_handler(signal, sender, instance, transition, old_state, new_state): 68 | self.assertEqual(sender, Task) 69 | self.assertEqual(instance, self.task) 70 | resolved_handler_c.inc() 71 | 72 | def new_handler(signal, sender, instance, transition, old_state, new_state): 73 | self.assertEqual(sender, Task) 74 | new_handler_c.inc() 75 | 76 | 77 | signals.workflow_task_transition.connect(transition_handler, dispatch_uid='transition') 78 | signals.workflow_task_resolved.connect(resolved_handler, dispatch_uid='resolved') 79 | signals.workflow_task_resolved.connect(new_handler, dispatch_uid='new') 80 | 81 | transition_handler.transition = self.transition1 82 | 83 | self.task.status = self.state_pro 84 | self.task.save() 85 | 86 | transition_handler.transition = self.transition2 87 | 88 | self.task.status = self.state_pro 89 | self.task.save() 90 | 91 | transition_handler.transition = self.transition3 92 | 93 | self.task.status = self.state_pub 94 | self.task.save() 95 | 96 | self.create_task('new task', 'description') 97 | 98 | self.assertEqual(2, transition_handler_c.i) 99 | self.assertEqual(1, resolved_handler_c.i) 100 | self.assertEqual(1, new_handler_c.i) 101 | 102 | 103 | def test_revisions(self): 104 | self.task.description = 'revision 1' 105 | self.task.status = self.state_pro 106 | self.task.save_revision(self.author, 'comment 1') 107 | 108 | self.task.description = 'revision 2' 109 | self.task.save_revision(self.author, 'comment 2') 110 | 111 | self.assertEqual(2, self.task.nr_of_versions()) 112 | 113 | versions = self.task.versions() 114 | self.assertEqual('comment 2', versions[0].revision.comment) 115 | self.assertEqual('comment 1', versions[1].revision.comment) 116 | 117 | self.assertEqual('revision 2', versions[0].field_dict['description']) 118 | self.assertEqual('revision 1', versions[1].field_dict['description']) 119 | 120 | self.assertEqual(self.author, versions[0].revision.user) 121 | self.assertEqual(self.author, versions[1].revision.user) 122 | 123 | 124 | self.assertEqual('revision 2', self.task.description) 125 | # Revert and refresh 126 | versions[1].revert() 127 | self.task = Task.objects.get(id=self.task.id) 128 | self.assertEqual('revision 1', self.task.description) 129 | 130 | def test_task_manager(self): 131 | asset = Asset.objects.create(title="gg") 132 | asset.save() 133 | print asset.tasks 134 | asset.add_task(self.task) 135 | print asset.tasks.all() 136 | asset.add_task(self.task) 137 | print asset.tasks.all() 138 | 139 | #print dir(self.task) 140 | print asset.tasks_for_author(self.task.author) 141 | 142 | asset.remove_task(self.task) 143 | print asset.tasks.all() 144 | -------------------------------------------------------------------------------- /django_project/fixtures/initial_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "django_project.project", 5 | "fields": { 6 | "created_at": "2014-01-17T09:34:18.055Z", 7 | "author": 1, 8 | "modified_at": "2014-01-17T09:34:18.055Z", 9 | "name": "Peragro Tempus", 10 | "slug": "peragro-tempus" 11 | } 12 | }, 13 | { 14 | "pk": 1, 15 | "model": "django_project.component", 16 | "fields": { 17 | "project": 1, 18 | "description": "", 19 | "name": "Scene 1", 20 | "slug": "scene-1" 21 | } 22 | }, 23 | { 24 | "pk": 1, 25 | "model": "django_project.membership", 26 | "fields": { 27 | "member": 1, 28 | "project": 1, 29 | "joined_at": "2014-01-17T09:49:46.298Z" 30 | } 31 | }, 32 | { 33 | "pk": 1, 34 | "model": "django_project.milestone", 35 | "fields": { 36 | "date_completed": null, 37 | "description": "Milestone 1 is the initial milestone.", 38 | "author": 1, 39 | "created_at": "2014-01-17T09:46:18.825Z", 40 | "modified_at": "2014-01-17T09:34:18.055Z", 41 | "project": 1, 42 | "deadline": "2014-01-27", 43 | "slug": "milestone-1", 44 | "name": "Milestone 1" 45 | } 46 | }, 47 | { 48 | "pk": 2, 49 | "model": "django_project.milestone", 50 | "fields": { 51 | "date_completed": null, 52 | "description": "Milestone 2 is the next milestone.", 53 | "author": 1, 54 | "created_at": "2014-01-17T09:46:52.833Z", 55 | "modified_at": "2014-01-17T09:34:18.055Z", 56 | "project": 1, 57 | "deadline": "2014-01-31", 58 | "slug": "milestone-2", 59 | "name": "Milestone 2" 60 | } 61 | }, 62 | { 63 | "pk": 1, 64 | "model": "django_project.priority", 65 | "fields": { 66 | "project": 1, 67 | "slug": "minor", 68 | "order": 0, 69 | "name": "minor", 70 | "description": "" 71 | } 72 | }, 73 | { 74 | "pk": 2, 75 | "model": "django_project.priority", 76 | "fields": { 77 | "project": 1, 78 | "slug": "major", 79 | "order": 1, 80 | "name": "major", 81 | "description": "" 82 | } 83 | }, 84 | { 85 | "pk": 1, 86 | "model": "django_project.status", 87 | "fields": { 88 | "name": "new", 89 | "is_resolved": false, 90 | "slug": "new", 91 | "project": 1, 92 | "is_initial": true, 93 | "order": 0, 94 | "description": "" 95 | } 96 | }, 97 | { 98 | "pk": 2, 99 | "model": "django_project.status", 100 | "fields": { 101 | "name": "in progress", 102 | "is_resolved": false, 103 | "slug": "in-progress", 104 | "project": 1, 105 | "is_initial": false, 106 | "order": 1, 107 | "description": "" 108 | } 109 | }, 110 | { 111 | "pk": 3, 112 | "model": "django_project.status", 113 | "fields": { 114 | "name": "done", 115 | "is_resolved": true, 116 | "slug": "done", 117 | "project": 1, 118 | "is_initial": false, 119 | "order": 2, 120 | "description": "" 121 | } 122 | }, 123 | { 124 | "pk": 1, 125 | "model": "django_project.transition", 126 | "fields": { 127 | "source": 1, 128 | "destination": 2 129 | } 130 | }, 131 | { 132 | "pk": 2, 133 | "model": "django_project.transition", 134 | "fields": { 135 | "source": 2, 136 | "destination": 2 137 | } 138 | }, 139 | { 140 | "pk": 3, 141 | "model": "django_project.transition", 142 | "fields": { 143 | "source": 2, 144 | "destination": 3 145 | } 146 | }, 147 | { 148 | "pk": 1, 149 | "model": "django_project.tasktype", 150 | "fields": { 151 | "project": 1, 152 | "order": 0, 153 | "name": "Modeling", 154 | "description": "" 155 | } 156 | }, 157 | { 158 | "pk": 2, 159 | "model": "django_project.tasktype", 160 | "fields": { 161 | "project": 1, 162 | "order": 2, 163 | "name": "Animation", 164 | "description": "" 165 | } 166 | }, 167 | { 168 | "pk": 3, 169 | "model": "django_project.tasktype", 170 | "fields": { 171 | "project": 1, 172 | "order": 3, 173 | "name": "Lighting", 174 | "description": "" 175 | } 176 | }, 177 | { 178 | "pk": 4, 179 | "model": "django_project.tasktype", 180 | "fields": { 181 | "project": 1, 182 | "order": 1, 183 | "name": "Layout", 184 | "description": "" 185 | } 186 | }, 187 | { 188 | "pk": 1, 189 | "model": "django_project.task", 190 | "fields": { 191 | "status": 1, 192 | "priority": 1, 193 | "description": "Task 1 description.", 194 | "author": 1, 195 | "created_at": "2014-01-17T09:47:54.997Z", 196 | "component": 1, 197 | "summary": "Task 1", 198 | "project": 1, 199 | "deadline": null, 200 | "milestone": null, 201 | "owner": null, 202 | "type": 1 203 | } 204 | }, 205 | { 206 | "pk": 2, 207 | "model": "django_project.task", 208 | "fields": { 209 | "status": 1, 210 | "priority": 2, 211 | "description": "Task 2 description.", 212 | "author": 1, 213 | "created_at": "2014-01-17T09:48:42.909Z", 214 | "component": 1, 215 | "summary": "Task 2", 216 | "project": 1, 217 | "deadline": null, 218 | "milestone": 1, 219 | "owner": null, 220 | "type": 1 221 | } 222 | }, 223 | { 224 | "pk": 3, 225 | "model": "django_project.task", 226 | "fields": { 227 | "status": 2, 228 | "priority": 1, 229 | "description": "Task 3 description", 230 | "author": 1, 231 | "created_at": "2014-01-17T09:49:23.992Z", 232 | "component": 1, 233 | "summary": "Task 3", 234 | "project": 1, 235 | "deadline": null, 236 | "milestone": 1, 237 | "owner": 1, 238 | "type": 1 239 | } 240 | } 241 | ] 242 | -------------------------------------------------------------------------------- /follow/tests.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.contrib.auth.models import User, AnonymousUser, Group 3 | from django.core.urlresolvers import reverse 4 | from django.test import TestCase, override_settings 5 | from follow import signals, utils 6 | from follow.models import Follow 7 | from follow.utils import register 8 | import follow.urls 9 | 10 | register(User) 11 | register(Group) 12 | 13 | class FollowTest(TestCase): 14 | @override_settings(ROOT_URLCONF=follow.urls) 15 | def setUp(self): 16 | 17 | self.lennon = User.objects.create(username='lennon') 18 | self.lennon.set_password('test') 19 | self.lennon.save() 20 | self.hendrix = User.objects.create(username='hendrix') 21 | 22 | self.musicians = Group.objects.create() 23 | 24 | self.lennon.groups.add(self.musicians) 25 | 26 | def test_follow(self): 27 | follow = Follow.objects.create(self.lennon, self.hendrix) 28 | 29 | _, result = Follow.objects.get_or_create(self.lennon, self.hendrix) 30 | self.assertEqual(False, result) 31 | 32 | result = Follow.objects.is_following(self.lennon, self.hendrix) 33 | self.assertEqual(True, result) 34 | 35 | result = Follow.objects.is_following(self.hendrix, self.lennon) 36 | self.assertEqual(False, result) 37 | 38 | result = Follow.objects.get_follows(User) 39 | self.assertEqual(1, len(result)) 40 | self.assertEqual(self.lennon, result[0].user) 41 | 42 | result = Follow.objects.get_follows(self.hendrix) 43 | self.assertEqual(1, len(result)) 44 | self.assertEqual(self.lennon, result[0].user) 45 | 46 | result = self.hendrix.get_follows() 47 | self.assertEqual(1, len(result)) 48 | self.assertEqual(self.lennon, result[0].user) 49 | 50 | result = self.lennon.get_follows() 51 | self.assertEqual(0, len(result), result) 52 | 53 | utils.toggle(self.lennon, self.hendrix) 54 | self.assertEqual(0, len(self.hendrix.get_follows())) 55 | 56 | utils.toggle(self.lennon, self.hendrix) 57 | self.assertEqual(1, len(self.hendrix.get_follows())) 58 | 59 | def test_get_follows_for_queryset(self): 60 | utils.follow(self.hendrix, self.lennon) 61 | utils.follow(self.lennon, self.hendrix) 62 | 63 | result = Follow.objects.get_follows(User.objects.all()) 64 | self.assertEqual(2, result.count()) 65 | 66 | def test_follow_http(self): 67 | 68 | User.get_absolute_url = lambda u: "/users/%s/" % u.username 69 | 70 | self.client.login(username='lennon', password='test') 71 | 72 | follow_url = reverse('follow', args=['auth', 'user', self.hendrix.id]) 73 | unfollow_url = reverse('follow', args=['auth', 'user', self.hendrix.id]) 74 | toggle_url = reverse('toggle', args=['auth', 'user', self.hendrix.id]) 75 | 76 | response = self.client.post(follow_url) 77 | self.assertEqual(302, response.status_code) 78 | 79 | response = self.client.post(follow_url) 80 | self.assertEqual(302, response.status_code) 81 | 82 | response = self.client.post(unfollow_url) 83 | self.assertEqual(302, response.status_code) 84 | 85 | response = self.client.post(toggle_url) 86 | self.assertEqual(302, response.status_code) 87 | 88 | def test_get_fail(self): 89 | self.client.login(username='lennon', password='test') 90 | follow_url = reverse('follow', args=['auth', 'user', self.hendrix.id]) 91 | unfollow_url = reverse('follow', args=['auth', 'user', self.hendrix.id]) 92 | 93 | response = self.client.get(follow_url) 94 | self.assertEqual(400, response.status_code) 95 | 96 | response = self.client.get(unfollow_url) 97 | self.assertEqual(400, response.status_code) 98 | 99 | def test_no_absolute_url(self): 100 | self.client.login(username='lennon', password='test') 101 | 102 | #get_absolute_url = User.get_absolute_url 103 | User.get_absolute_url = None 104 | 105 | follow_url = utils.follow_link(self.hendrix) 106 | 107 | response = self.client.post(follow_url) 108 | self.assertEqual(500, response.status_code) 109 | 110 | def test_template_tags(self): 111 | follow_url = reverse('follow', args=['auth', 'user', self.hendrix.id]) 112 | unfollow_url = reverse('unfollow', args=['auth', 'user', self.hendrix.id]) 113 | 114 | request = type('Request', (object,), {'user': self.lennon})() 115 | 116 | self.assertEqual(follow_url, utils.follow_link(self.hendrix)) 117 | self.assertEqual(unfollow_url, utils.unfollow_link(self.hendrix)) 118 | 119 | tpl = template.Template("""{% load follow_tags %}{% follow_url obj %}""") 120 | ctx = template.Context({ 121 | 'obj':self.hendrix, 122 | 'request': request 123 | }) 124 | 125 | self.assertEqual(follow_url, tpl.render(ctx)) 126 | 127 | utils.follow(self.lennon, self.hendrix) 128 | 129 | self.assertEqual(unfollow_url, tpl.render(ctx)) 130 | 131 | utils.unfollow(self.lennon, self.hendrix) 132 | 133 | self.assertEqual(follow_url, tpl.render(ctx)) 134 | 135 | tpl = template.Template("""{% load follow_tags %}{% follow_url obj user %}""") 136 | ctx2 = template.Context({ 137 | 'obj': self.lennon, 138 | 'user': self.hendrix, 139 | 'request': request 140 | }) 141 | 142 | self.assertEqual(utils.follow_url(self.hendrix, self.lennon), tpl.render(ctx2)) 143 | 144 | tpl = template.Template("""{% load follow_tags %}{% if request.user|is_following:obj %}True{% else %}False{% endif %}""") 145 | 146 | self.assertEqual("False", tpl.render(ctx)) 147 | 148 | utils.follow(self.lennon, self.hendrix) 149 | 150 | self.assertEqual("True", tpl.render(ctx)) 151 | 152 | tpl = template.Template("""{% load follow_tags %}{% follow_form obj %}""") 153 | self.assertEqual(True, isinstance(tpl.render(ctx), unicode)) 154 | 155 | tpl = template.Template("""{% load follow_tags %}{% follow_form obj "follow/form.html" %}""") 156 | self.assertEqual(True, isinstance(tpl.render(ctx), unicode)) 157 | 158 | def test_signals(self): 159 | Handler = type('Handler', (object,), { 160 | 'inc': lambda self: setattr(self, 'i', getattr(self, 'i') + 1), 161 | 'i': 0 162 | }) 163 | user_handler = Handler() 164 | group_handler = Handler() 165 | 166 | def follow_handler(sender, user, target, instance, **kwargs): 167 | self.assertEqual(sender, User) 168 | self.assertEqual(self.lennon, user) 169 | self.assertEqual(self.hendrix, target) 170 | self.assertEqual(True, isinstance(instance, Follow)) 171 | user_handler.inc() 172 | 173 | def unfollow_handler(sender, user, target, instance, **kwargs): 174 | self.assertEqual(sender, User) 175 | self.assertEqual(self.lennon, user) 176 | self.assertEqual(self.hendrix, target) 177 | self.assertEqual(True, isinstance(instance, Follow)) 178 | user_handler.inc() 179 | 180 | def group_follow_handler(sender, **kwargs): 181 | self.assertEqual(sender, Group) 182 | group_handler.inc() 183 | 184 | def group_unfollow_handler(sender, **kwargs): 185 | self.assertEqual(sender, Group) 186 | group_handler.inc() 187 | 188 | signals.followed.connect(follow_handler, sender=User, dispatch_uid='userfollow') 189 | signals.unfollowed.connect(unfollow_handler, sender=User, dispatch_uid='userunfollow') 190 | 191 | signals.followed.connect(group_follow_handler, sender=Group, dispatch_uid='groupfollow') 192 | signals.unfollowed.connect(group_unfollow_handler, sender=Group, dispatch_uid='groupunfollow') 193 | 194 | utils.follow(self.lennon, self.hendrix) 195 | utils.unfollow(self.lennon, self.hendrix) 196 | self.assertEqual(2, user_handler.i) 197 | 198 | utils.follow(self.lennon, self.musicians) 199 | utils.unfollow(self.lennon, self.musicians) 200 | 201 | self.assertEqual(2, user_handler.i) 202 | self.assertEqual(2, group_handler.i) 203 | 204 | def test_anonymous_is_following(self): 205 | self.assertEqual(False, Follow.objects.is_following(AnonymousUser(), self.lennon)) 206 | -------------------------------------------------------------------------------- /django_project/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import User, Group, AnonymousUser, Permission 5 | 6 | from django.utils.translation import ugettext_lazy as __ 7 | from django.utils.translation import ugettext as _ 8 | 9 | from autoslug import AutoSlugField 10 | 11 | from smart_selects.db_fields import ChainedForeignKey 12 | 13 | from django_project.mixins import ProjectMixin, TaskMixin, CommentMixin 14 | 15 | 16 | class Project(ProjectMixin, models.Model): 17 | """ 18 | """ 19 | name = models.CharField(_('name'), max_length=64) 20 | slug = AutoSlugField(max_length=128, populate_from='name', unique_with='author') 21 | 22 | description = models.TextField(null=True, blank=True) 23 | 24 | author = models.ForeignKey(User, name=_('author'), related_name='created_projects') 25 | 26 | members = models.ManyToManyField(User, verbose_name=_('members'), through="Membership") 27 | 28 | created_at = models.DateTimeField(_('created at'), auto_now_add=True) 29 | modified_at = models.DateTimeField(_('modified at'), auto_now=True) 30 | 31 | 32 | class Meta: 33 | #verbose_name = _('project') 34 | #verbose_name_plural = _('projects') 35 | #ordering = ['name'] 36 | #get_latest_by = 'created_at' 37 | #unique_together = ['author', 'name'] 38 | permissions = ( 39 | ('view_project', 'Can view project'), 40 | ('admin_project', 'Can administer project'), 41 | ('can_read_repository', 'Can read repository'), 42 | ('can_write_to_repository', 'Can write to repository'), 43 | ('can_add_task', 'Can add task'), 44 | ('can_change_task', 'Can change task'), 45 | ('can_delete_task', 'Can delete task'), 46 | ('can_view_tasks', 'Can view tasks'), 47 | ('can_add_member', 'Can add member'), 48 | ('can_change_member', 'Can change member'), 49 | ('can_delete_member', 'Can delete member'), 50 | ) 51 | 52 | def __unicode__(self): 53 | return self.name 54 | 55 | class Component(models.Model): 56 | """ 57 | """ 58 | project = models.ForeignKey(Project) 59 | name = models.CharField(max_length=64) 60 | slug = AutoSlugField(max_length=64, populate_from='name', always_update=True, unique_with='project') 61 | description = models.TextField(null=True, blank=True) 62 | 63 | class Meta: 64 | verbose_name = _('component') 65 | verbose_name_plural = _('components') 66 | ordering = ('name',) 67 | unique_together = ('project', 'name') 68 | 69 | def __unicode__(self): 70 | return self.project.name+': '+self.name 71 | 72 | 73 | class Membership(models.Model): 74 | """ 75 | """ 76 | member = models.ForeignKey(User, verbose_name=_('member')) 77 | project = models.ForeignKey(Project, verbose_name=_('project')) 78 | joined_at = models.DateTimeField(_('joined at'), auto_now_add=True) 79 | 80 | def __unicode__(self): 81 | return u"%s@%s" % (self.member, self.project) 82 | 83 | class Meta: 84 | unique_together = ('project', 'member') 85 | 86 | 87 | class Milestone(models.Model): 88 | """ 89 | """ 90 | project = models.ForeignKey(Project, verbose_name=_('project')) 91 | name = models.CharField(max_length=64) 92 | slug = AutoSlugField(max_length=64, populate_from='name', always_update=True, unique_with='project') 93 | description = models.TextField() 94 | author = models.ForeignKey(User, verbose_name=_('author')) 95 | created_at = models.DateTimeField(_('created at'), auto_now_add=True) 96 | modified_at = models.DateTimeField(_('modified at'), auto_now=True) 97 | deadline = models.DateField(_('deadline'), default=datetime.date.today() + datetime.timedelta(days=10)) 98 | date_completed = models.DateField(_('date completed'), null=True, blank=True) 99 | 100 | class Meta: 101 | ordering = ('created_at',) 102 | verbose_name = _('milestone') 103 | verbose_name_plural = _('milestones') 104 | unique_together = ('project', 'name') 105 | 106 | def __unicode__(self): 107 | return self.project.name+': '+self.name 108 | 109 | 110 | class DictModel(models.Model): 111 | name = models.CharField(_('name'), max_length=32) 112 | description = models.TextField(_('description'), null=True, blank=True) 113 | 114 | def __unicode__(self): 115 | return self.name 116 | 117 | class Meta: 118 | abstract = True 119 | ordering = ('id',) 120 | 121 | 122 | class OrderedDictModel(DictModel): 123 | """ 124 | DictModel with order column and default ordering. 125 | """ 126 | order = models.IntegerField(_('order')) 127 | 128 | class Meta: 129 | abstract = True 130 | ordering = ['order', 'name'] 131 | 132 | 133 | class Priority(OrderedDictModel): 134 | """ 135 | """ 136 | project = models.ForeignKey(Project) 137 | slug = AutoSlugField(max_length=64, populate_from='name', always_update=True, unique_with='project') 138 | 139 | class Meta: 140 | verbose_name = _('priority level') 141 | verbose_name_plural = _('priority levels') 142 | unique_together = ('project', 'name') 143 | 144 | def __unicode__(self): 145 | return self.project.name+': '+self.name 146 | 147 | 148 | class TransitionChainedForeignKeyQuerySet(models.Manager): 149 | def filter(self, **kwargs): 150 | if 'project' in kwargs: 151 | kwargs['project'] = self.model.objects.get(pk=kwargs['project']).project.pk 152 | return super(TransitionChainedForeignKeyQuerySet, self).filter(**kwargs) 153 | 154 | 155 | class Status(OrderedDictModel): 156 | """ 157 | """ 158 | project = models.ForeignKey(Project) 159 | is_resolved = models.BooleanField(verbose_name=_('is resolved'), default=False) 160 | is_initial = models.BooleanField(verbose_name=_('is initial'), default=False) 161 | destinations = models.ManyToManyField('self', verbose_name=_('destinations'), through='Transition', symmetrical=False, blank=True) 162 | slug = AutoSlugField(max_length=64, populate_from='name', always_update=True, unique_with='project') 163 | 164 | objects = models.Manager() 165 | special = TransitionChainedForeignKeyQuerySet() 166 | 167 | def can_change_to(self, new_status): 168 | """ 169 | Checks if ``Transition`` object with ``source`` set to ``self`` and 170 | ``destination`` to given ``new_status`` exists. 171 | """ 172 | try: 173 | Transition.objects.only('id')\ 174 | .get(source__id=self.id, destination__id=new_status.id) 175 | return True 176 | except Transition.DoesNotExist: 177 | return False 178 | 179 | class Meta: 180 | verbose_name = _('status') 181 | verbose_name_plural = _('statuses') 182 | unique_together = ('project', 'name') 183 | ordering = ['order'] 184 | 185 | def __unicode__(self): 186 | return self.project.name+': '+self.name 187 | 188 | class ChainedForeignKeyTransition(ChainedForeignKey): 189 | 190 | def formfield(self, **kwargs): 191 | defaults = { 192 | #'queryset': self.rel.to._default_manager.complex_filter(self.rel.limit_choices_to), 193 | 'queryset': self.rel.to.special.complex_filter(self.rel.limit_choices_to), 194 | 'manager': 'special', 195 | } 196 | defaults.update(kwargs) 197 | return super(ChainedForeignKeyTransition, self).formfield(**defaults) 198 | 199 | class Transition(models.Model): 200 | """ 201 | Instances allow to change source Status to destination Status. 202 | Needed for custom workflows. 203 | """ 204 | source = models.ForeignKey(Status, verbose_name=_('source status'), related_name='sources') 205 | destination = ChainedForeignKeyTransition(Status, chained_field="source", chained_model_field="project", verbose_name=_('destination status')) 206 | 207 | class Meta: 208 | verbose_name = _('transition') 209 | verbose_name_plural = _('transitions') 210 | unique_together = ('source', 'destination') 211 | 212 | def __unicode__(self): 213 | return u'%s->%s' % (self.source, self.destination) 214 | 215 | 216 | class TaskType(OrderedDictModel): 217 | """ 218 | """ 219 | project = models.ForeignKey(Project) 220 | 221 | class Meta: 222 | verbose_name = _('task type') 223 | verbose_name_plural = _('task types') 224 | unique_together = ('project', 'name') 225 | 226 | def __unicode__(self): 227 | return self.project.name+': '+self.name 228 | 229 | 230 | class Task(TaskMixin, models.Model): 231 | """ 232 | """ 233 | project = models.ForeignKey(Project, verbose_name=_('project')) 234 | 235 | author = models.ForeignKey(User, verbose_name=_('author'), related_name='created_tasks', blank=True) 236 | 237 | owner = models.ForeignKey(User, verbose_name=_('owner'), related_name='owned_tasks', null=True, blank=True) 238 | 239 | summary = models.CharField(_('summary'), max_length=64) 240 | description = models.TextField(_('description')) 241 | 242 | status = ChainedForeignKey(Status, chained_field="project", chained_model_field="project", verbose_name=_('status')) 243 | priority = ChainedForeignKey(Priority, chained_field="project", chained_model_field="project", verbose_name=_('priority')) 244 | type = ChainedForeignKey(TaskType, chained_field="project", chained_model_field="project", verbose_name=_('task type')) 245 | 246 | deadline = models.DateField(_('deadline'), null=True, blank=True, help_text='YYYY-MM-DD') 247 | 248 | milestone = ChainedForeignKey(Milestone, chained_field="project", chained_model_field="project", verbose_name=_('milestone'), null=True, blank=True) 249 | component = ChainedForeignKey(Component, chained_field="project", chained_model_field="project", verbose_name=_('component')) 250 | 251 | created_at = models.DateTimeField(_('created at'), auto_now_add=True, editable=False) 252 | 253 | def __unicode__(self): 254 | return u'%s' % (self.summary) 255 | 256 | 257 | from django.conf import settings 258 | from django.contrib.contenttypes.fields import GenericForeignKey 259 | from django_project.managers import CommentManager 260 | from django.contrib.contenttypes.models import ContentType 261 | 262 | COMMENT_MAX_LENGTH = getattr(settings, 'COMMENT_MAX_LENGTH', 3000) 263 | 264 | class Comment(CommentMixin, models.Model): 265 | """ 266 | """ 267 | author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('author'), related_name="%(class)s_comments") 268 | 269 | content_type = models.ForeignKey(ContentType, 270 | verbose_name=_('content type'), 271 | related_name="content_type_set_for_%(class)s") 272 | object_pk = models.TextField(_('object ID')) 273 | content_object = GenericForeignKey(ct_field="content_type", fk_field="object_pk") 274 | 275 | comment = models.TextField(_('comment'), max_length=COMMENT_MAX_LENGTH) 276 | 277 | # Metadata about the comment 278 | submit_date = models.DateTimeField(_('date/time submitted'), auto_now_add=True, editable=False) 279 | 280 | # Manager 281 | objects = CommentManager() 282 | 283 | class Meta: 284 | ordering = ('-submit_date',) 285 | permissions = [("can_moderate", "Can moderate comments")] 286 | verbose_name = _('comment') 287 | verbose_name_plural = _('comments') 288 | 289 | def __str__(self): 290 | return "%s: %s..." % (self.author.username, self.comment[:50]) 291 | 292 | 293 | class ObjectTask(models.Model): 294 | """ 295 | """ 296 | task = models.ForeignKey(Task, verbose_name=_('task'), related_name="%(class)s_tasks") 297 | 298 | content_type = models.ForeignKey(ContentType, 299 | verbose_name=_('content type'), 300 | related_name="content_type_set_for_%(class)s") 301 | object_id = models.TextField(_('object ID')) 302 | content_object = GenericForeignKey() 303 | 304 | class Meta: 305 | verbose_name = _('objecttask') 306 | verbose_name_plural = _('objecttasks') 307 | 308 | def __str__(self): 309 | return "%s for %s" % (str(self.task), str(self.content_object)) 310 | 311 | 312 | from follow import utils 313 | from reversion import revisions as reversion 314 | 315 | reversion.register(Task) 316 | 317 | utils.register(User) 318 | 319 | utils.register(Project) 320 | utils.register(Milestone) 321 | utils.register(Task) 322 | 323 | # IMPORTANT LINE, really leave it there! 324 | from django_project import handlers 325 | -------------------------------------------------------------------------------- /django_project/serializers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib 3 | 4 | from django.db import transaction 5 | 6 | from django.contrib.auth.models import User, Group 7 | from rest_framework import serializers 8 | from rest_framework.reverse import reverse 9 | from rest_framework.relations import HyperlinkedRelatedField 10 | from rest_framework.relations import RelatedField 11 | 12 | from notifications.models import Notification 13 | from follow.models import Follow 14 | 15 | from django_project.models import Project, Task, Milestone, Component, Comment, ObjectTask 16 | from django_project import models 17 | 18 | 19 | class SerializerMethodFieldArgs(serializers.Field): 20 | """ 21 | A field that gets its value by calling a method on the serializer it's attached to. 22 | """ 23 | def __init__(self, method_name, *args): 24 | self.method_name = method_name 25 | self.args = args 26 | super(SerializerMethodFieldArgs, self).__init__() 27 | 28 | def field_to_native(self, obj, field_name): 29 | value = getattr(self.parent, self.method_name)(obj, *self.args) 30 | return self.to_native(value) 31 | 32 | 33 | class HyperlinkedRelatedMethod(RelatedField): 34 | """ 35 | A field that replaces usage of: 36 | class A(GenericForeignKeyMixin): 37 | field = SerializerMethodFieldArgs('get_related_object_url', 'field') 38 | """ 39 | def __init__(self, **kwargs): 40 | kwargs['read_only'] = True 41 | super(HyperlinkedRelatedMethod, self).__init__(**kwargs) 42 | 43 | def field_to_native(self, obj, field_name): 44 | self.parent.context = self.context 45 | value = GenericForeignKeyMixin.get_related_object_url(self.parent, obj, field_name) 46 | return self.to_native(value) 47 | 48 | 49 | class GenericForeignKeyMixin(object): 50 | def get_related_object_url(self, obj, field): 51 | try: 52 | obj = getattr(obj, field) 53 | if callable(obj): 54 | obj = obj() 55 | default_view_name = '%(model_name)s-detail' 56 | 57 | format_kwargs = { 58 | 'app_label': obj._meta.app_label, 59 | 'model_name': obj._meta.object_name.lower() 60 | } 61 | view_name = default_view_name % format_kwargs 62 | 63 | s = serializers.HyperlinkedIdentityField(source=obj, view_name=view_name) 64 | s.initialize(self, None) 65 | return s.field_to_native(obj, None) 66 | except Exception as e: 67 | print('WARN', e) 68 | return None 69 | 70 | 71 | class ExtendedHyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer): 72 | def to_native(self, obj): 73 | res = super(ExtendedHyperlinkedModelSerializer, self).to_native(obj) 74 | if obj: 75 | res['id'] = obj.serializable_value('pk') 76 | for field_name, field in self.fields.items(): 77 | if isinstance(field , HyperlinkedRelatedMethod): 78 | serializable_value = getattr(obj, field_name)#obj.serializable_value(field_name)() 79 | if callable(serializable_value): 80 | serializable_value = serializable_value() 81 | res[field_name] = {'url': res[field_name]} 82 | res[field_name]["id"] = serializable_value.pk if serializable_value else None 83 | res[field_name]["descr"] = str(serializable_value) if serializable_value else None 84 | res[field_name]["type"] = str(serializable_value.__class__.__name__).lower() if serializable_value else None 85 | elif isinstance(field , RelatedField) and hasattr(field, 'attname'): 86 | serializable_value = obj.serializable_value(field_name) 87 | res[field_name] = {'url': res[field_name]} 88 | if isinstance(serializable_value, int): 89 | res[field_name]["id"] = obj.serializable_value(field_name) 90 | res[field_name]["descr"] = str(getattr(obj, field_name)) 91 | elif serializable_value == None: 92 | res[field_name]["id"] = None 93 | res[field_name]["descr"] = None 94 | #else: 95 | # print field_name, field.__class__ 96 | return res 97 | 98 | 99 | class FollowSerializerMixin(object): 100 | def to_native(self, obj): 101 | ret = super(FollowSerializerMixin, self).to_native(obj) 102 | if obj and 'request' in self.context: 103 | ret['is_following'] = Follow.objects.is_following(self.context['request'].user, obj) 104 | return ret 105 | 106 | 107 | 108 | 109 | class FollowSerializer(serializers.Serializer): 110 | def to_native(self, obj): 111 | def reverse_url(result): 112 | return reverse('%s-detail'%result.target._meta.object_name.lower(), args=[result.target.id]) 113 | 114 | ret = {'url': reverse_url(obj), 'type': obj.target._meta.object_name, '__str__': str(obj.target) } 115 | return ret 116 | 117 | 118 | class GroupSerializer(ExtendedHyperlinkedModelSerializer): 119 | class Meta: 120 | model = Group 121 | fields = ('id', 'url', 'name') 122 | 123 | 124 | class UserSerializer(FollowSerializerMixin, ExtendedHyperlinkedModelSerializer): 125 | groups = GroupSerializer(many=True) 126 | class Meta: 127 | model = User 128 | fields = ('id', 'url', 'username', 'email', 'groups') 129 | 130 | 131 | class UserNameSerializer(ExtendedHyperlinkedModelSerializer): 132 | name = serializers.CharField(source='username', read_only=True) 133 | class Meta: 134 | model = User 135 | fields = ('id', 'url', 'name') 136 | 137 | 138 | class MilestoneSerializer(FollowSerializerMixin, ExtendedHyperlinkedModelSerializer): 139 | class Meta: 140 | model = Milestone 141 | read_only_fields = ('project', 'slug', 'author', ) 142 | 143 | 144 | class ProjectMemberSerializer(ExtendedHyperlinkedModelSerializer): 145 | class Meta: 146 | model = User 147 | fields = ('id', 'url', 'username') 148 | 149 | 150 | class ProjectSerializer(FollowSerializerMixin, ExtendedHyperlinkedModelSerializer): 151 | members = ProjectMemberSerializer(many=True) 152 | author = serializers.PrimaryKeyRelatedField(required=False, 153 | read_only=False, 154 | queryset=User.objects.all()) 155 | class Meta: 156 | model = Project 157 | exclude = ('members', ) 158 | 159 | def validate_author(self, attrs, source): 160 | if attrs[source] is None: 161 | if self.context['request'].user.is_authenticated(): 162 | attrs[source] = self.context['request'].user 163 | else: 164 | raise serializers.ValidationError("you need to be logged in") 165 | 166 | return attrs 167 | 168 | 169 | class ComponentSerializer(ExtendedHyperlinkedModelSerializer): 170 | class Meta: 171 | model = Component 172 | read_only_fields = ('project', 'slug', ) 173 | 174 | 175 | class TaskTypeSerializer(ExtendedHyperlinkedModelSerializer): 176 | class Meta: 177 | model = models.TaskType 178 | read_only_fields = ('project', ) 179 | 180 | 181 | class PrioritySerializer(ExtendedHyperlinkedModelSerializer): 182 | class Meta: 183 | model = models.Priority 184 | read_only_fields = ('project', ) 185 | 186 | 187 | class StatusSerializer(ExtendedHyperlinkedModelSerializer): 188 | class Meta: 189 | model = models.Status 190 | read_only_fields = ('project', 'slug', ) 191 | 192 | 193 | class ObjectTaskSerializer(GenericForeignKeyMixin, serializers.HyperlinkedRelatedField): 194 | def to_native(self, obj): 195 | ret = {} 196 | ret['content_object'] = {} 197 | ret['content_object']['url'] = self.get_related_object_url(obj, 'content_object') 198 | ret['content_object']['descr'] = str(obj.content_object) 199 | ret['content_object']['type'] = obj.content_object.__class__.__name__ 200 | return ret 201 | 202 | def from_native(self, value): 203 | from django.core.urlresolvers import resolve 204 | from rest_framework.compat import urlparse 205 | 206 | value = urlparse.urlparse(value).path 207 | match = resolve(value) 208 | 209 | content_object = match.func.cls.queryset.get(**match.kwargs) 210 | return ObjectTask(content_object=content_object) 211 | 212 | 213 | class TaskSerializer(FollowSerializerMixin, ExtendedHyperlinkedModelSerializer): 214 | objecttask_tasks = ObjectTaskSerializer(many=True, read_only=False, view_name='filereference-detail', queryset=ObjectTask.objects.all()) 215 | class Meta: 216 | model = Task 217 | read_only_fields = ('author', 'project') 218 | 219 | def restore_object(self, attrs, instance=None): 220 | objecttask_tasks = attrs['objecttask_tasks'] 221 | ret = super(TaskSerializer, self).restore_object(attrs, instance) 222 | 223 | qs = ret.objecttask_tasks.all() 224 | qs._result_cache = objecttask_tasks 225 | qs._prefetch_done = True 226 | ret._prefetched_objects_cache = {'objecttask_tasks': qs} 227 | 228 | return ret 229 | 230 | def save_object(self, task, *args, **kwargs): 231 | with transaction.atomic(): 232 | task.save_revision(self.context['request'].user, task.description, *args, **kwargs) #TODO: add interesting commit message! 233 | for ot in task.objecttask_tasks.all(): 234 | ot.task = task 235 | ot.save() 236 | 237 | 238 | class NotificationSerializer(GenericForeignKeyMixin, ExtendedHyperlinkedModelSerializer): 239 | id = serializers.IntegerField(read_only=True) 240 | level = serializers.CharField() 241 | 242 | recipient = HyperlinkedRelatedMethod(read_only=True) 243 | actor = HyperlinkedRelatedMethod(read_only=True) 244 | 245 | verb = serializers.CharField() 246 | description = serializers.CharField() 247 | 248 | target = HyperlinkedRelatedMethod(read_only=True) 249 | 250 | action_object = HyperlinkedRelatedMethod(read_only=True) 251 | 252 | timesince = serializers.CharField() 253 | 254 | __str__ = serializers.CharField() 255 | 256 | class Meta: 257 | model = Notification 258 | 259 | def get_default_fields(self): 260 | return {} 261 | 262 | 263 | 264 | class RelatedSerializer(serializers.Serializer): 265 | def to_native(self, version): 266 | ver = {} 267 | ver['id'] = version.id 268 | #ver['dir'] = dir(version) 269 | ver['object'] = version.field_dict 270 | #ver['object_version'] = version.object_version 271 | return ver 272 | 273 | 274 | class VersionSerializer(serializers.Serializer): 275 | def to_native(self, version): 276 | ver = {} 277 | ver['id'] = version.id 278 | ver['revision'] = {} 279 | ver['revision']['comment'] = version.revision.comment 280 | if version.revision.user: 281 | ver['revision']['editor'] = version.revision.user.username 282 | else: 283 | ver['revision']['editor'] = 'Anonymous' 284 | ver['revision']['revision_id'] = version.revision_id 285 | ver['revision']['related'] = RelatedSerializer(version.revision.version_set.exclude(pk=version.pk).all(), many=True).data 286 | ver['revision']['date_created'] = version.revision.date_created 287 | ver['object'] = version.field_dict 288 | ver['m2m_data'] = version.object_version.m2m_data 289 | return ver 290 | 291 | 292 | class CommentSerializer(GenericForeignKeyMixin, ExtendedHyperlinkedModelSerializer): 293 | content_object = SerializerMethodFieldArgs('get_related_object_url', 'content_object') 294 | content_object_descr = serializers.CharField(source='content_object', read_only=True) 295 | 296 | class Meta: 297 | model = Comment 298 | exclude = ('content_type', 'object_pk', ) 299 | read_only_fields = ('author',) 300 | 301 | def get_parent_object(self, instance=None): 302 | if instance: 303 | return instance.content_object 304 | else: 305 | from django.core.urlresolvers import resolve 306 | #TODO: there must be a better way to get the parent viewset???? 307 | path = '/'.join(self.context['request'].path.split('/')[:-2])+'/' 308 | parent_viewset = resolve(path) 309 | object = parent_viewset.func.cls.queryset.get(**parent_viewset.kwargs) 310 | return object 311 | 312 | def restore_object(self, attrs, instance=None): 313 | #assert instance is None, 'Cannot update comment with CommentSerializer' 314 | 315 | object = self.get_parent_object(instance) 316 | values = {'comment': attrs['comment'], 'content_object':object, 'author':self.context['request'].user} 317 | if instance: 318 | for (key, value) in values.items(): 319 | setattr(instance, key, value) 320 | return instance 321 | else: 322 | comment = Comment(**values) 323 | return comment 324 | -------------------------------------------------------------------------------- /django_project/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-04-21 22:17 3 | from __future__ import unicode_literals 4 | 5 | import autoslug.fields 6 | import datetime 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | import django.db.models.deletion 10 | import django_project.mixins 11 | import django_project.models 12 | import smart_selects.db_fields 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | initial = True 18 | 19 | dependencies = [ 20 | ('contenttypes', '0002_remove_content_type_name'), 21 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 22 | ] 23 | 24 | operations = [ 25 | migrations.CreateModel( 26 | name='Comment', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('object_pk', models.TextField(verbose_name='object ID')), 30 | ('comment', models.TextField(max_length=3000, verbose_name='comment')), 31 | ('submit_date', models.DateTimeField(auto_now_add=True, verbose_name='date/time submitted')), 32 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_comments', to=settings.AUTH_USER_MODEL, verbose_name='author')), 33 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_type_set_for_comment', to='contenttypes.ContentType', verbose_name='content type')), 34 | ], 35 | options={ 36 | 'permissions': [('can_moderate', 'Can moderate comments')], 37 | 'verbose_name': 'comment', 38 | 'verbose_name_plural': 'comments', 39 | 'ordering': ('-submit_date',), 40 | }, 41 | bases=(django_project.mixins.CommentMixin, models.Model), 42 | ), 43 | migrations.CreateModel( 44 | name='Component', 45 | fields=[ 46 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 47 | ('name', models.CharField(max_length=64)), 48 | ('slug', autoslug.fields.AutoSlugField(always_update=True, editable=False, max_length=64, populate_from='name', unique_with=('project',))), 49 | ('description', models.TextField(blank=True, null=True)), 50 | ], 51 | options={ 52 | 'verbose_name': 'component', 53 | 'verbose_name_plural': 'components', 54 | 'ordering': ('name',), 55 | }, 56 | ), 57 | migrations.CreateModel( 58 | name='Membership', 59 | fields=[ 60 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 61 | ('joined_at', models.DateTimeField(auto_now_add=True, verbose_name='joined at')), 62 | ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='member')), 63 | ], 64 | ), 65 | migrations.CreateModel( 66 | name='Milestone', 67 | fields=[ 68 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 69 | ('name', models.CharField(max_length=64)), 70 | ('slug', autoslug.fields.AutoSlugField(always_update=True, editable=False, max_length=64, populate_from='name', unique_with=('project',))), 71 | ('description', models.TextField()), 72 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 73 | ('modified_at', models.DateTimeField(auto_now=True, verbose_name='modified at')), 74 | ('deadline', models.DateField(default=datetime.date(2016, 5, 1), verbose_name='deadline')), 75 | ('date_completed', models.DateField(blank=True, null=True, verbose_name='date completed')), 76 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author')), 77 | ], 78 | options={ 79 | 'verbose_name': 'milestone', 80 | 'verbose_name_plural': 'milestones', 81 | 'ordering': ('created_at',), 82 | }, 83 | ), 84 | migrations.CreateModel( 85 | name='ObjectTask', 86 | fields=[ 87 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 88 | ('object_id', models.TextField(verbose_name='object ID')), 89 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_type_set_for_objecttask', to='contenttypes.ContentType', verbose_name='content type')), 90 | ], 91 | options={ 92 | 'verbose_name': 'objecttask', 93 | 'verbose_name_plural': 'objecttasks', 94 | }, 95 | ), 96 | migrations.CreateModel( 97 | name='Priority', 98 | fields=[ 99 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 100 | ('name', models.CharField(max_length=32, verbose_name='name')), 101 | ('description', models.TextField(blank=True, null=True, verbose_name='description')), 102 | ('order', models.IntegerField(verbose_name='order')), 103 | ('slug', autoslug.fields.AutoSlugField(always_update=True, editable=False, max_length=64, populate_from='name', unique_with=('project',))), 104 | ], 105 | options={ 106 | 'verbose_name': 'priority level', 107 | 'verbose_name_plural': 'priority levels', 108 | }, 109 | ), 110 | migrations.CreateModel( 111 | name='Project', 112 | fields=[ 113 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 114 | ('name', models.CharField(max_length=64, verbose_name='name')), 115 | ('slug', autoslug.fields.AutoSlugField(editable=False, max_length=128, populate_from='name', unique_with=('author',))), 116 | ('description', models.TextField(blank=True, null=True)), 117 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 118 | ('modified_at', models.DateTimeField(auto_now=True, verbose_name='modified at')), 119 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_projects', to=settings.AUTH_USER_MODEL)), 120 | ('members', models.ManyToManyField(through='django_project.Membership', to=settings.AUTH_USER_MODEL, verbose_name='members')), 121 | ], 122 | options={ 123 | 'permissions': (('view_project', 'Can view project'), ('admin_project', 'Can administer project'), ('can_read_repository', 'Can read repository'), ('can_write_to_repository', 'Can write to repository'), ('can_add_task', 'Can add task'), ('can_change_task', 'Can change task'), ('can_delete_task', 'Can delete task'), ('can_view_tasks', 'Can view tasks'), ('can_add_member', 'Can add member'), ('can_change_member', 'Can change member'), ('can_delete_member', 'Can delete member')), 124 | }, 125 | bases=(django_project.mixins.ProjectMixin, models.Model), 126 | ), 127 | migrations.CreateModel( 128 | name='Status', 129 | fields=[ 130 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 131 | ('name', models.CharField(max_length=32, verbose_name='name')), 132 | ('description', models.TextField(blank=True, null=True, verbose_name='description')), 133 | ('order', models.IntegerField(verbose_name='order')), 134 | ('is_resolved', models.BooleanField(default=False, verbose_name='is resolved')), 135 | ('is_initial', models.BooleanField(default=False, verbose_name='is initial')), 136 | ('slug', autoslug.fields.AutoSlugField(always_update=True, editable=False, max_length=64, populate_from='name', unique_with=('project',))), 137 | ], 138 | options={ 139 | 'verbose_name': 'status', 140 | 'verbose_name_plural': 'statuses', 141 | 'ordering': ['order'], 142 | }, 143 | ), 144 | migrations.CreateModel( 145 | name='Task', 146 | fields=[ 147 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 148 | ('summary', models.CharField(max_length=64, verbose_name='summary')), 149 | ('description', models.TextField(verbose_name='description')), 150 | ('deadline', models.DateField(blank=True, help_text='YYYY-MM-DD', null=True, verbose_name='deadline')), 151 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 152 | ('author', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='created_tasks', to=settings.AUTH_USER_MODEL, verbose_name='author')), 153 | ('component', smart_selects.db_fields.ChainedForeignKey(chained_field='project', chained_model_field='project', on_delete=django.db.models.deletion.CASCADE, to='django_project.Component', verbose_name='component')), 154 | ('milestone', smart_selects.db_fields.ChainedForeignKey(blank=True, chained_field='project', chained_model_field='project', null=True, on_delete=django.db.models.deletion.CASCADE, to='django_project.Milestone', verbose_name='milestone')), 155 | ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_tasks', to=settings.AUTH_USER_MODEL, verbose_name='owner')), 156 | ('priority', smart_selects.db_fields.ChainedForeignKey(chained_field='project', chained_model_field='project', on_delete=django.db.models.deletion.CASCADE, to='django_project.Priority', verbose_name='priority')), 157 | ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project', verbose_name='project')), 158 | ('status', smart_selects.db_fields.ChainedForeignKey(chained_field='project', chained_model_field='project', on_delete=django.db.models.deletion.CASCADE, to='django_project.Status', verbose_name='status')), 159 | ], 160 | bases=(django_project.mixins.TaskMixin, models.Model), 161 | ), 162 | migrations.CreateModel( 163 | name='TaskType', 164 | fields=[ 165 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 166 | ('name', models.CharField(max_length=32, verbose_name='name')), 167 | ('description', models.TextField(blank=True, null=True, verbose_name='description')), 168 | ('order', models.IntegerField(verbose_name='order')), 169 | ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project')), 170 | ], 171 | options={ 172 | 'verbose_name': 'task type', 173 | 'verbose_name_plural': 'task types', 174 | }, 175 | ), 176 | migrations.CreateModel( 177 | name='Transition', 178 | fields=[ 179 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 180 | ('destination', django_project.models.ChainedForeignKeyTransition(chained_field='source', chained_model_field='project', on_delete=django.db.models.deletion.CASCADE, to='django_project.Status', verbose_name='destination status')), 181 | ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sources', to='django_project.Status', verbose_name='source status')), 182 | ], 183 | options={ 184 | 'verbose_name': 'transition', 185 | 'verbose_name_plural': 'transitions', 186 | }, 187 | ), 188 | migrations.AddField( 189 | model_name='task', 190 | name='type', 191 | field=smart_selects.db_fields.ChainedForeignKey(chained_field='project', chained_model_field='project', on_delete=django.db.models.deletion.CASCADE, to='django_project.TaskType', verbose_name='task type'), 192 | ), 193 | migrations.AddField( 194 | model_name='status', 195 | name='destinations', 196 | field=models.ManyToManyField(blank=True, through='django_project.Transition', to='django_project.Status', verbose_name='destinations'), 197 | ), 198 | migrations.AddField( 199 | model_name='status', 200 | name='project', 201 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project'), 202 | ), 203 | migrations.AddField( 204 | model_name='priority', 205 | name='project', 206 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project'), 207 | ), 208 | migrations.AddField( 209 | model_name='objecttask', 210 | name='task', 211 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='objecttask_tasks', to='django_project.Task', verbose_name='task'), 212 | ), 213 | migrations.AddField( 214 | model_name='milestone', 215 | name='project', 216 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project', verbose_name='project'), 217 | ), 218 | migrations.AddField( 219 | model_name='membership', 220 | name='project', 221 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project', verbose_name='project'), 222 | ), 223 | migrations.AddField( 224 | model_name='component', 225 | name='project', 226 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_project.Project'), 227 | ), 228 | migrations.AlterUniqueTogether( 229 | name='transition', 230 | unique_together=set([('source', 'destination')]), 231 | ), 232 | migrations.AlterUniqueTogether( 233 | name='tasktype', 234 | unique_together=set([('project', 'name')]), 235 | ), 236 | migrations.AlterUniqueTogether( 237 | name='status', 238 | unique_together=set([('project', 'name')]), 239 | ), 240 | migrations.AlterUniqueTogether( 241 | name='priority', 242 | unique_together=set([('project', 'name')]), 243 | ), 244 | migrations.AlterUniqueTogether( 245 | name='milestone', 246 | unique_together=set([('project', 'name')]), 247 | ), 248 | migrations.AlterUniqueTogether( 249 | name='membership', 250 | unique_together=set([('project', 'member')]), 251 | ), 252 | migrations.AlterUniqueTogether( 253 | name='component', 254 | unique_together=set([('project', 'name')]), 255 | ), 256 | ] 257 | -------------------------------------------------------------------------------- /django_project/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.contrib.auth.models import User, Group 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.core.urlresolvers import resolve 5 | 6 | from rest_framework.views import APIView 7 | from rest_framework import viewsets 8 | from rest_framework.response import Response 9 | from rest_framework import status 10 | from rest_framework import filters 11 | 12 | from rest_framework.permissions import IsAuthenticated 13 | 14 | from notifications.models import Notification 15 | 16 | from follow.models import Follow 17 | import follow 18 | 19 | from rest_framework_extensions.decorators import action, link 20 | from rest_framework_extensions.mixins import NestedViewSetMixin as RFENestedViewSetMixin 21 | 22 | from django_project import serializers 23 | from django_project import models 24 | 25 | from django_project import signals 26 | from django_project import filters as dp_filters 27 | 28 | class MetaDataModelViewSet(viewsets.ModelViewSet): 29 | """ 30 | Collects metadata of each subclass(methods aren't overridden) 31 | using 'metadata_()' methods. 32 | """ 33 | def metadata(self, request): 34 | ret = super(MetaDataModelViewSet, self).metadata(request) 35 | 36 | mros = type(self).mro() # Get the Inheritance tree 37 | for mro in mros: 38 | for method in mro.__dict__: # Only iterate non-inherted class methods 39 | if method.startswith('metadata_'): 40 | name = method[len('metadata_'):] 41 | val = getattr(mro, method)(self, request) 42 | if val: 43 | is_dict = type(val) is dict 44 | if name not in ret: 45 | ret[name] = {} if is_dict else [] 46 | if is_dict: 47 | ret[name].update(val) 48 | else: 49 | ret[name].extend(val) 50 | 51 | return ret 52 | 53 | 54 | class FollowingModelViewSet(MetaDataModelViewSet): 55 | def metadata_methods(self, request): 56 | print('FollowingModelViewSet::metadata_methods', self.kwargs) 57 | if has_instance_key(self.kwargs): 58 | path = request._request.path 59 | methods = [] 60 | 61 | methods.append({'url': path+'follow/', 'methods': ['POST', 'DELETE']}) 62 | methods.append({'url': path+'followers/', 'methods': ['GET']}) 63 | methods.append({'url': path+'activity/', 'methods': ['GET']}) 64 | 65 | return methods 66 | 67 | def metadata_filtering(self, request): 68 | return {'is_following': {'searches': 'exact'}} 69 | 70 | def get_queryset(self): 71 | qs = super(FollowingModelViewSet, self).get_queryset() 72 | if self.request.query_params.get('is_following', '').lower() == 'true' and self.request.user.is_authenticated(): 73 | qs = qs.filter(**{'follow_%s__user'%qs.model._meta.model_name: self.request.user}) 74 | return qs 75 | 76 | @action(methods=['POST', 'DELETE'], permission_classes=[IsAuthenticated]) 77 | def follow(self, request, pk, **kwargs): 78 | obj = self.queryset.get(id=int(pk)) 79 | 80 | #follow, created = Follow.objects.get_or_create(request.user, obj) 81 | can_change_follow = True 82 | if hasattr(self, 'can_change_follow'): 83 | can_change_follow = self.can_change_follow(request.user, obj) 84 | 85 | if can_change_follow: 86 | if request.method == 'DELETE': 87 | if Follow.objects.is_following(request.user, obj): 88 | fol = follow.utils.unfollow(request.user, obj) 89 | signals.unfollow.send(FollowingModelViewSet, follower=request.user, followee=obj) 90 | return Response(status=status.HTTP_205_RESET_CONTENT) 91 | elif request.method == 'POST': 92 | if not Follow.objects.is_following(request.user, obj): 93 | fol = follow.utils.follow(request.user, obj) 94 | signals.follow.send(FollowingModelViewSet, follower=request.user, followee=obj) 95 | return Response(status=status.HTTP_201_CREATED) 96 | else: 97 | return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) 98 | 99 | 100 | @link() 101 | def followers(self, request, pk, **kwargs): 102 | obj = self.queryset.get(id=int(pk)) 103 | 104 | users = User.objects.filter(id__in=Follow.objects.get_follows(obj).values_list('user')) 105 | 106 | serializer = paginate_data(self, users, serializers.UserSerializer) 107 | 108 | return Response(serializer.data) 109 | 110 | @link() 111 | def activity(self, request, pk, **kwargs): 112 | print("FollowingModelViewSet: ", pk) 113 | obj = self.queryset.get(id=int(pk)) 114 | 115 | kwargs = {} 116 | kwargs['public'] = True 117 | if self.notifications_field=='recipient': 118 | kwargs['recipient'] = obj 119 | else: 120 | kwargs[self.notifications_field+'_content_type'] = ContentType.objects.get_for_model(obj) 121 | kwargs[self.notifications_field+'_object_id'] = obj.id 122 | 123 | if self.notifications_field=='target': 124 | kwargs['recipient'] = obj.author 125 | 126 | notifications = Notification.objects.filter(**kwargs) 127 | 128 | serializer = paginate_data(self, notifications, serializers.NotificationSerializer) 129 | 130 | return Response(serializer.data) 131 | 132 | 133 | def nested_viewset_with_genericfk(parent_viewset, viewset): 134 | class Wrapper(viewset): 135 | def get_queryset(self): 136 | return super(Wrapper, self).get_queryset().filter( 137 | content_type=ContentType.objects.get_for_model(parent_viewset.queryset.model) 138 | ) 139 | return Wrapper 140 | 141 | 142 | class NestedViewSetMixin(RFENestedViewSetMixin): 143 | def pre_save(self, obj): 144 | if self.request.user.is_authenticated(): 145 | obj.author = self.request.user 146 | #TODO: validate this is a nested view when saving 147 | if not hasattr(obj, 'project') or not obj.project: 148 | if 'parent_lookup_project' in self.kwargs: 149 | print('**NestedViewSetMixin:', self.kwargs, self.__class__.__name__) 150 | project_pk = self.kwargs['parent_lookup_project'] 151 | print('--NestedViewSetMixin: setting project to ', project_pk) 152 | obj.project = models.Project.objects.get(id=int(project_pk)) 153 | 154 | 155 | class FilteredModelViewSetMixin(object): 156 | filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter,) 157 | 158 | def metadata(self, request): 159 | from django_filters import DateRangeFilter 160 | ret = super(FilteredModelViewSetMixin, self).metadata(request) 161 | if 'pk' not in self.kwargs: 162 | f = self.filter_class(request.GET, queryset=self.get_queryset()) 163 | if hasattr(self, 'search_fields'): 164 | ret['search'] = {'search_by_field': 'search', 'searches': self.search_fields} 165 | if hasattr(self, 'filter_class') and hasattr(self.filter_class.Meta, 'order_by'): 166 | if f.ordering_field: 167 | ret['ordering'] = {'order_by_field': f.order_by_field, 'choices': f.ordering_field.choices} 168 | if len(f.filters): 169 | ret['filtering'] = ret.get('filtering', {}) 170 | for name, field in f.filters.items(): 171 | if 'queryset' in field.extra: 172 | ret['filtering'][name] = {'values': [(opt.id, str(opt)) for opt in field.extra['queryset']]} 173 | elif isinstance(field, DateRangeFilter): 174 | print(field.options) 175 | ret['filtering'][name] = {'values': [(id, tup[0]) for id, tup in field.options.items()]} 176 | else: 177 | ret['filtering'][name] = {'searches': field.lookup_type} 178 | 179 | return ret 180 | 181 | #----------------------------------------------------------------------- 182 | 183 | class UserViewSet(NestedViewSetMixin, FollowingModelViewSet): 184 | """ 185 | API endpoint that allows users to be viewed or edited. 186 | """ 187 | queryset = User.objects.all() 188 | serializer_class = serializers.UserSerializer 189 | notifications_field = 'recipient' 190 | 191 | def can_change_follow(self, user, obj): 192 | # Don't allow users to follow themselfs 193 | return user.id != obj.id 194 | 195 | @link(permission_classes=[]) 196 | def following(self, request, pk): 197 | obj = self.queryset.get(id=int(pk)) 198 | 199 | results = Follow.objects.filter(user=obj) 200 | serializer = paginate_data(self, results, serializers.FollowSerializer) 201 | 202 | return Response(serializer.data) 203 | 204 | 205 | class CurrentUserDetail(APIView): 206 | """ 207 | Retrieve the current User 208 | """ 209 | permission_classes = (IsAuthenticated,) 210 | 211 | def get(self, request, format=None): 212 | serializer = serializers.UserSerializer(request.user) 213 | return Response(serializer.data) 214 | 215 | 216 | class GroupViewSet(FollowingModelViewSet): 217 | """ 218 | API endpoint that allows groups to be viewed or edited. 219 | """ 220 | queryset = Group.objects.all() 221 | serializer_class = serializers.GroupSerializer 222 | 223 | 224 | class ProjectViewSet(NestedViewSetMixin, FilteredModelViewSetMixin, FollowingModelViewSet): 225 | """ 226 | API endpoint that allows users to be viewed or edited. 227 | """ 228 | queryset = models.Project.objects.all() 229 | serializer_class = serializers.ProjectSerializer 230 | notifications_field = 'target' 231 | filter_class = dp_filters.ProjectFilter 232 | search_fields = ('author__username', 'name') 233 | 234 | def can_change_follow(self, user, obj): 235 | # Don't allow project owners to unfollow the project 236 | return user.id != obj.author.id 237 | 238 | @link(is_for_list=True) 239 | def statistics(self, request, **kwargs): 240 | from datetime import datetime 241 | qs = self.get_queryset() 242 | print(list(qs)) 243 | ret = {} 244 | ret['Total'] = qs.count() 245 | ret['Todo'] = qs.exclude(task__status__is_resolved=True).count() 246 | ret['Past Due'] = qs.filter(task__deadline__lt=datetime.now()).values_list('pk', flat=True).distinct().count() 247 | ret['Complete'] = qs.filter(task__status__is_resolved=True).count() 248 | return Response(ret) 249 | 250 | 251 | class MilestoneModelViewSet(NestedViewSetMixin, FilteredModelViewSetMixin, FollowingModelViewSet): 252 | queryset = models.Milestone.objects.all() 253 | serializer_class = serializers.MilestoneSerializer 254 | filter_class = dp_filters.MilestoneFilter 255 | 256 | @link(is_for_list=True) 257 | def statistics(self, request, **kwargs): 258 | from datetime import datetime 259 | qs = self.get_queryset() 260 | ret = {} 261 | ret['Total'] = qs.count() 262 | ret['Todo'] = qs.exclude(deadline__lt=datetime.now()).exclude(task__owner=None).exclude(date_completed__lt=datetime.now()).count() 263 | ret['Past Due'] = qs.filter(deadline__lt=datetime.now()).count() 264 | ret['Unassigned'] = qs.filter(task__owner=None).count() 265 | ret['Complete'] = qs.filter(date_completed__lt=datetime.now()).count() 266 | return Response(ret) 267 | 268 | 269 | class ComponentViewSet(NestedViewSetMixin, viewsets.ModelViewSet): 270 | """ 271 | API endpoint that allows Components to be viewed or edited. 272 | """ 273 | queryset = models.Component.objects.all() 274 | serializer_class = serializers.ComponentSerializer 275 | 276 | 277 | class TaskTypeViewSet(NestedViewSetMixin, viewsets.ModelViewSet): 278 | """ 279 | API endpoint that allows TaskTypes to be viewed or edited. 280 | """ 281 | queryset = models.TaskType.objects.all() 282 | serializer_class = serializers.TaskTypeSerializer 283 | 284 | 285 | class PriorityViewSet(NestedViewSetMixin, viewsets.ModelViewSet): 286 | """ 287 | API endpoint that allows Priorities to be viewed or edited. 288 | """ 289 | queryset = models.Priority.objects.all() 290 | serializer_class = serializers.PrioritySerializer 291 | 292 | 293 | class StatusViewSet(NestedViewSetMixin, viewsets.ModelViewSet): 294 | """ 295 | API endpoint that allows Statuses to be viewed or edited. 296 | """ 297 | queryset = models.Status.objects.all() 298 | serializer_class = serializers.StatusSerializer 299 | 300 | 301 | class TaskViewSet(NestedViewSetMixin, FilteredModelViewSetMixin, FollowingModelViewSet): 302 | """ 303 | API endpoint that allows users to be viewed or edited. 304 | """ 305 | queryset = models.Task.objects.all() 306 | serializer_class = serializers.TaskSerializer 307 | notifications_field = 'action_object' 308 | filter_class = dp_filters.TaskFilter 309 | search_fields = ('summary', 'description') 310 | 311 | 312 | def pre_save(self, obj): 313 | obj.author = self.request.user 314 | #TODO: validate this is a nested view when saving 315 | if not hasattr(obj, 'project') or not obj.project: 316 | project_pk = self.kwargs['parent_lookup_project'] 317 | obj.project = models.Project.objects.get(id=int(project_pk)) 318 | 319 | def metadata_methods(self, request): 320 | print('TaskViewSet::metadata_methods') 321 | if has_instance_key(self.kwargs): 322 | path = request._request.path 323 | methods = [] 324 | methods.append({'url': path+'revisions/', 'methods': ['GET']}) 325 | return methods 326 | 327 | def metadata_options(self, request): 328 | print('TaskViewSet::metadata_options') 329 | project_pk = None 330 | if has_primary_key(self.kwargs): 331 | if 'project_pk' in self.kwargs: 332 | project_pk = self.kwargs['project_pk'] 333 | elif has_instance_key(self.kwargs): 334 | task = self.queryset.get(id=int(self.kwargs['pk'])) 335 | project_pk = task.project.id 336 | 337 | if project_pk: 338 | data = {} 339 | for model, field in [('Priority', 'priority'), ('TaskType', 'type'), ('Component', 'component'), ('Milestone', 'milestone')]: 340 | qs = getattr(models, model).objects.filter(project_id=int(project_pk)) 341 | data[field] = getattr(serializers, model+'Serializer')(qs, many=True, context={'request': request}).data 342 | for model, field in [('User', 'owner')]: 343 | qs = getattr(models, model).objects.filter(membership__project_id=int(project_pk)) 344 | data[field] = getattr(serializers, model+'NameSerializer')(qs, many=True, context={'request': request}).data 345 | 346 | for model, field in [('Status', 'status')]: 347 | if 'pk' in self.kwargs: 348 | #We're holding a task for editting. So only return allowed transitions. 349 | task = self.queryset.get(id=int(self.kwargs['pk'])) 350 | qs = getattr(models, model).objects.filter(transition__source=task.status) 351 | else: 352 | #We're going to create a task, so only return intial statuses. 353 | qs = getattr(models, model).objects.filter(project_id=int(project_pk), is_initial=True) 354 | data[field] = getattr(serializers, model+'Serializer')(qs, many=True, context={'request': request}).data 355 | return data 356 | 357 | @link(permission_classes=[]) 358 | def revisions(self, request, pk, **kwargs): 359 | task = self.queryset.get(id=int(pk)) 360 | versions = task.versions() 361 | 362 | serializer = paginate_data(self, versions, serializers.VersionSerializer) 363 | 364 | return Response(serializer.data) 365 | 366 | @link(permission_classes=[]) 367 | def objects(self, request, pk, **kwargs): 368 | task = self.queryset.get(id=int(pk)) 369 | objects = task.objecttask_tasks.all() 370 | 371 | serializer = paginate_data(self, objects, serializers.ObjectTaskSerializer) 372 | 373 | return Response(serializer.data) 374 | 375 | @link(is_for_list=True) 376 | def statistics(self, request, **kwargs): 377 | from datetime import datetime 378 | qs = self.get_queryset() 379 | ret = {} 380 | ret['Total'] = qs.count() 381 | ret['Todo'] = qs.exclude(deadline__lt=datetime.now()).exclude(owner=None).exclude(status__is_resolved=True).count() 382 | ret['Past Due'] = qs.filter(deadline__lt=datetime.now()).count() 383 | ret['Unassigned'] = qs.filter(owner=None).count() 384 | ret['Complete'] = qs.filter(status__is_resolved=True).count() 385 | return Response(ret) 386 | 387 | 388 | class CommentModelViewSet(NestedViewSetMixin, FilteredModelViewSetMixin, viewsets.ModelViewSet): 389 | queryset = models.Comment.objects.all() 390 | serializer_class = serializers.CommentSerializer 391 | filter_class = dp_filters.CommentFilter 392 | search_fields = ('user__username', 'comment') 393 | 394 | 395 | class NotificationModelViewSet(viewsets.ReadOnlyModelViewSet): 396 | queryset = Notification.objects.all() 397 | serializer_class = serializers.NotificationSerializer 398 | 399 | 400 | 401 | def has_primary_key(kwargs): 402 | return (True in map(lambda x: x.endswith('_pk'), kwargs.keys())) 403 | 404 | def has_instance_key(kwargs): 405 | return 'pk' in kwargs 406 | 407 | def paginate_data(self, data, serializer_class=None): 408 | from rest_framework.pagination import PaginationSerializer 409 | page = self.paginate_queryset(data) 410 | if serializer_class: 411 | class SerializerClass(PaginationSerializer): 412 | class Meta: 413 | object_serializer_class = serializer_class 414 | 415 | pagination_serializer_class = SerializerClass 416 | context = self.get_serializer_context() 417 | return pagination_serializer_class(instance=page, context=context) 418 | else: 419 | 420 | return PaginationSerializer(instance=page) 421 | --------------------------------------------------------------------------------