commit 5be5ea023c6377e475f683fe2ad8f076ace27fed Author: Scot Hacker Date: Mon Sep 27 01:12:13 2010 -0700 Moving from google code to github. diff --git a/README b/README new file mode 100644 index 0000000..98ef102 --- /dev/null +++ b/README @@ -0,0 +1,14 @@ +django-todo is a pluggable multi-user, multi-group task management and +assignment application for Django. It can serve as anything from a +personal to-do system to a complete, working ticketing system for organizations. + +For documentation, see the django-todo wiki pages: + +Overview and screenshots +http://github.com/shacker/django-todo/wiki/Overview-and-screenshots + +Requirements and installation +http://github.com/shacker/django-todo/wiki/Requirements-and-Installation + +Version history +http://github.com/shacker/django-todo/wiki/Version-history diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..bbe6aee --- /dev/null +++ b/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from todo.models import Item, User, List, Comment + +class ItemAdmin(admin.ModelAdmin): + list_display = ('title', 'list', 'priority', 'due_date') + list_filter = ('list',) + ordering = ('priority',) + search_fields = ('name',) + + +admin.site.register(List) +admin.site.register(Comment) +admin.site.register(Item,ItemAdmin) \ No newline at end of file diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..407a5d1 --- /dev/null +++ b/forms.py @@ -0,0 +1,83 @@ +from django.db import models +from django import forms +from django.forms import ModelForm +from django.contrib.auth.models import User,Group +from todo.models import Item, List +import datetime + +class AddListForm(ModelForm): + # The picklist showing allowable groups to which a new list can be added + # determines which groups the user belongs to. This queries the form object + # to derive that list. + def __init__(self, user, *args, **kwargs): + super(AddListForm, self).__init__(*args, **kwargs) + self.fields['group'].queryset = Group.objects.filter(user=user) + + class Meta: + model = List + + + +class AddItemForm(ModelForm): + + # The picklist showing the users to which a new task can be assigned + # must find other members of the groups the current list belongs to. + def __init__(self, task_list, *args, **kwargs): + super(AddItemForm, self).__init__(*args, **kwargs) + # print dir(self.fields['list']) + # print self.fields['list'].initial + self.fields['assigned_to'].queryset = User.objects.filter(groups__in=[task_list.group]) + + due_date = forms.DateField( + required=False, + widget=forms.DateTimeInput(attrs={'class':'due_date_picker'}) + ) + + title = forms.CharField( + widget=forms.widgets.TextInput(attrs={'size':35}) + ) + + class Meta: + model = Item + + + +class EditItemForm(ModelForm): + + # The picklist showing the users to which a new task can be assigned + # must find other members of the groups the current list belongs to. + def __init__(self, *args, **kwargs): + super(EditItemForm, self).__init__(*args, **kwargs) + self.fields['assigned_to'].queryset = User.objects.filter(groups__in=[self.instance.list.group]) + + class Meta: + model = Item + exclude = ('created_date','created_by',) + + + +class AddExternalItemForm(ModelForm): + """Form to allow users who are not part of the GTD system to file a ticket.""" + + title = forms.CharField( + widget=forms.widgets.TextInput(attrs={'size':35}) + ) + note = forms.CharField ( + widget=forms.widgets.Textarea(), + help_text='Foo', + ) + + class Meta: + model = Item + exclude = ('list','created_date','due_date','created_by','assigned_to',) + + + +class SearchForm(ModelForm): + """Search.""" + + q = forms.CharField( + widget=forms.widgets.TextInput(attrs={'size':35}) + ) + + diff --git a/media/todo/css/styles.css b/media/todo/css/styles.css new file mode 100644 index 0000000..27d80eb --- /dev/null +++ b/media/todo/css/styles.css @@ -0,0 +1,106 @@ +/*Distributed*/ + +ul.messages li { + color:green; + font-weight:bold; +} + +.overdue { + color:#9A2441; + font-weight:bold; +} + +/* Lighter font for completed items */ +#completed li { + color: gray; +} + +a.todo { + text-decoration:none; + color:#474747; +} + +a.showlink { + text-decoration:underline; +} + +#tasktable a { + font-weight:bold; + font-family:"Arial"; + text-decoration:none; + color:#474747; +} + + +label { + display: block; + font-weight: bold; +} + +input { + color: #3A3A3A; + font-family:Verdana; + font-size:14px; +} + +input[type='text'] { + width:300px; +} + +input#id_priority { + width:30px; +} + +.todo-button { + border: 1px solid #9B9B9B; + background: #E0E0E0; + padding-bottom:2px; + font-weight:bold; +} + +hr { + color:#E5E5E5; +} + +#slideToggle { + color:#4A8251; +} + +.todo-break { + margin-top: 30px; + border-top: 1px dotted gray; +} + +table.nocolor, table.nocolor tr, table.nocolor td { + background-color: ; +} + +table#tasktable td, table#tasktable th { + padding: 5px; +/* font-size:0.9em;*/ +} + +table#tasktable th { + text-align: left; +/* background-color: #9BCAE4; */ + background-color: #046380; + color: #fff; +} + +table#tasktable tr.row1 { + background-color: #AEF0BB; +} + +table#tasktable tr.row2 { + background-color: #B6F3D5; +} + +.minor { + font-style:italic; + font-size:0.8em; +} + +.task_note, .task_comments { + width: 70%; + overflow: visible; +} diff --git a/media/todo/css/ui.datepicker.css b/media/todo/css/ui.datepicker.css new file mode 100644 index 0000000..49b00c0 --- /dev/null +++ b/media/todo/css/ui.datepicker.css @@ -0,0 +1,213 @@ +/* Main Style Sheet for jQuery UI date picker */ +#ui-datepicker-div, .ui-datepicker-inline { + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + padding: 0; + margin: 0; + background: #ddd; + width: 185px; +} +#ui-datepicker-div { + display: none; + border: 1px solid #777; + z-index: 100; /*must have*/ +} +.ui-datepicker-inline { + float: left; + display: block; + border: 0; +} +.ui-datepicker-rtl { + direction: rtl; +} +.ui-datepicker-dialog { + padding: 5px !important; + border: 4px ridge #ddd !important; +} +.ui-datepicker-disabled { + position: absolute; + z-index: 100; + background-color: white; + opacity: 0.5; +} +button.ui-datepicker-trigger { + width: 25px; +} +img.ui-datepicker-trigger { + margin: 2px; + vertical-align: middle; +} +.ui-datepicker-prompt { + float: left; + padding: 2px; + background: #ddd; + color: #000; +} +* html .ui-datepicker-prompt { + width: 185px; +} +.ui-datepicker-control, .ui-datepicker-links, .ui-datepicker-header, .ui-datepicker { + clear: both; + float: left; + width: 100%; + color: #fff; +} +.ui-datepicker-control { + background: #400; + padding: 2px 0px; +} +.ui-datepicker-links { + background: #000; + padding: 2px 0px; +} +.ui-datepicker-control, .ui-datepicker-links { + font-weight: bold; + font-size: 80%; +} +.ui-datepicker-links label { /* disabled links */ + padding: 2px 5px; + color: #888; +} +.ui-datepicker-clear, .ui-datepicker-prev { + float: left; + width: 34%; +} +.ui-datepicker-rtl .ui-datepicker-clear, .ui-datepicker-rtl .ui-datepicker-prev { + float: right; + text-align: right; +} +.ui-datepicker-current { + float: left; + width: 30%; + text-align: center; +} +.ui-datepicker-close, .ui-datepicker-next { + float: right; + width: 34%; + text-align: right; +} +.ui-datepicker-rtl .ui-datepicker-close, .ui-datepicker-rtl .ui-datepicker-next { + float: left; + text-align: left; +} +.ui-datepicker-header { + padding: 1px 0 3px; + background: #333; + text-align: center; + font-weight: bold; + height: 1.3em; +} +.ui-datepicker-header select { + background: #333; + color: #fff; + border: 0px; + font-weight: bold; +} +.ui-datepicker { + background: #ccc; + text-align: center; + font-size: 100%; +} +.ui-datepicker a { + display: block; + width: 100%; +} +.ui-datepicker-title-row { + background: #777; +} +.ui-datepicker-days-row { + background: #eee; + color: #666; +} +.ui-datepicker-week-col { + background: #777; + color: #fff; +} +.ui-datepicker-days-cell { + color: #000; + border: 1px solid #ddd; +} +.ui-datepicker-days-cell a{ + display: block; +} +.ui-datepicker-week-end-cell { + background: #ddd; +} +.ui-datepicker-title-row .ui-datepicker-week-end-cell { + background: #777; +} +.ui-datepicker-days-cell-over { + background: #fff; + border: 1px solid #777; +} +.ui-datepicker-unselectable { + color: #888; +} +.ui-datepicker-today { + background: #fcc !important; +} +.ui-datepicker-current-day { + background: #999 !important; +} +.ui-datepicker-status { + background: #ddd; + width: 100%; + font-size: 80%; + text-align: center; +} + +/* ________ Datepicker Links _______ + +** Reset link properties and then override them with !important */ +#ui-datepicker-div a, .ui-datepicker-inline a { + cursor: pointer; + margin: 0; + padding: 0; + background: none; + color: #000; +} +.ui-datepicker-inline .ui-datepicker-links a { + padding: 0 5px !important; +} +.ui-datepicker-control a, .ui-datepicker-links a { + padding: 2px 5px !important; + color: #eee !important; +} +.ui-datepicker-title-row a { + color: #eee !important; +} +.ui-datepicker-control a:hover { + background: #fdd !important; + color: #333 !important; +} +.ui-datepicker-links a:hover, .ui-datepicker-title-row a:hover { + background: #ddd !important; + color: #333 !important; +} + +/* ___________ MULTIPLE MONTHS _________*/ + +.ui-datepicker-multi .ui-datepicker { + border: 1px solid #777; +} +.ui-datepicker-one-month { + float: left; + width: 185px; +} +.ui-datepicker-new-row { + clear: left; +} + +/* ___________ IE6 IFRAME FIX ________ */ + +.ui-datepicker-cover { + display: none; /*sorry for IE5*/ + display/**/: block; /*sorry for IE5*/ + position: absolute; /*must have*/ + z-index: -1; /*must have*/ + filter: mask(); /*must have*/ + top: -4px; /*must have*/ + left: -4px; /*must have*/ + width: 200px; /*must have*/ + height: 200px; /*must have*/ +} diff --git a/media/todo/js/jquery.tablednd_0_5.js b/media/todo/js/jquery.tablednd_0_5.js new file mode 100644 index 0000000..d4c9fec --- /dev/null +++ b/media/todo/js/jquery.tablednd_0_5.js @@ -0,0 +1,382 @@ +/** + * TableDnD plug-in for JQuery, allows you to drag and drop table rows + * You can set up various options to control how the system will work + * Copyright (c) Denis Howlett + * Licensed like jQuery, see http://docs.jquery.com/License. + * + * Configuration options: + * + * onDragStyle + * This is the style that is assigned to the row during drag. There are limitations to the styles that can be + * associated with a row (such as you can't assign a border--well you can, but it won't be + * displayed). (So instead consider using onDragClass.) The CSS style to apply is specified as + * a map (as used in the jQuery css(...) function). + * onDropStyle + * This is the style that is assigned to the row when it is dropped. As for onDragStyle, there are limitations + * to what you can do. Also this replaces the original style, so again consider using onDragClass which + * is simply added and then removed on drop. + * onDragClass + * This class is added for the duration of the drag and then removed when the row is dropped. It is more + * flexible than using onDragStyle since it can be inherited by the row cells and other content. The default + * is class is tDnD_whileDrag. So to use the default, simply customise this CSS class in your + * stylesheet. + * onDrop + * Pass a function that will be called when the row is dropped. The function takes 2 parameters: the table + * and the row that was dropped. You can work out the new order of the rows by using + * table.rows. + * onDragStart + * Pass a function that will be called when the user starts dragging. The function takes 2 parameters: the + * table and the row which the user has started to drag. + * onAllowDrop + * Pass a function that will be called as a row is over another row. If the function returns true, allow + * dropping on that row, otherwise not. The function takes 2 parameters: the dragged row and the row under + * the cursor. It returns a boolean: true allows the drop, false doesn't allow it. + * scrollAmount + * This is the number of pixels to scroll if the user moves the mouse cursor to the top or bottom of the + * window. The page should automatically scroll up or down as appropriate (tested in IE6, IE7, Safari, FF2, + * FF3 beta + * dragHandle + * This is the name of a class that you assign to one or more cells in each row that is draggable. If you + * specify this class, then you are responsible for setting cursor: move in the CSS and only these cells + * will have the drag behaviour. If you do not specify a dragHandle, then you get the old behaviour where + * the whole row is draggable. + * + * Other ways to control behaviour: + * + * Add class="nodrop" to any rows for which you don't want to allow dropping, and class="nodrag" to any rows + * that you don't want to be draggable. + * + * Inside the onDrop method you can also call $.tableDnD.serialize() this returns a string of the form + * []=&[]= so that you can send this back to the server. The table must have + * an ID as must all the rows. + * + * Other methods: + * + * $("...").tableDnDUpdate() + * Will update all the matching tables, that is it will reapply the mousedown method to the rows (or handle cells). + * This is useful if you have updated the table rows using Ajax and you want to make the table draggable again. + * The table maintains the original configuration (so you don't have to specify it again). + * + * $("...").tableDnDSerialize() + * Will serialize and return the serialized string as above, but for each of the matching tables--so it can be + * called from anywhere and isn't dependent on the currentTable being set up correctly before calling + * + * Known problems: + * - Auto-scoll has some problems with IE7 (it scrolls even when it shouldn't), work-around: set scrollAmount to 0 + * + * Version 0.2: 2008-02-20 First public version + * Version 0.3: 2008-02-07 Added onDragStart option + * Made the scroll amount configurable (default is 5 as before) + * Version 0.4: 2008-03-15 Changed the noDrag/noDrop attributes to nodrag/nodrop classes + * Added onAllowDrop to control dropping + * Fixed a bug which meant that you couldn't set the scroll amount in both directions + * Added serialize method + * Version 0.5: 2008-05-16 Changed so that if you specify a dragHandle class it doesn't make the whole row + * draggable + * Improved the serialize method to use a default (and settable) regular expression. + * Added tableDnDupate() and tableDnDSerialize() to be called when you are outside the table + */ +jQuery.tableDnD = { + /** Keep hold of the current table being dragged */ + currentTable : null, + /** Keep hold of the current drag object if any */ + dragObject: null, + /** The current mouse offset */ + mouseOffset: null, + /** Remember the old value of Y so that we don't do too much processing */ + oldY: 0, + + /** Actually build the structure */ + build: function(options) { + // Set up the defaults if any + + this.each(function() { + // This is bound to each matching table, set up the defaults and override with user options + this.tableDnDConfig = jQuery.extend({ + onDragStyle: null, + onDropStyle: null, + // Add in the default class for whileDragging + onDragClass: "tDnD_whileDrag", + onDrop: null, + onDragStart: null, + scrollAmount: 5, + serializeRegexp: /[^\-]*$/, // The regular expression to use to trim row IDs + serializeParamName: null, // If you want to specify another parameter name instead of the table ID + dragHandle: null // If you give the name of a class here, then only Cells with this class will be draggable + }, options || {}); + // Now make the rows draggable + jQuery.tableDnD.makeDraggable(this); + }); + + // Now we need to capture the mouse up and mouse move event + // We can use bind so that we don't interfere with other event handlers + jQuery(document) + .bind('mousemove', jQuery.tableDnD.mousemove) + .bind('mouseup', jQuery.tableDnD.mouseup); + + // Don't break the chain + return this; + }, + + /** This function makes all the rows on the table draggable apart from those marked as "NoDrag" */ + makeDraggable: function(table) { + var config = table.tableDnDConfig; + if (table.tableDnDConfig.dragHandle) { + // We only need to add the event to the specified cells + var cells = jQuery("td."+table.tableDnDConfig.dragHandle, table); + cells.each(function() { + // The cell is bound to "this" + jQuery(this).mousedown(function(ev) { + jQuery.tableDnD.dragObject = this.parentNode; + jQuery.tableDnD.currentTable = table; + jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev); + if (config.onDragStart) { + // Call the onDrop method if there is one + config.onDragStart(table, this); + } + return false; + }); + }) + } else { + // For backwards compatibility, we add the event to the whole row + var rows = jQuery("tr", table); // get all the rows as a wrapped set + rows.each(function() { + // Iterate through each row, the row is bound to "this" + var row = jQuery(this); + if (! row.hasClass("nodrag")) { + row.mousedown(function(ev) { + if (ev.target.tagName == "TD") { + jQuery.tableDnD.dragObject = this; + jQuery.tableDnD.currentTable = table; + jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev); + if (config.onDragStart) { + // Call the onDrop method if there is one + config.onDragStart(table, this); + } + return false; + } + }).css("cursor", "move"); // Store the tableDnD object + } + }); + } + }, + + updateTables: function() { + this.each(function() { + // this is now bound to each matching table + if (this.tableDnDConfig) { + jQuery.tableDnD.makeDraggable(this); + } + }) + }, + + /** Get the mouse coordinates from the event (allowing for browser differences) */ + mouseCoords: function(ev){ + if(ev.pageX || ev.pageY){ + return {x:ev.pageX, y:ev.pageY}; + } + return { + x:ev.clientX + document.body.scrollLeft - document.body.clientLeft, + y:ev.clientY + document.body.scrollTop - document.body.clientTop + }; + }, + + /** Given a target element and a mouse event, get the mouse offset from that element. + To do this we need the element's position and the mouse position */ + getMouseOffset: function(target, ev) { + ev = ev || window.event; + + var docPos = this.getPosition(target); + var mousePos = this.mouseCoords(ev); + return {x:mousePos.x - docPos.x, y:mousePos.y - docPos.y}; + }, + + /** Get the position of an element by going up the DOM tree and adding up all the offsets */ + getPosition: function(e){ + var left = 0; + var top = 0; + /** Safari fix -- thanks to Luis Chato for this! */ + if (e.offsetHeight == 0) { + /** Safari 2 doesn't correctly grab the offsetTop of a table row + this is detailed here: + http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari/ + the solution is likewise noted there, grab the offset of a table cell in the row - the firstChild. + note that firefox will return a text node as a first child, so designing a more thorough + solution may need to take that into account, for now this seems to work in firefox, safari, ie */ + e = e.firstChild; // a table cell + } + + while (e.offsetParent){ + left += e.offsetLeft; + top += e.offsetTop; + e = e.offsetParent; + } + + left += e.offsetLeft; + top += e.offsetTop; + + return {x:left, y:top}; + }, + + mousemove: function(ev) { + if (jQuery.tableDnD.dragObject == null) { + return; + } + + var dragObj = jQuery(jQuery.tableDnD.dragObject); + var config = jQuery.tableDnD.currentTable.tableDnDConfig; + var mousePos = jQuery.tableDnD.mouseCoords(ev); + var y = mousePos.y - jQuery.tableDnD.mouseOffset.y; + //auto scroll the window + var yOffset = window.pageYOffset; + if (document.all) { + // Windows version + //yOffset=document.body.scrollTop; + if (typeof document.compatMode != 'undefined' && + document.compatMode != 'BackCompat') { + yOffset = document.documentElement.scrollTop; + } + else if (typeof document.body != 'undefined') { + yOffset=document.body.scrollTop; + } + + } + + if (mousePos.y-yOffset < config.scrollAmount) { + window.scrollBy(0, -config.scrollAmount); + } else { + var windowHeight = window.innerHeight ? window.innerHeight + : document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight; + if (windowHeight-(mousePos.y-yOffset) < config.scrollAmount) { + window.scrollBy(0, config.scrollAmount); + } + } + + + if (y != jQuery.tableDnD.oldY) { + // work out if we're going up or down... + var movingDown = y > jQuery.tableDnD.oldY; + // update the old value + jQuery.tableDnD.oldY = y; + // update the style to show we're dragging + if (config.onDragClass) { + dragObj.addClass(config.onDragClass); + } else { + dragObj.css(config.onDragStyle); + } + // If we're over a row then move the dragged row to there so that the user sees the + // effect dynamically + var currentRow = jQuery.tableDnD.findDropTargetRow(dragObj, y); + if (currentRow) { + // TODO worry about what happens when there are multiple TBODIES + if (movingDown && jQuery.tableDnD.dragObject != currentRow) { + jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow.nextSibling); + } else if (! movingDown && jQuery.tableDnD.dragObject != currentRow) { + jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow); + } + } + } + + return false; + }, + + /** We're only worried about the y position really, because we can only move rows up and down */ + findDropTargetRow: function(draggedRow, y) { + var rows = jQuery.tableDnD.currentTable.rows; + for (var i=0; i rowY - rowHeight) && (y < (rowY + rowHeight))) { + // that's the row we're over + // If it's the same as the current row, ignore it + if (row == draggedRow) {return null;} + var config = jQuery.tableDnD.currentTable.tableDnDConfig; + if (config.onAllowDrop) { + if (config.onAllowDrop(draggedRow, row)) { + return row; + } else { + return null; + } + } else { + // If a row has nodrop class, then don't allow dropping (inspired by John Tarr and Famic) + var nodrop = jQuery(row).hasClass("nodrop"); + if (! nodrop) { + return row; + } else { + return null; + } + } + return row; + } + } + return null; + }, + + mouseup: function(e) { + if (jQuery.tableDnD.currentTable && jQuery.tableDnD.dragObject) { + var droppedRow = jQuery.tableDnD.dragObject; + var config = jQuery.tableDnD.currentTable.tableDnDConfig; + // If we have a dragObject, then we need to release it, + // The row will already have been moved to the right place so we just reset stuff + if (config.onDragClass) { + jQuery(droppedRow).removeClass(config.onDragClass); + } else { + jQuery(droppedRow).css(config.onDropStyle); + } + jQuery.tableDnD.dragObject = null; + if (config.onDrop) { + // Call the onDrop method if there is one + config.onDrop(jQuery.tableDnD.currentTable, droppedRow); + } + jQuery.tableDnD.currentTable = null; // let go of the table too + } + }, + + serialize: function() { + if (jQuery.tableDnD.currentTable) { + return jQuery.tableDnD.serializeTable(jQuery.tableDnD.currentTable); + } else { + return "Error: No Table id set, you need to set an id on your table and every row"; + } + }, + + serializeTable: function(table) { + var result = ""; + var tableId = table.id; + var rows = table.rows; + for (var i=0; i 0) result += "&"; + var rowId = rows[i].id; + if (rowId && rowId && table.tableDnDConfig && table.tableDnDConfig.serializeRegexp) { + rowId = rowId.match(table.tableDnDConfig.serializeRegexp)[0]; + } + + result += tableId + '[]=' + rowId; + } + return result; + }, + + serializeTables: function() { + var result = ""; + this.each(function() { + // this is now bound to each matching table + result += jQuery.tableDnD.serializeTable(this); + }); + return result; + } + +} + +jQuery.fn.extend( + { + tableDnD : jQuery.tableDnD.build, + tableDnDUpdate : jQuery.tableDnD.updateTables, + tableDnDSerialize: jQuery.tableDnD.serializeTables + } +); \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..c460ae7 --- /dev/null +++ b/models.py @@ -0,0 +1,96 @@ +from django.db import models +from django.forms.models import ModelForm +from django import forms +from django.contrib import admin +from django.contrib.auth.models import User,Group +import string, datetime +from django.template.defaultfilters import slugify + + +class List(models.Model): + name = models.CharField(max_length=60) + slug = models.SlugField(max_length=60,editable=False) + # slug = models.SlugField(max_length=60) + group = models.ForeignKey(Group) + + def save(self, *args, **kwargs): + if not self.id: + self.slug = slugify(self.name) + + super(List, self).save(*args, **kwargs) + + + + def __unicode__(self): + return self.name + + # Custom manager lets us do things like Item.completed_tasks.all() + objects = models.Manager() + + def incomplete_tasks(self): + # Count all incomplete tasks on the current list instance + return Item.objects.filter(list=self,completed=0) + + class Meta: + ordering = ["name"] + verbose_name_plural = "Lists" + + # Prevents (at the database level) creation of two lists with the same name in the same group + unique_together = ("group", "slug") + + + + + +class Item(models.Model): + title = models.CharField(max_length=140) + list = models.ForeignKey(List) + created_date = models.DateField() + due_date = models.DateField(blank=True,null=True,) + completed = models.BooleanField() + completed_date = models.DateField(blank=True,null=True) + created_by = models.ForeignKey(User, related_name='created_by') + assigned_to = models.ForeignKey(User, related_name='todo_assigned_to') + note = models.TextField(blank=True,null=True) + priority = models.PositiveIntegerField(max_length=3) + + # Model method: Has due date for an instance of this object passed? + def overdue_status(self): + "Returns whether the item's due date has passed or not." + if datetime.date.today() > self.due_date : + return 1 + + def __unicode__(self): + return self.title + + # Auto-set the item creation / completed date + def save(self): + # Set datetime on initial item save + if not self.id: + self.created_date = datetime.datetime.now() + + # If Item is being marked complete, set the completed_date + if self.completed : + self.completed_date = datetime.datetime.now() + super(Item, self).save() + + + class Meta: + ordering = ["priority"] + + +class Comment(models.Model): + """ + Not using Django's built-in comments becase we want to be able to save + a comment and change task details at the same time. Rolling our own since it's easy. + """ + author = models.ForeignKey(User) + task = models.ForeignKey(Item) + date = models.DateTimeField(default=datetime.datetime.now) + body = models.TextField(blank=True) + + def __unicode__(self): + return '%s - %s' % ( + self.author, + self.date, + ) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7c1d512 --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +from setuptools import setup, find_packages + +setup( + name='django-todo', + version='1.2', + description='A multi-user, multi-group task management and assignment system for Django.', + author='Scot Hacker', + author_email='shacker@birdhouse.org', + url='http://github.com/shacker/django-todo', + packages=find_packages(), + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Framework :: Django', + ], + include_package_data=True, + zip_safe=False, +) diff --git a/templates/todo/add_external_task.html b/templates/todo/add_external_task.html new file mode 100644 index 0000000..48f2aae --- /dev/null +++ b/templates/todo/add_external_task.html @@ -0,0 +1,83 @@ +{% extends "todo/base.html" %} +{% block page_heading %}{% endblock %} +{% block title %}File Ticket{% endblock %} + +{% block content %} + + +{% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+{% endif %} + + + +

{{ task }}

+ +
+{% csrf_token %} + + +{% if task.note %} +
Note: {{ task.note|safe|urlize|linebreaks }}
+{% endif %} + +
+

File Trouble Ticket

+

Trouble with a computer or other technical system at the J-School?
+Use this form to report the difficulty - we'll get right back to you.

+ +{% if form.errors %} + + {% for error in form.errors %} +
    +
  • The {{ error|escape }} field is required.
  • +
+ {% endfor %} +
+ +{% endif %} + + + + {{ form.management_form }} + {{ form.id }} + + + + + + + + + + + + + + + + + + + + +
Summary:{{ form.title }}
+ Include the workstation number in your summary, e.g.
+ "Radio Lab # 4: Purple smoke pouring out the back." +
Note:{{ form.note }}
+ Please describe the problem.
Priority:{{ form.priority }}
+ Enter a number between 1 and 5,
+ where 5 is highest ("Computer is on fire = True"). +
+

+
+ + +
+ + +{% endblock %} diff --git a/templates/todo/add_list.html b/templates/todo/add_list.html new file mode 100644 index 0000000..4d6a935 --- /dev/null +++ b/templates/todo/add_list.html @@ -0,0 +1,24 @@ +{% extends "todo/base.html" %} +{% block page_heading %}{% endblock %} +{% block title %}Add Todo List{% endblock %} + +{% block content %} + +{% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+{% endif %} + + +

Add a list:

+ +
+ {% csrf_token %} +{{ form }}
+

+
+ +{% endblock %} diff --git a/templates/todo/base.html b/templates/todo/base.html new file mode 100644 index 0000000..7c35efb --- /dev/null +++ b/templates/todo/base.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block page_heading %}GTD (Getting Things Done){% endblock %} +{% block extrahead %} + + + + + + + + + +{% endblock extrahead %} diff --git a/templates/todo/del_list.html b/templates/todo/del_list.html new file mode 100644 index 0000000..0853751 --- /dev/null +++ b/templates/todo/del_list.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}{{ list_title }} to-do items{% endblock %} + +{% block content %} + +{# Only admins can delete lists. #} +{% ifequal can_del 1 %} + + + {% if list_killed %} + +

{{ list.name }} is gone.

+ + Return to lists + + {% else %} + +

Delete entire list: {{ list.name }} ?

+ +

Category tally:

+ +
    +
  • Incomplete: {{ item_count_undone }}
  • +
  • Complete: {{ item_count_done }}
  • +
  • Total: {{ item_count_total }}
  • +
+ +

... all of which will be irretrievably blown away. Are you sure you want to do that?

+ +
+ {% csrf_token %} + + +

+
+ + + Return to list: {{ list.name }} + + {% endif %} + + +{% else %} + +

Sorry, you don't have permission to delete lists. Please contact your group administrator.

+ +{% endifequal %} + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/todo/email/assigned_body.txt b/templates/todo/email/assigned_body.txt new file mode 100644 index 0000000..bfb5e68 --- /dev/null +++ b/templates/todo/email/assigned_body.txt @@ -0,0 +1,20 @@ +Dear {{ task.assigned_to.first_name }} - + +A new task on the list {{ task.list.name }} has been assigned to you by {{ task.created_by.first_name }} {{ task.created_by.last_name }}: + +{{ task.title }} + +{% if task.note %} +{% autoescape off %} +Note: {{ task.note }} +{% endautoescape %} +{% endif %} + + + + +Task details/comments: +http://{{ site }}{% url todo-task_detail task.id %} + +List {{ task.list.name }}: +http://{{ site }}{% url todo-incomplete_tasks task.list.id task.list.slug %} diff --git a/templates/todo/email/assigned_subject.txt b/templates/todo/email/assigned_subject.txt new file mode 100644 index 0000000..b6cc9db --- /dev/null +++ b/templates/todo/email/assigned_subject.txt @@ -0,0 +1 @@ +GTD: New task - {% autoescape off %}Note: {{ task.title }}{% endautoescape %} \ No newline at end of file diff --git a/templates/todo/email/newcomment_body.txt b/templates/todo/email/newcomment_body.txt new file mode 100644 index 0000000..dd0efc8 --- /dev/null +++ b/templates/todo/email/newcomment_body.txt @@ -0,0 +1,16 @@ +A new task comment has been added. + +Task: {{ task.title }} +Commenter: {{ user.first_name }} {{ user.last_name }} + +Comment: +{% autoescape off %} +{{ body }} +{% endautoescape %} + +Task details/comments: +https://{{ site }}{% url todo-task_detail task.id %} + +List {{ task.list.name }}: +https://{{ site }}{% url todo-incomplete_tasks task.list.id task.list.slug %} + diff --git a/templates/todo/list_lists.html b/templates/todo/list_lists.html new file mode 100644 index 0000000..7c791c0 --- /dev/null +++ b/templates/todo/list_lists.html @@ -0,0 +1,40 @@ +{% extends "todo/base.html" %} + +{% block title %}{{ list_title }} Todo Lists{% endblock %} + +{% block content %} + + + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + +

To-do Lists

+ +

{{ item_count }} items in {{ list_count }} lists

+ + {% regroup list_list by group as section_list %} + + {% for group in section_list %} +

{{ group.grouper }}

+
    + {% for item in group.list %} +
  • {{ item.name }} ({{ item.incomplete_tasks.count }}/{{ item.item_set.count }})
  • + {% endfor %} +
+ + {% endfor %} + + + +
+   +
+ +

Create new todo list

+ +{% endblock %} \ No newline at end of file diff --git a/templates/todo/search_results.html b/templates/todo/search_results.html new file mode 100644 index 0000000..4c6f05d --- /dev/null +++ b/templates/todo/search_results.html @@ -0,0 +1,37 @@ +{% extends "todo/base.html" %} + +{% block title %}Search results{% endblock %} +{% block body_id %}post_search{% endblock %} + + +{% block content_title %} +

Search

+{% endblock %} + + +{% block content %} + + {% if message %} +

{{ message }}

+ {% endif %} + + {% if found_items %} +

{{found_items.count}} search results for term: "{{ query_string }}"

+
+ + {% for f in found_items %} +

{{ f.title }}
+ + + On list: {{ f.list }}
+ Assigned to: {{ f.assigned_to }} (created by: {{ f.created_by }})
+ Complete: {{ f.completed|yesno:"Yes,No" }} +
+

+ {% endfor %} +
+ {% else %} +

No results to show, sorry.

+ + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/todo/view_list.html b/templates/todo/view_list.html new file mode 100644 index 0000000..e82be1c --- /dev/null +++ b/templates/todo/view_list.html @@ -0,0 +1,191 @@ +{% extends "todo/base.html" %} + +{% block title %}Todo List: {{ list.name }}{% endblock %} + +{% block content %} + + + + + {% ifequal list_slug "mine" %} +

Tasks assigned to {{ request.user }}

+ {% else %} + {% ifequal auth_ok 1 %} +

Tasks filed under "{{ list.name }}"

+

This list belongs to group {{ list.group }}

+ {% endifequal %} + {% endifequal %} + + + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + + + {% ifequal auth_ok 1 %} +
+ {% csrf_token %} + + {# Only show task adder if viewing a proper list #} + {% ifnotequal list_slug "mine" %} +

→ Click to add task ←

+ +
+ + + + + + + + + + + + + + +
{{ form.title.errors }}{{ form.due_date.errors }}
{{ form.title }} {{ form.due_date }} {{ form.assigned_to }}
{{ form.note }} +

*Email notifications will only be sent if task is assigned to someone besides yourself.

+
+ + + + + + +

+
+ + {% endifnotequal %} + + + {% ifequal view_completed 0 %} + +

Incomplete tasks :: Drag rows to set priorities

+ + + + + + + + + + + + + {% ifequal list_slug "mine" %} + + {% endifequal %} + + + + {% for task in task_list %} + + + + + + + + + + {% ifequal list_slug "mine" %} + + {% endifequal %} + + + + {% endfor %} +
DoneTaskCreatedDue onOwnerAssignedNoteCommListDel
{{ task.title|truncatewords:20 }}{{ task.created_date|date:"m/d/Y" }} + {% if task.overdue_status %}{% endif %} + {{ task.due_date|date:"m/d/Y" }} + {% if task.overdue_status %}{% endif %} + {{ task.created_by }}{{ task.assigned_to }}{% if task.note %}≈{% endif %} {% ifnotequal task.comment_set.all.count 0 %}{{ task.comment_set.all.count }}{% endifnotequal %} + + {{ task.list }}
+

+ +

View completed tasks

+ {% endifequal %} + + {% ifequal view_completed 1 %} +

Completed tasks

+ + + + + + + + + + {% ifequal list_slug "mine" %} + + {% endifequal %} + + + + + {% for task in completed_list %} + + + + + + + + + {% endfor %} + +
UndoTaskCreatedCompleted onNoteCommListDel
{{ task.title|truncatewords:20 }}{{ task.created_date|date:"m/d/Y" }}{{ task.completed_date|date:"m/d/Y" }}{% if task.note %}≈{% endif %} {% ifnotequal task.comment_set.all.count 0 %}{{ task.comment_set.all.count }}{% endifnotequal %} + +
+

+
+

View incomplete tasks

+ {% endifequal %} + + {% ifequal can_del 1 %} + {% ifnotequal list_slug "mine" %} +

Delete this list

+ {% endifnotequal %} + {% endifequal %} + + {% endifequal %} +{% endblock %} \ No newline at end of file diff --git a/templates/todo/view_task.html b/templates/todo/view_task.html new file mode 100644 index 0000000..197245f --- /dev/null +++ b/templates/todo/view_task.html @@ -0,0 +1,120 @@ +{% extends "todo/base.html" %} + +{% block title %}Task: {{ task.title }}{% endblock %} + +{% block content %} + + + + +{% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+{% endif %} + + +{% ifequal auth_ok 1 %} + +

{{ task }}

+ +
+{% csrf_token %} +

→ Click to edit details ←

+ +

In list: {{ task.list }}
+Assigned to: {{ task.assigned_to.first_name }} {{ task.assigned_to.last_name }}
+Created by: {{ task.created_by.first_name }} {{ task.created_by.last_name }}
+Due date: {{ task.due_date }}
+Completed: {{ form.completed }}
+

+ + +{% if task.note %} +
Note: {{ task.note|safe|urlize|linebreaks }}
+{% endif %} + +
+

Edit Task

+ + + {{ form.management_form }} + + + {{ form.id }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Task:{{ form.title }}
List:{{ form.list }}
Due:{{ form.due_date }}
Assigned to:{{ form.assigned_to }}
Note:{{ form.note }}
Priority:{{ form.priority }}
+

+
+ + +
+ + +

Add comment

+ + +

+ +
+ +

Comments on this task

+ +
+{% for comment in comment_list %} +

{{ comment.author.first_name }} {{ comment.author.last_name }}, {{ comment.date|date:"F d Y P" }}

+ {{ comment.body|safe|urlize|linebreaks }} +{% empty %} +

No Comments

+{% endfor %} +
+ +{% endifequal %} + +{% endblock %} diff --git a/urls.py b/urls.py new file mode 100644 index 0000000..25c7ce0 --- /dev/null +++ b/urls.py @@ -0,0 +1,22 @@ +from django.conf.urls.defaults import * +from django.views.generic.simple import direct_to_template +from django.contrib.auth import views as auth_views + +urlpatterns = patterns('', + url(r'^mine/$', 'todo.views.view_list',{'list_slug':'mine'},name="todo-mine"), + url(r'^(?P\d{1,4})/(?P[\w-]+)/delete$', 'todo.views.del_list',name="todo-del_list"), + url(r'^task/(?P\d{1,6})$', 'todo.views.view_task', name='todo-task_detail'), + url(r'^(?P\d{1,4})/(?P[\w-]+)$', 'todo.views.view_list', name='todo-incomplete_tasks'), + url(r'^(?P\d{1,4})/(?P[\w-]+)/completed$', 'todo.views.view_list', {'view_completed':1},name='todo-completed_tasks'), + url(r'^add_list/$', 'todo.views.add_list',name="todo-add_list"), + url(r'^search/$', 'todo.views.search',name="todo-search"), + url(r'^$', 'todo.views.list_lists',name="todo-lists"), + + # View reorder_tasks is only called by JQuery for drag/drop task ordering + url(r'^reorder_tasks/$', 'todo.views.reorder_tasks',name="todo-reorder_tasks"), + + url(r'^ticket/add/$', 'todo.views.external_add',name="todo-external-add"), + url(r'^recent/added/$', 'todo.views.view_list',{'list_slug':'recent-add'},name="todo-recently_added"), + url(r'^recent/completed/$', 'todo.views.view_list',{'list_slug':'recent-complete'},name="todo-recently_completed"), +) + diff --git a/views.py b/views.py new file mode 100644 index 0000000..9936c4a --- /dev/null +++ b/views.py @@ -0,0 +1,415 @@ +from django import forms +from django.shortcuts import render_to_response +from todo.models import Item, List, Comment +from todo.forms import AddListForm, AddItemForm, EditItemForm, AddExternalItemForm, SearchForm +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 +from django.contrib import auth +from django.template import RequestContext +from django.http import HttpResponseRedirect, HttpResponse +from django.core.urlresolvers import reverse +from django.contrib.sites.models import Site +from django.template.loader import render_to_string +from django.core.mail import send_mail +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.db.models import Q + +import datetime + +# Need for links in email templates +current_site = Site.objects.get_current() + + +@login_required +def list_lists(request): + + """ + Homepage view - list of lists a user can view, and ability to add a list. + """ + + # Make sure belongs to at least one group. + group_count = request.user.groups.all().count() + if group_count == 0: + request.user.message_set.create(message="You do not yet belong to any groups. Ask your administrator to add you to one.") + + + # Only show lists to the user that belong to groups they are members of. + # Superusers see all lists + if request.user.is_superuser: + list_list = List.objects.all().order_by('group','name') + else: + list_list = List.objects.filter(group__in=request.user.groups.all).order_by('group','name') + + # Count everything + list_count = list_list.count() + + # Note admin users see all lists, so count shouldn't filter by just lists the admin belongs to + if request.user.is_superuser : + item_count = Item.objects.filter(completed=0).count() + else: + item_count = Item.objects.filter(completed=0).filter(list__group__in=request.user.groups.all()).count() + + return render_to_response('todo/list_lists.html', locals(), context_instance=RequestContext(request)) + + +@login_required +def del_list(request,list_id,list_slug): + + """ + Delete an entire list. Danger Will Robinson! Only staff members should be allowed to access this view. + """ + + if request.user.is_staff: + can_del = 1 + + # Get this list's object (to derive list.name, list.id, etc.) + list = get_object_or_404(List, slug=list_slug) + + # If delete confirmation is in the POST, delete all items in the list, then kill the list itself + if request.method == 'POST': + # Can the items + del_items = Item.objects.filter(list=list.id) + for del_item in del_items: + del_item.delete() + + # Kill the list + del_list = List.objects.get(id=list.id) + del_list.delete() + + # A var to send to the template so we can show the right thing + list_killed = 1 + + else: + item_count_done = Item.objects.filter(list=list.id,completed=1).count() + item_count_undone = Item.objects.filter(list=list.id,completed=0).count() + item_count_total = Item.objects.filter(list=list.id).count() + + return render_to_response('todo/del_list.html', locals(), context_instance=RequestContext(request)) + + +@login_required +def view_list(request,list_id=0,list_slug=None,view_completed=0): + + """ + Display and manage items in a task list + """ + + # Make sure the accessing user has permission to view this list. + # Always authorize the "mine" view. Admins can view/edit all lists. + + if list_slug == "mine" or list_slug == "recent-add" or list_slug == "recent-complete" : + auth_ok =1 + else: + list = get_object_or_404(List, slug=list_slug) + listid = list.id + + # Check whether current user is a member of the group this list belongs to. + if list.group in request.user.groups.all() or request.user.is_staff or list_slug == "mine" : + auth_ok = 1 # User is authorized for this view + else: # User does not belong to the group this list is attached to + request.user.message_set.create(message="You do not have permission to view/edit this list.") + + + # First check for items in the mark_done POST array. If present, change + # their status to complete. + if request.POST.getlist('mark_done'): + done_items = request.POST.getlist('mark_done') + # Iterate through array of done items and update its representation in the model + for thisitem in done_items: + p = Item.objects.get(id=thisitem) + p.completed = 1 + p.completed_date = datetime.datetime.now() + p.save() + request.user.message_set.create(message="Item \"%s\" marked complete." % p.title ) + + + # Undo: Set completed items back to incomplete + if request.POST.getlist('undo_completed_task'): + undone_items = request.POST.getlist('undo_completed_task') + for thisitem in undone_items: + p = Item.objects.get(id=thisitem) + p.completed = 0 + p.save() + request.user.message_set.create(message="Previously completed task \"%s\" marked incomplete." % p.title) + + + # And delete any requested items + if request.POST.getlist('del_task'): + deleted_items = request.POST.getlist('del_task') + for thisitem in deleted_items: + p = Item.objects.get(id=thisitem) + p.delete() + request.user.message_set.create(message="Item \"%s\" deleted." % p.title ) + + # And delete any *already completed* items + if request.POST.getlist('del_completed_task'): + deleted_items = request.POST.getlist('del_completed_task') + for thisitem in deleted_items: + p = Item.objects.get(id=thisitem) + p.delete() + request.user.message_set.create(message="Deleted previously completed item \"%s\"." % p.title) + + + thedate = datetime.datetime.now() + created_date = "%s-%s-%s" % (thedate.year, thedate.month, thedate.day) + + + # Get list of items with this list ID, or filter on items assigned to me, or recently added/completed + if list_slug == "mine": + task_list = Item.objects.filter(assigned_to=request.user, completed=0) + completed_list = Item.objects.filter(assigned_to=request.user, completed=1) + + elif list_slug == "recent-add": + # We'll assume this only includes uncompleted items to avoid confusion. + # Only show items in lists that are in groups that the current user is also in. + task_list = Item.objects.filter(list__group__in=(request.user.groups.all()),completed=0).order_by('-created_date')[:50] + # completed_list = Item.objects.filter(assigned_to=request.user, completed=1) + + elif list_slug == "recent-complete": + # Only show items in lists that are in groups that the current user is also in. + task_list = Item.objects.filter(list__group__in=request.user.groups.all(),completed=1).order_by('-completed_date')[:50] + # completed_list = Item.objects.filter(assigned_to=request.user, completed=1) + + + else: + task_list = Item.objects.filter(list=list.id, completed=0) + completed_list = Item.objects.filter(list=list.id, completed=1) + + + if request.POST.getlist('add_task') : + form = AddItemForm(list, request.POST,initial={ + 'assigned_to':request.user.id, + 'priority':999, + }) + + if form.is_valid(): + # Save task first so we have a db object to play with + new_task = form.save() + + # Send email alert only if the Notify checkbox is checked AND the assignee is not the same as the submittor + # Email subect and body format are handled by templates + if "notify" in request.POST : + if new_task.assigned_to != request.user : + + # Send email + email_subject = render_to_string("todo/email/assigned_subject.txt", { 'task': new_task }) + email_body = render_to_string("todo/email/assigned_body.txt", { 'task': new_task, 'site': current_site, }) + try: + send_mail(email_subject, email_body, new_task.created_by.email, [new_task.assigned_to.email], fail_silently=False) + except: + request.user.message_set.create(message="Task saved but mail not sent. Contact your administrator." ) + + request.user.message_set.create(message="New task \"%s\" has been added." % new_task.title ) + return HttpResponseRedirect(request.path) + + else: + if list_slug != "mine" and list_slug != "recent-add" and list_slug != "recent-complete" : # We don't allow adding a task on the "mine" view + form = AddItemForm(list, initial={ + 'assigned_to':request.user.id, + 'priority':999, + } ) + + if request.user.is_staff: + can_del = 1 + + return render_to_response('todo/view_list.html', locals(), context_instance=RequestContext(request)) + + +@login_required +def view_task(request,task_id): + + """ + View task details. Allow task details to be edited. + """ + + task = get_object_or_404(Item, pk=task_id) + comment_list = Comment.objects.filter(task=task_id) + + # Before doing anything, make sure the accessing user has permission to view this item. + # Determine the group this task belongs to, and check whether current user is a member of that group. + # Admins can edit all tasks. + + if task.list.group in request.user.groups.all() or request.user.is_staff: + + auth_ok = 1 + if request.POST: + form = EditItemForm(request.POST,instance=task) + + if form.is_valid(): + form.save() + + # Also save submitted comment, if non-empty + if request.POST['comment-body']: + c = Comment( + author=request.user, + task=task, + body=request.POST['comment-body'], + ) + c.save() + + # And email comment to all people who have participated in this thread. + email_subject = render_to_string("todo/email/assigned_subject.txt", { 'task': task }) + email_body = render_to_string("todo/email/newcomment_body.txt", { 'task': task, 'body':request.POST['comment-body'], 'site': current_site, 'user':request.user }) + + # Get list of all thread participants - task creator plus everyone who has commented on it. + recip_list = [] + recip_list.append(task.created_by.email) + commenters = Comment.objects.filter(task=task) + for c in commenters: + recip_list.append(c.author.email) + # Eliminate duplicate emails with the Python set() function + recip_list = set(recip_list) + + # Send message + try: + send_mail(email_subject, email_body, task.created_by.email, recip_list, fail_silently=False) + request.user.message_set.create(message="Comment sent to thread participants.") + except: + request.user.message_set.create(message="Comment saved but mail not sent. Contact your administrator." ) + + + request.user.message_set.create(message="The task has been edited.") + return HttpResponseRedirect(reverse('todo-incomplete_tasks', args=[task.list.id, task.list.slug])) + + else: + form = EditItemForm(instance=task) + if task.due_date: + thedate = task.due_date + else: + thedate = datetime.datetime.now() + + + else: + request.user.message_set.create(message="You do not have permission to view/edit this task.") + + return render_to_response('todo/view_task.html', locals(), context_instance=RequestContext(request)) + + + +@login_required +def reorder_tasks(request): + """ + Handle task re-ordering (priorities) from JQuery drag/drop in view_list.html + """ + + newtasklist = request.POST.getlist('tasktable[]') + # First item in received list is always empty - remove it + del newtasklist[0] + + # Items arrive in order, so all we need to do is increment up from one, saving + # "i" as the new priority for the current object. + i = 1 + for t in newtasklist: + newitem = Item.objects.get(pk=t) + newitem.priority = i + newitem.save() + i = i + 1 + + # All views must return an httpresponse of some kind ... without this we get + # error 500s in the log even though things look peachy in the browser. + return HttpResponse(status=201) + + +@login_required +def external_add(request): + """ + Allow users who don't have access to the rest of the ticket system to file a ticket in a specific list. + This is useful if, for example, a core web team are in a group that can file todos for each other, + but you also want students to be able to post trouble tickets to a list just for the sysadmin. This + way we don't have to put all students into a group that gives them access to the whole ticket system. + """ + if request.POST: + form = AddExternalItemForm(request.POST) + + if form.is_valid(): + # Don't commit the save until we've added in the fields we need to set + item = form.save(commit=False) + item.list_id = 20 # Hate hard-coding in IDs like this. + item.created_by = request.user + item.assigned_to = User.objects.get(username='roy_baril') + item.save() + + # Send email + email_subject = render_to_string("todo/email/assigned_subject.txt", { 'task': item.title }) + email_body = render_to_string("todo/email/assigned_body.txt", { 'task': item, 'site': current_site, }) + try: + send_mail(email_subject, email_body, item.created_by.email, [item.assigned_to.email], fail_silently=False) + except: + request.user.message_set.create(message="Task saved but mail not sent. Contact your administrator." ) + + request.user.message_set.create(message="Your trouble ticket has been submitted. We'll get back to you soon.") + return HttpResponseRedirect(reverse('intranet_home')) + + + else: + form = AddExternalItemForm() + + return render_to_response('todo/add_external_task.html', locals(), context_instance=RequestContext(request)) + + + +@login_required +def add_list(request): + """ + Allow users to add a new todo list to the group they're in. + """ + + if request.POST: + form = AddListForm(request.user,request.POST) + if form.is_valid(): + try: + form.save() + request.user.message_set.create(message="A new list has been added.") + return HttpResponseRedirect(request.path) + except IntegrityError: + request.user.message_set.create(message="There was a problem saving the new list. Most likely a list with the same name in the same group already exists.") + + else: + form = AddListForm(request.user) + + return render_to_response('todo/add_list.html', locals(), context_instance=RequestContext(request)) + + + + +@login_required +def search(request): + """ + Search for tasks + """ + + if request.GET: + + query_string = '' + found_items = None + if ('q' in request.GET) and request.GET['q'].strip(): + query_string = request.GET['q'] + + found_items = Item.objects.filter( + Q(title__icontains=query_string) | + Q(note__icontains=query_string) + ) + else: + + # What if they selected the "completed" toggle but didn't type in a query string? + # In that case we still need found_items in a queryset so it can be "excluded" below. + found_items = Item.objects.all() + + if request.GET['inc_complete'] == "0" : + found_items = found_items.exclude(completed=True) + + else : + query_string = None + found_items = None + + return render_to_response('todo/search_results.html', + { 'query_string': query_string, 'found_items': found_items }, + context_instance=RequestContext(request)) + + + + + + + \ No newline at end of file