]> git.ozlabs.org Git - patchwork/commitdiff
Inital commit
authorJeremy Kerr <jk@ozlabs.org>
Thu, 21 Aug 2008 01:38:06 +0000 (09:38 +0800)
committerJeremy Kerr <jk@ozlabs.org>
Thu, 21 Aug 2008 01:38:06 +0000 (09:38 +0800)
Signed-off-by: Jeremy Kerr <jk@ozlabs.org>
67 files changed:
apps/__init__.py [new file with mode: 0644]
apps/manage.py [new file with mode: 0755]
apps/patchwork/__init__.py [new file with mode: 0644]
apps/patchwork/bin/parsemail-batch.sh [new file with mode: 0644]
apps/patchwork/bin/parsemail.py [new file with mode: 0755]
apps/patchwork/bin/parsemail.sh [new file with mode: 0755]
apps/patchwork/bin/patchparser.py [new file with mode: 0644]
apps/patchwork/bin/setup.py [new file with mode: 0755]
apps/patchwork/bin/update-patchwork-status.py [new file with mode: 0755]
apps/patchwork/context_processors.py [new file with mode: 0644]
apps/patchwork/filters.py [new file with mode: 0644]
apps/patchwork/forms.py [new file with mode: 0644]
apps/patchwork/models.py [new file with mode: 0644]
apps/patchwork/paginator.py [new file with mode: 0644]
apps/patchwork/parser.py [new file with mode: 0644]
apps/patchwork/requestcontext.py [new file with mode: 0644]
apps/patchwork/sql/project.sql [new file with mode: 0644]
apps/patchwork/sql/state.sql [new file with mode: 0644]
apps/patchwork/templatetags/__init__.py [new file with mode: 0644]
apps/patchwork/templatetags/filter.py [new file with mode: 0644]
apps/patchwork/templatetags/listurl.py [new file with mode: 0644]
apps/patchwork/templatetags/order.py [new file with mode: 0644]
apps/patchwork/templatetags/patch.py [new file with mode: 0644]
apps/patchwork/templatetags/person.py [new file with mode: 0644]
apps/patchwork/templatetags/pwurl.py [new file with mode: 0644]
apps/patchwork/templatetags/syntax.py [new file with mode: 0644]
apps/patchwork/urls.py [new file with mode: 0644]
apps/patchwork/utils.py [new file with mode: 0644]
apps/patchwork/views/__init__.py [new file with mode: 0644]
apps/patchwork/views/base.py [new file with mode: 0644]
apps/patchwork/views/bundle.py [new file with mode: 0644]
apps/patchwork/views/patch.py [new file with mode: 0644]
apps/patchwork/views/user.py [new file with mode: 0644]
apps/settings.py [new file with mode: 0644]
apps/urls.py [new file with mode: 0644]
docs/INSTALL [new file with mode: 0644]
htdocs/css/style.css [new file with mode: 0644]
htdocs/images/filter-add.png [new file with mode: 0644]
htdocs/images/filter-remove.png [new file with mode: 0644]
htdocs/images/title-background.png [new file with mode: 0644]
htdocs/js/autocomplete.js [new file with mode: 0644]
htdocs/js/filters.js [new file with mode: 0644]
htdocs/js/people.js [new file with mode: 0644]
lib/apache2/patchwork.fastcgi.conf [new file with mode: 0644]
lib/apache2/patchwork.mod_python.conf [new file with mode: 0644]
lib/sql/grant-all.sql [new file with mode: 0644]
templates/patchwork/base.html [new file with mode: 0644]
templates/patchwork/bundle-public.html [new file with mode: 0644]
templates/patchwork/bundle.html [new file with mode: 0644]
templates/patchwork/filters.html [new file with mode: 0644]
templates/patchwork/list.html [new file with mode: 0644]
templates/patchwork/login.html [new file with mode: 0644]
templates/patchwork/logout.html [new file with mode: 0644]
templates/patchwork/pagination.html [new file with mode: 0644]
templates/patchwork/patch-form.html [new file with mode: 0644]
templates/patchwork/patch-list.html [new file with mode: 0644]
templates/patchwork/patch.html [new file with mode: 0644]
templates/patchwork/patchlist.html [new file with mode: 0644]
templates/patchwork/profile.html [new file with mode: 0644]
templates/patchwork/project.html [new file with mode: 0644]
templates/patchwork/projects.html [new file with mode: 0644]
templates/patchwork/register-confirm.html [new file with mode: 0644]
templates/patchwork/register.html [new file with mode: 0644]
templates/patchwork/todo-list.html [new file with mode: 0644]
templates/patchwork/todo-lists.html [new file with mode: 0644]
templates/patchwork/user-link-confirm.html [new file with mode: 0644]
templates/patchwork/user-link.html [new file with mode: 0644]

diff --git a/apps/__init__.py b/apps/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/apps/manage.py b/apps/manage.py
new file mode 100755 (executable)
index 0000000..1f6f0ed
--- /dev/null
@@ -0,0 +1,14 @@
+#!/usr/bin/python
+
+import sys
+
+from django.core.management import execute_manager
+try:
+    import settings # Assumed to be in the same directory.
+except ImportError:
+    import sys
+    sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+    sys.exit(1)
+
+if __name__ == "__main__":
+    execute_manager(settings)
diff --git a/apps/patchwork/__init__.py b/apps/patchwork/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/apps/patchwork/bin/parsemail-batch.sh b/apps/patchwork/bin/parsemail-batch.sh
new file mode 100644 (file)
index 0000000..dbf81cc
--- /dev/null
@@ -0,0 +1,49 @@
+#!/bin/sh
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+PATCHWORK_BASE="/srv/patchwork"
+
+if $# -ne 2
+then
+       echo "usage: $0 <dir>" >&2
+       exit 1
+fi
+
+mail_dir="$1"
+
+if ! -d "$mail_dir"
+then
+       echo "$mail_dir should be a directory"?&2
+       exit 1
+fi
+
+ls -1rt "$mail_dir" |
+while read line;
+do
+       echo $line
+       PYTHONPATH="$PATCHWORK_BASE/apps":"$PATCHWORK_BASE/lib/python" \
+               DJANGO_SETTINGS_MODULE=settings \
+               "$PATCHWORK_BASE/apps/patchworkbin/parsemail.py" <
+               "$mail_dir/$line"
+done
+
+
+
diff --git a/apps/patchwork/bin/parsemail.py b/apps/patchwork/bin/parsemail.py
new file mode 100755 (executable)
index 0000000..d41bd92
--- /dev/null
@@ -0,0 +1,263 @@
+#!/usr/bin/python
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+import sys
+import re
+import datetime
+import time
+import operator
+from email import message_from_file
+from email.header import Header
+from email.utils import parsedate_tz, mktime_tz
+
+from patchparser import parse_patch
+from patchwork.models import Patch, Project, Person, Comment
+
+list_id_headers = ['List-ID', 'X-Mailing-List']
+
+def find_project(mail):
+    project = None
+    listid_re = re.compile('.*<([^>]+)>.*', re.S)
+
+    for header in list_id_headers:
+        if header in mail:
+            match = listid_re.match(mail.get(header))
+            if not match:
+                continue
+
+            listid = match.group(1)
+
+            try:
+                project = Project.objects.get(listid = listid)
+                break
+            except:
+                pass
+
+    return project
+
+def find_author(mail):
+
+    from_header = mail.get('From').strip()
+    (name, email) = (None, None)
+
+    # tuple of (regex, fn)
+    #  - where fn returns a (name, email) tuple from the match groups resulting
+    #    from re.match().groups()
+    from_res = [
+        # for "Firstname Lastname" <example@example.com> style addresses
+       (re.compile('"?(.*?)"?\s*<([^>]+)>'), (lambda g: (g[0], g[1]))),
+
+       # for example@example.com (Firstname Lastname) style addresses
+       (re.compile('"?(.*?)"?\s*\(([^\)]+)\)'), (lambda g: (g[1], g[0]))),
+
+       # everything else
+       (re.compile('(.*)'), (lambda g: (None, g[0]))),
+    ]
+
+    for regex, fn in from_res:
+        match = regex.match(from_header)
+        if match:
+            (name, email) = fn(match.groups())
+            break
+
+    if email is None:
+        raise Exception("Could not parse From: header")
+
+    email = email.strip()
+    if name is not None:
+        name = name.strip()
+
+    try:
+        person = Person.objects.get(email = email)
+    except Person.DoesNotExist:
+        person = Person(name = name, email = email)
+
+    return person
+
+def mail_date(mail):
+    t = parsedate_tz(mail.get('Date', ''))
+    if not t:
+        print "using now()"
+        return datetime.datetime.utcnow()
+    return datetime.datetime.utcfromtimestamp(mktime_tz(t))
+
+def mail_headers(mail):
+    return reduce(operator.__concat__,
+            ['%s: %s\n' % (k, Header(v, header_name = k, \
+                    continuation_ws = '\t').encode()) \
+                for (k, v) in mail.items()])
+
+def find_content(project, mail):
+    patchbuf = None
+    commentbuf = ''
+
+    for part in mail.walk():
+        if part.get_content_maintype() != 'text':
+            continue
+
+        #print "\t%s, %s" % \
+        #    (part.get_content_subtype(), part.get_content_charset())
+
+        charset = part.get_content_charset()
+        if not charset:
+            charset = mail.get_charset()
+        if not charset:
+            charset = 'utf-8'
+
+        payload = unicode(part.get_payload(decode=True), charset, "replace")
+
+        if part.get_content_subtype() == 'x-patch':
+            patchbuf = payload
+
+        if part.get_content_subtype() == 'plain':
+            if not patchbuf:
+                (patchbuf, c) = parse_patch(payload)
+            else:
+                c = payload
+
+            if c is not None:
+                commentbuf += c.strip() + '\n'
+
+    patch = None
+    comment = None
+
+    if patchbuf:
+        mail_headers(mail)
+        patch = Patch(name = clean_subject(mail.get('Subject')),
+                content = patchbuf, date = mail_date(mail),
+                headers = mail_headers(mail))
+
+    if commentbuf:
+        if patch:
+           cpatch = patch
+       else:
+            cpatch = find_patch_for_comment(mail)
+            if not cpatch:
+                return (None, None)
+        comment = Comment(patch = cpatch, date = mail_date(mail),
+                content = clean_content(commentbuf),
+                headers = mail_headers(mail))
+
+    return (patch, comment)
+
+def find_patch_for_comment(mail):
+    # construct a list of possible reply message ids
+    refs = []
+    if 'In-Reply-To' in mail:
+        refs.append(mail.get('In-Reply-To'))
+
+    if 'References' in mail:
+        rs = mail.get('References').split()
+        rs.reverse()
+        for r in rs:
+            if r not in refs:
+                refs.append(r)
+
+    for ref in refs:
+        patch = None
+
+        # first, check for a direct reply
+        try:
+            patch = Patch.objects.get(msgid = ref)
+            return patch
+        except Patch.DoesNotExist:
+            pass
+
+        # see if we have comments that refer to a patch
+        try:
+            comment = Comment.objects.get(msgid = ref)
+            return comment.patch
+        except Comment.DoesNotExist:
+            pass
+
+
+    return None
+
+re_re = re.compile('^(re|fwd?)[:\s]\s*', re.I)
+prefix_re = re.compile('^\[.*\]\s*')
+whitespace_re = re.compile('\s+')
+
+def clean_subject(subject):
+    subject = re_re.sub(' ', subject)
+    subject = prefix_re.sub('', subject)
+    subject = whitespace_re.sub(' ', subject)
+    return subject.strip()
+
+sig_re = re.compile('^(-{2,3} ?|_+)\n.*', re.S | re.M)
+def clean_content(str):
+    str = sig_re.sub('', str)
+    return str.strip()
+
+def main(args):
+    mail = message_from_file(sys.stdin)
+
+    # some basic sanity checks
+    if 'From' not in mail:
+        return 0
+
+    if 'Subject' not in mail:
+        return 0
+
+    if 'Message-Id' not in mail:
+        return 0
+
+    hint = mail.get('X-Patchwork-Hint', '').lower()
+    if hint == 'ignore':
+        return 0;
+
+    project = find_project(mail)
+    if project is None:
+        print "no project found"
+        return 0
+
+    msgid = mail.get('Message-Id').strip()
+
+    author = find_author(mail)
+
+    (patch, comment) = find_content(project, mail)
+
+    if patch:
+        author.save()
+        patch.submitter = author
+        patch.msgid = msgid
+        patch.project = project
+        try:
+            patch.save()
+        except Exception, ex:
+            print ex.message
+
+    if comment:
+        author.save()
+        # looks like the original constructor for Comment takes the pk
+        # when the Comment is created. reset it here.
+        if patch:
+            comment.patch = patch
+        comment.submitter = author
+        comment.msgid = msgid
+        try:
+            comment.save()
+        except Exception, ex:
+            print ex.message
+
+    return 0
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))
diff --git a/apps/patchwork/bin/parsemail.sh b/apps/patchwork/bin/parsemail.sh
new file mode 100755 (executable)
index 0000000..0178e18
--- /dev/null
@@ -0,0 +1,28 @@
+#!/bin/sh
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+PATCHWORK_BASE="/srv/patchwork"
+
+PYTHONPATH="$PATCHWORK_BASE/apps":"$PATCHWORK_BASE/lib/python" \
+        DJANGO_SETTINGS_MODULE=settings \
+        "$PATCHWORK_BASE/apps/patchworkbin/parsemail.py"
+
+exit 0
diff --git a/apps/patchwork/bin/patchparser.py b/apps/patchwork/bin/patchparser.py
new file mode 100644 (file)
index 0000000..16d1de4
--- /dev/null
@@ -0,0 +1,158 @@
+#!/usr/bin/python
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+import re
+
+def parse_patch(text):
+    patchbuf = ''
+    commentbuf = ''
+    buf = ''
+
+    # state specified the line we just saw, and what to expect next
+    state = 0
+    # 0: text
+    # 1: suspected patch header (diff, ====, Index:)
+    # 2: patch header line 1 (---)
+    # 3: patch header line 2 (+++)
+    # 4: patch hunk header line (@@ line)
+    # 5: patch hunk content
+    #
+    # valid transitions:
+    #  0 -> 1 (diff, ===, Index:)
+    #  0 -> 2 (---)
+    #  1 -> 2 (---)
+    #  2 -> 3 (+++)
+    #  3 -> 4 (@@ line)
+    #  4 -> 5 (patch content)
+    #  5 -> 1 (run out of lines from @@-specifed count)
+    #
+    # Suspected patch header is stored into buf, and appended to
+    # patchbuf if we find a following hunk. Otherwise, append to
+    # comment after parsing.
+
+    # line counts while parsing a patch hunk
+    lc = (0, 0)
+    hunk = 0
+
+    hunk_re = re.compile('^\@\@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? \@\@')
+
+    for line in text.split('\n'):
+        line += '\n'
+
+        if state == 0:
+            if line.startswith('diff') or line.startswith('===') \
+                    or line.startswith('Index: '):
+                state = 1
+                buf += line
+
+            elif line.startswith('--- '):
+                state = 2
+                buf += line
+
+            else:
+                commentbuf += line
+
+        elif state == 1:
+            buf += line
+            if line.startswith('--- '):
+                state = 2
+
+        elif state == 2:
+            if line.startswith('+++ '):
+                state = 3
+                buf += line
+
+            elif hunk:
+                state = 1
+                buf += line
+
+            else:
+                state = 0
+                commentbuf += buf + line
+                buf = ''
+
+        elif state == 3:
+            match = hunk_re.match(line)
+            if match:
+
+                def fn(x):
+                    if not x:
+                        return 1
+                    return int(x)
+
+                lc = map(fn, match.groups())
+
+                state = 4
+                patchbuf += buf + line
+                buf = ''
+
+            elif line.startswith('--- '):
+                patchbuf += buf + line
+                buf = ''
+                state = 2
+
+            elif hunk:
+                state = 1
+                buf += line
+
+            else:
+                state = 0
+                commentbuf += buf + line
+                buf = ''
+
+        elif state == 4 or state == 5:
+            if line.startswith('-'):
+                lc[0] -= 1
+            elif line.startswith('+'):
+                lc[1] -= 1
+            else:
+                lc[0] -= 1
+                lc[1] -= 1
+
+            patchbuf += line
+
+            if lc[0] <= 0 and lc[1] <= 0:
+                state = 3
+                hunk += 1
+            else:
+                state = 5
+
+        else:
+            raise Exception("Unknown state %d! (line '%s')" % (state, line))
+
+    commentbuf += buf
+
+    if patchbuf == '':
+        patchbuf = None
+
+    if commentbuf == '':
+        commentbuf = None
+
+    return (patchbuf, commentbuf)
+
+if __name__ == '__main__':
+    import sys
+    (patch, comment) = parse_patch(sys.stdin.read())
+    if patch:
+        print "Patch: ------\n" + patch
+    if comment:
+        print "Comment: ----\n" + comment
diff --git a/apps/patchwork/bin/setup.py b/apps/patchwork/bin/setup.py
new file mode 100755 (executable)
index 0000000..7d55815
--- /dev/null
@@ -0,0 +1,29 @@
+#!/usr/bin/env python
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from patchwork.models import UserProfile
+from django.contrib.auth.models import User
+
+# give each existing user a userprofile
+for user in User.objects.all():
+    p = UserProfile(user = user)
+    p.save()
diff --git a/apps/patchwork/bin/update-patchwork-status.py b/apps/patchwork/bin/update-patchwork-status.py
new file mode 100755 (executable)
index 0000000..c774d63
--- /dev/null
@@ -0,0 +1,70 @@
+#!/usr/bin/env python
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+import sys
+import subprocess
+from optparse import OptionParser
+
+def commits(options, revlist):
+    cmd = ['git-rev-list', revlist]
+    proc = subprocess.Popen(cmd, stdout = subprocess.PIPE, cwd = options.repodir)
+
+    revs = []
+
+    for line in proc.stdout.readlines():
+        revs.append(line.strip())
+
+    return revs
+
+def commit(options, rev):
+    cmd = ['git-diff', '%(rev)s^..%(rev)s' % {'rev': rev}]
+    proc = subprocess.Popen(cmd, stdout = subprocess.PIPE, cwd = options.repodir)
+
+    buf = proc.communicate()[0]
+
+    return buf
+
+
+def main(args):
+    parser = OptionParser(usage = '%prog [options] revspec')
+    parser.add_option("-p", "--project", dest = "project", action = 'store',
+                  help="use project PROJECT", metavar="PROJECT")
+    parser.add_option("-d", "--dir", dest = "repodir", action = 'store',
+                  help="use git repo in DIR", metavar="DIR")
+
+    (options, args) = parser.parse_args(args[1:])
+
+    if len(args) != 1:
+        parser.error("incorrect number of arguments")
+
+    revspec = args[0]
+    revs = commits(options, revspec)
+
+    for rev in revs:
+        print rev
+        print commit(options, rev)
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))
+
+
diff --git a/apps/patchwork/context_processors.py b/apps/patchwork/context_processors.py
new file mode 100644 (file)
index 0000000..f4ab5a9
--- /dev/null
@@ -0,0 +1,32 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from patchwork.models import Bundle
+from patchwork.utils import order_map, get_order
+
+def bundle(request):
+    user = request.user
+    if not user.is_authenticated():
+        return {}
+    return {'bundles': Bundle.objects.filter(owner = user)}
+
+
+def patchlists(request):
+
diff --git a/apps/patchwork/filters.py b/apps/patchwork/filters.py
new file mode 100644 (file)
index 0000000..f7fb652
--- /dev/null
@@ -0,0 +1,433 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from patchwork.models import Person, State
+from django.utils.safestring import mark_safe
+from django.utils.html import escape
+from django.contrib.auth.models import User
+
+class Filter(object):
+    def __init__(self, filters):
+        self.filters = filters
+        self.applied = False
+        self.forced = False
+
+    def name(self):
+        """The 'name' of the filter, to be displayed in the filter UI"""
+        return self.name
+
+    def condition(self):
+        """The current condition of the filter, to be displayed in the
+           filter UI"""
+        return self.key
+
+    def key(self):
+        """The key for this filter, to appear in the querystring. A key of
+           None will remove the param=ley pair from the querystring."""
+        return None
+
+    def set_status(self, *kwargs):
+        """Views can call this to force a specific filter status. For example,
+           a user's todo page needs to setup the delegate filter to show
+           that user's delegated patches"""
+        pass
+
+    def parse(self, dict):
+        if self.param not in dict.keys():
+            return
+        self._set_key(dict[self.param])
+
+    def url_without_me(self):
+        return self.filters.querystring_without_filter(self)
+
+    def form_function(self):
+        return 'function(form) { return "unimplemented" }'
+
+    def form(self):
+        if self.forced:
+            return mark_safe('<input type="hidden" value="%s">%s' % (self.param,
+                        self.condition()))
+            return self.condition()
+        return self._form()
+
+    def kwargs(self):
+        return {}
+
+    def __str__(self):
+        return '%s: %s' % (self.name, self.kwargs())
+
+
+class SubmitterFilter(Filter):
+    param = 'submitter'
+    def __init__(self, filters):
+        super(SubmitterFilter, self).__init__(filters)
+        self.name = 'Submitter'
+       self.person = None
+        self.person_match = None
+
+    def _set_key(self, str):
+        self.person = None
+        self.person_match = None
+        submitter_id = None
+        try:
+            submitter_id = int(str)
+        except ValueError:
+            pass
+        except:
+            return
+
+        if submitter_id:
+            self.person = Person.objects.get(id = int(str))
+            self.applied = True
+            return
+
+
+        people = Person.objects.filter(name__icontains = str)
+
+        if not people:
+            return
+
+        self.person_match = str
+        self.applied = True
+
+    def kwargs(self):
+        if self.person:
+            user = self.person.user
+            if user:
+                return {'submitter__in':
+                    Person.objects.filter(user = user).values('pk').query}
+            return {'submitter': self.person}
+
+        if self.person_match:
+            return {'submitter__name__icontains': self.person_match}
+        return {}
+
+    def condition(self):
+        if self.person:
+            return self.person.name
+        elif self.person_match:
+            return self.person_match
+        return ''
+
+    def _form(self):
+       name = ''
+       if self.person:
+           name = self.person.name
+       return mark_safe(('<input onKeyUp="submitter_field_change(this)" ' +
+                       'name="submitter" id="submitter_input" ' +
+                        'value="%s">&nbsp;' % escape(name)) +
+                       '<select id="submitter_select" ' +
+                       'disabled="true"></select>')
+
+    def key(self):
+        if self.person:
+            return self.person.id
+        return self.person_match
+
+class StateFilter(Filter):
+    param = 'state'
+    def __init__(self, filters):
+        super(StateFilter, self).__init__(filters)
+        self.name = 'State'
+       self.state = None
+
+    def _set_key(self, str):
+        try:
+            self.state = State.objects.get(id=int(str))
+        except:
+            return
+
+        self.applied = True
+
+    def kwargs(self):
+        return {'state': self.state}
+
+    def condition(self):
+        return self.state.name
+
+    def key(self):
+        if self.state is None:
+            return None
+        return self.state.id
+
+    def _form(self):
+        str = '<select name="%s">' % self.param
+        str += '<option value="">any</option>'
+        for state in State.objects.all():
+           selected = ''
+            if self.state and self.state == state:
+               selected = ' selected="true"'
+
+            str += '<option value="%d" %s>%s</option>' % \
+               (state.id, selected, state.name)
+        str += '</select>'
+        return mark_safe(str);
+
+    def form_function(self):
+        return 'function(form) { return form.x.value }'
+
+class SearchFilter(Filter):
+    param = 'q'
+    def __init__(self, filters):
+        super(SearchFilter, self).__init__(filters)
+        self.name = 'Search'
+        self.param = 'q'
+       self.search = None
+
+    def _set_key(self, str):
+       str = str.strip()
+       if str == '':
+           return
+        self.search = str
+        self.applied = True
+
+    def kwargs(self):
+        return {'name__icontains': self.search}
+
+    def condition(self):
+        return self.search
+
+    def key(self):
+        return self.search
+
+    def _form(self):
+       value = ''
+       if self.search:
+           value = escape(self.search)
+        return mark_safe('<input name="%s" value="%s">' %\
+                       (self.param, value))
+
+    def form_function(self):
+        return mark_safe('function(form) { return form.x.value }')
+
+class ArchiveFilter(Filter):
+    param = 'archive'
+    def __init__(self, filters):
+        super(ArchiveFilter, self).__init__(filters)
+        self.name = 'Archived'
+        self.archive_state = False
+        self.applied = True
+        self.param_map = {
+            True: 'true',
+            False: '',
+            None:  'both'
+        }
+        self.description_map = {
+            True: 'Yes',
+            False: 'No',
+            None: 'Both'
+        }
+
+    def _set_key(self, str):
+        self.archive_state = False
+        self.applied = True
+        for (k, v) in self.param_map.iteritems():
+            if str == v:
+                self.archive_state = k
+        if self.archive_state == None:
+            self.applied = False
+
+    def kwargs(self):
+        if self.archive_state == None:
+            return {}
+        return {'archived': self.archive_state}
+
+    def condition(self):
+        return self.description_map[self.archive_state]
+
+    def key(self):
+       if self.archive_state == False:
+           return None
+       return self.param_map[self.archive_state]
+
+    def _form(self):
+        s = ''
+        for b in [False, True, None]:
+            label = self.description_map[b]
+            selected = ''
+            if self.archive_state == b:
+                selected = 'checked="true"'
+            s += ('<input type="radio" name="%(param)s" ' + \
+                   '%(selected)s value="%(value)s">%(label)s' + \
+                   '&nbsp;&nbsp;&nbsp;&nbsp;') % \
+                    {'label': label,
+                     'param': self.param,
+                     'selected': selected,
+                     'value': self.param_map[b]
+                    }
+        return mark_safe(s)
+
+    def url_without_me(self):
+        qs = self.filters.querystring_without_filter(self)
+        if qs != '?':
+            qs += '&'
+        return qs + 'archive=both'
+
+
+class DelegateFilter(Filter):
+    param = 'delegate'
+    AnyDelegate = 1
+
+    def __init__(self, filters):
+        super(DelegateFilter, self).__init__(filters)
+        self.name = 'Delegate'
+        self.param = 'delegate'
+
+        # default to applied, but no delegate - this will result in patches with
+        # no delegate
+        self.delegate = None
+        self.applied = True
+
+    def _set_key(self, str):
+        if str == "*":
+            self.applied = False
+            self.delegate = None
+            return
+
+        applied = False
+        try:
+            self.delegate = User.objects.get(id = str)
+            self.applied = True
+        except:
+            pass
+
+    def kwargs(self):
+        if not self.applied:
+            return {}
+        return {'delegate': self.delegate}
+
+    def condition(self):
+        if self.delegate:
+            return self.delegate.get_profile().name()
+        return 'Nobody'
+
+    def _form(self):
+        delegates = User.objects.filter(userprofile__maintainer_projects =
+                self.filters.project)
+
+        str = '<select name="delegate">'
+
+        selected = ''
+        if not self.applied:
+            selected = 'selected'
+
+        str += '<option %s value="*">------</option>' % selected
+
+        selected = ''
+        if self.delegate is None:
+            selected = 'selected'
+
+        str += '<option %s value="">Nobody</option>' % selected
+
+        for d in delegates:
+            selected = ''
+            if d == self.delegate:
+                selected = ' selected'
+
+            str += '<option %s value="%s">%s</option>' % (selected,
+                    d.id, d.get_profile().name())
+        str += '</select>'
+
+        return mark_safe(str)
+
+    def key(self):
+        if self.delegate:
+            return self.delegate.id
+        if self.applied:
+            return None
+        return '*'
+
+    def url_without_me(self):
+        qs = self.filters.querystring_without_filter(self)
+        if qs != '?':
+            qs += '&'
+        return qs + ('%s=*' % self.param)
+
+    def set_status(self, *args, **kwargs):
+        if 'delegate' in kwargs:
+            self.applied = self.forced = True
+            self.delegate = kwargs['delegate']
+        if self.AnyDelegate in args:
+            self.applied = False
+            self.forced = True
+
+filterclasses = [SubmitterFilter, \
+                 StateFilter,
+                 SearchFilter,
+                 ArchiveFilter,
+                 DelegateFilter]
+
+class Filters:
+
+    def __init__(self, request):
+        self._filters = map(lambda c: c(self), filterclasses)
+        self.dict = request.GET
+        self.project = None
+
+        for f in self._filters:
+            f.parse(self.dict)
+
+    def set_project(self, project):
+        self.project = project
+
+    def filter_conditions(self):
+        kwargs = {}
+        for f in self._filters:
+            if f.applied:
+                kwargs.update(f.kwargs())
+        return kwargs
+
+    def apply(self, queryset):
+        kwargs = self.filter_conditions()
+        if not kwargs:
+            return queryset
+        return queryset.filter(**kwargs)
+
+    def params(self):
+        return [ (f.param, f.key()) for f in self._filters \
+               if f.key() is not None ]
+
+    def querystring(self, remove = None):
+        params = dict(self.params())
+
+        for (k, v) in self.dict.iteritems():
+            if k not in params:
+                params[k] = v[0]
+
+        if remove is not None:
+            if remove.param in params.keys():
+                del params[remove.param]
+
+        return '?' + '&'.join(['%s=%s' % x for x in params.iteritems()])
+
+    def querystring_without_filter(self, filter):
+        return self.querystring(filter)
+
+    def applied_filters(self):
+        return filter(lambda x: x.applied, self._filters)
+
+    def available_filters(self):
+        return self._filters
+
+    def set_status(self, filterclass, *args, **kwargs):
+        for f in self._filters:
+            if isinstance(f, filterclass):
+                f.set_status(*args, **kwargs)
+                return
diff --git a/apps/patchwork/forms.py b/apps/patchwork/forms.py
new file mode 100644 (file)
index 0000000..ed55c4f
--- /dev/null
@@ -0,0 +1,213 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from django.contrib.auth.models import User
+from django import newforms as forms
+
+from patchwork.models import RegistrationRequest, Patch, State, Bundle, \
+         UserProfile
+
+class RegisterForm(forms.ModelForm):
+    password = forms.CharField(widget = forms.PasswordInput)
+    email = forms.EmailField(max_length = 200)
+
+    class Meta:
+        model = RegistrationRequest
+        exclude = ['key']
+
+    def clean_email(self):
+        value = self.cleaned_data['email']
+        try:
+            User.objects.get(email = value)
+            raise forms.ValidationError(('The email address %s has ' +
+                    'has already been registered') % value)
+        except User.DoesNotExist:
+            pass
+        try:
+            RegistrationRequest.objects.get(email = value)
+            raise forms.ValidationError(('The email address %s has ' +
+                    'has already been registered') % value)
+        except RegistrationRequest.DoesNotExist:
+            pass
+        return value
+
+    def clean_username(self):
+        value = self.cleaned_data['username']
+        try:
+            User.objects.get(username = value)
+            raise forms.ValidationError(('The username %s has ' +
+                    'has already been registered') % value)
+        except User.DoesNotExist:
+            pass
+        try:
+            RegistrationRequest.objects.get(username = value)
+            raise forms.ValidationError(('The username %s has ' +
+                    'has already been registered') % value)
+        except RegistrationRequest.DoesNotExist:
+            pass
+        return value
+
+class LoginForm(forms.Form):
+    username = forms.CharField(max_length = 30)
+    password = forms.CharField(widget = forms.PasswordInput)
+
+class BundleForm(forms.ModelForm):
+    class Meta:
+        model = Bundle
+        fields = ['name', 'public']
+
+class CreateBundleForm(forms.ModelForm):
+    def __init__(self, *args, **kwargs):
+        super(CreateBundleForm, self).__init__(*args, **kwargs)
+
+    class Meta:
+        model = Bundle
+        fields = ['name']
+
+    def clean_name(self):
+        name = self.cleaned_data['name']
+        count = Bundle.objects.filter(owner = self.instance.owner, \
+                name = name).count()
+        if count > 0:
+            raise forms.ValidationError('A bundle called %s already exists' \
+                    % name)
+        return name
+
+class DelegateField(forms.ModelChoiceField):
+    def __init__(self, project, *args, **kwargs):
+       queryset = User.objects.filter(userprofile__in = \
+                UserProfile.objects \
+                        .filter(maintainer_projects = project) \
+                        .values('pk').query)
+        super(DelegateField, self).__init__(queryset, *args, **kwargs)
+
+
+class PatchForm(forms.ModelForm):
+    def __init__(self, instance = None, project = None, *args, **kwargs):
+       if (not project) and instance:
+            project = instance.project
+        if not project:
+           raise Exception("meep")
+        super(PatchForm, self).__init__(instance = instance, *args, **kwargs)
+        self.fields['delegate'] = DelegateField(project)
+
+    class Meta:
+        model = Patch
+        fields = ['state', 'archived', 'delegate']
+
+class UserProfileForm(forms.ModelForm):
+    class Meta:
+        model = UserProfile
+        fields = ['primary_project', 'patches_per_page']
+
+class OptionalDelegateField(DelegateField):
+    no_change_choice = ('*', 'no change')
+
+    def __init__(self, no_change_choice = None, *args, **kwargs):
+        self.filter = None
+        if (no_change_choice):
+            self.no_change_choice = no_change_choice
+        super(OptionalDelegateField, self). \
+            __init__(initial = self.no_change_choice[0], *args, **kwargs)
+
+    def _get_choices(self):
+        choices = list(
+                super(OptionalDelegateField, self)._get_choices())
+        choices.append(self.no_change_choice)
+        return choices
+
+    choices = property(_get_choices, forms.ChoiceField._set_choices)
+
+    def is_no_change(self, value):
+        return value == self.no_change_choice[0]
+
+    def clean(self, value):
+        if value == self.no_change_choice[0]:
+            return value
+        return super(OptionalDelegateField, self).clean(value)
+
+class OptionalModelChoiceField(forms.ModelChoiceField):
+    no_change_choice = ('*', 'no change')
+
+    def __init__(self, no_change_choice = None, *args, **kwargs):
+        self.filter = None
+        if (no_change_choice):
+            self.no_change_choice = no_change_choice
+        super(OptionalModelChoiceField, self). \
+            __init__(initial = self.no_change_choice[0], *args, **kwargs)
+
+    def _get_choices(self):
+        choices = list(
+                super(OptionalModelChoiceField, self)._get_choices())
+        choices.append(self.no_change_choice)
+        return choices
+
+    choices = property(_get_choices, forms.ChoiceField._set_choices)
+
+    def is_no_change(self, value):
+        return value == self.no_change_choice[0]
+
+    def clean(self, value):
+        if value == self.no_change_choice[0]:
+            return value
+        return super(OptionalModelChoiceField, self).clean(value)
+
+class MultipleBooleanField(forms.ChoiceField):
+    no_change_choice = ('*', 'no change')
+    def __init__(self, *args, **kwargs):
+        super(MultipleBooleanField, self).__init__(*args, **kwargs)
+        self.choices = [self.no_change_choice] + \
+                [(True, 'Archived'), (False, 'Unarchived')]
+
+    def is_no_change(self, value):
+        return value == self.no_change_choice[0]
+
+class MultiplePatchForm(PatchForm):
+    state = OptionalModelChoiceField(queryset = State.objects.all())
+    archived = MultipleBooleanField()
+
+    def __init__(self, project, *args, **kwargs):
+        super(MultiplePatchForm, self).__init__(project = project,
+                *args, **kwargs)
+        self.fields['delegate'] = OptionalDelegateField(project = project)
+
+    def save(self, instance, commit = True):
+        opts = instance.__class__._meta
+        if self.errors:
+            raise ValueError("The %s could not be changed because the data "
+                    "didn't validate." % opts.object_name)
+        data = self.cleaned_data
+        # remove 'no change fields' from the data
+        for f in opts.fields:
+            if not f.name in data:
+                continue
+
+            field = getattr(self, f.name, None)
+            if not field:
+                continue
+
+            if field.is_no_change(data[f.name]):
+                del data[f.name]
+
+        return forms.save_instance(self, instance,
+                self._meta.fields, 'changed', commit)
+
+class UserPersonLinkForm(forms.Form):
+    email = forms.EmailField(max_length = 200)
diff --git a/apps/patchwork/models.py b/apps/patchwork/models.py
new file mode 100644 (file)
index 0000000..f6943fc
--- /dev/null
@@ -0,0 +1,362 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django.db import models
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.contrib.sites.models import Site
+from django.conf import settings
+import django.oldforms as oldforms
+
+import re
+import datetime, time
+import string
+import random
+import hashlib
+from email.mime.text import MIMEText
+import email.utils
+
+class Person(models.Model):
+    email = models.CharField(max_length=255, unique = True)
+    name = models.CharField(max_length=255, null = True)
+    user = models.ForeignKey(User, null = True)
+
+    def __str__(self):
+        if self.name:
+            return '%s <%s>' % (self.name, self.email)
+        else:
+            return self.email
+
+    def link_to_user(self, user):
+        self.name = user.get_profile().name()
+        self.user = user
+
+    class Meta:
+        verbose_name_plural = 'People'
+
+    class Admin:
+        pass
+
+class Project(models.Model):
+    linkname = models.CharField(max_length=255, unique=True)
+    name = models.CharField(max_length=255, unique=True)
+    listid = models.CharField(max_length=255, unique=True)
+    listemail = models.CharField(max_length=200)
+
+    def __str__(self):
+        return self.name
+
+    class Admin:
+        pass
+
+class UserProfile(models.Model):
+    user = models.ForeignKey(User, unique = True)
+    primary_project = models.ForeignKey(Project, null = True)
+    maintainer_projects = models.ManyToManyField(Project,
+            related_name = 'maintainer_project')
+    send_email = models.BooleanField(default = False,
+            help_text = 'Selecting this option allows patchwork to send ' +
+                'email on your behalf')
+    patches_per_page = models.PositiveIntegerField(default = 100,
+            null = False, blank = False,
+            help_text = 'Number of patches to display per page')
+
+    def name(self):
+        if self.user.first_name or self.user.last_name:
+           names = filter(bool, [self.user.first_name, self.user.last_name])
+           return ' '.join(names)
+        return self.user.username
+
+    def contributor_projects(self):
+        submitters = Person.objects.filter(user = self.user)
+        return Project.objects \
+            .filter(id__in = \
+                    Patch.objects.filter(
+                        submitter__in = submitters) \
+                    .values('project_id').query)
+
+
+    def sync_person(self):
+        pass
+
+    def n_todo_patches(self):
+        return self.todo_patches().count()
+
+    def todo_patches(self, project = None):
+
+        # filter on project, if necessary
+        if project:
+            qs = Patch.objects.filter(project = project)
+        else:
+            qs = Patch.objects
+
+        qs = qs.filter(archived = False) \
+             .filter(delegate = self.user) \
+             .filter(state__in = \
+                     State.objects.filter(action_required = True) \
+                         .values('pk').query)
+        return qs
+
+    def save(self):
+       super(UserProfile, self).save()
+       people = Person.objects.filter(email = self.user.email)
+       if not people:
+           person = Person(email = self.user.email,
+                   name = self.name(), user = self.user)
+            person.save()
+       else:
+           for person in people:
+                person.user = self.user
+                person.save()
+
+    class Admin:
+        pass
+
+    def __str__(self):
+        return self.name()
+
+def _confirm_key():
+    allowedchars = string.ascii_lowercase + string.digits
+    str = ''
+    for i in range(1, 32):
+        str += random.choice(allowedchars)
+    return str;
+
+class RegistrationRequest(models.Model):
+    username = models.CharField(max_length = 30, unique = True)
+    first_name = models.CharField(max_length = 50)
+    last_name = models.CharField(max_length = 50)
+    email = models.CharField(max_length = 200, unique = True)
+    password = models.CharField(max_length = 200)
+    key = models.CharField(max_length = 32, default = _confirm_key)
+
+    def create_user(self):
+        user = User.objects.create_user(self.username,
+                self.email, self.password)
+        user.first_name = self.first_name
+        user.last_name = self.last_name
+        user.save()
+        profile = UserProfile(user = user)
+        profile.save()
+        self.delete()
+
+        # link a person to this user. if none exists, create.
+        person = None
+        try:
+            person = Person.objects.get(email = user.email)
+        except Exception:
+            pass
+        if not person:
+            person = Person(email = user.email)
+
+        person.link_to_user(user)
+        person.save()
+
+        return user
+
+    class Admin:
+        pass
+
+class UserPersonConfirmation(models.Model):
+    user = models.ForeignKey(User)
+    email = models.CharField(max_length = 200)
+    date = models.DateTimeField(default=datetime.datetime.now)
+    key = models.CharField(max_length = 32, default = _confirm_key)
+
+    def confirm(self):
+        person = None
+        try:
+            person = Person.objects.get(email = self.email)
+        except Exception:
+            pass
+        if not person:
+            person = Person(email = self.email)
+
+        person.link_to_user(self.user)
+        person.save()
+
+
+    class Admin:
+        pass
+
+
+class State(models.Model):
+    name = models.CharField(max_length = 100)
+    ordering = models.IntegerField(unique = True)
+    action_required = models.BooleanField(default = True)
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        ordering = ['ordering']
+
+    class Admin:
+        pass
+
+class HashField(models.Field):
+    __metaclass__ = models.SubfieldBase
+
+    def __init__(self, algorithm = 'sha1', *args, **kwargs):
+        self.algorithm = algorithm
+        super(HashField, self).__init__(*args, **kwargs)
+
+    def db_type(self):
+        n_bytes = len(hashlib.new(self.algorithm).digest())
+       if settings.DATABASE_ENGINE == 'postgresql':
+           return 'bytea'
+       elif settings.DATABASE_ENGINE == 'mysql':
+           return 'binary(%d)' % n_bytes
+
+    def to_python(self, value):
+        return value
+
+    def get_db_prep_save(self, value):
+        return ''.join(map(lambda x: '\\%03o' % ord(x), value))
+
+    def get_manipulator_field_objs(self):
+        return [oldforms.TextField]
+
+class Patch(models.Model):
+    project = models.ForeignKey(Project)
+    msgid = models.CharField(max_length=255, unique = True)
+    name = models.CharField(max_length=255)
+    date = models.DateTimeField(default=datetime.datetime.now)
+    submitter = models.ForeignKey(Person)
+    delegate = models.ForeignKey(User, blank = True, null = True)
+    state = models.ForeignKey(State)
+    archived = models.BooleanField(default = False)
+    headers = models.TextField(blank = True)
+    content = models.TextField()
+    commit_ref = models.CharField(max_length=255, null = True, blank = True)
+    hash = HashField()
+
+    def __str__(self):
+        return self.name
+
+    def comments(self):
+       return Comment.objects.filter(patch = self)
+
+    def save(self):
+       try:
+            s = self.state
+        except:
+            self.state = State.objects.get(ordering =  0)
+        if hash is None:
+            print "no hash"
+        super(Patch, self).save()
+
+    def is_editable(self, user):
+        if not user.is_authenticated():
+            return False
+
+        if self.submitter.user == user or self.delegate == user:
+            return True
+
+        profile = user.get_profile()
+        return self.project in user.get_profile().maintainer_projects.all()
+
+    def form(self):
+        f = PatchForm(instance = self, prefix = self.id)
+        return f
+
+    def filename(self):
+        fname_re = re.compile('[^-_A-Za-z0-9\.]+')
+        str = fname_re.sub('-', self.name)
+        return str.strip('-') + '.patch'
+
+    def mbox(self):
+        comment = None
+        try:
+            comment = Comment.objects.get(msgid = self.msgid)
+        except Exception:
+            pass
+
+        body = ''
+        if comment:
+            body = comment.content.strip() + "\n\n"
+        body += self.content
+
+        mail = MIMEText(body)
+        mail['Subject'] = self.name
+        mail['Date'] = email.utils.formatdate(
+                        time.mktime(self.date.utctimetuple()))
+        mail['From'] = str(self.submitter)
+        mail['X-Patchwork-Id'] = str(self.id)
+        mail.set_unixfrom('From patchwork ' + self.date.ctime())
+
+        return mail
+
+
+    @models.permalink
+    def get_absolute_url(self):
+        return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
+
+    class Meta:
+        verbose_name_plural = 'Patches'
+        ordering = ['date']
+
+    class Admin:
+        pass
+
+class Comment(models.Model):
+    patch = models.ForeignKey(Patch)
+    msgid = models.CharField(max_length=255, unique = True)
+    submitter = models.ForeignKey(Person)
+    date = models.DateTimeField(default = datetime.datetime.now)
+    headers = models.TextField(blank = True)
+    content = models.TextField()
+
+    class Admin:
+        pass
+
+    class Meta:
+        ordering = ['date']
+
+class Bundle(models.Model):
+    owner = models.ForeignKey(User)
+    project = models.ForeignKey(Project)
+    name = models.CharField(max_length = 50, null = False, blank = False)
+    patches = models.ManyToManyField(Patch)
+    public = models.BooleanField(default = False)
+
+    def n_patches(self):
+        return self.patches.all().count()
+
+    class Meta:
+        unique_together = [('owner', 'name')]
+
+    class Admin:
+        pass
+
+    def public_url(self):
+        if not self.public:
+            return None
+        site = Site.objects.get_current()
+        return 'http://%s%s' % (site.domain,
+                reverse('patchwork.views.bundle.public',
+                        kwargs = {
+                                'username': self.owner.username,
+                                'bundlename': self.name
+                        }))
+
+    def mbox(self):
+        return '\n'.join([p.mbox().as_string(True) \
+                        for p in self.patches.all()])
+
diff --git a/apps/patchwork/paginator.py b/apps/patchwork/paginator.py
new file mode 100644 (file)
index 0000000..8d8be64
--- /dev/null
@@ -0,0 +1,88 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from django.core import paginator
+from django.conf import settings
+
+DEFAULT_PATCHES_PER_PAGE = 100
+LONG_PAGE_THRESHOLD = 30
+LEADING_PAGE_RANGE_DISPLAYED = TRAILING_PAGE_RANGE_DISPLAYED = 10
+LEADING_PAGE_RANGE = TRAILING_PAGE_RANGE = 8
+NUM_PAGES_OUTSIDE_RANGE = 2
+ADJACENT_PAGES = 4
+
+# parts from:
+#  http://blog.localkinegrinds.com/2007/09/06/digg-style-pagination-in-django/
+
+class Paginator(paginator.Paginator):
+    def __init__(self, request, objects):
+
+        patches_per_page = settings.DEFAULT_PATCHES_PER_PAGE
+
+        if request.user.is_authenticated():
+            patches_per_page = request.user.get_profile().patches_per_page
+
+        n = request.META.get('ppp')
+        if n:
+            try:
+                patches_per_page = int(n)
+            except ValueError:
+                pass
+
+        super(Paginator, self).__init__(objects, patches_per_page)
+
+        try:
+            page_no = int(request.GET.get('page'))
+            self.current_page = self.page(int(page_no))
+        except Exception:
+            page_no = 1
+            self.current_page = self.page(page_no)
+
+        self.leading_set = self.trailing_set = []
+
+        pages = self.num_pages
+
+        if pages <= LEADING_PAGE_RANGE_DISPLAYED:
+            self.adjacent_set = [n for n in range(1, pages + 1) \
+                           if n > 0 and n <= pages]
+        elif page_no <= LEADING_PAGE_RANGE:
+            self.adjacent_set = [n for n in \
+                           range(1, LEADING_PAGE_RANGE_DISPLAYED + 1) \
+                           if n > 0 and n <= pages]
+            self.leading_set = [n + pages for n in \
+                                      range(0, -NUM_PAGES_OUTSIDE_RANGE, -1)]
+        elif page_no > pages - TRAILING_PAGE_RANGE:
+            self.adjacent_set = [n for n in \
+                           range(pages - TRAILING_PAGE_RANGE_DISPLAYED + 1, \
+                                   pages + 1) if n > 0 and n <= pages]
+            self.trailing_set = [n + 1 for n in range(0, \
+                    NUM_PAGES_OUTSIDE_RANGE)]
+        else:
+            self.adjacent_set = [n for n in range(page_no - ADJACENT_PAGES, \
+                    page_no + ADJACENT_PAGES + 1) if n > 0 and n <= pages]
+            self.leading_set = [n + pages for n in \
+                    range(0, -NUM_PAGES_OUTSIDE_RANGE, -1)]
+            self.trailing_set = [n + 1 for n in \
+                                           range(0, NUM_PAGES_OUTSIDE_RANGE)]
+
+
+        self.leading_set.reverse()
+        self.long_page = \
+                len(self.current_page.object_list) >= LONG_PAGE_THRESHOLD
diff --git a/apps/patchwork/parser.py b/apps/patchwork/parser.py
new file mode 100644 (file)
index 0000000..ecc1d4b
--- /dev/null
@@ -0,0 +1,206 @@
+#!/usr/bin/python
+#
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+import re
+import hashlib
+
+_hunk_re = re.compile('^\@\@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? \@\@')
+_filename_re = re.compile('^(---|\+\+\+) (\S+)')
+
+def parse_patch(text):
+    patchbuf = ''
+    commentbuf = ''
+    buf = ''
+
+    # state specified the line we just saw, and what to expect next
+    state = 0
+    # 0: text
+    # 1: suspected patch header (diff, ====, Index:)
+    # 2: patch header line 1 (---)
+    # 3: patch header line 2 (+++)
+    # 4: patch hunk header line (@@ line)
+    # 5: patch hunk content
+    #
+    # valid transitions:
+    #  0 -> 1 (diff, ===, Index:)
+    #  0 -> 2 (---)
+    #  1 -> 2 (---)
+    #  2 -> 3 (+++)
+    #  3 -> 4 (@@ line)
+    #  4 -> 5 (patch content)
+    #  5 -> 1 (run out of lines from @@-specifed count)
+    #
+    # Suspected patch header is stored into buf, and appended to
+    # patchbuf if we find a following hunk. Otherwise, append to
+    # comment after parsing.
+
+    # line counts while parsing a patch hunk
+    lc = (0, 0)
+    hunk = 0
+
+
+    for line in text.split('\n'):
+        line += '\n'
+
+        if state == 0:
+            if line.startswith('diff') or line.startswith('===') \
+                    or line.startswith('Index: '):
+                state = 1
+                buf += line
+
+            elif line.startswith('--- '):
+                state = 2
+                buf += line
+
+            else:
+                commentbuf += line
+
+        elif state == 1:
+            buf += line
+            if line.startswith('--- '):
+                state = 2
+
+        elif state == 2:
+            if line.startswith('+++ '):
+                state = 3
+                buf += line
+
+            elif hunk:
+                state = 1
+                buf += line
+
+            else:
+                state = 0
+                commentbuf += buf + line
+                buf = ''
+
+        elif state == 3:
+            match = _hunk_re.match(line)
+            if match:
+
+                def fn(x):
+                    if not x:
+                        return 1
+                    return int(x)
+
+                lc = map(fn, match.groups())
+
+                state = 4
+                patchbuf += buf + line
+                buf = ''
+
+            elif line.startswith('--- '):
+                patchbuf += buf + line
+                buf = ''
+                state = 2
+
+            elif hunk:
+                state = 1
+                buf += line
+
+            else:
+                state = 0
+                commentbuf += buf + line
+                buf = ''
+
+        elif state == 4 or state == 5:
+            if line.startswith('-'):
+                lc[0] -= 1
+            elif line.startswith('+'):
+                lc[1] -= 1
+            else:
+                lc[0] -= 1
+                lc[1] -= 1
+
+            patchbuf += line
+
+            if lc[0] <= 0 and lc[1] <= 0:
+                state = 3
+                hunk += 1
+            else:
+                state = 5
+
+        else:
+            raise Exception("Unknown state %d! (line '%s')" % (state, line))
+
+    commentbuf += buf
+
+    if patchbuf == '':
+        patchbuf = None
+
+    if commentbuf == '':
+        commentbuf = None
+
+    return (patchbuf, commentbuf)
+
+def patch_hash(str):
+    str = str.replace('\r', '')
+    str = str.strip() + '\n'
+    lines = str.split('\n')
+
+    prefixes = ['-', '+', ' ']
+    hash = hashlib.sha1()
+
+    for line in str.split('\n'):
+
+        if len(line) <= 0:
+            continue
+
+       hunk_match = _hunk_re.match(line)
+       filename_match = _filename_re.match(line)
+
+        if filename_match:
+            # normalise -p1 top-directories
+            if filename_match.group(1) == '---':
+                filename = 'a/'
+            else:
+                filename = 'b/'
+            filename += '/'.join(filename_match.group(2).split('/')[1:])
+
+            line = filename_match.group(1) + ' ' + filename
+
+            
+       elif hunk_match:
+            # remove line numbers
+            def fn(x):
+                if not x:
+                    return 1
+                return int(x)
+            line_nos = map(fn, hunk_match.groups())
+            line = '@@ -%d +%d @@' % tuple(line_nos)
+
+        elif line[0] in prefixes:
+            pass
+
+        else:
+            continue
+
+        hash.update(line + '\n')
+
+if __name__ == '__main__':
+    import sys
+#    (patch, comment) = parse_patch(sys.stdin.read())
+#    if patch:
+#        print "Patch: ------\n" + patch
+#    if comment:
+#        print "Comment: ----\n" + comment
+    normalise_patch_content(sys.stdin.read())
diff --git a/apps/patchwork/requestcontext.py b/apps/patchwork/requestcontext.py
new file mode 100644 (file)
index 0000000..cb9a782
--- /dev/null
@@ -0,0 +1,82 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django.template import RequestContext
+from django.utils.html import escape
+from patchwork.filters import Filters
+from patchwork.models import Bundle, Project
+
+def bundle(request):
+    user = request.user
+    if not user.is_authenticated():
+        return {}
+    return {'bundles': Bundle.objects.filter(owner = user)}
+
+def _params_as_qs(params):
+    return '&'.join([ '%s=%s' % (escape(k), escape(v)) for k, v in params ])
+
+def _params_as_hidden_fields(params):
+    return '\n'.join([ '<input type="hidden" name="%s" value="%s"/>' % \
+                (escape(k), escape(v)) for k, v in params ])
+
+class PatchworkRequestContext(RequestContext):
+    def __init__(self, request, project = None,
+            dict = None, processors = None,
+            list_view = None, list_view_params = {}):
+        self._project = project
+        self.filters = Filters(request)
+        if processors is None:
+            processors = []
+        processors.append(bundle)
+        super(PatchworkRequestContext, self). \
+                __init__(request, dict, processors);
+
+        self.update({'filters': self.filters})
+        if list_view:
+            params = self.filters.params()
+            for param in ['order', 'page']:
+                value = request.REQUEST.get(param, None)
+                if value:
+                        params.append((param, value))
+            self.update({
+                'list_view': {
+                        'view':         list_view,
+                        'view_params':  list_view_params,
+                        'params':       params
+                }})
+
+        self.projects = Project.objects.all()
+
+        self.update({
+                'project': self.project,
+                'other_projects': len(self.projects) > 1
+            })
+
+    def _set_project(self, project):
+        self._project = project
+        self.filters.set_project(project)
+        self.update({'project': self._project})
+
+    def _get_project(self):
+        return self._project
+
+    project = property(_get_project, _set_project)
+
+    def add_message(self, message):
+        self['messages'].append(message)
diff --git a/apps/patchwork/sql/project.sql b/apps/patchwork/sql/project.sql
new file mode 100644 (file)
index 0000000..f0db525
--- /dev/null
@@ -0,0 +1,6 @@
+insert into patchwork_project (linkname, name, listid, listemail)
+    values ('cbe-oss-dev', 'Cell Broadband Engine development',
+            'cbe-oss-dev.ozlabs.org', 'cbe-oss-dev@ozlabs.org');
+insert into patchwork_project (linkname, name, listid, listemail)
+    values ('linuxppc-dev', 'Linux PPC development',
+            'linuxppc-dev.ozlabs.org', 'linuxppc-dev@ozlabs.org');
diff --git a/apps/patchwork/sql/state.sql b/apps/patchwork/sql/state.sql
new file mode 100644 (file)
index 0000000..c673fd8
--- /dev/null
@@ -0,0 +1,20 @@
+insert into patchwork_state (ordering, name, action_required) values
+    (0, 'New', True);
+insert into patchwork_state (ordering, name, action_required) values
+    (1, 'Under Review', True);
+insert into patchwork_state (ordering, name, action_required) values
+    (2, 'Accepted', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (3, 'Rejected', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (4, 'RFC', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (5, 'Not Applicable', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (6, 'Changes Requested', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (7, 'Awaiting Upstream', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (8, 'Superseded', False);
+insert into patchwork_state (ordering, name, action_required) values
+    (9, 'Deferred', False);
diff --git a/apps/patchwork/templatetags/__init__.py b/apps/patchwork/templatetags/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/apps/patchwork/templatetags/filter.py b/apps/patchwork/templatetags/filter.py
new file mode 100644 (file)
index 0000000..b940599
--- /dev/null
@@ -0,0 +1,36 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django import template
+from django.utils.html import escape
+
+import re
+
+
+register = template.Library()
+
+@register.filter
+def personify(person):
+    if person.name:
+       linktext = escape(person.name)
+    else:
+       linktext = escape(person.email)
+
+    return '<a href="javascript:personpopup(\'%s\')">%s</a>' % (escape(person.email), linktext)
+
diff --git a/apps/patchwork/templatetags/listurl.py b/apps/patchwork/templatetags/listurl.py
new file mode 100644 (file)
index 0000000..22e2a1b
--- /dev/null
@@ -0,0 +1,136 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django import template
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.encoding import smart_str
+from patchwork.filters import filterclasses
+from django.conf import settings
+from django.core.urlresolvers import reverse, NoReverseMatch
+import re
+
+register = template.Library()
+
+# params to preserve across views
+list_params = [ c.param for c in filterclasses ] + ['order', 'page']
+
+class ListURLNode(template.defaulttags.URLNode):
+    def __init__(self, kwargs):
+        super(ListURLNode, self).__init__(None, [], {})
+        self.params = {}
+        for (k, v) in kwargs.iteritems():
+            if k in list_params:
+                self.params[k] = v
+
+    def render(self, context):
+        view_name = template.Variable('list_view.view').resolve(context)
+        kwargs = template.Variable('list_view.view_params') \
+                      .resolve(context)
+
+        str = None
+        try:
+            str = reverse(view_name, args=[], kwargs=kwargs)
+        except NoReverseMatch:
+            try:
+                project_name = settings.SETTINGS_MODULE.split('.')[0]
+                str = reverse(project_name + '.' + view_name,
+                               args=[], kwargs=kwargs)
+            except NoReverseMatch:
+                raise
+        
+        if str is None:
+            return ''
+
+        params = []
+        try:
+            qs_var = template.Variable('list_view.params')
+            params = dict(qs_var.resolve(context))
+        except Exception:
+            pass
+
+        for (k, v) in self.params.iteritems():
+            params[smart_str(k,'ascii')] = v.resolve(context)
+
+        if not params:
+            return str
+
+        return str + '?' + '&'.join(['%s=%s' % (k, escape(v)) \
+                        for (k, v) in params.iteritems()])
+
+@register.tag
+def listurl(parser, token):
+    bits = token.contents.split(' ', 1)
+    if len(bits) < 1:
+        raise TemplateSyntaxError("'%s' takes at least one argument"
+                                  " (path to a view)" % bits[0])
+    kwargs = {}
+    if len(bits) > 1:
+        for arg in bits[1].split(','):
+            if '=' in arg:
+                k, v = arg.split('=', 1)
+                k = k.strip()
+                kwargs[k] = parser.compile_filter(v)
+            else:
+                raise TemplateSyntaxError("'%s' requires name=value params" \
+                                          % bits[0])
+    return ListURLNode(kwargs)
+
+class ListFieldsNode(template.Node):
+    def __init__(self, params):
+        self.params = params
+
+    def render(self, context):
+        self.view_name = template.Variable('list_view.view').resolve(context)
+        try:
+            qs_var = template.Variable('list_view.params')
+            params = dict(qs_var.resolve(context))
+        except Exception:
+            pass
+
+        params.update(self.params)
+
+        if not params:
+            return ''
+
+        str = ''
+        for (k, v) in params.iteritems():
+            str += '<input type="hidden" name="%s" value="%s"\>' % \
+                   (k, escape(v))
+
+        return mark_safe(str)
+
+@register.tag
+def listfields(parser, token):
+    bits = token.contents.split(' ', 1)
+    if len(bits) < 1:
+        raise TemplateSyntaxError("'%s' takes at least one argument"
+                                  " (path to a view)" % bits[0])
+    params = {}
+    if len(bits) > 2:
+        for arg in bits[2].split(','):
+            if '=' in arg:
+                k, v = arg.split('=', 1)
+                k = k.strip()
+                params[k] = parser.compile_filter(v)
+            else:
+                raise TemplateSyntaxError("'%s' requires name=value params" \
+                                          % bits[0])
+    return ListFieldsNode(bits[1], params)
+
diff --git a/apps/patchwork/templatetags/order.py b/apps/patchwork/templatetags/order.py
new file mode 100644 (file)
index 0000000..e392f03
--- /dev/null
@@ -0,0 +1,66 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from django import template
+import re
+
+register = template.Library()
+
+@register.tag(name = 'ifpatcheditable')
+def do_patch_is_editable(parser, token):
+    try:
+        tag_name, name, cur_order = token.split_contents()
+    except ValueError:
+        raise template.TemplateSyntaxError("%r tag requires two arguments" \
+                % token.contents.split()[0])
+
+    end_tag = 'endifpatcheditable'
+    nodelist_true = parser.parse([end_tag, 'else'])
+
+    token = parser.next_token()
+    if token.contents == 'else':
+        nodelist_false = parser.parse([end_tag])
+        parser.delete_first_token()
+    else:
+        nodelist_false = template.NodeList()
+
+    return EditablePatchNode(patch_var, nodelist_true, nodelist_false)
+
+class EditablePatchNode(template.Node):
+    def __init__(self, patch_var, nodelist_true, nodelist_false):
+        self.nodelist_true = nodelist_true
+        self.nodelist_false = nodelist_false
+        self.patch_var = template.Variable(patch_var)
+        self.user_var = template.Variable('user')
+
+    def render(self, context):
+        try:
+            patch = self.patch_var.resolve(context)
+            user = self.user_var.resolve(context)
+        except template.VariableDoesNotExist:
+            return ''
+
+        if not user.is_authenticated():
+            return self.nodelist_false.render(context)
+
+        if not patch.is_editable(user):
+            return self.nodelist_false.render(context)
+
+        return self.nodelist_true.render(context)
diff --git a/apps/patchwork/templatetags/patch.py b/apps/patchwork/templatetags/patch.py
new file mode 100644 (file)
index 0000000..bec0cab
--- /dev/null
@@ -0,0 +1,65 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django import template
+import re
+
+register = template.Library()
+
+@register.tag(name = 'ifpatcheditable')
+def do_patch_is_editable(parser, token):
+    try:
+        tag_name, patch_var = token.split_contents()
+    except ValueError:
+        raise template.TemplateSyntaxError("%r tag requires one argument" \
+                % token.contents.split()[0])
+
+    end_tag = 'endifpatcheditable'
+    nodelist_true = parser.parse([end_tag, 'else'])
+
+    token = parser.next_token()
+    if token.contents == 'else':
+        nodelist_false = parser.parse([end_tag])
+        parser.delete_first_token()
+    else:
+        nodelist_false = template.NodeList()
+
+    return EditablePatchNode(patch_var, nodelist_true, nodelist_false)
+
+class EditablePatchNode(template.Node):
+    def __init__(self, patch_var, nodelist_true, nodelist_false):
+        self.nodelist_true = nodelist_true
+        self.nodelist_false = nodelist_false
+        self.patch_var = template.Variable(patch_var)
+        self.user_var = template.Variable('user')
+
+    def render(self, context):
+        try:
+            patch = self.patch_var.resolve(context)
+            user = self.user_var.resolve(context)
+        except template.VariableDoesNotExist:
+            return ''
+
+        if not user.is_authenticated():
+            return self.nodelist_false.render(context)
+
+        if not patch.is_editable(user):
+            return self.nodelist_false.render(context)
+
+        return self.nodelist_true.render(context)
diff --git a/apps/patchwork/templatetags/person.py b/apps/patchwork/templatetags/person.py
new file mode 100644 (file)
index 0000000..6a6a6af
--- /dev/null
@@ -0,0 +1,40 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django import template
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+import re
+
+register = template.Library()
+
+@register.filter
+def personify(person):
+
+    if person.name:
+       linktext = escape(person.name)
+    else:
+       linktext = escape(person.email)
+
+    str = '<a href="mailto:%s">%s</a>' % \
+                (escape(person.email), linktext)
+
+    return mark_safe(str)
+
+
diff --git a/apps/patchwork/templatetags/pwurl.py b/apps/patchwork/templatetags/pwurl.py
new file mode 100644 (file)
index 0000000..98bc1ca
--- /dev/null
@@ -0,0 +1,76 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django import template
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from patchwork.filters import filterclasses
+import re
+
+register = template.Library()
+
+# params to preserve across views
+list_params = [ c.param for c in filterclasses ] + ['order', 'page']
+
+class ListURLNode(template.defaulttags.URLNode):
+    def __init__(self, *args, **kwargs):
+        super(ListURLNode, self).__init__(*args, **kwargs)
+        self.params = {}
+        for (k, v) in kwargs:
+            if k in list_params:
+                self.params[k] = v
+
+    def render(self, context):
+        self.view_name = template.Variable('list_view.view')
+        str = super(ListURLNode, self).render(context)
+        if str == '':
+            return str
+        params = []
+        try:
+            qs_var = template.Variable('list_view.params')
+            params = dict(qs_var.resolve(context))
+        except Exception:
+            pass
+
+        params.update(self.params)
+
+        if not params:
+            return str
+
+        return str + '?' + '&'.join(['%s=%s' % (k, escape(v)) \
+                        for (k, v) in params.iteritems()])
+
+@register.tag
+def listurl(parser, token):
+    bits = token.contents.split(' ', 1)
+    if len(bits) < 1:
+        raise TemplateSyntaxError("'%s' takes at least one argument"
+                                  " (path to a view)" % bits[0])
+    args = ['']
+    kwargs = {}
+    if len(bits) > 1:
+        for arg in bits[2].split(','):
+            if '=' in arg:
+                k, v = arg.split('=', 1)
+                k = k.strip()
+                kwargs[k] = parser.compile_filter(v)
+            else:
+                args.append(parser.compile_filter(arg))
+    return PatchworkURLNode(bits[1], args, kwargs)
+
diff --git a/apps/patchwork/templatetags/syntax.py b/apps/patchwork/templatetags/syntax.py
new file mode 100644 (file)
index 0000000..a538062
--- /dev/null
@@ -0,0 +1,72 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django import template
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+import re
+
+register = template.Library()
+
+def _compile(t):
+    (r, str) = t
+    return (re.compile(r, re.M | re.I), str)
+
+_patch_span_res = map(_compile, [
+        ('^(Index:?|diff|\-\-\-|\+\+\+|\*\*\*) .*$', 'p_header'),
+        ('^\+.*$', 'p_add'),
+        ('^-.*$', 'p_del'),
+        ('^!.*$', 'p_mod'),
+        ])
+
+_patch_chunk_re = \
+        re.compile('^(@@ \-\d+(?:,\d+)? \+\d+(?:,\d+)? @@)(.*)$', re.M | re.I)
+
+_comment_span_res = map(_compile, [
+        ('^\s*Signed-off-by: .*$', 'signed-off-by'),
+        ('^\s*Acked-by: .*$', 'acked-by'),
+        ('^\s*From: .*$', 'from'),
+        ('^\s*&gt;.*$', 'quote'),
+        ])
+
+_span = '<span class="%s">%s</span>'
+
+@register.filter
+def patchsyntax(patch):
+    content = escape(patch.content)
+
+    for (r,cls) in _patch_span_res:
+        content = r.sub(lambda x: _span % (cls, x.group(0)), content)
+
+    content = _patch_chunk_re.sub( \
+            lambda x: \
+                _span % ('p_chunk', x.group(1)) + ' ' + \
+                _span % ('p_context', x.group(2)), \
+            content)
+
+    return mark_safe(content)
+
+@register.filter
+def commentsyntax(comment):
+    content = escape(comment.content)
+
+    for (r,cls) in _comment_span_res:
+        content = r.sub(lambda x: _span % (cls, x.group(0)), content)
+
+    return mark_safe(content)
diff --git a/apps/patchwork/urls.py b/apps/patchwork/urls.py
new file mode 100644 (file)
index 0000000..4a7ccb1
--- /dev/null
@@ -0,0 +1,61 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django.conf.urls.defaults import *
+
+urlpatterns = patterns('',
+    # Example:
+    (r'^$', 'patchwork.views.projects'),
+    (r'^project/(?P<project_id>[^/]+)/list/$', 'patchwork.views.patch.list'),
+    (r'^project/(?P<project_id>[^/]+)/$', 'patchwork.views.project'),
+
+    # patch views
+    (r'^patch/(?P<patch_id>\d+)/$', 'patchwork.views.patch.patch'),
+    (r'^patch/(?P<patch_id>\d+)/raw/$', 'patchwork.views.patch.content'),
+    (r'^patch/(?P<patch_id>\d+)/mbox/$', 'patchwork.views.patch.mbox'),
+
+    # registration process
+    (r'^register/$', 'patchwork.views.user.register'),
+    (r'^register/confirm/(?P<key>[^/]+)/$',
+        'patchwork.views.user.register_confirm'),
+
+    (r'^login/$', 'patchwork.views.user.login'),
+    (r'^logout/$', 'patchwork.views.user.logout'),
+
+    # logged-in user stuff
+    (r'^user/$', 'patchwork.views.user.profile'),
+    (r'^user/todo/$', 'patchwork.views.user.todo_lists'),
+    (r'^user/todo/(?P<project_id>[^/]+)/$', 'patchwork.views.user.todo_list'),
+
+    (r'^user/bundle/(?P<bundle_id>[^/]+)/$',
+       'patchwork.views.bundle.bundle'),
+    (r'^user/bundle/(?P<bundle_id>[^/]+)/mbox/$',
+       'patchwork.views.bundle.mbox'),
+
+    (r'^user/link/$', 'patchwork.views.user.link'),
+    (r'^user/link/(?P<key>[^/]+)/$', 'patchwork.views.user.link_confirm'),
+    (r'^user/unlink/(?P<person_id>[^/]+)/$', 'patchwork.views.user.unlink'),
+
+    # public view for bundles
+    (r'^bundle/(?P<username>[^/]*)/(?P<bundlename>[^/]*)/$',
+                                'patchwork.views.bundle.public'),
+
+    # submitter autocomplete
+    (r'^submitter/$', 'patchwork.views.submitter_complete'),
+)
diff --git a/apps/patchwork/utils.py b/apps/patchwork/utils.py
new file mode 100644 (file)
index 0000000..7cf88bc
--- /dev/null
@@ -0,0 +1,193 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from patchwork.forms import MultiplePatchForm
+from patchwork.models import Bundle, Project, State
+from django.conf import settings
+from django.shortcuts import render_to_response, get_object_or_404
+
+def get_patch_ids(d, prefix = 'patch_id'):
+    ids = []
+
+    for (k, v) in d.items():
+        a = k.split(':')
+        if len(a) != 2:
+            continue
+        if a[0] != prefix:
+            continue
+        if not v:
+            continue
+        ids.append(a[1])
+
+    return ids
+
+class Order(object):
+    order_map = {
+        'date':                'date',
+        'name':                'name',
+        'state':       'state__ordering',
+        'submitter':   'submitter__name'
+    }
+    default_order = 'date'
+
+    def __init__(self, str = None):
+        self.reversed = False
+
+        if str is None or str == '':
+            self.order = self.default_order
+            return
+
+        reversed = False
+        if str[0] == '-':
+            str = str[1:]
+            reversed = True
+
+        if str not in self.order_map.keys():
+            self.order = self.default_order
+            return
+
+        self.order = str
+        self.reversed = reversed
+
+    def __str__(self):
+        str = self.order
+        if self.reversed:
+            str = '-' + str
+        return str
+
+    def name(self):
+        return self.order
+
+    def reversed_name(self):
+        if self.reversed:
+            return self.order
+        else:
+            return '-' + self.order
+
+    def query(self):
+        q = self.order_map[self.order]
+        if self.reversed:
+            q = '-' + q
+        return q
+
+bundle_actions = ['create', 'add', 'remove']
+def set_bundle(user, action, data, patches, context):
+    # set up the bundle
+    bundle = None
+    if action == 'create':
+        bundle = Bundle(owner = user, project = project,
+                name = data['bundle_name'])
+        bundle.save()
+        str = 'added to new bundle "%s"' % bundle.name
+        auth_required = False
+
+    elif action =='add':
+        bundle = get_object_or_404(Bundle, id = data['bundle_id'])
+        str = 'added to bundle "%s"' % bundle.name
+        auth_required = False
+
+    elif action =='remove':
+        bundle = get_object_or_404(Bundle, id = data['removed_bundle_id'])
+        str = 'removed from bundle "%s"' % bundle.name
+        auth_required = False
+
+    if not bundle:
+        return ['no such bundle']
+
+    for patch in patches:
+        if action == 'create' or action == 'add':
+            bundle.patches.add(patch)
+
+        elif action == 'remove':
+            bundle.patches.remove(patch)
+
+    if len(patches) > 0:
+        if len(patches) == 1:
+            str = 'patch ' + str
+        else:
+            str = 'patches ' + str
+        context.add_message(str)
+
+    bundle.save()
+
+    return []
+
+
+def set_patches(user, action, data, patches, context):
+    errors = []
+    form = MultiplePatchForm(data = data)
+
+    try:
+        project = Project.objects.get(id = data['project'])
+    except:
+        errors = ['No such project']
+        return (errors, form)
+
+    str = ''
+
+    print "action: ", action
+
+    # this may be a bundle action, which doesn't modify a patch. in this
+    # case, don't require a valid form, or patch editing permissions
+    if action in bundle_actions:
+        errors = set_bundle(user, action, data, patches, context)
+        return (errors, form)
+
+    if not form.is_valid():
+        errors = ['The submitted form data was invalid']
+        return (errors, form)
+
+    for patch in patches:
+        if not patch.is_editable(user):
+            errors.append('You don\'t have permissions to edit the ' + \
+                    'patch "%s"' \
+                    % patch.name)
+            continue
+
+        if action == 'update':
+            form.save(patch)
+            str = 'updated'
+
+        elif action == 'ack':
+            pass
+
+        elif action == 'archive':
+            patch.archived = True
+            patch.save()
+            str = 'archived'
+
+        elif action == 'unarchive':
+            patch.archived = True
+            patch.save()
+            str = 'un-archived'
+
+        elif action == 'delete':
+            patch.delete()
+            str = 'un-archived'
+
+
+    if len(patches) > 0:
+        if len(patches) == 1:
+            str = 'patch ' + str
+        else:
+            str = 'patches ' + str
+        context.add_message(str)
+
+    return (errors, form)
diff --git a/apps/patchwork/views/__init__.py b/apps/patchwork/views/__init__.py
new file mode 100644 (file)
index 0000000..2636d29
--- /dev/null
@@ -0,0 +1,90 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from base import *
+from patchwork.utils import Order, get_patch_ids, set_patches
+from patchwork.paginator import Paginator
+from patchwork.forms import MultiplePatchForm
+
+def generic_list(request, project, view,
+        view_args = {}, filter_settings = [], patches = None):
+
+    context = PatchworkRequestContext(request,
+            list_view = view,
+            list_view_params = view_args)
+
+    context.project = project
+    order = Order(request.REQUEST.get('order'))
+
+    form = MultiplePatchForm(project)
+
+    if request.method == 'POST' and \
+                       request.POST.get('form') == 'patchlistform':
+        action = request.POST.get('action', None)
+        if action:
+            action = action.lower()
+
+        # special case: the user may have hit enter in the 'create bundle'
+        # text field, so if non-empty, assume the create action:
+        if request.POST.get('bundle_name', False):
+            action = 'create'
+
+        ps = []
+        for patch_id in get_patch_ids(request.POST):
+            try:
+                patch = Patch.objects.get(id = patch_id)
+            except Patch.DoesNotExist:
+                pass
+            ps.append(patch)
+
+        (errors, form) = set_patches(request.user, action, request.POST, \
+                ps, context)
+        if errors:
+            context['errors'] = errors
+
+    if not (request.user.is_authenticated() and \
+           project in request.user.get_profile().maintainer_projects.all()):
+        form = None
+
+    for (filterclass, setting) in filter_settings:
+        if isinstance(setting, dict):
+            context.filters.set_status(filterclass, **setting)
+        elif isinstance(setting, list):
+            context.filters.set_status(filterclass, *setting)
+        else:
+            context.filters.set_status(filterclass, setting)
+
+    if not patches:
+        patches = Patch.objects.filter(project=project)
+
+    patches = context.filters.apply(patches)
+    patches = patches.order_by(order.query())
+
+    paginator = Paginator(request, patches)
+
+    context.update({
+            'page':             paginator.current_page,
+            'patchform':        form,
+            'project':          project,
+            'order':            order,
+            })
+
+    return context
+
diff --git a/apps/patchwork/views/base.py b/apps/patchwork/views/base.py
new file mode 100644 (file)
index 0000000..16fa5db
--- /dev/null
@@ -0,0 +1,66 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from patchwork.models import Patch, Project, Person, RegistrationRequest
+from patchwork.filters import Filters
+from patchwork.forms import RegisterForm, LoginForm, PatchForm
+from django.shortcuts import render_to_response, get_object_or_404
+from django.http import HttpResponse, HttpResponseRedirect
+from django.db import transaction
+from django.contrib.auth.models import User
+from django.contrib.auth.decorators import login_required
+import django.core.urlresolvers
+from patchwork.requestcontext import PatchworkRequestContext
+from django.core import serializers
+
+def projects(request):
+    context = PatchworkRequestContext(request)
+    projects = Project.objects.all()
+
+    if projects.count() == 1:
+        return HttpResponseRedirect(
+                django.core.urlresolvers.reverse('patchwork.views.patch.list',
+                    kwargs = {'project_id': projects[0].linkname}))
+
+    context['projects'] = projects
+    return render_to_response('patchwork/projects.html', context)
+
+def project(request, project_id):
+    context = PatchworkRequestContext(request)
+    project = get_object_or_404(Project, linkname = project_id)
+    context.project = project
+
+    context['maintainers'] = User.objects.filter( \
+            userprofile__maintainer_projects = project)
+    context['n_patches'] = Patch.objects.filter(project = project,
+            archived = False).count()
+    context['n_archived_patches'] = Patch.objects.filter(project = project,
+            archived = True).count()
+
+    return render_to_response('patchwork/project.html', context)
+
+def submitter_complete(request):
+    search = request.GET.get('q', '')
+    response = HttpResponse(mimetype = "text/plain")
+    if len(search) > 3:
+       queryset = Person.objects.filter(name__icontains = search)
+       json_serializer = serializers.get_serializer("json")()
+       json_serializer.serialize(queryset, ensure_ascii=False, stream=response)
+    return response
diff --git a/apps/patchwork/views/bundle.py b/apps/patchwork/views/bundle.py
new file mode 100644 (file)
index 0000000..be6a937
--- /dev/null
@@ -0,0 +1,158 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import User
+from django.shortcuts import render_to_response, get_object_or_404
+from patchwork.requestcontext import PatchworkRequestContext
+from django.http import HttpResponse, HttpResponseRedirect
+import django.core.urlresolvers
+from patchwork.models import Patch, Bundle, Project
+from patchwork.utils import get_patch_ids
+from patchwork.forms import BundleForm
+from patchwork.views import generic_list
+from patchwork.filters import DelegateFilter
+from patchwork.paginator import Paginator
+
+@login_required
+def setbundle(request):
+    context = PatchworkRequestContext(request)
+
+    bundle = None
+
+    if request.method == 'POST':
+        action = request.POST.get('action', None)
+        if action is None:
+            pass
+        elif action == 'create':
+            project = get_object_or_404(Project,
+                    id = request.POST.get('project'))
+            bundle = Bundle(owner = request.user, project = project,
+                    name = request.POST['name'])
+            bundle.save()
+            patch_id = request.POST.get('patch_id', None)
+            if patch_id:
+                patch = get_object_or_404(Patch, id = patch_id)
+                bundle.patches.add(patch)
+            bundle.save()
+        elif action == 'add':
+            bundle = get_object_or_404(Bundle,
+                    owner = request.user, id = request.POST['id'])
+            bundle.save()
+
+            patch_id = request.get('patch_id', None)
+            if patch_id:
+                patch_ids = patch_id
+            else:
+                patch_ids = get_patch_ids(request.POST)
+
+            for id in patch_ids:
+                try:
+                    patch = Patch.objects.get(id = id)
+                    bundle.patches.add(patch)
+                except ex:
+                    pass
+
+            bundle.save()
+        elif action == 'delete':
+            try:
+                bundle = Bundle.objects.get(owner = request.user,
+                        id = request.POST['id'])
+                bundle.delete()
+            except Exception:
+                pass
+
+            bundle = None
+
+    else:
+        bundle = get_object_or_404(Bundle, owner = request.user,
+                id = request.POST['bundle_id'])
+
+    if 'error' in context:
+        pass
+
+    if bundle:
+        return HttpResponseRedirect(
+                django.core.urlresolvers.reverse(
+                    'patchwork.views.bundle.bundle',
+                    kwargs = {'bundle_id': bundle.id}
+                    )
+                )
+    else:
+        return HttpResponseRedirect(
+                django.core.urlresolvers.reverse(
+                    'patchwork.views.bundle.list')
+                )
+
+@login_required
+def bundle(request, bundle_id):
+    bundle = get_object_or_404(Bundle, id = bundle_id)
+    filter_settings = [(DelegateFilter, DelegateFilter.AnyDelegate)]
+
+    if request.method == 'POST' and request.POST.get('form') == 'bundle':
+        action = request.POST.get('action', '').lower()
+        if action == 'delete':
+            bundle.delete()
+            return HttpResponseRedirect(
+                    django.core.urlresolvers.reverse(
+                        'patchwork.views.user.profile')
+                    )
+        elif action == 'update':
+            form = BundleForm(request.POST, instance = bundle)
+            if form.is_valid():
+                form.save()
+    else:
+        form = BundleForm(instance = bundle)
+
+    context = generic_list(request, bundle.project,
+            'patchwork.views.bundle.bundle',
+            view_args = {'bundle_id': bundle_id},
+            filter_settings = filter_settings,
+            patches = bundle.patches.all())
+
+    context['bundle'] = bundle
+    context['bundleform'] = form
+
+    return render_to_response('patchwork/bundle.html', context)
+
+@login_required
+def mbox(request, bundle_id):
+    bundle = get_object_or_404(Bundle, id = bundle_id)
+    response = HttpResponse(mimetype='text/plain')
+    response.write(bundle.mbox())
+    return response
+
+def public(request, username, bundlename):
+    user = get_object_or_404(User, username = username)
+    bundle = get_object_or_404(Bundle, name = bundlename, public = True)
+    filter_settings = [(DelegateFilter, DelegateFilter.AnyDelegate)]
+    context = generic_list(request, bundle.project,
+            'patchwork.views.bundle.public',
+            view_args = {'username': username, 'bundlename': bundlename},
+            filter_settings = filter_settings,
+            patches = bundle.patches.all())
+
+    context.update({'bundle': bundle,
+            'user': user});
+    return render_to_response('patchwork/bundle-public.html', context)
+
+@login_required
+def set_patches(request):
+    context = PatchworkRequestContext(request)
+
diff --git a/apps/patchwork/views/patch.py b/apps/patchwork/views/patch.py
new file mode 100644 (file)
index 0000000..d509e28
--- /dev/null
@@ -0,0 +1,180 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from patchwork.models import Patch, Project, Person, RegistrationRequest, Bundle
+from patchwork.filters import Filters
+from patchwork.forms import RegisterForm, LoginForm, PatchForm, MultiplePatchForm, CreateBundleForm
+from patchwork.utils import get_patch_ids, set_patches, Order
+from patchwork.requestcontext import PatchworkRequestContext
+from django.shortcuts import render_to_response, get_object_or_404
+from django.http import HttpResponse, HttpResponseRedirect, \
+            HttpResponseForbidden
+from django.contrib.auth.models import User
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth import authenticate, login
+import django.core.urlresolvers
+from patchwork.paginator import Paginator
+from patchwork.views import generic_list
+
+def patch(request, patch_id):
+    context = PatchworkRequestContext(request)
+    patch = get_object_or_404(Patch, id=patch_id)
+    context.project = patch.project
+    editable = patch.is_editable(request.user)
+    messages = []
+
+    form = None
+    createbundleform = None
+
+    if editable:
+        form = PatchForm(instance = patch)
+    if request.user.is_authenticated():
+        createbundleform = CreateBundleForm()
+
+    if request.method == 'POST':
+        action = request.POST.get('action', None)
+        if action:
+            action = action.lower()
+
+        if action == 'createbundle':
+            bundle = Bundle(owner = request.user, project = patch.project)
+            createbundleform = CreateBundleForm(instance = bundle,
+                    data = request.POST)
+            if createbundleform.is_valid():
+                createbundleform.save()
+                bundle.patches.add(patch)
+                bundle.save()
+                createbundleform = CreateBundleForm()
+                context.add_message('Bundle %s created' % bundle.name)
+
+        elif action == 'addtobundle':
+            bundle = get_object_or_404(Bundle, id = \
+                        request.POST.get('bundle_id'))
+            bundle.patches.add(patch)
+            bundle.save()
+            context.add_message('Patch added to bundle "%s"' % bundle.name)
+
+       # all other actions require edit privs
+        elif not editable:
+            return HttpResponseForbidden()
+
+        elif action is None:
+            form = PatchForm(data = request.POST, instance = patch)
+            if form.is_valid():
+                form.save()
+                context.add_message('Patch updated')
+
+       elif action == 'archive':
+           patch.archived = True
+           patch.save()
+            context.add_message('Patch archived')
+
+       elif action == 'unarchive':
+           patch.archived = False
+           patch.save()
+            context.add_message('Patch un-archived')
+
+        elif action == 'ack':
+            pass
+
+        elif action == 'delete':
+            patch.delete()
+
+
+    context['patch'] = patch
+    context['patchform'] = form
+    context['createbundleform'] = createbundleform
+    context['project'] = patch.project
+
+    return render_to_response('patchwork/patch.html', context)
+
+def content(request, patch_id):
+    patch = get_object_or_404(Patch, id=patch_id)
+    response = HttpResponse(mimetype="text/x-patch")
+    response.write(patch.content)
+    response['Content-Disposition'] = 'attachment; filename=' + \
+        patch.filename().replace(';', '').replace('\n', '')
+    return response
+
+def mbox(request, patch_id):
+    patch = get_object_or_404(Patch, id=patch_id)
+    response = HttpResponse(mimetype="text/plain")
+    response.write(patch.mbox().as_string(True))
+    response['Content-Disposition'] = 'attachment; filename=' + \
+        patch.filename().replace(';', '').replace('\n', '')
+    return response
+
+
+def list(request, project_id):
+    project = get_object_or_404(Project, linkname=project_id)
+    context = generic_list(request, project, 'patchwork.views.patch.list',
+            view_args = {'project_id': project.linkname})
+    return render_to_response('patchwork/list.html', context)
+
+    context = PatchworkRequestContext(request,
+            list_view = 'patchwork.views.patch.list',
+            list_view_params = {'project_id': project_id})
+    order = get_order(request)
+    project = get_object_or_404(Project, linkname=project_id)
+    context.project = project
+
+    form = None
+    errors = []
+
+    if request.method == 'POST':
+        action = request.POST.get('action', None)
+        if action:
+            action = action.lower()
+
+        # special case: the user may have hit enter in the 'create bundle'
+        # text field, so if non-empty, assume the create action:
+        if request.POST.get('bundle_name', False):
+            action = 'create'
+
+        ps = []
+        for patch_id in get_patch_ids(request.POST):
+            try:
+                patch = Patch.objects.get(id = patch_id)
+            except Patch.DoesNotExist:
+                pass
+            ps.append(patch)
+
+        (errors, form) = set_patches(request.user, action, request.POST, ps)
+        if errors:
+            context['errors'] = errors
+
+
+    elif request.user.is_authenticated() and \
+           project in request.user.get_profile().maintainer_projects.all():
+        form = MultiplePatchForm(project)
+
+    patches = Patch.objects.filter(project=project).order_by(order)
+    patches = context.filters.apply(patches)
+
+    paginator = Paginator(request, patches)
+
+    context.update({
+            'page':             paginator.current_page,
+            'patchform':        form,
+            'project':          project,
+            'errors':           errors,
+            })
+
+    return render_to_response('patchwork/list.html', context)
diff --git a/apps/patchwork/views/user.py b/apps/patchwork/views/user.py
new file mode 100644 (file)
index 0000000..223cfc6
--- /dev/null
@@ -0,0 +1,201 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from django.contrib.auth.decorators import login_required
+from patchwork.requestcontext import PatchworkRequestContext
+from django.shortcuts import render_to_response, get_object_or_404
+from django.contrib import auth
+from django.http import HttpResponse, HttpResponseRedirect
+from patchwork.models import Project, Patch, Bundle, Person, \
+         RegistrationRequest, UserProfile, UserPersonConfirmation, State
+from patchwork.forms import RegisterForm, LoginForm, MultiplePatchForm, \
+         UserProfileForm, UserPersonLinkForm
+from patchwork.utils import Order, get_patch_ids, set_patches
+from patchwork.filters import DelegateFilter
+from patchwork.paginator import Paginator
+from patchwork.views import generic_list
+import django.core.urlresolvers
+
+def register(request):
+    context = PatchworkRequestContext(request)
+    template = 'patchwork/register.html'
+
+    if request.method != 'POST':
+        form = RegisterForm()
+        context['form'] = form
+        return render_to_response(template, context)
+
+    reg_req = RegistrationRequest()
+    form = RegisterForm(instance = reg_req, data = request.POST)
+
+    if form.is_valid():
+        form.save()
+        context['request'] = reg_req
+    else:
+        context['form'] = form
+
+    return render_to_response(template, context)
+
+def register_confirm(request, key):
+    context = PatchworkRequestContext(request)
+    req = get_object_or_404(RegistrationRequest, key = key)
+    req.create_user()
+    user = auth.authenticate(username = req.username, password = req.password)
+    auth.login(request, user)
+
+    return render_to_response('patchwork/register-confirm.html', context)
+
+def login(request):
+    context = PatchworkRequestContext(request)
+    template = 'patchwork/login.html'
+    error = None
+
+    if request.method == 'POST':
+        form = LoginForm(request.POST)
+        context['form'] = form
+
+        if not form.is_valid():
+            return render_to_response(template, context)
+
+        data = form.cleaned_data
+        user = auth.authenticate(username = data['username'],
+                password = data['password'])
+
+        if user is not None and user.is_active:
+            auth.login(request, user)
+            url = request.POST.get('next', None) or \
+                    django.core.urlresolvers.reverse( \
+                           'patchwork.views.user.profile')
+            return HttpResponseRedirect(url)
+
+        context['error'] = 'Invalid username or password'
+
+    else:
+        context['form'] = LoginForm()
+
+    return render_to_response(template, context)
+
+def logout(request):
+    auth.logout(request)
+    return render_to_response('patchwork/logout.html')
+
+@login_required
+def profile(request):
+    context = PatchworkRequestContext(request)
+
+    if request.method == 'POST':
+        form = UserProfileForm(instance = request.user.get_profile(),
+                data = request.POST)
+        if form.is_valid():
+            form.save()
+    else:
+        form = UserProfileForm(instance = request.user.get_profile())
+
+    context.project = request.user.get_profile().primary_project
+    context['bundles'] = Bundle.objects.filter(owner = request.user)
+    context['profileform'] = form
+
+    people = Person.objects.filter(user = request.user)
+    context['linked_emails'] = people
+    context['linkform'] = UserPersonLinkForm()
+
+    return render_to_response('patchwork/profile.html', context)
+
+@login_required
+def link(request):
+    context = PatchworkRequestContext(request)
+
+    form = UserPersonLinkForm(request.POST)
+    if request.method == 'POST':
+        form = UserPersonLinkForm(request.POST)
+        if form.is_valid():
+            conf = UserPersonConfirmation(user = request.user,
+                    email = form.cleaned_data['email'])
+            conf.save()
+            context['confirmation'] = conf
+
+    context['linkform'] = form
+
+    return render_to_response('patchwork/user-link.html', context)
+
+@login_required
+def link_confirm(request, key):
+    context = PatchworkRequestContext(request)
+    confirmation = get_object_or_404(UserPersonConfirmation, key = key)
+
+    errors = confirmation.confirm()
+    if errors:
+        context['errors'] = errors
+    else:
+        context['person'] = Person.objects.get(email = confirmation.email)
+
+    confirmation.delete()
+
+    return render_to_response('patchwork/user-link-confirm.html', context)
+
+@login_required
+def unlink(request, person_id):
+    context = PatchworkRequestContext(request)
+    person = get_object_or_404(Person, id = person_id)
+
+    if request.method == 'POST':
+        if person.email != request.user.email:
+            person.user = None
+            person.save()
+
+    url = django.core.urlresolvers.reverse('patchwork.views.user.profile')
+    return HttpResponseRedirect(url)
+
+
+@login_required
+def todo_lists(request):
+    todo_lists = []
+
+    for project in Project.objects.all():
+        patches = request.user.get_profile().todo_patches(project = project)
+        if not patches.count():
+            continue
+
+        todo_lists.append({'project': project, 'n_patches': patches.count()})
+
+    if len(todo_lists) == 1:
+        return todo_list(request, todo_lists[0]['project'].linkname)
+
+    context = PatchworkRequestContext(request)
+    context['todo_lists'] = todo_lists
+    context.project = request.user.get_profile().primary_project
+    return render_to_response('patchwork/todo-lists.html', context)
+
+@login_required
+def todo_list(request, project_id):
+    project = get_object_or_404(Project, linkname = project_id)
+    patches = request.user.get_profile().todo_patches(project = project)
+    filter_settings = [(DelegateFilter,
+            {'delegate': request.user})]
+
+    context = generic_list(request, project,
+            'patchwork.views.user.todo_list',
+            view_args = {'project_id': project.linkname},
+            filter_settings = filter_settings,
+            patches = patches)
+
+    context['action_required_states'] = \
+        State.objects.filter(action_required = True).all()
+    return render_to_response('patchwork/todo-list.html', context)
diff --git a/apps/settings.py b/apps/settings.py
new file mode 100644 (file)
index 0000000..0d74b10
--- /dev/null
@@ -0,0 +1,94 @@
+# Django settings for patchwork project.
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+     ('Jeremy Kerr', 'jk@ozlabs.org'),
+)
+
+MANAGERS = ADMINS
+
+DATABASE_ENGINE = 'postgresql'           # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'ado_mssql'.
+DATABASE_NAME = 'patchwork'             # Or path to database file if using sqlite3.
+DATABASE_USER = ''             # Not used with sqlite3.
+DATABASE_PASSWORD = ''         # Not used with sqlite3.
+DATABASE_HOST = ''             # Set to empty string for localhost. Not used with sqlite3.
+DATABASE_PORT = ''             # Set to empty string for default. Not used with sqlite3.
+
+# Local time zone for this installation. Choices can be found here:
+# http://www.postgresql.org/docs/8.1/static/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE
+# although not all variations may be possible on all operating systems.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'Australia/Canberra'
+
+# Language code for this installation. All choices can be found here:
+# http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
+# http://blogs.law.harvard.edu/tech/stories/storyReader$15
+LANGUAGE_CODE = 'en-au'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# Absolute path to the directory that holds media.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = '/srv/patchwork/lib/python/django/contrib/admin/media'
+
+# URL that handles the media served from MEDIA_ROOT.
+# Example: "http://media.lawrence.com"
+MEDIA_URL = ''
+
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
+# trailing slash.
+# Examples: "http://foo.com/media/", "/media/".
+ADMIN_MEDIA_PREFIX = '/media/'
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = '00000000000000000000000000000000000000000000000000'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+    'django.template.loaders.filesystem.load_template_source',
+    'django.template.loaders.app_directories.load_template_source',
+#     'django.template.loaders.eggs.load_template_source',
+)
+
+MIDDLEWARE_CLASSES = (
+    'django.middleware.common.CommonMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.middleware.doc.XViewMiddleware',
+)
+
+ROOT_URLCONF = 'apps.urls'
+
+LOGIN_URL = '/patchwork/login'
+
+TEMPLATE_DIRS = (
+    # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+    # Always use forward slashes, even on Windows.
+    # Don't forget to use absolute paths, not relative paths.
+    '/srv/patchwork/templates'
+)
+TEMPLATE_CONTEXT_PROCESSORS = (
+    "django.core.context_processors.auth",
+    "django.core.context_processors.debug",
+    "django.core.context_processors.i18n",
+    "django.core.context_processors.media")
+
+AUTH_PROFILE_MODULE = "patchwork.userprofile"
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'django.contrib.admin',
+    'patchwork',
+)
+
+DEFAULT_PATCHES_PER_PAGE = 100
diff --git a/apps/urls.py b/apps/urls.py
new file mode 100644 (file)
index 0000000..e11cbd9
--- /dev/null
@@ -0,0 +1,35 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from django.conf.urls.defaults import *
+
+urlpatterns = patterns('',
+    # Example:
+    (r'^', include('patchwork.urls')),
+
+    # Uncomment this for admin:
+     (r'^admin/', include('django.contrib.admin.urls')),
+
+     (r'^css/(?P<path>.*)$', 'django.views.static.serve',
+       {'document_root': '/home/jk/devel/patchwork/pwsite/htdocs/css'}),
+     (r'^js/(?P<path>.*)$', 'django.views.static.serve',
+       {'document_root': '/home/jk/devel/patchwork/pwsite/htdocs/js'}),
+     (r'^images/(?P<path>.*)$', 'django.views.static.serve',
+       {'document_root': '/home/jk/devel/patchwork/pwsite/htdocs/images'}),
+)
diff --git a/docs/INSTALL b/docs/INSTALL
new file mode 100644 (file)
index 0000000..da8dd54
--- /dev/null
@@ -0,0 +1,143 @@
+Deploying Patchwork
+
+Patchwork uses the django framework - there is some background on deploying
+django applications here:
+
+ http://www.djangobook.com/en/1.0/chapter20/
+
+You'll need the following (applications used for patchwork development are
+in brackets):
+
+  * A python interpreter
+  * djano
+  * A webserver (apache)
+  * mod_python or flup
+  * A database server (postgresql)
+
+1. Database setup
+
+    At present, I've tested with PostgreSQL and (to a lesser extent) MySQL
+    database servers. If you have any (positive or negative) experiences with
+    either, email me.
+
+    For the following commands, a $ prefix signifies that the command should be
+    entered at your shell prompt, and a > prefix signifies the commant-line
+    client for your sql server (psql or mysql)
+
+    Create a database for the system, add accounts for two system users: the
+    web user (the user that your web server runs as) and the mail user (the
+    user that your mail server runs as). On Ubuntu these are
+    www-data and nobody, respectively.
+
+      PostgreSQL:
+        createdb patchwork
+        createuser www-data
+        createuser nobody
+
+      MySQL:
+        $ mysql
+       > CREATE DATABASE 'patchwork';
+        > INSERT INTO user (Host, User) VALUES ('localhost', 'www-data');
+        > INSERT INTO user (Host, User) VALUES ('localhost', 'nobody');
+
+2. Django setup
+
+       You'll need to customise apps/settings.py to suit your database, and
+       change the SECRET_KEY variable too. While you're there, change the
+       following to suit your installation:
+
+         ADMINS,
+         TIME_ZONE
+         LANGUAGE_CODE
+         MEDIA_ROOT
+
+       Then, get patchwork to create its tables in your configured database:
+
+        cd apps/
+        ./manage.py syncdb
+
+       And add privileges for your mail and web users:
+
+       Postgresql:
+         psql -f lib/sql/grant-all.sql patchwork
+
+
+3. Apache setup
+
+Example apache configuration files are in lib/apache/.
+
+mod_python:
+
+       This should be the simpler of the two to set up. An example apache
+       configuration file is in:
+
+         lib/apache/patchwork.mod_python.conf
+
+       However, mod_python and mod_php may not work well together. So, if your
+       web server is used for serving php files, the fastcgi method may suit
+       instead.
+
+fastcgi:
+
+       django has built-in support for fastcgi, which requires the
+       'flup' python module. An example configuration is in:
+
+         lib/apache/patchwork.fastcgi.conf
+
+       - this also requires the mod_rewrite apache module to be loaded.
+
+       Once you have apache set up, you can start the fastcgi server with:
+
+         cd /srv/patchwork/apps
+         ./manage.py runfcgi method=prefork \
+                             socket=/srv/patchwork/var/fcgi.sock \
+                             pidfile=/srv/patchwork/var/fcgi.pid
+
+4. Configure patchwork
+    Now, you should be able to administer patchwork, by visiting the
+    URL:
+
+      http://your-host/admin/
+
+    You'll probably want to do the following:
+
+      * Set up your projects
+
+
+5. Subscribe a local address to the mailing list
+
+     You will need an email address for patchwork to receive email on - for
+     example - patchwork@, and this address will need to be subscribed to the
+     list. Depending on the mailing list, you will probably need to confirm the
+     subscription - temporarily direct the alias to yourself to do this.
+
+6. Setup your MTA to deliver mail to the parsemail script
+
+    Your MTA will need to deliver mail to the parsemail script in the email/
+    directory. (Note, do not use the parsemail.py script directly). Something
+    like this in /etc/aliases is suitable for postfix:
+
+      patchwork: "|/srv/patchwork/apps/patchwork/bin/parsemail.sh"
+
+    You may need to customise the parsemail.sh script if you haven't installed
+    patchwork in /srv/patchwork.
+
+    Test that you can deliver a patch to this script:
+
+     sudo -u nobody /srv/patchwork/apps/patchwork/bin/parsemail.sh < mail
+
+
+Some errors:
+
+* __init__() got an unexpected keyword argument 'max_length'
+
+ - you're running an old version of django. If your distribution doesn't
+   provide a newer version, just download and extract django into
+   lib/python/django
+
+* ERROR: permission denied for relation patchwork_...
+
+ - the user that patchwork is running as (ie, the user of the web-server)
+   doesn't have access to the patchwork tables in the database. Check that
+   your web-server user exists in the database, and that it has permissions
+   to the tables.
diff --git a/htdocs/css/style.css b/htdocs/css/style.css
new file mode 100644 (file)
index 0000000..2d8d628
--- /dev/null
@@ -0,0 +1,417 @@
+body {
+       background-color: white;
+       color: black;
+       margin: 0em;
+       font-size: 9pt;
+}
+
+.floaty {
+       position: fixed;
+       left: 0.1em;
+       top: 17em;
+}
+
+
+#title {
+       background: url('/images/title-background.png') top left repeat-x;
+       background-color: #786fb4;
+       width: 100%;
+       margin: 0px;
+       padding-top: 0.1em;
+       padding-bottom: 0.0em;
+       padding-left: 2em;
+}
+
+#title h1, #title h1 a {
+       font-size: 16pt;
+       color: white;
+}
+
+.beta {
+    font-size: 60%;
+    vertical-align: sub;
+    line-height: 2em;
+}
+
+#auth {
+       border-left: thin solid white;
+       padding-top: 0em;
+       padding-left: 2em;
+       padding-right: 5em;
+       padding-top: 0.5em;
+       padding-bottom: 0.5em;
+       font-size: 90%;
+       float: right;
+       color: white;
+}
+#auth a {
+       color: white;
+}
+
+#nav {
+        background: #e8e8e8;
+       border-bottom: 0.2em solid #786fb4;
+       font-size: 90%;
+       padding: 0.2em 0.5em;
+}
+
+#nav a {
+       text-decoration: underline;
+}
+
+
+#content {
+       padding: 1em;
+}
+
+form {
+       padding: 0em;
+       margin: 0em;
+}
+
+a:visited { color: #000000; }
+a { color: #786fb4; }
+
+table {
+       border-collapse: collapse;
+}
+
+/* messages */
+#messages {
+       background: #e0e0f0;
+       margin: 0.5em 1em 0.0em 0.5em;
+       padding: 0.3em;
+}
+
+#messages .message {
+        color: green;
+}
+
+/* patch lists */
+table.patchlist {
+        width: 98%;
+       border: thin solid black;
+        padding: 0em 1em;
+}
+
+table.patchlist th {
+       background: #eeeeee;
+       border-bottom: thin solid black;
+       text-align: left;
+       padding-left: 6px;
+}
+
+table.patchlist td {
+       padding: 2px 6px 2px 6px;
+       margin: 0px;
+       margin-top: 10px;
+}
+
+table.patchlist td.patchlistfilters {
+       background: #c0c0ff;
+       border-top: thin solid #;
+       border-top: thin solid gray;
+       border-bottom: thin solid black;
+       font-size: smaller;
+
+}
+table.patchlist tr.odd {
+       background: #ffffff;
+}
+
+table.patchlist tr.even {
+       background: #eeeeee;
+}
+
+a.colactive {
+       color: red;
+}
+
+a.colinactive {
+       color: black;
+       text-decoration: none;
+}
+a.colinactive:hover {
+       color: red;
+}
+
+div.filters {
+}
+
+/* list pagination */
+.paginator { padding-bottom: 1em; padding-top: 1em; font-size: 80%; }
+.paginator .prev-na,
+.paginator .next-na {
+        padding:.3em;
+        font-weight: normal;
+        border: 0.1em solid #c0c0c0;
+        background-color: #f9f9f9;
+        color: #a0a0a0;
+}
+.paginator .prev a, .paginator .prev a:visited,
+.paginator .next a, .paginator .next a:visited {
+        border: 0.1em solid #b0b0d0;
+        background-color: #eeeeee;
+        color: #786fb4;
+        padding: .3em;
+        font-weight: bold;
+}
+.paginator .prev, .paginator .prev-na { margin-right:.5em; }
+.paginator .next, .paginator .next-na { margin-left:.5em; }
+.paginator .page a, .paginator .page a:visited, .paginator .curr {
+        padding: .25em;
+        font-weight: bold;
+        border: 1px solid #b0b0d0;
+        background-color: #eeeeee;
+        margin: 0em .25em;       
+        color: #786fb4;
+}
+.paginator .curr { 
+        background-color: #b0b0d0;
+        color:#fff;
+        border:1px solid #c0c0ff;
+        font-weight:bold;
+}
+.paginator .page a:hover,
+.paginator .curr a:hover,
+.paginator .prev a:hover,
+.paginator .next a:hover {
+        color: #ffffff;
+        background-color: #c0c0ff;
+        border:1px solid #234f32;
+}
+/*
+div.filters h2 {
+       font-size: 110%;
+}
+
+table.filters tr th, table.filters tr td {
+       text-align: left;
+       padding: 0px 10px 0px 10px;
+       vertical-align: middle;
+}
+table.filters tr th {
+       width: 8em;
+}
+
+table.filters tr td {
+       padding-top: 0.1em;
+       width: 12em;
+}
+
+table.filters tr td.clearcol {
+       text-align: right;
+       width: 16px;
+}
+*/
+
+img {
+       border: 0;
+}
+
+input {
+       border: thin solid #909090;
+}
+
+#footer {
+       padding: 1em;
+       font-size: small;
+       text-align: center;
+       color: #909090;
+}
+
+#footer a {
+        color: #909090;
+}
+
+/* patch view */
+table.patchmeta th {
+        text-align: left;
+}
+
+table.patchmeta tr th, table.patchmeta tr td {
+       text-align: left;
+       padding: 3px 10px 3px 10px;
+       vertical-align: middle;
+}
+
+.patchnav {
+    padding-left: 1em;
+    padding-top: 1em;
+}
+
+.comment .meta {
+       background: #f0f0f0;
+}
+
+.patch .content {
+       border: thin solid gray;
+       padding: 1em;
+}
+
+.quote {
+       color: #007f00;
+}
+
+span.p_header  { color: #2e8b57; font-weight: bold; }
+span.p_chunk   { color: #a52a2a; font-weight: bold; }
+span.p_context { color: #a020f0; }
+span.p_add     { color: #008b8b; }
+span.p_del     { color: #6a5acd; }
+span.p_mod     { color: #0000ff; }
+
+.acked-by {
+       color: red;
+
+}
+
+.signed-off-by {
+       color: red;
+       font-weight: bold;
+}
+
+.from {
+       font-weight: bold;
+}
+
+/* bundles */
+table.bundlelist {
+        border: thin solid black;
+}
+
+table.bundlelist th {
+        padding-left: 2em;
+        padding-right: 2em;
+        background: #eeeeee;
+        border-bottom: thin solid black;
+}
+
+table.bundlelist td
+{
+        padding-left: 2em;
+        padding-right: 2em;
+}
+
+/* forms that appear for a patch */
+div.patchform {
+       border: thin solid gray;
+       padding-left: 0.6em;
+       padding-right: 0.6em;
+       float: left;
+       margin: 0.5em 1em;
+}
+
+div.patchform h3 {
+       margin-top: 0em;
+       margin-left: -0.6em;
+       margin-right: -0.6em;
+       padding-left: 0.3em;
+       padding-right: 0.3em;
+       background: #786fb4;
+       color: white;
+       font-size: 100%;
+}
+
+div.patchform ul {
+       list-style-type: none;
+       padding-left: 0.2em;
+       margin-top: 0em;
+}
+
+/* forms */
+table.form {
+}
+
+span.help_text {
+       font-size: 80%;
+}
+
+
+table.form td {
+       padding: 0.6em;
+       vertical-align: top;
+}
+
+table.form th.headerrow {
+       background: #786fb4;
+       color: white;
+       font-weight: bold;
+       text-align: center;
+}
+
+table.form th {
+       font-weight: normal;
+       text-align: left;
+       vertical-align: top;
+       padding-top: 0.6em;
+}
+
+table.form td.form-help {
+       font-size: smaller;
+       padding-bottom: 1em;
+       padding-top: 0em;
+}
+
+table.form tr td.submitrow {
+       border-bottom: 0.2em solid #786fb4;
+       text-align: center;
+}
+
+table.registerform {
+       margin-left: auto;
+       margin-right: auto;
+}
+table.loginform {
+       margin-left: auto;
+       margin-right: auto;
+       width: 30em;
+}
+
+/* form errors */
+.errorlist {
+        color: red;
+       list-style-type: none;
+       padding-left: 0.2em;
+       margin: 0em;
+}
+
+/* generic table with header columns on the left */
+table.horizontal {
+       border-collapse: collapse;
+       border: thin solid #e8e8e8;
+}
+
+table.horizontal th {
+       text-align: left;
+}
+
+table.horizontal td, table.horizontal th {
+       padding: 0.5em 1em;
+       border: thin solid #e8e8e8;
+}
+
+/* generic table with header row */
+table.vertical {
+        border-collapse: collapse;
+}
+table.vertical th {
+       background: #786fb4;
+       color: white;
+       font-weight: bold;
+       text-align: center;
+}
+
+table.vertical th, table.vertical td {
+        padding: 0.2em 0.8em;
+       border: thin solid #e8e8e8;
+}
+
+td.numberformat {
+        text-align: right;
+}
diff --git a/htdocs/images/filter-add.png b/htdocs/images/filter-add.png
new file mode 100644 (file)
index 0000000..3992342
Binary files /dev/null and b/htdocs/images/filter-add.png differ
diff --git a/htdocs/images/filter-remove.png b/htdocs/images/filter-remove.png
new file mode 100644 (file)
index 0000000..82f2a32
Binary files /dev/null and b/htdocs/images/filter-remove.png differ
diff --git a/htdocs/images/title-background.png b/htdocs/images/title-background.png
new file mode 100644 (file)
index 0000000..d850ad7
Binary files /dev/null and b/htdocs/images/title-background.png differ
diff --git a/htdocs/js/autocomplete.js b/htdocs/js/autocomplete.js
new file mode 100644 (file)
index 0000000..115ffba
--- /dev/null
@@ -0,0 +1,43 @@
+
+
+function ac_keyup(input)
+{
+       input.autocomplete.keyup();
+}
+
+function AutoComplete(input)
+{
+       this.input = input;
+       this.div = null;
+       this.last_value = '';
+
+       input.autocomplete = this;
+
+       this.hide = function()
+       {
+               if (this.div) {
+                       this.div.style.display = 'none';
+                       this.div = null;
+               }
+
+       }
+
+       this.show = function()
+       {
+               if (!this.div) {
+                       this.div = 
+
+       this.keyup = function()
+       {
+               value = input.value;
+
+               if (value == this.last_value)
+                       return;
+
+               if (value.length < 3) {
+                       this.hide();
+               }
+
+
+}
+
diff --git a/htdocs/js/filters.js b/htdocs/js/filters.js
new file mode 100644 (file)
index 0000000..d8596ea
--- /dev/null
@@ -0,0 +1,78 @@
+
+var available_filters = new Array();
+
+function Filter(param, input_html, fn)
+{
+       this.param = param;
+       this.input_html = input_html;
+       this.fn = fn;
+}
+
+function add_filter_change(input)
+{
+       index = input.selectedIndex - 1;
+
+       if (index < 0 || index >= available_filters.length)
+               return;
+
+       filter = available_filters[index];
+
+       value_element = document.getElementById("addfiltervalue");
+       value_element.innerHTML = filter.input_html;
+}
+
+function filter_form_submit(form)
+{
+       filter_index = form.filtertype.selectedIndex - 1;
+
+       if (filter_index < 0 || filter_index >= available_filters.length)
+               return false;
+
+       filter = available_filters[filter_index];
+
+       value = filter.fn(form);
+       updated = false;
+
+       form = document.forms.filterparams;
+
+       for (x = 0; x < form.elements.length; x++) {
+               if (form.elements[x].name == filter.param) {
+                       form.elements[x].value = value;
+                       updated = true;
+               }
+       }
+
+       if (!updated && value) {
+               form.innerHTML = form.innerHTML +
+                       '<input type="hidden" name="' + filter.param +
+                       '" value="' + value + '"/>';
+       }
+
+       form.submit();
+
+       return false;
+}
+
+
+var submitter_input_prev_value = '';
+
+function submitter_input_change(input)
+{
+       value = input.value;
+
+       if (value.length < 3)
+               return;
+
+       if (value == submitter_input_prev_value)
+               return;
+
+       div = document.getElementById('submitter_complete');
+       div.innerHTML = value;
+       div.style.display = 'block';
+       div.style.position = 'relative';
+       div.style.top = '4em';
+       div.style.width = '15em';
+       div.style.background = '#f0f0f0';
+       div.style.padding = '0.2em';
+       div.style.border = 'thin solid red';
+}
diff --git a/htdocs/js/people.js b/htdocs/js/people.js
new file mode 100644 (file)
index 0000000..7fb4e9f
--- /dev/null
@@ -0,0 +1,5 @@
+
+function personpopup(name)
+{
+    alert("meep!");
+}
diff --git a/lib/apache2/patchwork.fastcgi.conf b/lib/apache2/patchwork.fastcgi.conf
new file mode 100644 (file)
index 0000000..78d8147
--- /dev/null
@@ -0,0 +1,17 @@
+NameVirtualHost patchwork.example.com:80
+<VirtualHost patchwork.example.com:80>
+       DocumentRoot /srv/patchwork/htdocs/
+
+       Alias /media/ /srv/patchwork/lib/python/django/contrib/admin/media/
+
+       FastCGIExternalServer /srv/patchwork/htdocs/patchwork.fcgi -socket /srv/patchwork/var/fcgi.sock
+
+       RewriteEngine On
+       RewriteCond %{REQUEST_URI} !^/(images|css|js|media)/.*
+       RewriteCond %{REQUEST_FILENAME} !-f
+       RewriteRule ^/(.*)$ /patchwork.fcgi/$1 [QSA,L]
+
+       LogLevel warn
+       ErrorLog /var/log/apache2/patchwork-error.log
+       CustomLog /var/log/apache2/patchwork-acess.log combined
+</VirtualHost>
diff --git a/lib/apache2/patchwork.mod_python.conf b/lib/apache2/patchwork.mod_python.conf
new file mode 100644 (file)
index 0000000..a84a9e2
--- /dev/null
@@ -0,0 +1,22 @@
+NameVirtualHost patchwork.example.com:80
+<VirtualHost patchwork.example.com:80>
+       DocumentRoot /srv/patchwork/htdocs/
+
+       Alias /media/ /srv/patchwork/lib/python/django/contrib/admin/media/
+
+       <Location "/">
+           SetHandler python-program
+           PythonHandler django.core.handlers.modpython
+           PythonPath "['/srv/patchwork/apps', '/srv/patchwork/lib/python'] + sys.path"
+           SetEnv DJANGO_SETTINGS_MODULE patchwork.settings
+       </Location>
+
+       <Location "/(images|css|js|media)/">
+           SetHandler None
+       </Location>
+
+       LogLevel warn
+       ErrorLog /var/log/apache2/patchwork-error.log
+       CustomLog /var/log/apache2/patchwork-acess.log combined
+
+</VirtualHost>
diff --git a/lib/sql/grant-all.sql b/lib/sql/grant-all.sql
new file mode 100644 (file)
index 0000000..4b8a43b
--- /dev/null
@@ -0,0 +1,68 @@
+BEGIN;
+-- give necessary permissions to the web server. Becuase the admin is all
+-- web-based, these need to be quite permissive
+GRANT SELECT, UPDATE, INSERT, DELETE ON
+       auth_message,
+       django_session,
+       django_site,
+       django_admin_log,
+       django_content_type,
+       auth_group_permissions,
+       auth_user,
+       auth_user_groups,
+       auth_group,
+       auth_user_user_permissions,
+       auth_permission,
+       patchwork_registrationrequest,
+       patchwork_userpersonconfirmation,
+       patchwork_state,
+       patchwork_comment,
+       patchwork_person,
+       patchwork_userprofile,
+       patchwork_userprofile_maintainer_projects,
+       patchwork_project,
+       patchwork_bundle,
+       patchwork_bundle_patches,
+       patchwork_patch
+TO "www-data";
+GRANT SELECT, UPDATE ON
+       auth_group_id_seq,
+       auth_group_permissions_id_seq,
+       auth_message_id_seq,
+       auth_permission_id_seq,
+       auth_user_groups_id_seq,
+       auth_user_id_seq,
+       auth_user_user_permissions_id_seq,
+       django_admin_log_id_seq,
+       django_content_type_id_seq,
+       django_site_id_seq,
+       patchwork_bundle_id_seq,
+       patchwork_bundle_patches_id_seq,
+       patchwork_comment_id_seq,
+       patchwork_patch_id_seq,
+       patchwork_person_id_seq,
+       patchwork_project_id_seq,
+       patchwork_registrationrequest_id_seq,
+       patchwork_state_id_seq,
+       patchwork_userpersonconfirmation_id_seq,
+       patchwork_userprofile_id_seq,
+       patchwork_userprofile_maintainer_projects_id_seq
+TO "www-data";
+
+-- allow the mail user (in this case, 'nobody') to add patches
+GRANT INSERT, SELECT ON
+       patchwork_patch,
+       patchwork_comment,
+       patchwork_person
+TO "nobody";
+GRANT SELECT ON
+       patchwork_project
+TO "nobody";
+GRANT UPDATE, SELECT ON
+       patchwork_patch_id_seq,
+       patchwork_person_id_seq,
+       patchwork_comment_id_seq
+TO "nobody";
+
+COMMIT;
+
diff --git a/templates/patchwork/base.html b/templates/patchwork/base.html
new file mode 100644 (file)
index 0000000..c3a2206
--- /dev/null
@@ -0,0 +1,77 @@
+{% load pwurl %}
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+  <title>{% block title %}Patchwork{% endblock %} - Patchwork</title>
+  <link rel="stylesheet" type="text/css" href="/css/style.css"/>
+{% block headers %}{% endblock %}
+ </head>
+ <body>
+  <div id="title">
+  <h1 style="float: left;">
+     <a
+      href="{% url patchwork.views.projects %}">Patchwork</a><span
+      class="beta">&alpha;</span>
+    {% block heading %}{% endblock %}</h1>
+  <div id="auth">
+{% if user.is_authenticated %}
+   Logged in as
+    <a href="{% url patchwork.views.user.profile %}"
+     ><strong>{{ user.username }}</strong></a>
+    <br/>
+     <a href="{% url patchwork.views.user.profile %}">profile</a> ::
+     <a href="{% url patchwork.views.user.todo_lists %}">todo
+      ({{ user.get_profile.n_todo_patches }})</a><br/>
+     <a href="{% url patchwork.views.user.logout %}">logout</a> ::
+     <a href="/help/">help</a>
+{% else %}
+     <a href="{% url patchwork.views.user.login %}">login</a>
+     <br/>
+     <a href="{% url patchwork.views.user.register %}">register</a>
+     <br/>
+     <a href="/help/">help</a>
+{% endif %}
+   </div>
+   <div style="clear: both;"></div>
+  </div>
+  <div id="nav">
+  {% if project %}
+   <strong>Project</strong>: {{ project.linkname }}
+    :
+    <a href="{% url patchwork.views.patch.list project_id=project.linkname %}"
+     >patches</a>
+    :
+    <a href="{% url patchwork.views.project project_id=project.linkname %}"
+     >project info</a>
+   {% if other_projects %}
+    :
+    <a href="{% url patchwork.views.projects %}"
+    >other projects</a>
+    {% endif %}
+   {% else %}
+    <a href="{% url patchwork.views.projects %}"
+    >project list</a>
+   {% endif %}
+  </div>
+{% if messages %}
+  <div id="messages">
+  {% for message in messages %}
+   <div class="message">{{ message }}</div>
+  {% endfor %}
+  </div>
+{% endif %}
+  <div id="content">
+{% block body %}
+{% endblock %}
+  </div>
+  <div id="footer">
+   <a href="http://ozlabs.org/~jk/projects/patchwork">patchwork</a>
+   patch tracking system
+  </div>
+ </body>
+</html>
+
+
+
diff --git a/templates/patchwork/bundle-public.html b/templates/patchwork/bundle-public.html
new file mode 100644 (file)
index 0000000..0ee57da
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "patchwork/base.html" %}
+
+{% load person %}
+
+{% block title %}{{project.name}}{% endblock %}
+{% block heading %}Bundle: {{bundle.name}}{% endblock %}
+
+{% block body %}
+
+{% include "patchwork/patch-list.html" %}
+
+{% endblock %}
diff --git a/templates/patchwork/bundle.html b/templates/patchwork/bundle.html
new file mode 100644 (file)
index 0000000..8fa694a
--- /dev/null
@@ -0,0 +1,39 @@
+{% extends "patchwork/base.html" %}
+
+{% load person %}
+
+{% block title %}{{project.name}}{% endblock %}
+{% block heading %}bundle: {{bundle.name}}{% endblock %}
+
+{% block body %}
+
+<p>This bundle contains patches for the {{ bundle.project.linkname }}
+project.</p>
+
+<p><a href="{% url patchwork.views.bundle.mbox bundle_id=bundle.id %}"
+>Download bundle as mbox</a></p>
+
+
+<form method="post">
+ <input type="hidden" name="form" value="bundle"/>
+<table class="form">
+
+ <tr>
+  <th colspan="2" class="headerrow">Bundle settings</th>
+ </tr>
+
+{{ bundleform }}
+ <tr>
+  <td colspan="2" class="submitrow">
+   <input type="submit" name="action" value="Update"/>
+   <input type="submit" name="action" value="Delete"/>
+  </td>
+ </tr>
+</table>
+</form>
+
+<div style="clear: both; padding: 1em;"></div>
+
+{% include "patchwork/patch-list.html" %}
+
+{% endblock %}
diff --git a/templates/patchwork/filters.html b/templates/patchwork/filters.html
new file mode 100644 (file)
index 0000000..482bc98
--- /dev/null
@@ -0,0 +1,173 @@
+
+<script type="text/javascript" language="JavaScript">
+var filterform_displayed = false;
+function filter_click()
+{
+    form = document.getElementById('filterform');
+    if (!form) {
+        return;
+    }
+
+    if (filterform_displayed) {
+        form.style['display'] = 'none';
+        filterform_displayed = false;
+    } else {
+        form.style['display'] = 'block';
+        filterform_displayed = true;
+    }
+
+
+}
+function enable_selected_submitter(select, input)
+{
+    select.name = 'submitter';
+    input.name = '';
+}
+function filter_form_submit(form)
+{
+    var i;
+
+    submitter_select = document.getElementById("submitter_select");
+    submitter_input = document.getElementById("submitter_input");
+    if (!submitter_select || !submitter_input) {
+        req = null;
+        return;
+    }
+
+    /* submitter handling. if possible, use the select box, otherwise leave
+     * as-is (and so the text box is used). */
+
+    if (submitter_select.options.length == 0) {
+        /* if there's no match, just use the input */
+
+    } else if (submitter_select.options.length == 1) {
+        /* if there's only one match, request by id */
+        submitter_select.selectedIndex = 0;
+        enable_selected_submitter(submitter_select, submitter_input);
+
+    } else if (submitter_select.selectedIndex != -1) {
+        /* if the user has explicitly selected, request by id */
+        enable_selected_submitter(submitter_select, submitter_input);
+
+    }
+
+    for (i = 0; i < form.elements.length; i++) {
+        var e = form.elements[i];
+        if (e.type == 'submit') {
+            continue;
+        }
+
+        /* handle submitter data */
+        if (e.type == 'select-one') {
+            if (e.name == '') {
+                e.disabled = true;
+            }
+            if (e.selectedIndex != -1
+                    && e.options[e.selectedIndex].value == '') {
+                e.disabled = true;
+            }
+
+            continue;
+        }
+
+        if (e.value == '') {
+            e.disabled = true;
+        }
+    }
+}
+
+var req = null;
+
+function submitter_complete_response()
+{
+    if (req.readyState != 4) {
+        return
+    }
+
+    var completions;
+    eval("completions = " + req.responseText);
+
+    if (completions.length == 0) {
+        req = null;
+        return;
+    }
+
+    submitter_select = document.getElementById("submitter_select");
+    submitter_input = document.getElementById("submitter_input");
+    if (!submitter_select || !submitter_input) {
+        req = null;
+        return;
+    }
+
+    submitter_select.options = [];
+
+    for (i = 0; i < completions.length; i++) {
+        name = completions[i]['fields']['name'];
+        if (name) {
+            name = completions[i]['fields']['name'] +
+                ' <' + completions[i]['fields']['email'] + '>';
+        } else {
+            name = completions[i]['fields']['email'];
+        }
+        o = new Option(name, completions[i]['pk']);
+        submitter_select.options[i] = o;
+    }
+    submitter_select.disabled = false;
+    req = null;
+}
+
+function submitter_field_change(field)
+{
+    var value = field.value;
+    if (value.length < 4) {
+        return;
+    }
+
+    if (req) {
+         return;
+    }
+
+    var url = '{% url patchwork.views.submitter_complete %}?q=' + value;
+    req = new XMLHttpRequest();
+    req.onreadystatechange = submitter_complete_response;
+    req.open("GET", url, true);
+    req.send('');
+}
+</script>
+
+<div class="filters">
+ <div id="filtersummary">
+  <strong><a href="javascript:filter_click()">Filters</a>:</strong>
+ {% if filters.applied_filters %}
+  {% for filter in filters.applied_filters %}
+   {{ filter.name }} = {{ filter.condition }}
+    {% if not filter.forced %}
+     <a href="{{ filter.url_without_me }}"><img
+      src="/images/filter-remove.png"></a>
+    {% endif %}
+   {% if not forloop.last %}&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;{% endif %}
+  {% endfor %}
+ {% else %}
+  none
+  <a href="javascript:filter_click()"><img src="/images/filter-add.png"></a>
+ {% endif %}
+ </div>
+ <div id="filterform" style="padding-top: 1em; display: none">
+  <form action="" method="get" onSubmit="return filter_form_submit(this)">
+    <table>
+    {% for filter in filters.available_filters %}
+     <tr>
+      <td>{{ filter.name }}</td>
+      <td>{{ filter.form }}</td>
+     </tr>
+    {% endfor %}
+     <tr>
+      <td/>
+      <td><input type="submit" value="Apply"/></td>
+     </tr>
+    </table>
+  </form>
+ </div>
+</div>
+
+
diff --git a/templates/patchwork/list.html b/templates/patchwork/list.html
new file mode 100644 (file)
index 0000000..755c047
--- /dev/null
@@ -0,0 +1,24 @@
+{% extends "patchwork/base.html" %}
+
+{% load person %}
+
+{% block title %}{{project.name}}{% endblock %}
+{% block heading %}{{project.name}}{% endblock %}
+
+{% block body %}
+
+<h2>Incoming patches</h2>
+
+{% if errors %}
+<p>The following error{{ errors|length|pluralize:" was,s were" }} encountered
+while updating patches:</p>
+<ul class="errorlist">
+{% for error in errors %}
+ <li>{{ error }}</li>
+{% endfor %}
+</ul>
+{% endif %}
+
+{% include "patchwork/patch-list.html" %}
+
+{% endblock %}
diff --git a/templates/patchwork/login.html b/templates/patchwork/login.html
new file mode 100644 (file)
index 0000000..4706dda
--- /dev/null
@@ -0,0 +1,26 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}Patchwork Login{% endblock %}
+{% block heading %}Patchwork Login{% endblock %}
+
+
+{% block body %}
+<form method="post">
+<table class="form loginform">
+ <tr>
+  <th colspan="2" class="headerrow">login</th>
+ </tr>
+ {% if error %}
+  <tr>
+   <td colspan="2">{{ error }}</td>
+  </tr>
+ {% endif %}
+ {{ form }}
+ <tr>
+  <td colspan="2" class="submitrow">
+   <input type="submit" value="Login"/>
+  </td>
+ </tr>
+</table>
+</form>
+{% endblock %}
diff --git a/templates/patchwork/logout.html b/templates/patchwork/logout.html
new file mode 100644 (file)
index 0000000..737f1ce
--- /dev/null
@@ -0,0 +1,8 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}Patchwork{% endblock %}
+{% block heading %}Patchwork{% endblock %}
+
+{% block body %}
+<p>Logged out</p>
+{% endblock %}
diff --git a/templates/patchwork/pagination.html b/templates/patchwork/pagination.html
new file mode 100644 (file)
index 0000000..3e95126
--- /dev/null
@@ -0,0 +1,45 @@
+{% load listurl %}
+
+{% ifnotequal page.paginator.num_pages 1 %}
+<div class="paginator">
+{% if page.has_previous %}
+ <span class="prev">
+  <a href="{% listurl page=page.previous_page_number %}"
+     title="Previous Page">&laquo; Previous</a></span>
+{% else %}
+ <span class="prev-na">&laquo; Previous</span>
+{% endif %}
+{% if page.paginator.trailing_set %}
+ {% for p in page.paginator.trailing_set %}
+ <span class="page"><a href="{% listurl page=p %}" >{{ p }}</a></span>
+ {% endfor %}
+        ...
+{% endif %}
+{% for p in page.paginator.adjacent_set %}
+  {% ifequal p page.number %}
+    <span class="curr" title="Current Page">{{ p }}</span>
+  {% else %}
+    <span class="page"><a href="{% listurl page=p %}"
+     title="Page {{ p }}">{{ p }}</a></span>
+  {% endifequal %}
+{% endfor %}
+{% if page.paginator.leading_set %}
+        ...
+ {% for p in page.paginator.leading_set %}
+    <span class="page"><a href="{% listurl page=p %}">{{ p }}</a></span>
+ {% endfor %}
+{% endif %}
+{% if page.has_next %}
+ <span class="next">
+  <a href="{% listurl page=page.next_page_number %}"
+   title="Next Page">Next &raquo;</a>
+  </span>
+{% else %}
+ <span class="next-na">Next &raquo;</span>
+{% endif %}
+</div> 
+{% endifnotequal %}
diff --git a/templates/patchwork/patch-form.html b/templates/patchwork/patch-form.html
new file mode 100644 (file)
index 0000000..9d2c954
--- /dev/null
@@ -0,0 +1,87 @@
+
+<div class="patchform"
+  style="border: thin solid black; padding-left: 0.8em; margin-top: 2em;">
+
+ <div class="patchform-properties"
+  style="float: left; margin-right: 4em;">
+  <h3>Properties</h3>
+    <table class="form">
+     <tr>
+      <th>Change state:</th>
+      <td>{{ patchform.state }}</td>
+     </tr>
+     <tr>
+      <th>Delegate to:</td>
+      <td>{{ patchform.delegate }}</td>
+     </tr>
+     <tr>
+      <td></td>
+      <td>
+       <input type="submit" value="Update">
+      </td>
+     </tr>
+    </table>
+  </form>
+ </div>
+
+ <div class="patchform-actions" style="padding-left: 4em;">
+  <h3>Actions</h3>
+   <table class="form">
+    <tr>
+     <td>Ack:</td>
+     <td>
+      <form action="{% url patchwork.views.patch patch=patch.id %}"
+       method="post">
+       <input type="hidden" name="action" value="act"/>
+       <input type="submit" value="Ack"/>
+      </form>
+     </td>
+    </tr>
+    <tr>
+     <td>Create bundle:</td>
+     <td>
+       {% if createbundleform.name.errors %}
+       <span class="errors">{{createbundleform.errors}}</span>
+       {% endif %}
+      <form method="post">
+       <input type="hidden" name="action" value="createbundle"/>
+        {{ createbundleform.name }}
+       <input value="Create" type="submit"/>
+      </form>
+      </td>
+    </tr>
+{% if bundles %}
+    <tr>
+     <td>Add to bundle:</td>
+     <td>
+      <form action="{% url patchwork.views.bundle.setbundle %}" method="post">
+       <input type="hidden" name="action" value="add"/>
+       <input type="hidden" name="patch_id" value="{{ patch.id }}"/>
+       <select name="name"/>
+        {% for bundle in bundles %}
+         <option value="{{bundle.id}}">{{bundle.name}}</option>
+        {% endfor %}
+        </select>
+       <input value="Add" type="submit"/>
+      </form>
+     </td>
+    </tr>
+{% endif %}
+    <tr>
+     <td>Archive:</td>
+     <td>
+      <form method="post">
+       <input type="hidden" name="action" value="archive"/>
+       <input type="submit" value="Archive"/>
+      </form>
+     </td>
+    </tr>
+   </table>
+  </form>
+
+ </div>
+
+ <div style="clear: both;">
+ </div>
+</div>
+
diff --git a/templates/patchwork/patch-list.html b/templates/patchwork/patch-list.html
new file mode 100644 (file)
index 0000000..0a15e9c
--- /dev/null
@@ -0,0 +1,185 @@
+{% load person %}
+{% load listurl %}
+
+{% include "patchwork/pagination.html" %}
+
+
+<table class="patchlist">
+ <tr>
+  <td class="patchlistfilters">
+ {% include "patchwork/filters.html" %}
+  </td>
+ </tr>
+</table>
+
+{% if page.paginator.long_page and user.is_authenticated %}
+<div class="floaty">
+ <a title="jump to form" href="#patchforms"><span
+  style="font-size: 120%">&#9662;</span></a>
+</div>
+{% endif %}
+
+<form method="post">
+<input type="hidden" name="form" value="patchlistform"/>
+<input type="hidden" name="project" value="{{project.id}}"/>
+<table class="patchlist">
+ <tr>
+  {% if patchform or bundle %}
+  <th/>
+  {% endif %}
+
+  <th>
+   {% ifequal order.name "name" %}
+    <a class="colactive"
+     href="{% listurl order=order.reversed_name %}">Patch</a>
+   {% else %}
+    <a class="colinactive" href="{% listurl order="name" %}">Patch</a>
+   {% endifequal %}
+  </th>
+
+  <th>
+   {% ifequal order.name "date" %}
+    <a class="colactive"
+     href="{% listurl order=order.reversed_name %}">Date</a>
+   {% else %}
+    <a class="colinactive" href="{% listurl order="date" %}">Date</a>
+   {% endifequal %}
+  </th>
+
+  <th>
+   {% ifequal order.name "submitter" %}
+    <a class="colactive"
+     href="{% listurl order=order.reversed_name %}">Submiter</a>
+   {% else %}
+    <a class="colinactive" href="{% listurl order="submitter" %}">Submitter</a>
+   {% endifequal %}
+  </th>
+
+  <th>
+   {% ifequal order.name "state" %}
+    <a class="colactive"
+     href="{% listurl order=order.reversed_name %}">State</a>
+   {% else %}
+    <a class="colinactive" href="{% listurl order="state" %}">State</a>
+   {% endifequal %}
+  </th>
+
+ </tr>
+
+{% if page %}
+ {% for patch in page.object_list %}
+  <tr>
+    {% if patchform or bundle %}
+    <td>
+    <input type="checkbox" name="patch_id:{{patch.id}}"/>
+    </td>
+    {% endif %}
+   <td><a href="{% url patchwork.views.patch.patch patch_id=patch.id %}"
+     >{{ patch.name }}</a></td>
+   <td>{{ patch.date|date:"Y-m-d" }}</td>
+   <td>{{ patch.submitter|personify }}</td>
+   <td>{{ patch.state }}</td>
+  </tr>
+ {% endfor %}
+</table>
+
+{% include "patchwork/pagination.html" %}
+
+<div class="patchforms" id="patchforms" name="patchforms">
+
+{% if patchform %}
+ <div class="patchform patchform-properties">
+  <h3>Properties</h3>
+    <table class="form">
+     <tr>
+      <th>Change state:</th>
+      <td>
+       {{ patchform.state }}
+       {{ patchform.state.errors }}
+      </td>
+     </tr>
+     <tr>
+      <th>Delegate to:</td>
+      <td>
+       {{ patchform.delegate }}
+       {{ patchform.delegate.errors }}
+      </td>
+     </tr>
+     <tr>
+      <th>Archive:</td>
+      <td>
+       {{ patchform.archived }}
+       {{ patchform.archived.errors }}
+      </td>
+     </tr>
+     <tr>
+      <td></td>
+      <td>
+       <input type="submit" name="action" value="Update"/>
+      </td>
+     </tr>
+    </table>
+ </div>
+
+{% endif %}
+
+{% if user.is_authenticated %}
+ <div class="patchform patchform-bundle">
+  <h3>Bundling</h3>
+   <table class="form">
+   <!--
+    <tr>
+     <td>Ack:</td>
+     <td>
+       <input type="submit" name="action" value="Ack"/>
+      </form>
+     </td>
+    </tr>
+    -->
+    <tr>
+     <td>Create bundle:</td>
+     <td>
+      <input type="text" name="bundle_name"/>
+      <input name="action" value="Create" type="submit"/>
+      </td>
+    </tr>
+  {% if bundles %}
+    <tr>
+     <td>Add to bundle:</td>
+     <td>
+       <select name="bundle_id"/>
+        {% for bundle in bundles %}
+         <option value="{{bundle.id}}">{{bundle.name}}</option>
+        {% endfor %}
+        </select>
+       <input name="action" value="Add" type="submit"/>
+     </td>
+    </tr>
+  {% endif %}
+  {% if bundle %}
+   <tr>
+     <td>Remove from bundle:</td>
+     <td>
+       <input type="hidden" name="removed_bundle_id" value="{{bundle.id}}"/>
+       <input name="action" value="Remove" type="submit"/>
+     </td>
+    </tr>
+  {% endif %}
+  </table>
+ </div>
+{% endif %}
+
+
+ <div style="clear: both;">
+ </div>
+</div>
+
+{% else %}
+ <tr>
+  <td colspan="5">No patches to display</td>
+ </tr>
+{% endif %}
+
+ </table>
+</form>
+
diff --git a/templates/patchwork/patch.html b/templates/patchwork/patch.html
new file mode 100644 (file)
index 0000000..6ca6761
--- /dev/null
@@ -0,0 +1,214 @@
+{% extends "patchwork/base.html" %}
+
+{% load syntax %}
+{% load person %}
+{% load patch %}
+
+{% block title %}{{patch.name}} - Patchwork{% endblock %}
+{% block heading %}{{patch.name}}{%endblock%}
+
+{% block body %}
+<script language="JavaScript" type="text/javascript">
+function toggle_headers(link_id, headers_id)
+{
+    var link = document.getElementById(link_id)
+    var headers = document.getElementById(headers_id)
+
+    var hidden = headers.style['display'] == 'none';
+
+    if (hidden) {
+        link.innerHTML = 'hide';
+        headers.style['display'] = 'block';
+    } else {
+        link.innerHTML = 'show';
+        headers.style['display'] = 'none';
+    }
+
+}
+</script>
+
+<table class="patchmeta">
+ <tr>
+  <th>Submitter</th>
+  <td>{{ patch.submitter|personify }}</td></tr>
+ </tr>
+ <tr>
+  <th>Date</th>
+  <td>{{ patch.date }}</td>
+ </tr>
+ <tr>
+  <th>Message ID</th>
+  <td>{{ patch.msgid }}</td>
+ </tr>
+ <tr>
+  <th>Download</th>
+  <td>
+   <a href="{% url patchwork.views.patch.mbox patch_id=patch.id %}"
+   >mbox</a> |
+   <a href="{% url patchwork.views.patch.content patch_id=patch.id %}"
+   >patch</a>
+   </td>
+ </tr>
+ <tr>
+  <th>Permalink</th>
+  <td><a href="{{ patch.get_absolute_url }}">{{ patch.get_absolute_url }}</a>
+ </tr>
+  <tr>
+   <th>State</td>
+   <td>{{ patch.state.name }}{% if patch.archived %}, archived{% endif %}</td>
+  </tr>
+{% if patch.delegate %}
+  <tr>
+   <th>Delegated to:</td>
+   <td>{{ patch.delegate.get_profile.name }}</td>
+  </tr>
+{% endif %}
+ <tr>
+  <th>Headers</th>
+  <td><a id="togglepatchheaders"
+   href="javascript:toggle_headers('togglepatchheaders', 'patchheaders')"
+   >show</a>
+   <div id="patchheaders" class="patchheaders" style="display:none;">
+    <pre>{{patch.headers}}</pre>
+   </div>
+  </td>
+ </tr>
+</table>
+
+<div class="patchforms">
+
+{% if patchform %}
+ <div class="patchform patchform-properties">
+  <h3>Patch Properties</h3>
+   <form method="post">
+    <table class="form">
+     <tr>
+      <th>Change state:</th>
+      <td>
+       {{ patchform.state }}
+       {{ patchform.state.errors }}
+      </td>
+     </tr>
+     <tr>
+      <th>Delegate to:</td>
+      <td>
+       {{ patchform.delegate }}
+       {{ patchform.delegate.errors }}
+      </td>
+     </tr>
+     <tr>
+      <th>Archived:</td>
+      <td>
+       {{ patchform.archived }}
+       {{ patchform.archived.errors }}
+      </td>
+     </tr>
+     <tr>
+      <td></td>
+      <td>
+       <input type="submit" value="Update">
+      </td>
+     </tr>
+    </table>
+  </form>
+ </div>
+{% endif %}
+
+{% if createbundleform %}
+ <div class="patchform patchform-bundle">
+  <h3>Bundling</h3>
+   <table class="form">
+   <!--
+    <tr>
+     <td>Ack:</td>
+     <td>
+      <form action="{% url patchwork.views.patch patch=patch.id %}"
+       method="post">
+       <input type="hidden" name="action" value="act"/>
+       <input type="submit" value="Ack"/>
+      </form>
+     </td>
+    </tr>
+    -->
+    <tr>
+     <td>Create bundle:</td>
+     <td>
+       {% if createbundleform.non_field_errors %}
+       <dd class="errors">{{createbundleform.non_field_errors}}</dd>
+       {% endif %}
+      <form method="post">
+       <input type="hidden" name="action" value="createbundle"/>
+       {% if createbundleform.name.errors %}
+       <dd class="errors">{{createbundleform.name.errors}}</dd>
+       {% endif %}
+        {{ createbundleform.name }}
+       <input value="Create" type="submit"/>
+      </form>
+      </td>
+    </tr>
+{% if bundles %}
+    <tr>
+     <td>Add to bundle:</td>
+     <td>
+      <form method="post">
+       <input type="hidden" name="action" value="addtobundle"/>
+       <select name="bundle_id"/>
+        {% for bundle in bundles %}
+         <option value="{{bundle.id}}">{{bundle.name}}</option>
+        {% endfor %}
+        </select>
+       <input value="Add" type="submit"/>
+      </form>
+     </td>
+    </tr>
+{% endif %}
+   </table>
+  </form>
+
+ </div>
+{% endif %}
+
+{% if actionsform %}
+ <div class="patchform patchform-actions">
+  <h3>Actions</h3>
+   <table class="form">
+    <tr>
+     <td>Ack:</td>
+     <td>
+      <form action="{% url patchwork.views.patch patch=patch.id %}"
+       method="post">
+       <input type="hidden" name="action" value="act"/>
+       <input type="submit" value="Ack"/>
+      </form>
+     </td>
+    </tr>
+   </table>
+  </form>
+ </div>
+
+{% endif %}
+ <div style="clear: both;">
+ </div>
+</div>
+
+
+
+
+<h2>Comments</h2>
+{% for comment in patch.comments %}
+<div class="comment">
+<div class="meta">{{ comment.submitter|personify }} - {{comment.date}}</div>
+<pre class="content">
+{{ comment|commentsyntax }}
+</pre>
+</div>
+{% endfor %}
+
+<h2>Patch</h2>
+<div class="patch">
+<pre class="content">
+{{ patch|patchsyntax }}
+</pre>
+</div>
+
+{% endblock %}
diff --git a/templates/patchwork/patchlist.html b/templates/patchwork/patchlist.html
new file mode 100644 (file)
index 0000000..1bcd2c1
--- /dev/null
@@ -0,0 +1,36 @@
+
+{% load person %}
+
+{% if patches %}
+<form method="post">
+<table class="patchlist">
+ <tr>
+  {% if patchform %}
+  <th/>
+  {% endif %}
+  <th><a class="colinactive" href="list?person=846&order=patch">Patch</a></th>
+  <th><a class="colinactive" href="list?person=846&order=date">Date</a></th>
+  <th><a class="colinactive" href="list?person=846&order=submitter">Submitter</a></th>
+  <th><a class="colinactive" href="list?person=846&order=state">State</a></th>
+ </tr>
+ {% for patch in patches %}
+  <tr>
+    {% if patchform %}
+    <td>
+    <input type="hidden" name="patch_ids" value="{{ patch.id }}"/>
+    <input type="checkbox" name="patch-{{patch.id}}">
+    </td>
+    {% endif %}
+   <td><a href="{% url patchwork.views.patch.patch patch_id=patch.id %}">{{ patch.name }}</a></td>
+   <td>{{ patch.date|date:"Y-m-d" }}</td>
+   <td>{{ patch.submitter|personify }}</td>
+   <td>{{ patch.state }}</td>
+  </tr>
+ {% endfor %}
+</table>
+
+{% include "patchwork/patch-form.html" %}
+
+{% else %}
+ <p>No patches to display</p>
+{% endif %}
diff --git a/templates/patchwork/profile.html b/templates/patchwork/profile.html
new file mode 100644 (file)
index 0000000..35f3d4f
--- /dev/null
@@ -0,0 +1,114 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}User Profile: {{ user.username }}{% endblock %}
+{% block heading %}User Profile: {{ user.username }}{% endblock %}
+
+
+{% block body %}
+
+<p>
+{% if user.get_profile.maintainer_projects.count %}
+Maintainer of
+{% for project in user.get_profile.maintainer_projects.all %}
+<a href="{% url patchwork.views.patch.list project_id=project.linkname %}"
+>{{ project.linkname }}</a>{% if not forloop.last %},{% endif %}{% endfor %}.
+{% endif %}
+
+{% if user.get_profile.contributor_projects.count %}
+Contributor to
+{% for project in user.get_profile.contributor_projects.all %}
+<a href="{% url patchwork.views.patch.list project_id=project.linkname %}"
+>{{ project.linkname }}</a>{% if not forloop.last %},{% endif %}{% endfor %}.
+{% endif %}
+</p>
+
+<h2>Todo</h2>
+{% if user.get_profile.n_todo_patches %}
+<p>Your <a href="{% url patchwork.views.user.todo_lists %}">todo
+list</a> contains {{ user.get_profile.n_todo_patches }}
+patch{{ user.get_profile.n_todo_patches|pluralize:"es" }}.</p>
+{% else %}
+<p>Your todo list contains patches that have been delegated to you. You
+have no items in your todo list at present.</p>
+{% endif %}
+<h2>Bundles</h2>
+
+{% if bundles %}
+<table class="bundlelist">
+ <tr>
+  <th>Bundle name</th>
+  <th>Patches</td>
+  <th>Public Link</th>
+ </tr>
+{% for bundle in bundles %}
+ <tr>
+  <td><a href="{% url patchwork.views.bundle.bundle bundle_id=bundle.id %}"
+   >{{ bundle.name }}</a></td>
+  <td style="text-align: right">{{ bundle.n_patches }}</td>
+  <td>
+   {% if bundle.public %}
+    <a href="{{ bundle.public_url }}">{{ bundle.public_url }}</a>
+   {% endif %}
+  </td>
+ </tr>
+{% endfor %}
+</table>
+{% else %}
+<p>no bundles</p>
+{% endif %}
+
+
+<h2>Linked email addresses</h2>
+<p>The following email addresses are associated with this patchwork account.
+Adding alternative addresses allows patchwork to group contributions that
+you have made under different addressses.</p>
+<p>Adding a new email address will send a confirmation email to that
+address.</p>
+<table class="vertical" style="width: 20em;">
+ <tr>
+  <th>email</th>
+  <th/>
+ </tr>
+ <tr>
+  <td>{{ user.email }}</td>
+  <td></td>
+ </tr>
+{% for email in linked_emails %}
+ {% ifnotequal email.email user.email %}
+ <tr>
+  <td>{{ email.email }}</td>
+  <td>
+   {% ifnotequal user.email email.email %}
+   <form action="{% url patchwork.views.user.unlink person_id=email.id %}"
+    method="post">
+    <input type="submit" value="Unlink"/>
+   </form>
+    {% endifnotequal %}
+ </tr>
+ {% endifnotequal %}
+{% endfor %}
+ <tr>
+  <td colspan="2">
+   <form action="{% url patchwork.views.user.link %}" method="post">
+    {{ linkform.email }}
+    <input type="submit" value="Add"/>
+   </form>
+  </td>
+ </tr>
+</table>
+
+<h2>Settings</h2>
+
+<form method="post">
+ <table class="form">
+{{ profileform }}
+  <tr>
+   <td/>
+   <td>
+    <input type="submit" value="Apply"/>
+   </td>
+  </tr>
+ </table>
+</form>
+
+{% endblock %}
diff --git a/templates/patchwork/project.html b/templates/patchwork/project.html
new file mode 100644 (file)
index 0000000..4ea1009
--- /dev/null
@@ -0,0 +1,32 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}{{ project.name }}{% endblock %}
+{% block heading %}{{ project.name }}{% endblock %}
+
+{% block body %}
+
+<table class="horizontal">
+ <tr>
+  <th>Name</th>
+  <td>{{project.name}}
+ </tr>
+ <tr>
+  <th>List address</th>
+  <td>{{project.listemail}}</td>
+ </tr>
+ <tr>
+  <th>Maintainer{{maintainers|length|pluralize}}</th>
+  <td>
+   {% for maintainer in maintainers %}
+    {{ maintainer.get_profile.name }}
+     &lt;<a href="mailto:{{maintainer.email}}">{{maintainer.email}}</a>&gt;
+     <br />
+   {% endfor %}
+ </tr>
+ <tr>
+  <th>Patch count</th>
+  <td>{{n_patches}} (+ {{n_archived_patches}} archived)</td>
+ </tr>
+</table>
+  
+{% endblock %}
diff --git a/templates/patchwork/projects.html b/templates/patchwork/projects.html
new file mode 100644 (file)
index 0000000..349f314
--- /dev/null
@@ -0,0 +1,21 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}Project List{% endblock %}
+{% block heading %}Project List{% endblock %}
+
+{% block body %}
+
+{% if projects %}
+ <dl>
+ {% for p in projects %}
+  <dt>
+   <a href="{% url patchwork.views.patch.list project_id=p.linkname %}"
+    >{{p.linkname}}</a></dt>
+  <dd>{{p.name}}</dd>
+ {% endfor %}
+ </dl>
+{% else %}
+ <p>Patchwork doesn't have any projects to display!</p>
+{% endif %}
+
+{% endblock %}
diff --git a/templates/patchwork/register-confirm.html b/templates/patchwork/register-confirm.html
new file mode 100644 (file)
index 0000000..2af5744
--- /dev/null
@@ -0,0 +1,13 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}Registration{% endblock %}
+{% block heading %}Registration{% endblock %}
+
+{% block body %}
+<p>Registraton confirmed!</p>
+
+<p>Your patchwork registration is complete. Head over to your <a
+ href="{% url patchwork.views.user.profile %}">profile</a> to start using
+patchwork's extra features.</p>
+
+{% endblock %}
diff --git a/templates/patchwork/register.html b/templates/patchwork/register.html
new file mode 100644 (file)
index 0000000..8bd422e
--- /dev/null
@@ -0,0 +1,122 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}Patchwork Registration{% endblock %}
+{% block heading %}Patchwork Registration{% endblock %}
+
+
+{% block body %}
+
+{% if request %}
+ <p>Registration successful!</p>
+ <p>email sent to {{ request.email }}</p>
+ <p>Beta note: While we're testing, the confirmation email has been replaced
+ by a single link:
+ <a href="{% url patchwork.views.user.register_confirm key=request.key %}"
+ >{% url patchwork.views.user.register_confirm key=request.key %}</a>
+</p>
+{% else %}
+<p>By creating a patchwork account, you can:<p>
+<ul>
+ <li>create "bundles" of patches</li>
+ <li>update the state of your own patches</li>
+</ul>
+<form method="post">
+<table class="form registerform">
+ <tr>
+  <th colspan="2" class="headerrow">register</th>
+ </tr>
+ {% if error %}
+  <tr>
+   <td colspan="2">{{ error }}</td>
+  </tr>
+ {% endif %}
+
+  <tr>
+   <td>{{ form.first_name.label_tag }}</td>
+   <td>
+{% if form.first_name.errors %}
+    {{ form.first_name.errors }}
+{% endif %}
+    {{ form.first_name }}
+{% if form.first_name.help_text %}
+    <div class="help_text"/>{{ form.first_name.help_text }}</div>
+{% endif %}
+   </td>
+  </tr>
+   
+  <tr>
+   <td>{{ form.last_name.label_tag }}</td>
+   <td>
+{% if form.last_name.errors %}
+    {{ form.last_name.errors }}
+{% endif %}
+    {{ form.last_name }}
+{% if form.last_name.help_text %}
+    <div class="help_text"/>{{ form.last_name.help_text }}</div>
+{% endif %}
+   </td>
+  </tr>
+
+  <tr>
+   <td></td>
+   <td class="form-help">
+    Your name is used to identify you on the site
+   </td>
+  </tr>
+   
+  <tr>
+   <td>{{ form.email.label_tag }}</td>
+   <td>
+{% if form.email.errors %}
+    {{ form.email.errors }}
+{% endif %}
+    {{ form.email }}
+{% if form.email.help_text %}
+    <div class="help_text"/>{{ form.email.help_text }}</div>
+{% endif %}
+   </td>
+  </tr>
+   
+  <tr>
+   <td></td>
+   <td class="form-help">
+    Patchwork will send a confirmation email to this address
+   </td>
+  </tr>
+
+  <tr>
+   <td>{{ form.username.label_tag }}</td>
+   <td>
+{% if form.username.errors %}
+    {{ form.username.errors }}
+{% endif %}
+    {{ form.username }}
+{% if form.username.help_text %}
+    <div class="help_text"/>{{ form.username.help_text }}</div>
+{% endif %}
+   </td>
+  </tr>
+   
+  <tr>
+   <td>{{ form.password.label_tag }}</td>
+   <td>
+{% if form.password.errors %}
+    {{ form.password.errors }}
+{% endif %}
+    {{ form.password }}
+{% if form.password.help_text %}
+    <div class="help_text"/>{{ form.password.help_text }}</div>
+{% endif %}
+   </td>
+  </tr>
+   
+ <tr>
+  <td colspan="2" class="submitrow">
+   <input type="submit" value="Register"/>
+  </td>
+ </tr>
+</table>
+</form>
+{% endif %}
+
+{% endblock %}
diff --git a/templates/patchwork/todo-list.html b/templates/patchwork/todo-list.html
new file mode 100644 (file)
index 0000000..8a5ab7a
--- /dev/null
@@ -0,0 +1,17 @@
+{% extends "patchwork/base.html" %}
+
+{% load person %}
+
+{% block title %}{{ user }}'s todo list{% endblock %}
+{% block heading %}{{user}}'s todo list for {{ project.linkname }}{% endblock %}
+
+{% block body %}
+
+<p>A Patchwork Todo-list contains patches that are assigned to you, and
+are in an "action required" state
+({% for state in action_required_states %}{% if forloop.last and not forloop.first %} or {% endif %}{{ state }}{% if not forloop.last and not forloop.first %}, {%endif %}{% endfor %}), and are not archived.
+</p>
+
+{% include "patchwork/patch-list.html" %}
+
+{% endblock %}
diff --git a/templates/patchwork/todo-lists.html b/templates/patchwork/todo-lists.html
new file mode 100644 (file)
index 0000000..8eb10cc
--- /dev/null
@@ -0,0 +1,29 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}{{ user }}'s todo lists{% endblock %}
+{% block heading %}{{ user }}'s todo lists{% endblock %}
+
+{% block body %}
+
+{% if todo_lists %}
+<p>You have multiple todo lists. Each todo list contains patches for a single
+ project.</p>
+<table class="vertical">
+ <tr>
+  <th>project</th>
+  <th>patches</th>
+ </tr>
+{% for todo_list in todo_lists %}
+ <tr>
+  <td><a
+   href="{% url patchwork.views.user.todo_list project_id=todo_list.project.linkname %}"
+    >{{ todo_list.project.name }}</a></td>
+  <td class="numberformat">{{ todo_list.n_patches }}</td>
+ </tr>
+{% endfor %}
+</table>
+
+{% else %}
+ No todo lists
+{% endif %}
+{% endblock %}
diff --git a/templates/patchwork/user-link-confirm.html b/templates/patchwork/user-link-confirm.html
new file mode 100644 (file)
index 0000000..61979cf
--- /dev/null
@@ -0,0 +1,19 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}{{ user.username }}{% endblock %}
+{% block heading %}link accounts for {{ user.username }}{% endblock %}
+
+
+{% block body %}
+
+{% if errors %}
+<p>{{ errors }}</p>
+{% else %}
+ <p>You have sucessfully linked the email address {{ person.email }} to
+  your patchwork account</p>
+
+{% endif %}
+<p>Back to <a href="{% url patchwork.views.user.profile %}">your
+ profile</a>.</p>
+
+{% endblock %}
diff --git a/templates/patchwork/user-link.html b/templates/patchwork/user-link.html
new file mode 100644 (file)
index 0000000..3eeb527
--- /dev/null
@@ -0,0 +1,30 @@
+{% extends "patchwork/base.html" %}
+
+{% block title %}{{ user.username }}{% endblock %}
+{% block heading %}link accounts for {{ user.username }}{% endblock %}
+
+
+{% block body %}
+
+{% if confirmation %}
+<p>A confirmation email has been sent to {{ confirmation.email }}.</p>
+
+<p>beta link: <a
+ href="{% url patchwork.views.user.link_confirm key=confirmation.key %}"
+ >{% url patchwork.views.user.link_confirm key=confirmation.key %}</a></p>
+
+{% else %}
+
+   {% if form.errors %}
+   <p>There was an error submitting your link request.</p>
+    {{ form.non_field_errors }}
+   {% endif %}
+
+   <form action="{% url patchwork.views.user.link %}" method="post">
+    {{linkform.email.errors}}
+    Link an email address: {{ linkform.email }}
+   </form>
+
+{% endif %}
+
+{% endblock %}