first commit for v0.2

This commit is contained in:
Élie Bouttier 2014-08-30 15:38:06 -07:00
parent 6ba03afc73
commit 1463854a45
143 changed files with 20775 additions and 2764 deletions

1
tracker/__init__.py Normal file
View file

@ -0,0 +1 @@
default_app_config = 'tracker.apps.TrackerConfig'

9
tracker/admin.py Normal file
View file

@ -0,0 +1,9 @@
from django.contrib import admin
from tracker.models import *
admin.site.register(Project)
admin.site.register(Issue)
admin.site.register(Label)
admin.site.register(Milestone)

10
tracker/apps.py Normal file
View file

@ -0,0 +1,10 @@
from django.apps import AppConfig
class TrackerConfig(AppConfig):
name = 'tracker'
verbose_name = 'Tracker'
def ready(self):
import tracker.signals

View file

@ -0,0 +1,9 @@
from tracker.models import Project
def projects(request):
if hasattr(request, 'projects'):
return {'projects': request.projects}
else:
return {}

44
tracker/forms.py Normal file
View file

@ -0,0 +1,44 @@
from django import forms
from bootstrap3_datetime.widgets import DateTimePicker
from django_markdown.widgets import MarkdownWidget
from tracker.models import *
__all__ = [
'ProjectForm', 'LabelForm', 'IssueForm', 'MilestoneForm', 'CommentForm'
]
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ['display_name', 'name', 'description', 'access']
help_texts = {
'name': 'Warning: if you change this value, this will break existing URLs.'
}
class IssueForm(forms.Form):
title = forms.CharField(max_length=128)
description = forms.CharField(widget=MarkdownWidget, required=False)
class CommentForm(forms.Form):
comment = forms.CharField(widget=MarkdownWidget)
class LabelForm(forms.ModelForm):
class Meta:
model = Label
fields = ['name', 'color', 'inverted']
class MilestoneForm(forms.ModelForm):
class Meta:
model = Milestone
fields = ['name', 'due_date']
widgets = {
'due_date': DateTimePicker(format="%Y-%m-%d %H:%M"),
}

66
tracker/middleware.py Normal file
View file

@ -0,0 +1,66 @@
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from tracker.models import Project
from permissions.models import PermissionModel as PermModel
# This middleware protect only views of the following modules
modules = [ 'accounts.views', 'permissions.views', 'tracker.views' ]
class ProjectMiddleware:
"""
This middleware must be call after authentication middleware.
"""
def process_view(self, request, view, view_args, view_kwargs):
if view.__module__ not in modules:
return
if not hasattr(request, 'user'):
raise ImproperlyConfigured(
"The project middleware requires the"
" authentication middleware to be installed. Edit your"
" MIDDLEWARE_CLASSES setting to insert"
" 'django.contrib.auth.middleware.AuthenticationMiddleware'"
" before the ProjectMiddleware class.")
# projects
if request.user.is_authenticated() and request.user.is_staff:
projects = Project.objects.all()
else:
query = Q(access=Project.ACCESS_PUBLIC)
if request.user.is_authenticated():
query |= Q(access=Project.ACCESS_REGISTERED)
# access granted through a team
teams = request.user.teams.values_list('name')
query |= Q(permissions__grantee_type=PermModel.GRANTEE_TEAM,
permissions__grantee_name__in=teams)
# access granted through a group
groups = request.user.groups.values_list('name')
query |= Q(permissions__grantee_type=PermModel.GRANTEE_GROUP,
permissions__grantee_name__in=groups)
# access granted by specific permission
query |= Q(permissions__grantee_type=PermModel.GRANTEE_USER,
permissions__grantee_name=request.user.username)
projects = Project.objects.filter(query).distinct()
request.projects = projects
# project
project = view_kwargs.get('project')
if not project:
return
try:
project = projects.get(name=project)
except ObjectDoesNotExist:
if request.user.is_authenticated():
raise PermissionDenied()
else:
return login_required(view)(request, *view_args, **view_kwargs)
view_kwargs['project'] = project
request.project = project

View file

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import colorful.fields
from django.conf import settings
import django.core.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Event',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
('date', models.DateTimeField(auto_now_add=True)),
('code', models.IntegerField(default=0)),
('_args', models.CharField(default='{}', max_length=1024, blank=True)),
('additionnal_section', models.TextField(default='', blank=True)),
('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Issue',
fields=[
('primarykey', models.AutoField(serialize=False, primary_key=True)),
('id', models.IntegerField(editable=False)),
('title', models.CharField(max_length=128)),
('opened_at', models.DateTimeField(auto_now_add=True)),
('closed', models.BooleanField(default=False)),
('assignee', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, blank=True)),
('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
('subscribers', models.ManyToManyField(null=True, to=settings.AUTH_USER_MODEL, blank=True)),
],
options={
},
bases=(models.Model,),
),
migrations.AddField(
model_name='event',
name='issue',
field=models.ForeignKey(to='tracker.Issue'),
preserve_default=True,
),
migrations.CreateModel(
name='Label',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
('name', models.CharField(max_length=32)),
('deleted', models.BooleanField(default=False)),
('color', colorful.fields.RGBColorField(default='#000000', verbose_name='Background color')),
('inverted', models.BooleanField(default=True, verbose_name='Inverse text color')),
],
options={
},
bases=(models.Model,),
),
migrations.AddField(
model_name='issue',
name='labels',
field=models.ManyToManyField(null=True, to='tracker.Label', blank=True),
preserve_default=True,
),
migrations.CreateModel(
name='Milestone',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
('name', models.CharField(validators=[django.core.validators.RegexValidator(regex='^[a-z0-9_.-]+$', message='Please enter only lowercase characters, number, dot, underscores or hyphens.')], max_length=32)),
('due_date', models.DateTimeField(null=True, blank=True)),
('closed', models.BooleanField(default=False)),
],
options={
},
bases=(models.Model,),
),
migrations.AddField(
model_name='issue',
name='milestone',
field=models.ForeignKey(to='tracker.Milestone', null=True, blank=True),
preserve_default=True,
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
('display_name', models.CharField(max_length=32, verbose_name='Project name', unique=True)),
('name', models.SlugField(max_length=32, verbose_name='URL name', unique=True)),
('description', models.TextField(default='', verbose_name='Description', blank=True)),
('access', models.IntegerField(default=1, choices=[(1, 'Public'), (2, 'Registration required'), (3, 'Private')])),
('subscribers', models.ManyToManyField(null=True, to=settings.AUTH_USER_MODEL, blank=True)),
],
options={
'ordering': ['name'],
},
bases=(models.Model,),
),
migrations.AddField(
model_name='milestone',
name='project',
field=models.ForeignKey(to='tracker.Project'),
preserve_default=True,
),
migrations.AlterUniqueTogether(
name='milestone',
unique_together=set([('project', 'name')]),
),
migrations.AddField(
model_name='label',
name='project',
field=models.ForeignKey(to='tracker.Project'),
preserve_default=True,
),
migrations.AddField(
model_name='issue',
name='project',
field=models.ForeignKey(to='tracker.Project'),
preserve_default=True,
),
migrations.AlterUniqueTogether(
name='issue',
unique_together=set([('project', 'id')]),
),
]

View file

371
tracker/models.py Normal file
View file

@ -0,0 +1,371 @@
from django.db import models
from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe
from django.utils.html import escape
from django.utils.encoding import python_2_unicode_compatible
from django import VERSION
from colorful.fields import RGBColorField
import json
from accounts.models import User
from tracker.templatetags.tracker_tags import *
__all__ = [ 'Project', 'Issue', 'Label', 'Milestone', 'Event' ]
@python_2_unicode_compatible
class Project(models.Model):
class Meta:
ordering = [ 'name' ]
ACCESS_PUBLIC = 1
ACCESS_REGISTERED = 2
ACCESS_PRIVATE = 3
ACCESS_TYPE = (
(ACCESS_PUBLIC, 'Public'),
(ACCESS_REGISTERED, 'Registration required'),
(ACCESS_PRIVATE, 'Private'),
)
display_name = models.CharField(max_length=32, unique=True,
verbose_name="Project name")
name = models.SlugField(max_length=32, unique=True,
verbose_name="URL name")
description = models.TextField(blank=True, default="",
verbose_name="Description")
access = models.IntegerField(choices=ACCESS_TYPE, default=ACCESS_PUBLIC)
subscribers = models.ManyToManyField(User, blank=True, null=True,
related_name='subscribed_projects')
def __str__(self):
return self.display_name
@python_2_unicode_compatible
class Label(models.Model):
project = models.ForeignKey(Project, related_name='labels')
name = models.CharField(max_length=32)
deleted = models.BooleanField(default=False)
color = RGBColorField(default='#000000',
verbose_name="Background color")
inverted = models.BooleanField(default=True,
verbose_name="Inverse text color")
@property
def quotted_name(self):
if ' ' in self.name:
name = '"' + escape(self.name) + '&quot'
else:
name = escape(self.name)
return mark_safe(name)
def __str__(self):
return self.name
@python_2_unicode_compatible
class Milestone(models.Model):
class Meta:
ordering = [ 'due_date' ]
name_validator = RegexValidator(regex='^[a-z0-9_.-]+$',
message="Please enter only lowercase characters, number, "
"dot, underscores or hyphens.")
project = models.ForeignKey(Project, related_name='milestones')
name = models.CharField(max_length=32, validators=[name_validator])
class Meta:
unique_together = ['project', 'name']
due_date = models.DateTimeField(blank=True, null=True)
closed = models.BooleanField(default=False)
def closed_issues(self):
return self.issues.filter(closed=True).count()
def total_issues(self):
return self.issues.count()
def progress(self):
closed = self.closed_issues()
total = self.total_issues()
if total:
return int(100 * closed / total)
else:
return 0
def __str__(self):
return self.name
@python_2_unicode_compatible
class Issue(models.Model):
# id is the id in the project, not the pk, so we need one
primarykey = models.AutoField(primary_key=True)
project = models.ForeignKey(Project, related_name='issues')
id = models.IntegerField(editable=False)
class Meta:
unique_together = ['project', 'id']
title = models.CharField(max_length=128)
author = models.ForeignKey(User, related_name='+')
opened_at = models.DateTimeField(auto_now_add=True)
closed = models.BooleanField(default=False)
labels = models.ManyToManyField(Label, blank=True, null=True,
related_name='issues')
milestone = models.ForeignKey(Milestone, blank=True, null=True,
related_name='issues')
assignee = models.ForeignKey(User, blank=True, null=True, related_name='+')
subscribers = models.ManyToManyField(User, blank=True, null=True,
related_name='subscribed_issues')
@staticmethod
def next_id(project):
last_issue = project.issues.last()
if last_issue:
return last_issue.id + 1
else:
return 1
@property
def comments(self):
return self.events.filter(code=Event.COMMENT)
def getdesc(self):
desc = self.events.filter(code=Event.DESCRIBE)
if desc.exists():
return desc.first().additionnal_section
else:
return None
def setdesc(self, value):
desc = self.events.filter(code=Event.DESCRIBE)
if desc.exists():
desc = desc.first()
desc.additionnal_section = value
desc.save()
else:
desc = Event(issue=self, author=self.author, code=Event.DESCRIBE,
additionnal_section=value)
desc.save()
def deldesc(self):
desc = self.events.filter(code=Event.DESCRIBE)
if desc.exists():
desc.first().delete()
description = property(getdesc, setdesc, deldesc)
def add_label(self, author, label, commit=True):
if self.labels.filter(pk=label.pk).exists():
return
self.labels.add(label)
if commit:
self.save()
event = Event(issue=self, author=author,
code=Event.ADD_LABEL, args={'label': label.id})
event.save()
def remove_label(self, author, label, commit=True):
self.labels.remove(label)
if commit:
self.save()
event = Event(issue=self, author=author,
code=Event.DEL_LABEL, args={'label': label.id})
event.save()
def add_milestone(self, author, milestone, commit=True):
if self.milestone == milestone:
return
if self.milestone:
event = Event(issue=self, author=author,
code=Event.CHANGE_MILESTONE,
args={'old_milestone': self.milestone.name,
'new_milestone': milestone.name})
event.save()
else:
event = Event(issue=self, author=author,
code=Event.SET_MILESTONE,
args={'milestone': milestone.name})
event.save()
self.milestone = milestone
if commit:
self.save()
def remove_milestone(self, author, milestone, commit=True):
self.milestone = None
if commit:
self.save()
event = Event(issue=self, author=author,
code=Event.UNSET_MILESTONE,
args={'milestone': milestone.name})
event.save()
def __str__(self):
return self.title
@python_2_unicode_compatible
class Event(models.Model):
UNKNOW = 0
CLOSE = 1
REOPEN = 2
RENAME = 3
ADD_LABEL = 4
DEL_LABEL = 5
SET_MILESTONE = 6
CHANGE_MILESTONE = 7
UNSET_MILESTONE = 8
REFERENCE = 9
COMMENT = 10
DESCRIBE = 11
ASSIGN = 12
UNASSIGN = 13
issue = models.ForeignKey(Issue, related_name="%(class)ss")
date = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(User)
code = models.IntegerField(default=UNKNOW)
_args = models.CharField(max_length=1024, blank=True, default="{}")
def getargs(self):
return json.loads(self._args)
def setargs(self, args):
self._args = json.dumps(args)
def delargs(self):
self._args = "{}"
args = property(getargs, setargs, delargs)
additionnal_section = models.TextField(blank=True, default="")
def editable(self):
return self.code == Event.COMMENT or self.code == Event.DESCRIBE
def glyphicon(self):
if self.code == Event.COMMENT:
return "comment"
elif self.code == Event.DESCRIBE:
return "edit"
elif self.code == Event.CLOSE:
return "ban-circle"
elif self.code == Event.REOPEN:
return "refresh"
elif self.code == Event.RENAME:
return "transfer"
elif self.code == Event.ADD_LABEL \
or self.code == Event.DEL_LABEL:
return "tag"
elif self.code == Event.SET_MILESTONE \
or self.code == Event.CHANGE_MILESTONE \
or self.code == Event.UNSET_MILESTONE:
return "road"
elif self.code == Event.REFERENCE:
return "transfer"
elif self.code == Event.ASSIGN \
or self.code == Event.UNASSIGN:
return "user"
else:
return "cog"
def __str__(self):
args = self.args
if self.code == Event.COMMENT or self.code == Event.DESCRIBE:
description = "commented"
elif self.code == Event.CLOSE:
description = "closed this issue"
elif self.code == Event.REOPEN:
description = "reopened this issue"
elif self.code == Event.RENAME:
description = "changed the title from <mark>{old_title}</mark> " \
"to <mark>{new_title}</mark>"
elif self.code == Event.ADD_LABEL or self.code == Event.DEL_LABEL:
label = Label.objects.get(id=args['label'])
if self.code == Event.ADD_LABEL:
action = 'added'
else:
action = 'removed'
description = '%s the <a href="%s" class="label" ' \
'style="%s">%s</a> label' \
% (action, same_label(label),
label_style(label), label)
elif self.code == Event.SET_MILESTONE \
or self.code == Event.UNSET_MILESTONE:
milestone = Milestone(name=args['milestone'],
project=self.issue.project)
if self.code == Event.SET_MILESTONE:
action = 'added'
else:
action = 'removed'
description = '%s this to the <span class="glyphicon ' \
'glyphicon-road"></span> <a href="%s">' \
'<b>{milestone}</b></a> milestone' \
% (action, same_milestone(milestone))
elif self.code == Event.CHANGE_MILESTONE:
old_ms = Milestone(name=args['old_milestone'],
project=self.issue.project)
new_ms = Milestone(name=args['new_milestone'],
project=self.issue.project)
description = 'moved this from the <span class="glyphicon ' \
'glyphicon-road"></span> <a href="%s">' \
'<b>{old_milestone}</b></a> milestone ' \
'to the <span class="glyphicon ' \
'glyphicon-road"></span> <a href="%s">' \
'<b>{new_milestone}</b></a> milestone' \
% (same_milestone(old_ms), same_milestone(new_ms))
elif self.code == Event.REFERENCE:
description = "referenced this issue"
else:
return None
# Escape args
safe_args = {k: escape(v) for k, v in args.items()}
return mark_safe(description.format(**safe_args))
if VERSION < (1, 7):
import tracker.signals

111
tracker/notifications.py Normal file
View file

@ -0,0 +1,111 @@
from __future__ import unicode_literals
from django.template.loader import render_to_string
from django.core.urlresolvers import reverse
from django.conf import settings
if 'djcelery' in settings.INSTALLED_APPS:
from tracker.tasks import send_mass_mail
else:
from django.core.mail import send_mass_mail
__all__ = [
'notify_new_issue', 'notify_new_comment',
'notify_close_issue', 'notify_reopen_issue'
]
def notify_new_issue(issue):
project = issue.project
dests = project.subscribers.all().distinct()
if hasattr(settings, 'FROM_ADDR'):
from_addr = settings.FROM_ADDR
else:
return
subject = "[%s] %s" % (project, issue.title)
data = []
for dest in dests:
if dest == issue.author:
continue
dest_addr = dest.email
if not dest_addr:
continue
c = {
'description': issue.description,
'uri': settings.BASE_URL + reverse('show-issue',
args=[project.name, issue.id]),
}
message = render_to_string('emails/new_issue.html', c)
data += [(subject, message,
"%s <%s>" % (issue.author.username, from_addr), [dest_addr])]
if 'djcelery' in settings.INSTALLED_APPS:
send_mass_mail.delay(tuple(data))
else:
send_mass_mail(tuple(data))
def notify_new_comment(event):
notify_event(event, 'new_comment')
def notify_close_issue(event):
notify_event(event, 'close_issue')
def notify_reopen_issue(event):
notify_event(event, 'reopen_issue')
def notify_event(event, template):
issue = event.issue
project = issue.project
dests = issue.subscribers.all()
dests |= project.subscribers.all()
dests = dests.distinct()
if hasattr(settings, 'FROM_ADDR'):
from_addr = settings.FROM_ADDR
else:
return
subject = "Re: [%s] %s" % (project, issue.title)
data = []
for dest in dests:
if dest == event.author:
continue
dest_addr = dest.email
if not dest_addr:
continue
c = {
'comment': event.additionnal_section,
'uri': settings.BASE_URL + reverse('show-issue',
args=[project.name, issue.id]),
}
message = render_to_string('emails/%s.html' % template, c)
data += [(subject, message,
'%s <%s>' % (event.author.username, from_addr), [dest_addr])]
if 'djcelery' in settings.INSTALLED_APPS:
send_mass_mail.delay(tuple(data))
else:
send_mass_mail(tuple(data))

14
tracker/signals.py Normal file
View file

@ -0,0 +1,14 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from tracker.models import Project, Label
@receiver(post_save, sender=Project, dispatch_uid="Default project labels.")
def create_default_project_labels(sender, instance, created, **kwargs):
if not created:
return
if not instance.labels.exists():
Label(project=instance, name='bug', color='#FF0000').save()
Label(project=instance, name='feature', color='#00A000').save()
Label(project=instance, name='documentation', color='#1D3DBE').save()

8
tracker/tasks.py Normal file
View file

@ -0,0 +1,8 @@
from django.core.mail import send_mass_mail as django_send_mass_mail
from celery import shared_task
@shared_task
def send_mass_mail(datatuple):
django_send_mass_mail(datatuple)

View file

View file

@ -0,0 +1,56 @@
from django import template
from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe
from django.utils.html import escape
register = template.Library()
@register.simple_tag
def label_style(label):
if label.inverted:
fg = '#fff'
else:
fg = '#000'
style = "background-color: {bg}; color: {fg}; vertical-align: middle;"
return style.format(bg=label.color, fg=fg)
@register.simple_tag
def labeled(label):
html = '<span class="label" style="{style}">{name}</span>'
return mark_safe(html.format(style=label_style(label),
name=escape(label.name)))
@register.simple_tag
def same_label(label):
url = reverse('list-issue', kwargs={'project': label.project.name})
url += '?q=is:open%20label:' + label.quotted_name
return mark_safe(url)
@register.simple_tag
def same_milestone(milestone):
url = reverse('list-issue', kwargs={'project': milestone.project.name})
url += '?q=is:open%20milestone:' + milestone.name
return mark_safe(url)
@register.simple_tag(takes_context=True)
def same_author(context, author):
url = reverse('list-issue', kwargs={'project': context['project'].name})
url += '?q=is:open%20author:' + author.username
return mark_safe(url)

3
tracker/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

45
tracker/urls.py Normal file
View file

@ -0,0 +1,45 @@
from django.conf.urls import url, include
urlpatterns = [
# Administration: redirect on first available admin page
url(r'^admin/$', 'tracker.views.admin', name='admin'),
# Settings
url(r'^admin/settings/$', 'tracker.views.settings', name='settings'),
# Projects
url(r'^$', 'tracker.views.project_list', name='list-project'),
url(r'^add/$', 'tracker.views.project_add', name='add-project'),
url(r'^(?P<project>[-\w]+)/edit/$', 'tracker.views.project_edit', name='edit-project'),
url(r'^(?P<project>[-\w]+)/delete/$', 'tracker.views.project_delete', name='delete-project'),
url(r'^(?P<project>[-\w]+)/subscribe/$', 'tracker.views.project_subscribe', name='subscribe-project'),
url(r'^(?P<project>[-\w]+)/unsubscribe/$', 'tracker.views.project_unsubscribe', name='unsubscribe-project'),
# Issues
url(r'^(?P<project>[-\w]+)/issues/$', 'tracker.views.issue_list', name='list-issue'),
url(r'^(?P<project>[-\w]+)/issues/add/$', 'tracker.views.issue_edit', name='add-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/$', 'tracker.views.issue_details', name='show-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/edit/$', 'tracker.views.issue_edit', name='edit-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/delete/$', 'tracker.views.issue_delete', name='delete-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/close/$', 'tracker.views.issue_close', name='close-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/reopen/$', 'tracker.views.issue_reopen', name='reopen-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/comment/$', 'tracker.views.issue_comment_edit', name='comment-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/comments/(?P<comment>[0-9]+)/edit/$', 'tracker.views.issue_comment_edit', name='edit-comment'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/comments/(?P<comment>[0-9]+)/delete/$', 'tracker.views.issue_comment_delete', name='delete-comment'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/subscribe/$', 'tracker.views.issue_subscribe', name='subscribe-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/unsubscribe/$', 'tracker.views.issue_unsubscribe', name='unsubscribe-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/add-label/(?P<label>[0-9]+)/$', 'tracker.views.issue_add_label', name='add-label-to-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/remove-label/(?P<label>[0-9]+)/$', 'tracker.views.issue_remove_label', name='remove-label-from-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/add-milestone/(?P<milestone>[a-z0-9_.-]+)/$', 'tracker.views.issue_add_milestone', name='add-milestone-to-issue'),
url(r'^(?P<project>[-\w]+)/issues/(?P<issue>[0-9]+)/remove-milestone/(?P<milestone>[a-z0-9_.-]+)/$', 'tracker.views.issue_remove_milestone', name='remove-milestone-from-issue'),
# Labels
url(r'^(?P<project>[-\w]+)/labels/$', 'tracker.views.label_list', name='list-label'),
url(r'^(?P<project>[-\w]+)/labels/add/$', 'tracker.views.label_edit', name='add-label'),
url(r'^(?P<project>[-\w]+)/labels/(?P<id>[0-9]+)/edit/$', 'tracker.views.label_edit', name='edit-label'),
url(r'^(?P<project>[-\w]+)/labels/(?P<id>[0-9]+)/delete/$', 'tracker.views.label_delete', name='delete-label'),
# Milestones
url(r'^(?P<project>[-\w]+)/milestones/$', 'tracker.views.milestone_list', name='list-milestone'),
url(r'^(?P<project>[-\w]+)/milestones/add/$', 'tracker.views.milestone_edit', name='add-milestone'),
url(r'^(?P<project>[-\w]+)/milestones/(?P<name>[a-z0-9_.-]+)/edit/$', 'tracker.views.milestone_edit', name='edit-milestone'),
url(r'^(?P<project>[-\w]+)/milestones/(?P<name>[a-z0-9_.-]+)/close/$', 'tracker.views.milestone_close', name='close-milestone'),
url(r'^(?P<project>[-\w]+)/milestones/(?P<name>[a-z0-9_.-]+)/reopen/$', 'tracker.views.milestone_reopen', name='reopen-milestone'),
url(r'^(?P<project>[-\w]+)/milestones/(?P<name>[a-z0-9_.-]+)/delete/$', 'tracker.views.milestone_delete', name='delete-milestone'),
]

760
tracker/views.py Normal file
View file

@ -0,0 +1,760 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from tracker.forms import *
from tracker.models import *
from tracker.notifications import *
from accounts.models import User
from permissions.models import ProjectPermission
from permissions.decorators import project_perm_required
import shlex
#########
# Admin #
#########
@login_required
def admin(request):
if request.user.has_perm('manage_settings'):
return redirect('settings')
elif request.user.has_perm('manage_user'):
return redirect('list-user')
elif request.user.has_perm('manage_group'):
return redirect('list-group')
elif request.user.has_perm('manage_team'):
return redirect('list-team')
elif request.user.has_perm('manage_global_permission'):
return redirect('list-global-permission')
else:
raise PermissionDenied()
############
# Settings #
############
@project_perm_required('manage_settings')
def settings(request):
return render(request, 'tracker/settings.html')
############
# Projects #
############
def project_list(request):
if not request.projects.exists():
if request.user.has_perm('create_project'):
messages.info(request, 'Start by creating a project.')
return redirect('add-project')
return render(request, 'tracker/project_list.html')
@project_perm_required('create_project')
def project_add(request):
form = ProjectForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
name = form.cleaned_data['name']
if Project.objects.filter(name__iexact=name).exists():
form._errors['name'] = ['There is already a project '
'with a similar name.']
else:
project = form.save()
messages.success(request, 'Project added successfully.')
project.subscribers.add(request.user)
ProjectPermission.objects.create(project=project,
manage_project_permission=True,
grantee=request.user)
return redirect('list-project-permission', project.name)
c = {
'form': form,
}
return render(request, 'tracker/project_add.html', c)
@project_perm_required('modify_project')
def project_edit(request, project):
form = ProjectForm(request.POST or None, instance=project)
if request.method == 'POST' and form.is_valid():
name = form.cleaned_data['display_name']
if Project.objects.filter(display_name__iexact=name) \
.exclude(pk=project.pk).exists():
form._errors['display_name'] = ['There is already a project '
'with a similar name.']
else:
project = form.save()
messages.success(request, 'Project modified successfully.')
return redirect('list-issue', project.name)
c = {
'project': project,
'form': form,
}
return render(request, 'tracker/project_edit.html', c)
@require_http_methods(["POST"])
@project_perm_required('delete_project')
def project_delete(request, project):
project.delete()
messages.success(request, 'Project deleted successfully.')
return redirect('list-project')
@login_required
def project_subscribe(request, project):
if project.subscribers.filter(pk=request.user.pk).exists():
messages.warning(request,
'You are already subscribed to this project.')
else:
project.subscribers.add(request.user)
project.save()
messages.success(request,
'You have been subscribed to this project successfully.')
next = request.GET.get('next')
if next:
return redirect(next)
else:
return redirect('list-issue', project.name)
@login_required
def project_unsubscribe(request, project):
if project.subscribers.filter(pk=request.user.pk).exists():
project.subscribers.remove(request.user)
project.save()
messages.success(request, 'You will not receive any notifications '
'for this project anymore.')
else:
messages.warning(request, 'You are not subscribed to this project.')
next = request.GET.get('next')
if next:
return redirect(next)
else:
return redirect('list-issue', project.name)
##########
# Issues #
##########
def issue_list(request, project):
issues = project.issues
labels = Label.objects.filter(project=project)
milestones = Milestone.objects.filter(project=project)
sort = request.GET.get('sort', '')
if sort:
sort = '&' + sort
is_open = ''
is_close = ''
is_all = ''
is_all_query = ''
query = request.GET.get('q', '')
if query == '':
query = 'is:open'
syntaxe_error = False
for constraint in shlex.split(query):
if constraint == '*':
continue
args = constraint.split(':')
if len(args) != 2:
messages.error(request, 'There is a syntaxe error in your filter.')
issues = None
break
key = args[0]
value = args[1]
if key == '':
continue
elif key == 'is':
if value == 'open':
issues = issues.filter(closed=False)
is_open = ' active'
elif value == 'close':
issues = issues.filter(closed=True)
is_close = ' active'
else:
messages.error(request, "The keyword 'is' must be followed "
"by 'open' or 'close'.")
issues = None
break
elif key == 'label':
try:
label = Label.objects.get(project=project,
name=value, deleted=False)
except ObjectDoesNotExist:
messages.error(request, "The label '%s' does not exist "
"or has been deleted." % value)
issues = None
break
else:
issues = issues.filter(labels=label)
labels = labels.exclude(pk=label.pk)
elif key == 'milestone':
try:
milestone = Milestone.objects.get(project=project, name=value)
except ObjectDoesNotExist:
messages.error(request, "The milestone '%s' does not exist."
% value)
issues = None
break
else:
issues = issues.filter(milestone=milestone)
milestones = milestones.exclude(pk=milestone.pk)
elif key == 'author' or key == 'user':
if User.objects.filter(username=value).exists():
issues = issues.filter(author__username=value)
else:
messages.error(request, "The user '%s' does not exist."
% value)
issues = None
break
else:
messages.error(request, "Unknow '%s' filtering criterion." % key)
issues = None
break
if key != 'is':
is_all_query += ' ' + constraint
if issues:
issues = issues.extra(order_by=['-opened_at'])
if is_open == '' and is_close == '':
is_all = ' active'
c = {
'project': project,
'issues': issues,
'query': query,
'sort': sort,
'is_open': is_open,
'is_close': is_close,
'is_all': is_all,
'is_all_query': is_all_query[1:],
'labels': labels,
'milestones': milestones,
}
return render(request, 'tracker/issue_list.html', c)
@login_required
def issue_edit(request, project, issue=None):
if issue:
if not request.user.has_perm('modify_issue', project):
raise PermissionDenied()
issue = get_object_or_404(Issue, project=project.name, id=issue)
init_data = {'title': issue.title,
'description': issue.description}
else:
if not request.user.has_perm('create_issue', project):
raise PermissionDenied()
issue = None
init_data = None
form = IssueForm(request.POST or init_data)
if request.method == 'POST' and form.is_valid():
title = form.cleaned_data['title']
description = form.cleaned_data['description']
if issue:
modified = False
if issue.title != title:
old_title = issue.title
issue.title = title
issue.save()
event = Event(issue=issue, author=request.user,
code=Event.RENAME,
args={'old_title': old_title, 'new_title': title})
event.save()
modified = True
if issue.description != description:
issue.description = description
modified = True
if modified:
messages.success(request, 'Issue updated successfully.')
else:
messages.info(request, 'Issue not modified.')
else:
issue = Issue(title=title, author=request.user,
project=project, id=Issue.next_id(project))
issue.save()
issue.subscribers.add(request.user)
issue.description = description
notify_new_issue(issue)
messages.success(request, 'Issue created successfully.')
return redirect('show-issue', project.name, issue.id)
c = {
'project': project,
'form': form,
'issue': issue,
}
return render(request, 'tracker/issue_edit.html', c)
def issue_details(request, project, issue):
issue = get_object_or_404(Issue, project=project, id=issue)
labels = Label.objects.filter(project=project, deleted=False) \
.exclude(id__in=issue.labels.all().values_list('id'))
milestones = Milestone.objects.filter(project=project)
if issue.milestone:
milestones = milestones.exclude(name=issue.milestone.name)
events = issue.events.all()
if request.user.has_perm('create_comment', project):
form = CommentForm(request.POST or None)
else:
form = None
c = {
'labels': labels,
'milestones': milestones,
'project': project,
'issue': issue,
'events': events,
'form': form,
}
return render(request, 'tracker/issue_details.html', c)
@login_required
def issue_comment_edit(request, project, issue, comment=None):
issue = get_object_or_404(Issue, project=project, id=issue)
if comment:
if not request.user.has_perm('modify_comment', project):
raise PermissionDenied()
event = get_object_or_404(Event, code=Event.COMMENT,
issue=issue, id=comment)
init_data = {'comment': event.additionnal_section}
else:
if not request.user.has_perm('create_comment', project):
raise PermissionDenied()
event = None
init_data = None
form = CommentForm(request.POST or init_data)
if request.method == 'POST' and form.is_valid():
comment = form.cleaned_data['comment']
if event:
if event.additionnal_section != comment:
event.additionnal_section = comment
event.save()
messages.success(request, 'Comment modified successfully.')
else:
messages.info(request, 'Comment not modified.')
else:
event = Event(issue=issue, author=request.user,
code=Event.COMMENT, additionnal_section=comment)
event.save()
issue.subscribers.add(request.user)
notify_new_comment(event)
messages.success(request, 'Comment added successfully.')
return redirect('show-issue', project.name, issue.id)
c = {
'project': project,
'issue': issue,
'comment': event,
'form': form,
}
return render(request, 'tracker/comment_edit.html', c)
@require_http_methods(["POST"])
@project_perm_required('delete_comment')
def issue_comment_delete(request, project, issue, comment):
comment = get_object_or_404(Event,
issue__project=project, issue__id=issue, id=comment)
comment.delete()
messages.success(request, 'Comment deleted successfully.')
return redirect('show-issue', project.name, issue)
@project_perm_required('manage_issue')
def issue_close(request, project, issue):
issue = get_object_or_404(Issue, project=project, id=issue, closed=False)
issue.closed = True
issue.save()
event = Event(issue=issue, author=request.user, code=Event.CLOSE)
event.save()
notify_close_issue(event)
return redirect('list-issue', project.name)
@project_perm_required('manage_issue')
def issue_reopen(request, project, issue):
issue = get_object_or_404(Issue, project=project, id=issue, closed=True)
issue.closed = False
issue.save()
event = Event(issue=issue, author=request.user, code=Event.REOPEN)
event.save()
notify_reopen_issue(event)
return redirect('show-issue', project.name, issue.id)
@require_http_methods(["POST"])
@project_perm_required('delete_issue')
def issue_delete(request, project, issue):
issue = get_object_or_404(Issue, project=project, id=issue)
issue.delete()
messages.success(request, 'Issue deleted successfully.')
return redirect('list-issue', project.name)
@project_perm_required('manage_tags')
def issue_add_label(request, project, issue, label):
issue = get_object_or_404(Issue, project=project, id=issue)
label = get_object_or_404(Label, project=project, id=label)
issue.add_label(request.user, label)
return redirect('show-issue', project.name, issue.id)
@project_perm_required('manage_tags')
def issue_remove_label(request, project, issue, label):
issue = get_object_or_404(Issue, project=project, id=issue)
label = get_object_or_404(Label, project=project, id=label)
issue.remove_label(request.user, label)
return redirect('show-issue', project.name, issue.id)
@project_perm_required('manage_tags')
def issue_add_milestone(request, project, issue, milestone):
issue = get_object_or_404(Issue, project=project, id=issue)
milestone = get_object_or_404(Milestone, project=project, name=milestone)
issue.add_milestone(request.user, milestone)
return redirect('show-issue', project.name, issue.id)
@project_perm_required('manage_tags')
def issue_remove_milestone(request, project, issue, milestone):
issue = get_object_or_404(Issue, project=project, id=issue)
milestone = get_object_or_404(Milestone, project=project, name=milestone)
issue.remove_milestone(request.user, milestone)
return redirect('show-issue', project.name, issue.id)
@login_required
def issue_subscribe(request, project, issue):
issue = get_object_or_404(Issue, project=project, id=issue)
if issue.subscribers.filter(pk=user.pk).exists():
messages.warning(request, 'You are already subscribed to this issue.')
else:
issue.subscribers.add(request.user)
issue.save()
messages.success(request,
'You have been subscribed to this issue successfully.')
return redirect('show-issue', project.name, issue.id)
@login_required
def issue_unsubscribe(request, project, issue):
issue = get_object_or_404(Issue, project=project, id=issue)
if issue.subscribers.filter(pk=user.pk).exists():
issue.subscribers.remove(request.user)
issue.save()
messages.success(request, 'You will not receive any notifications '
'for this issue anymore.')
else:
messages.warning(request, 'You are not subscribed to this issue.')
return redirect('show-issue', project.name, issue.id)
##########
# Labels #
##########
def label_list(request, project):
labels = project.labels.filter(deleted=False)
return render(request, 'tracker/label_list.html', {
'project': project,
'labels': labels,
})
@project_perm_required('manage_tags')
def label_edit(request, project, id=None):
if id:
label = get_object_or_404(Label, project=project, id=id)
else:
label = None
form = LabelForm(request.POST or None, instance=label)
if request.method == 'POST' and form.is_valid():
similar = Label.objects.filter(project=project,
name=form.cleaned_data['name'], deleted=False)
if label:
similar = similar.exclude(pk=label.pk)
if similar.exists():
form._errors['name'] = ['There is already a label with this name.']
else:
if label:
form.save()
messages.success(request, 'Label modified successfully.')
else:
label = form.save(commit=False)
label.project = project
label.save()
messages.success(request, 'Label added successfully.')
issue = request.GET.get('issue')
if issue:
return redirect('add-label-to-issue', project.name,
issue, label.id)
return redirect('list-label', project.name)
c = {
'project': project,
'form': form,
'label': label,
}
return render(request, 'tracker/label_edit.html', c)
@require_http_methods(["POST"])
@project_perm_required('delete_tags')
def label_delete(request, project, id):
label = get_object_or_404(Label, project=project, id=id)
for issue in label.issues.all():
issue.remove_label(request.user, label)
label.deleted = True
label.save()
messages.success(request, "Label deleted successfully.")
return redirect('list-label', project.name)
##########
# Labels #
##########
def milestone_list(request, project):
show = request.GET.get('show', 'open')
if show == 'open':
milestones = project.milestones.filter(closed=False)
elif show == 'close':
milestones = project.milestones.filter(closed=True)
elif show == 'all':
milestones = project.milestones.all()
else:
messages.error(request, 'There is an error in your filter.')
milestones = None
return render(request, 'tracker/milestone_list.html', {
'project': project,
'milestones': milestones,
'show': show,
})
@project_perm_required('manage_tags')
def milestone_edit(request, project, name=None):
if name:
milestone = get_object_or_404(Milestone, project=project, name=name)
else:
milestone = None
form = MilestoneForm(request.POST or None, instance=milestone)
if request.method == 'POST' and form.is_valid():
similar = Milestone.objects.filter(project=project,
name=form.cleaned_data['name'])
if milestone:
similar = similar.exclude(pk=milestone.pk)
if similar.exists():
form._errors['name'] = ['There is already a milestone '
'with this name.']
else:
if milestone:
if name != form.cleaned_data['name']:
for issue in milestone.issues.all():
event = Event(issue=issue, author=request.user,
code=Event.CHANGE_MILESTONE, args={
'old_milestone': name,
'new_milestone': form.cleaned_data['name']
})
event.save()
form.save()
messages.success(request, 'Milestone modified successfully.')
else:
milestone = form.save(commit=False)
milestone.project = project
milestone.save()
messages.success(request, 'Milestone added successfully.')
issue = request.GET.get('issue')
if issue:
return redirect('add-milestone-to-issue', project.name, issue,
milestone.name)
return redirect('list-milestone', project.name)
c = {
'project': project,
'form': form,
'milestone': milestone,
}
return render(request, 'tracker/milestone_edit.html', c)
@project_perm_required('manage_tags')
def milestone_close(request, project, name):
milestone = get_object_or_404(Milestone, project=project, name=name)
milestone.closed = True
milestone.save()
return redirect('list-milestone', project.name)
@project_perm_required('manage_tags')
def milestone_reopen(request, project, name):
milestone = get_object_or_404(Milestone, project=project, name=name)
milestone.closed = False
milestone.save()
return redirect('list-milestone', project.name)
@require_http_methods(["POST"])
@project_perm_required('delete_tags')
def milestone_delete(request, project, name):
milestone = get_object_or_404(Milestone, project=project, name=name)
for issue in milestone.issues.all():
issue.remove_milestone(request.user, milestone)
milestone.delete()
messages.success(request, "Label deleted successfully.")
return redirect('list-milestone', project.name)