├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------