A lot of new features and improvements

This commit is contained in:
Élie Bouttier 2014-08-02 19:17:18 -07:00
parent 8aa77915ad
commit 7f91c790ec
15 changed files with 587 additions and 229 deletions

View file

@ -2,10 +2,9 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
from django.conf import settings
import re
import colorful.fields import colorful.fields
import django.core.validators import django.core.validators
from django.conf import settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,11 +17,11 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Event', name='Event',
fields=[ fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), ('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('date', models.DateTimeField(auto_now=True)), ('date', models.DateTimeField(auto_now_add=True)),
('code', models.IntegerField(default=0)), ('code', models.IntegerField(default=0)),
('_args', models.CharField(default='{}', blank=True, max_length=1024)), ('_args', models.CharField(blank=True, default='{}', max_length=1024)),
('additionnal_section', models.TextField(default='', blank=True)), ('additionnal_section', models.TextField(blank=True, default='')),
('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)), ('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
@ -32,12 +31,12 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Issue', name='Issue',
fields=[ fields=[
('global_id', models.AutoField(serialize=False, primary_key=True)), ('global_id', models.AutoField(primary_key=True, serialize=False)),
('id', models.IntegerField(editable=False)), ('id', models.IntegerField(editable=False)),
('title', models.CharField(max_length=128)), ('title', models.CharField(max_length=128)),
('opened_at', models.DateTimeField(auto_now=True)), ('opened_at', models.DateTimeField(auto_now_add=True)),
('closed', models.BooleanField(default=False)), ('closed', models.BooleanField(default=False)),
('assignee', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), ('assignee', models.ForeignKey(blank=True, null=True, to=settings.AUTH_USER_MODEL)),
('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)), ('author', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
@ -53,10 +52,11 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Label', name='Label',
fields=[ fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), ('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('name', models.CharField(max_length=32)), ('name', models.CharField(max_length=32)),
('color', colorful.fields.RGBColorField(default='#FFFFFF')), ('deleted', models.BooleanField(default=False)),
('inverted', models.BooleanField(default=True)), ('color', colorful.fields.RGBColorField(verbose_name='Background color', default='#000000')),
('inverted', models.BooleanField(default=True, verbose_name='Inverse text color')),
], ],
options={ options={
}, },
@ -65,13 +65,13 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='issue', model_name='issue',
name='labels', name='labels',
field=models.ManyToManyField(blank=True, null=True, to='issue.Label'), field=models.ManyToManyField(blank=True, to='issue.Label', null=True),
preserve_default=True, preserve_default=True,
), ),
migrations.CreateModel( migrations.CreateModel(
name='Milestone', name='Milestone',
fields=[ fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), ('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('name', models.CharField(max_length=32)), ('name', models.CharField(max_length=32)),
('progression', models.SmallIntegerField(default=0)), ('progression', models.SmallIntegerField(default=0)),
('due_date', models.DateTimeField(null=True)), ('due_date', models.DateTimeField(null=True)),
@ -83,9 +83,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Project', name='Project',
fields=[ fields=[
('name', models.CharField(serialize=False, validators=[django.core.validators.RegexValidator(re.compile('^[-a-zA-Z0-9_]+$', 32), "Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens.", 'invalid')], primary_key=True, max_length=32, verbose_name='Short name (used in URL)')), ('name', models.CharField(primary_key=True, verbose_name='Short name (used in URL, definitive)', validators=[django.core.validators.RegexValidator(message='Please enter only lowercase characters, number, underscores or hyphens.', regex='^[a-z0-9_-]+$')], serialize=False, max_length=32)),
('display_name', models.CharField(max_length=32, verbose_name='Project name')), ('display_name', models.CharField(verbose_name='Project name', unique=True, max_length=32)),
('description', models.TextField(default='', blank=True, verbose_name='Description')), ('description', models.TextField(blank=True, default='', verbose_name='Description')),
], ],
options={ options={
}, },
@ -107,10 +107,6 @@ class Migration(migrations.Migration):
field=models.ForeignKey(to='issue.Project'), field=models.ForeignKey(to='issue.Project'),
preserve_default=True, preserve_default=True,
), ),
migrations.AlterUniqueTogether(
name='label',
unique_together=set([('project', 'name')]),
),
migrations.AddField( migrations.AddField(
model_name='issue', model_name='issue',
name='project', name='project',

View file

@ -1,6 +1,10 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.validators import validate_slug from django.core.validators import RegexValidator
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
from django.utils.html import escape
from django.core.urlresolvers import reverse
import json import json
@ -9,17 +13,28 @@ from colorful.fields import RGBColorField
class Project(models.Model): class Project(models.Model):
name = models.CharField(primary_key=True, blank=False, max_length=32, url_name_validator = RegexValidator(regex='^[a-z0-9_-]+$',
verbose_name="Short name (used in URL)", message="Please enter only lowercase characters, number, "
validators=[validate_slug]) "underscores or hyphens.")
display_name = models.CharField(max_length=32, name = models.CharField(primary_key=True, blank=False, max_length=32,
verbose_name="Short name (used in URL, definitive)",
validators=[url_name_validator])
display_name = models.CharField(max_length=32, unique=True,
verbose_name="Project name") verbose_name="Project name")
description = models.TextField(blank=True, default="", description = models.TextField(blank=True, default="",
verbose_name="Description") verbose_name="Description")
def create_default_labels(self):
Label(project=self, name='bug', color='#FF0000').save()
Label(project=self, name='feature', color='#00A000').save()
Label(project=self, name='documentation', color='#1D3DBE').save()
def __str__(self): def __str__(self):
return self.display_name return self.display_name
class Label(models.Model): class Label(models.Model):
@ -28,12 +43,11 @@ class Label(models.Model):
name = models.CharField(max_length=32) name = models.CharField(max_length=32)
class Meta: deleted = models.BooleanField(default=False)
unique_together = [ 'project', 'name' ]
color = RGBColorField(default='#FFFFFF') color = RGBColorField(default='#000000', verbose_name="Background color")
inverted = models.BooleanField(default=True) inverted = models.BooleanField(default=True, verbose_name="Inverse text color")
def style(self): def style(self):
@ -79,11 +93,11 @@ class Issue(models.Model):
author = models.ForeignKey(User, related_name='+') author = models.ForeignKey(User, related_name='+')
opened_at = models.DateTimeField(auto_now=True) opened_at = models.DateTimeField(auto_now_add=True)
closed = models.BooleanField(default=False) closed = models.BooleanField(default=False)
labels = models.ManyToManyField(Label, blank=True, null=True) labels = models.ManyToManyField(Label, blank=True, null=True, related_name='issues')
assignee = models.ForeignKey(User, blank=True, null=True, related_name='+') assignee = models.ForeignKey(User, blank=True, null=True, related_name='+')
@ -98,9 +112,47 @@ class Issue(models.Model):
def comments(self): def comments(self):
comments = self.events.filter(code=Event.COMMENT) comments = self.events.filter(issue=self,code=Event.COMMENT)
return comments[1:] return comments
def getdesc(self):
desc = self.events.filter(issue=self,code=Event.DESCRIBE)
if desc.count():
return desc.first().additionnal_section
else:
return None
def setdesc(self, value):
desc = self.events.filter(issue=self,code=Event.DESCRIBE)
if desc.count():
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(issue=self,code=Event.DESCRIBE)
if desc.count():
desc.first().delete()
description = property(getdesc, setdesc, deldesc)
def add_label(self, author, label, commit=True):
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 __str__(self): def __str__(self):
return self.title return self.title
@ -118,10 +170,11 @@ class Event(models.Model):
DEL_MILESTONE = 8 DEL_MILESTONE = 8
REFERENCE = 9 REFERENCE = 9
COMMENT = 10 COMMENT = 10
DESCRIBE = 11
issue = models.ForeignKey(Issue, related_name="%(class)ss") issue = models.ForeignKey(Issue, related_name="%(class)ss")
date = models.DateTimeField(auto_now=True) date = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(User) author = models.ForeignKey(User)
@ -139,34 +192,73 @@ class Event(models.Model):
additionnal_section = models.TextField(blank=True, default="") additionnal_section = models.TextField(blank=True, default="")
def editable(self):
return self.code == Event.COMMENT or self.code == Event.DESCRIBE
def boxed(self): def boxed(self):
return self.code == Event.COMMENT
return self.code == Event.COMMENT or self.code == Event.DESCRIBE
def glyphicon(self):
if self.code == Event.COMMENT or self.code == Event.DESCRIBE:
return "pencil"
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:
return "tag"
elif self.code == Event.DEL_LABEL:
return "tag"
elif self.code == Event.SET_MILESTONE:
return "road"
elif self.code == Event.CHANGE_MILESTONE:
return "road"
elif self.code == Event.DEL_MILESTONE:
return "road"
elif self.code == Event.REFERENCE:
return "transfer"
else:
return "cog"
def __str__(self): def __str__(self):
args = self.args args = self.args
if self.code == Event.COMMENT: if self.code == Event.COMMENT or self.code == Event.DESCRIBE:
description = "{author} commented" description = "commented"
elif self.code == Event.CLOSE: elif self.code == Event.CLOSE:
description = "{author} closed this issue" description = "closed this issue"
elif self.code == Event.REOPEN: elif self.code == Event.REOPEN:
description = "{author} reopened this issue" description = "reopened this issue"
elif self.code == Event.RENAME: elif self.code == Event.RENAME:
description = "{author} changed the title from {old_title} to {new_title}" description = "changed the title from <mark>{old_title}</mark> to <mark>{new_title}</mark>"
elif self.code == Event.ADD_LABEL: elif self.code == Event.ADD_LABEL or self.code == Event.DEL_LABEL:
description = "{author} added the {label} label" label = Label.objects.get(id=args['label'])
elif self.code == Event.DEL_LABEL: description = '{action} the <a href="{url}?q=is:open%20label:{label}"><span class="label" style="{style}">{label}</span></a> label'
description = "{author} deleted the {label} label" args['label'] = label.name
args['url'] = reverse('list-issue', kwargs={'project': self.issue.project.name})
args['style'] = label.style()
if self.code == Event.ADD_LABEL:
args['action'] = 'added'
else:
args['action'] = 'removed'
elif self.code == Event.SET_MILESTONE: elif self.code == Event.SET_MILESTONE:
description = "{author} added this to the {milestone} milestone" description = "added this to the {milestone} milestone"
elif self.code == Event.CHANGE_MILESTONE: elif self.code == Event.CHANGE_MILESTONE:
description = "{author} moved this from the {old_milestone} milestone to the {new_mileston} milestone" description = "moved this from the {old_milestone} milestone to the {new_mileston} milestone"
elif self.code == Event.DEL_MILESTONE: elif self.code == Event.DEL_MILESTONE:
description = "{author} deleted this from the {milestone} milestone" description = "deleted this from the {milestone} milestone"
elif self.code == Event.REFERENCE: elif self.code == Event.REFERENCE:
description = "{author} referenced this issue" description = "referenced this issue"
else: else:
return None return None
return description.format(author=self.author, **args) # Escape args
safe_args = {k: escape(v) for k, v in args.items()}
return mark_safe(description.format(**safe_args))

View file

@ -6,3 +6,7 @@ body {
.navbar { .navbar {
margin-bottom: 20px; margin-bottom: 20px;
} }
a .remove-label {
color: #CCC;
}

View file

@ -15,12 +15,16 @@
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script> <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]--> <![endif]-->
<title>Issue Tracker</title> <title>{% block title %}PonyTracker{% endblock %}</title>
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet"> <link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'css/issue.css' %}" rel="stylesheet"> <link href="{% static 'css/issue.css' %}" rel="stylesheet">
{% block css %}{% endblock %} {% block css %}{% endblock %}
<script src="{% static 'js/jquery.min.js' %}"></script>
{% block js %}{% endblock %} {% block js %}{% endblock %}
{% block media %}{% endblock %}
</head> </head>
<body> <body>
@ -37,7 +41,7 @@
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
</button> </button>
<a class="navbar-brand" href="{% url 'list-project' %}">{% block title %}Issue Tracker{% endblock %}</a> <a class="navbar-brand" href="{% url 'list-project' %}">{% block page_title %}PonyTracker{% endblock %}</a>
</div> </div>
<div class="navbar-collapse collapse"> <div class="navbar-collapse collapse">
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
@ -74,7 +78,7 @@
<div class="row"> <div class="row">
<div class="col-md-offset-2 col-md-8"> <div class="col-md-offset-1 col-md-10">
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
@ -91,9 +95,8 @@
</div> </div>
</div> <!-- /container --> </div> <!-- /container -->
<script src="{% static 'js/jquery.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script> <script src="{% static 'js/bootstrap.min.js' %}"></script>
{% block js_end %}{% endblock %} {% block js_end %}{% endblock %}
</body> </body>

View file

@ -1,60 +1,149 @@
{% extends 'issue/project.html' %} {% extends 'issue/project.html' %}
{% load humanize %}
{% load django_markdown %} {% load django_markdown %}
{% block issuetab %} class="active"{% endblock %} {% block issuetab %} class="active"{% endblock %}
{% block content %} {% block content %}
<div class="panel panel-default">
<div class="panel-heading"> <div class="well">
<h1>{{ issue }} <small>#{{ issue.id }}</small></h1> <h1>{{ issue }} <small>#{{ issue.id }}</small></h1>
{% if issue.closed %} {% if issue.closed %}
<span class="label label-danger">Closed</span> <span class="label label-danger" style="font-size: 120%;">Closed</span>
{% else %} {% else %}
<span class="label label-success">Open</span> <span class="label label-success" style="font-size: 120%;">Open</span>
{% endif %} {% endif %}
&#160;
{{ issue.author.username }} opened this issue on {{ issue.opened_at }} <a href="{% url 'list-issue' project.name %}?q=is:open%20author:{{ issue.author.username }}"><strong>{{ issue.author.username }}</strong></a> opened this issue {{ issue.opened_at|naturaltime }}
- {{ issue.comments.count }} comments - {{ issue.comments.count }} comments
</div>
<div class="row">
<div class="col-md-10">
{% for event in events %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="badge"><span class="glyphicon glyphicon-{{ event.glyphicon }}"></span></span>
&#160;
<a href="{% url 'list-issue' project.name %}?q=is:open%20author:{{ event.author.username }}"><strong>{{ event.author}}</strong></a> {{ event }} {{ event.date|naturaltime }}
{% if event.code == event.DESCRIBE %}
<div class="pull-right">
<a href="{% url 'edit-issue' project.name issue.id %}">
<button class="btn btn-primary btn-xs">Edit</button>
</a>
</div>
{% elif event.code == event.COMMENT %}
<div class="pull-right">
<a href="{% url 'edit-comment' project.name issue.id event.id %}">
<button class="btn btn-primary btn-xs">Edit</button>
</a>
</div>
{% endif %}
</div>
{% if event.additionnal_section %}
<div class="panel-body">
{{ event.additionnal_section|markdown }}
</div>
{% elif event.code == event.DESCRIBE %}
<div class="panel-body">
<em>No description provided.</em>
</div>
{% endif %}
</div>
{% endfor %}
<div class="row">
<div class="col-md-3">
<a href="{% url 'comment-issue' project.name issue.id %}"><button class="btn btn-default btn-block">Add a comment</button></a>
</div>
<div class="col-md-3">
<a href="{% url 'edit-issue' project.name issue.id %}"><button class="btn btn-default btn-block">Edit this issue</button></a>
</div>
<div class="col-md-3">
{% if issue.closed %}
<a href="{% url 'reopen-issue' project.name issue.id %}"><button class="btn btn-default btn-block">Reopen this issue</button></a>
{% else %}
<a href="{% url 'close-issue' project.name issue.id %}"><button class="btn btn-default btn-block">Close this issue</button></a>
{% endif %}
</div>
<div class="col-md-3">
<a href="{% url 'delete-issue' project.name issue.id %}"><button class="btn btn-default btn-block">Delete this issue</button></a>
</div>
</div>
</div> </div>
<table class="table"> <div class="col-md-2">
{% for event in events %} <b>Labels</b>
<tr> <div class="pull-right">
<td> <div class="dropdown">
{{ event }} on {{ event.date }} <button class="btn btn-default btn-xs" type="button" id="labels-menu" data-toggle="dropdown"><span class="glyphicon glyphicon-plus"></span></button>
{% if event.additionnal_section %} <ul class="dropdown-menu dropdown-menu-right" role="menu" aria-labelledby="labels-menu">
{% if event.boxed %} {% if labels.count %}
<br /> {% for label in labels %}
<br /> <li role="presentation">
<div class="well"> <a href="{% url 'add-label-to-issue' project.name issue.id label.id %}">
{{ event.additionnal_section|markdown }} <span class="label" style="{{ label.style }}">{{ label }}</span>
</div> </a>
{% else %} </li>
<hr> {% endfor %}
{{ event.additionnal_section|markdown }} <li role="presentation" class="divider"></li>
{% endif %} {% endif %}
{% endif %} <li role="presentation">
</td> <a href="{% url 'add-label' project.name %}?issue={{ issue.id }}">
</tr> <button class="btn btn-success btn-xs btn-block">New label...</button>
</a>
</li>
</ul>
</div>
</div>
<br /><br />
{% if issue.labels.count %}
{% for label in issue.labels.all %}
<div class="row">
<a href="{% url 'remove-label-from-issue' project.name issue.id label.id %}">
<span class="glyphicon glyphicon-remove remove-label"></span>
</a>
<a href="{% url 'list-issue' project.name %}?q=is:open%20label:{{ label.name }}">
<span class="label" style="{{ label.style }}">{{ label }}</span>
</a>
</div>
{% endfor %} {% endfor %}
</table> {% else %}
None yet
{% endif %}
<hr>
<h5>
<b>Milestons</b>
<div class="pull-right">
<a href="#">
<button class="btn btn-default btn-xs"><span class="glyphicon glyphicon-plus"></span></button>
</a>
</div>
</h5>
{% if issue.milestones.count %}
{% for milestone in issue.milestones.all %}
{{ milestone }}
{% endfor %}
{% else %}
No milestone
{% endif %}
</div>
</div> </div>
<a href="{% url 'comment-issue' project.name issue.id %}"><button class="btn btn-default">Comment this issue</button></a>
<a href="{% url 'edit-issue' project.name issue.id %}"><button class="btn btn-default">Edit this issue</button></a>
{% if issue.closed %}
<a href="{% url 'reopen-issue' project.name issue.id %}"><button class="btn btn-default">Reopen this issue</button></a>
{% else %}
<a href="{% url 'close-issue' project.name issue.id %}"><button class="btn btn-default">Close this issue</button></a>
{% endif %}
<a href="{% url 'delete-issue' project.name issue.id %}"><button class="btn btn-default">Delete this issue</button></a>
{% endblock %} {% endblock %}

View file

@ -2,6 +2,8 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block media %}{{ form.media }}{% endblock %}
{% block issuetab %} class="active"{% endblock %} {% block issuetab %} class="active"{% endblock %}
{% block content %} {% block content %}

View file

@ -4,6 +4,10 @@
{% block issuetab %} class="active"{% endblock %} {% block issuetab %} class="active"{% endblock %}
{% block media %}
{{ form.media }}
{% endblock media %}
{% block content %} {% block content %}
<form action="" method="post" role="form"> <form action="" method="post" role="form">

View file

@ -1,5 +1,7 @@
{% extends 'issue/project.html' %} {% extends 'issue/project.html' %}
{% load humanize %}
{% block issuetab %} class="active"{% endblock %} {% block issuetab %} class="active"{% endblock %}
{% block content %} {% block content %}
@ -14,14 +16,12 @@
<div class="form-group"> <div class="form-group">
<div class="input-group"> <div class="input-group">
<div class="dropdown"> <div class="dropdown">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" id="filters-menu"> {% if is_all_query %}
Filters <span class="caret"></span> <a href="{% url 'list-issue' project.name %}"><button type="button" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-remove"> Reset filter</span></button></a>
</button> {% endif %}
<ul class="dropdown-menu" role="menu" aria-labelledby="filters-menu"> <a href="{% url 'list-issue' project.name %}?q=is:open{% if is_all_query %} {{ is_all_query }}{% endif %}"><button type="button" class="btn btn-default btn-sm{{ is_open }}">Open</button></a>
<li role="presentation"><a role="menu-item" href="{% url 'list-issue' project.name %}?q=open">Open</a></li> <a href="{% url 'list-issue' project.name %}?q=is:close{% if is_all_query %} {{ is_all_query }}{% endif %}"><button type="button" class="btn btn-default btn-sm{{ is_close }}">Close</button></a>
<li role="presentation"><a role="menu-item" href="{% url 'list-issue' project.name %}?q=close">Close</a></li> <a href="{% url 'list-issue' project.name %}?q={% if is_all_query %}{{ is_all_query }}{% else %}*{% endif %}"><button type="button" class="btn btn-default btn-sm{{ is_all }}">Both</button></a>
<li role="presentation"><a role="menu-item" href="{% url 'list-issue' project.name %}?q=all">All</a></li>
</ul>
</div> </div>
</div> </div>
</div> </div>
@ -29,17 +29,13 @@
<div class="input-group"> <div class="input-group">
<input class="form-control" name="q" type="text" placeholder="filter" value="{{ query }}"/> <input class="form-control" name="q" type="text" placeholder="filter" value="{{ query }}"/>
<div class="input-group-addon"> <div class="input-group-addon">
<a href="#" onclick="$('#filter-form').submit();"> <a href="#" onclick="$('#filter-form').submit();"><span class="glyphicon glyphicon-search"></span></a>
<span class="glyphicon glyphicon-search"></span>
</a>
</div> </div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-group"> <div class="input-group">
<a href="{% url 'add-issue' project.name %}"> <a href="{% url 'add-issue' project.name %}"><button class="btn btn-success" type="button">New issue</button></a>
<button class="btn btn-success" type="button">New issue</button>
</a>
</div> </div>
</div> </div>
</form> </form>
@ -52,16 +48,17 @@
{% for issue in issues %} {% for issue in issues %}
<tr> <tr>
<td> <td>
<span class="text-headline"> {% if issue.closed %}
<a href="{% url 'show-issue' project.name issue.id %}">{{ issue }}</a> <span class="text-danger"><span class="glyphicon glyphicon-ok-circle"></span></span>
</span> {% else %}
<span class="text-success"><span class="glyphicon glyphicon-hand-right"></span></span>
{% endif %}
<b><a href="{% url 'show-issue' project.name issue.id %}">{{ issue }}</a></b>
{% for label in issue.labels.all %} {% for label in issue.labels.all %}
<a href="{% url 'list-issue' project.name %}?q=open%20label:{{ label.name }}"> <a href="{% url 'list-issue' project.name %}?q=is:open%20label:{{ label.name }}"><span class="label" style="{{ label.style }}">{{ label }}</span></a>
<span class="label" style="{{ label.style }}">{{ label }}</span>
</a>
{% endfor %} {% endfor %}
<br /> <br />
<small>#{{ issue.id }} opened by {{ issue.author.username }} on {{ issue.opened_at }}</small> <small>#{{ issue.id }} opened by <a href="{% url 'list-issue' project.name %}?q=is:open%20author:{{ issue.author.username }}"><strong>{{ issue.author.username }}</strong></a> {{ issue.opened_at|naturaltime }}</small>
<span class="badge">{{ issue.comments.count }}</span> <span class="badge">{{ issue.comments.count }}</span>
</td> </td>
</tr> </tr>

View file

@ -6,10 +6,16 @@
{% block content %} {% block content %}
<form action="" method="post" role="form"> <div class="row">
{{ form|crispy }} <div class="col-md-offset-3 col-md-6">
{% csrf_token %}
<button type="submit" class="btn btn-primary">Submit</button> <form action="" method="post" role="form">
</form> {{ form|crispy }}
{% csrf_token %}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -20,16 +20,16 @@
{% for label in labels %} {% for label in labels %}
<tr> <tr>
<td> <td>
<a href="{% url 'list-issue' project.name %}?q=open%20label:{{ label.name }}"> <a href="{% url 'list-issue' project.name %}?q=is:open%20label:{{ label.name }}">
<button class="btn" style="{{ label.style }}"> <button class="btn" style="{{ label.style }}">
<span class="glyphicon glyphicon-tag" style="font-size: 100%;"></span> {{ label }} <span class="glyphicon glyphicon-tag" style="font-size: 100%;"></span> {{ label }}
</button> </button>
</a> </a>
<div class="pull-right"> <div class="pull-right">
<a href=""> <a href="{% url 'edit-label' project.name label.id %}">
<button class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> Edit</button> <button class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> Edit</button>
</a> </a>
<a href=""> <a href="{% url 'delete-label' project.name label.id %}">
<button class="btn btn-danger"><span class="glyphicon glyphicon-remove"></span> Delete</button> <button class="btn btn-danger"><span class="glyphicon glyphicon-remove"></span> Delete</button>
</a> </a>
</div> </div>

View file

@ -1,11 +1,11 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ project }}{% endblock %} {% block title %}{{ project }} - PonyTracker{% endblock %}
{% block page_title %}{{ project }}{% endblock %}
{% block projectmenu %} {% block projectmenu %}
{% if request.user.is_authenticated and request.user.is_admin %} <li role="presentation"><a role="menuitem" tabindex="-1" href="{% url 'edit-project' project.name %}">Modify this project</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="{% url 'delete-project' project.name %}">Delete this project</a></li> <li role="presentation"><a role="menuitem" tabindex="-1" href="{% url 'delete-project' project.name %}">Delete this project</a></li>
{% endif %}
{% endblock %} {% endblock %}
{% block navbar %} {% block navbar %}

View file

@ -1,22 +1,45 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load django_markdown %}
{% block content %} {% block content %}
<h1>Projects</h1>
<table class="table table-hover"> <div class="panel panel-default">
<tr>
<th>Name</th>
<th>Description</th>
</tr>
{% for project in projects %}
<tr>
<td><a href="{% url 'list-issue' project.name %}">{{ project }}</a></td>
<td>{{ project.description }}</td>
</tr>
{% endfor %}
</table>
<a href="{% url 'add-project' %}"><button class="btn btn-success">Add project</button></a> <div class="panel-heading">
<h1>
Projects
<div class="pull-right">
<a href="{% url 'add-project' %}"><button class="btn btn-success">Add project</button></a>
</div>
</h1>
</div>
<div class="panel-body">
<div class="row">
<div class="col-md-3">
<strong>Name</strong>
</div>
<div class="col-md-8">
<strong>Description</strong>
</div>
</div>
{% for project in projects %}
<hr>
<div class="row">
<div class="col-md-3">
<a href="{% url 'list-issue' project.name %}">{{ project }}</a>
</div>
<div class="col-md-9">
{% if project.description %}
{{ project.description|linebreaksbr }}
{% else %}
<em>No description provided.</em>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %} {% endblock %}

View file

@ -2,23 +2,27 @@ from django.conf.urls import url
urlpatterns = [ urlpatterns = [
url(r'^$', 'issue.views.project_list', name='list-project'), url(r'^$', 'issue.views.project_list', name='list-project'),
url(r'^add$', 'issue.views.project_edit', name='add-project'), url(r'^add$', 'issue.views.project_add', name='add-project'),
url(r'^(?P<project>[a-z0-9]+)/edit$', 'issue.views.project_edit', name='edit-project'), url(r'^(?P<project>[a-z0-9_-]+)/edit$', 'issue.views.project_edit', name='edit-project'),
url(r'^(?P<project>[a-z0-9]+)/delete$', 'issue.views.project_delete', name='delete-project'), url(r'^(?P<project>[a-z0-9_-]+)/delete$', 'issue.views.project_delete', name='delete-project'),
url(r'^(?P<project>[a-z0-9]+)/issues$', 'issue.views.issue_list', name='list-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues$', 'issue.views.issue_list', name='list-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/add$', 'issue.views.issue_edit', name='add-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/add$', 'issue.views.issue_edit', name='add-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/(?P<id>[0-9]+)$', 'issue.views.issue', name='show-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)$', 'issue.views.issue', name='show-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/(?P<id>[0-9]+)/edit$', 'issue.views.issue_edit', name='edit-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)/edit$', 'issue.views.issue_edit', name='edit-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/(?P<id>[0-9]+)/close$', 'issue.views.issue_close', name='close-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)/close$', 'issue.views.issue_close', name='close-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/(?P<id>[0-9]+)/reopen$', 'issue.views.issue_reopen', name='reopen-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)/reopen$', 'issue.views.issue_reopen', name='reopen-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/(?P<id>[0-9]+)/comment$', 'issue.views.issue_comment', name='comment-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)/comment$', 'issue.views.issue_comment', name='comment-issue'),
url(r'^(?P<project>[a-z0-9]+)/issues/(?P<id>[0-9]+)/delete$', 'issue.views.issue_delete', name='delete-issue'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)/comments/(?P<comment>[0-9]+)/edit$', 'issue.views.issue_comment', name='edit-comment'),
url(r'^(?P<project>[a-z0-9]+)/labels$', 'issue.views.label_list', name='list-label'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<id>[0-9]+)/delete$', 'issue.views.issue_delete', name='delete-issue'),
url(r'^(?P<project>[a-z0-9]+)/labels/add$', 'issue.views.label_edit', name='add-label'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<issue>[0-9]+)/add-label/(?P<label>[0-9]+)$', 'issue.views.issue_add_label', name='add-label-to-issue'),
url(r'^(?P<project>[a-z0-9]+)/labels/edit/(?P<id>[0-9]+)$', 'issue.views.label_edit', name='edit-label'), url(r'^(?P<project>[a-z0-9_-]+)/issues/(?P<issue>[0-9]+)/remove-label/(?P<label>[0-9]+)$', 'issue.views.issue_remove_label', name='remove-label-from-issue'),
url(r'^(?P<project>[a-z0-9]+)/milestones$', 'issue.views.milestone_list', name='list-milestone'), url(r'^(?P<project>[a-z0-9_-]+)/labels$', 'issue.views.label_list', name='list-label'),
url(r'^(?P<project>[a-z0-9]+)/milestones/add$', 'issue.views.milestone_edit', name='add-milestone'), url(r'^(?P<project>[a-z0-9_-]+)/labels/add$', 'issue.views.label_edit', name='add-label'),
url(r'^(?P<project>[a-z0-9]+)/milestones/edit/(?P<id>[0-9]+)$', 'issue.views.milestone_edit', name='edit-milestone'), url(r'^(?P<project>[a-z0-9_-]+)/labels/(?P<id>[0-9]+)/edit$', 'issue.views.label_edit', name='edit-label'),
url(r'^(?P<project>[a-z0-9_-]+)/labels/(?P<id>[0-9]+)/delete$', 'issue.views.label_delete', name='delete-label'),
url(r'^(?P<project>[a-z0-9_-]+)/milestones$', 'issue.views.milestone_list', name='list-milestone'),
url(r'^(?P<project>[a-z0-9_-]+)/milestones/add$', 'issue.views.milestone_edit', name='add-milestone'),
url(r'^(?P<project>[a-z0-9_-]+)/milestones/edit/(?P<id>[0-9]+)$', 'issue.views.milestone_edit', name='edit-milestone'),
url(r'^login$', 'django.contrib.auth.views.login', {'template_name': 'login.html'}, name='login'), url(r'^login$', 'django.contrib.auth.views.login', {'template_name': 'login.html'}, name='login'),
url(r'^logout$', 'django.contrib.auth.views.logout', {'next_page': '/'}, name='logout'), url(r'^logout$', 'django.contrib.auth.views.logout', {'next_page': '/'}, name='logout'),
] ]

View file

@ -7,8 +7,10 @@ from django.core.exceptions import ObjectDoesNotExist
from issue.models import * from issue.models import *
from django_markdown.widgets import MarkdownWidget from django_markdown.widgets import MarkdownWidget
from stronghold.decorators import public
@public
def project_list(request): def project_list(request):
projects = Project.objects.all() projects = Project.objects.all()
@ -26,32 +28,67 @@ def project_list(request):
return render(request, 'issue/project_list.html', c) return render(request, 'issue/project_list.html', c)
def project_edit(request, project=None): def project_add(request):
if project: ProjectForm = modelform_factory(Project, fields=['display_name', 'name', 'description'])
project = get_object_or_404(Project, name=project) form = ProjectForm(request.POST or None)
class ProjectForm(forms.ModelForm):
class Meta:
model=Project
fields=['display_name', 'name', 'description']
form = ProjectForm(request.POST or None, instance=project)
if request.method == 'POST' and form.is_valid(): if request.method == 'POST' and form.is_valid():
project = form.save() if Project.objects \
messages.success(request, 'Project added successfully.') .filter(display_name__iexact=form.cleaned_data['display_name']) \
.count():
return redirect('list-issue', project.name) form._errors['display_name'] = ['There is already a project with a similar name.']
else:
project = form.save()
project.create_default_labels()
messages.success(request, 'Project added successfully.')
return redirect('list-issue', project.name)
projects = Project.objects.all()
c = {
'request': request,
'projects': projects,
'form': form,
}
return render(request, 'issue/project_edit.html', c)
def project_edit(request, project):
project = get_object_or_404(Project, name=project)
ProjectForm = modelform_factory(Project, fields=['display_name', 'description'])
form = ProjectForm(request.POST or None, instance=project)
if request.method == 'POST' and form.is_valid():
if Project.objects \
.filter(display_name__iexact=form.cleaned_data['display_name']) \
.exclude(pk=project.pk).count():
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)
projects = Project.objects.all() projects = Project.objects.all()
c = { c = {
'request': request, 'request': request,
'projects': projects, 'projects': projects,
'project': project,
'form': form, 'form': form,
} }
@ -75,41 +112,81 @@ def issue_list(request, project):
issues = project.issues issues = project.issues
query = request.GET.get('q', 'open') is_open = ''
is_close = ''
is_all = ''
is_all_query = ""
constraints = query.split(' ') query = request.GET.get('q', '')
error = False if query == '':
for constraint in constraints: query = 'is:open'
if constraint == 'open': syntaxe_error = False
issues = issues.filter(closed=False) for constraint in query.split(' '):
elif constraint == 'close':
issues = issues.filter(closed=True) if constraint == '':
elif constraint == 'all': continue
pass
else: if constraint == '*':
args = constraint.split(':') continue
if len(args) != 2:
error = True args = constraint.split(':')
break
if len(args) == 2 and args[0] == 'label': if len(args) != 2:
try: messages.error(request, 'There is a syntaxe error in your filter.')
label = Label.objects.get(name=args[1]) issues = None
except ObjectDoesNotExist: break
messages.info(request, "The label '%s' does not exist"
", ignoring the constraint." %args[1]) key = args[0]
else: value = args[1]
issues = issues.filter(labels=label)
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: else:
error = True messages.error(request, "The keyword 'is' must be followed by 'open' or 'close'.")
issues = None
break break
if error: elif key == 'label':
messages.error(request, 'There is a syntaxe error in your filter.') try:
issues = project.issues.filter(closed=False) label = Label.objects.get(project=project,name=value,deleted=False)
except ObjectDoesNotExist:
messages.error(request, "The label '%s' does not exist." %value)
issues = None
break
else:
issues = issues.filter(labels=label)
elif key == 'author' or key == 'user':
try:
author = User.objects.get(username=value)
except ObjectDoesNotExist:
messages.error(request, "The user '%s' does not exist." %value)
issues = None
break
else:
issues = issues.filter(author=author)
else:
messages.error(request, "Unknow '%s' filtering criterion." %keyword)
issues = None
break
issues = issues.extra(order_by=['-opened_at']) 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 = { c = {
'request': request, 'request': request,
@ -117,6 +194,10 @@ def issue_list(request, project):
'project': project, 'project': project,
'issues': issues, 'issues': issues,
'query': query, 'query': query,
'is_open': is_open,
'is_close': is_close,
'is_all': is_all,
'is_all_query': is_all_query[1:],
} }
return render(request, 'issue/issue_list.html', c) return render(request, 'issue/issue_list.html', c)
@ -128,7 +209,7 @@ def issue_edit(request, project, id=None):
if id: if id:
issue = get_object_or_404(Issue, project__name=project.name, id=id) issue = get_object_or_404(Issue, project__name=project.name, id=id)
init_data = {'title': issue.title, init_data = {'title': issue.title,
'comment': issue.events.first().additionnal_section} 'description': issue.description}
else: else:
issue = None issue = None
init_data = None init_data = None
@ -136,15 +217,14 @@ def issue_edit(request, project, id=None):
class IssueForm(forms.Form): class IssueForm(forms.Form):
title = forms.CharField(max_length=128) title = forms.CharField(max_length=128)
comment = forms.CharField(widget=MarkdownWidget) description = forms.CharField(widget=MarkdownWidget, required=False)
form = IssueForm(request.POST or init_data) form = IssueForm(request.POST or init_data)
if request.method == 'POST' and form.is_valid(): if request.method == 'POST' and form.is_valid():
title = form.cleaned_data['title'] title = form.cleaned_data['title']
comment = form.cleaned_data['comment'] description = form.cleaned_data['description']
print(comment)
if issue: if issue:
@ -160,10 +240,8 @@ def issue_edit(request, project, id=None):
event.save() event.save()
modified = True modified = True
first_comment = issue.events.first() if issue.description != description:
if first_comment.additionnal_section != comment: issue.description = description
first_comment.additionnal_section = comment
first_comment.save()
modified = True modified = True
if modified: if modified:
@ -177,9 +255,7 @@ def issue_edit(request, project, id=None):
issue = Issue(title=title, author=author, issue = Issue(title=title, author=author,
project=project, id=Issue.next_id(project)) project=project, id=Issue.next_id(project))
issue.save() issue.save()
event = Event(issue=issue, author=author, code=Event.COMMENT, issue.description = description
additionnal_section=comment)
event.save()
messages.success(request, 'Issue created successfully.') messages.success(request, 'Issue created successfully.')
return redirect('show-issue', project.name, issue.id) return redirect('show-issue', project.name, issue.id)
@ -200,12 +276,17 @@ def issue(request, project, id):
issue = get_object_or_404(Issue, project__name=project, id=id) issue = get_object_or_404(Issue, project__name=project, id=id)
projects = Project.objects.all() projects = Project.objects.all()
labels = Label.objects.filter(project=issue.project, deleted=False) \
.exclude(id__in=issue.labels.all().values_list('id'))
milestones = Milestone.objects.filter(project=issue.project)
events = issue.events.all() events = issue.events.all()
c = { c = {
'request': request, 'request': request,
'projects': projects, 'projects': projects,
'labels': labels,
'milestones': milestones,
'project': issue.project, 'project': issue.project,
'issue': issue, 'issue': issue,
'events': events, 'events': events,
@ -219,7 +300,7 @@ def issue_comment(request, project, id, comment=None):
if comment: if comment:
event = get_object_or_404(Event, code=Event.COMMENT, issue=issue, id=comment) event = get_object_or_404(Event, code=Event.COMMENT, issue=issue, id=comment)
init_data = {'comment': comment.additionnal_section} init_data = { 'comment': event.additionnal_section }
else: else:
event = None event = None
init_data = None init_data = None
@ -301,11 +382,31 @@ def issue_delete(request, project, id):
return redirect('list-issue', project) return redirect('list-issue', project)
def issue_add_label(request, project, issue, label):
issue = get_object_or_404(Issue, project__name=project, id=issue)
label = get_object_or_404(Label, project__name=project, id=label)
author = User.objects.get(username=request.user.username)
issue.add_label(author, label)
return redirect('show-issue', project, issue.id)
def issue_remove_label(request, project, issue, label):
issue = get_object_or_404(Issue, project__name=project, id=issue)
label = get_object_or_404(Label, project__name=project, id=label)
author = User.objects.get(username=request.user.username)
issue.remove_label(author, label)
return redirect('show-issue', project, issue.id)
def label_list(request, project): def label_list(request, project):
project = get_object_or_404(Project, name=project) project = get_object_or_404(Project, name=project)
labels = project.labels.all() labels = project.labels.filter(deleted=False)
projects = Project.objects.all() projects = Project.objects.all()
@ -318,28 +419,51 @@ def label_list(request, project):
return render(request, 'issue/label_list.html', c) return render(request, 'issue/label_list.html', c)
def label_edit(request, project, label=None): def label_edit(request, project, id=None):
project = get_object_or_404(Project, name=project) project = get_object_or_404(Project, name=project)
if label: if id:
label = get_object_or_404(Label, project=project, name=label) label = get_object_or_404(Label, project=project, id=id)
else:
label = None
class LabelForm(forms.ModelForm):
class Meta:
model = Label
fields = ['name', 'color', 'inverted']
LabelForm = modelform_factory(Label, fields=['name', 'color'])
form = LabelForm(request.POST or None, instance=label) form = LabelForm(request.POST or None, instance=label)
if request.method == 'POST' and form.is_valid(): if request.method == 'POST' and form.is_valid():
if label: similar = Label.objects.filter(project=project,
form.save() name=form.cleaned_data['name'], deleted=False)
messages.success(request, 'Label modified successfully.')
else:
label = form.save(commit=False)
label.project = project
label.save()
messages.success(request, 'Label added successfully.')
return redirect('list-label', project.name) if label:
similar = similar.exclude(pk=label.pk)
if similar.count():
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)
projects = Project.objects.all() projects = Project.objects.all()
@ -347,12 +471,25 @@ def label_edit(request, project, label=None):
'request': request, 'request': request,
'projects': projects, 'projects': projects,
'project': project, 'project': project,
'label': label,
'form': form, 'form': form,
} }
return render(request, 'issue/label_edit.html', c) return render(request, 'issue/label_edit.html', c)
def label_delete(request, project, id):
label = get_object_or_404(Label, project=project, id=id)
author = User.objects.get(username=request.user.username)
for issue in label.issues.all():
issue.remove_label(author, label)
label.deleted = True
label.save()
messages.success(request, "Label deleted successfully.")
return redirect('list-label', project)
def milestone_list(request, project): def milestone_list(request, project):
project = get_object_or_404(Project, name=project) project = get_object_or_404(Project, name=project)

View file

@ -3,5 +3,6 @@ Markdown==2.4.1
django-colorful==1.0.1 django-colorful==1.0.1
django-crispy-forms==1.4.0 django-crispy-forms==1.4.0
django-markdown==0.6.1 django-markdown==0.6.1
django-stronghold==0.2.6
ipython==2.1.0 ipython==2.1.0
pyinotify==0.9.4 pyinotify==0.9.4