diff options
| author | Jeremy Kerr <jk@ozlabs.org> | 2008-08-21 09:38:06 +0800 | 
|---|---|---|
| committer | Jeremy Kerr <jk@ozlabs.org> | 2008-08-21 09:38:06 +0800 | 
| commit | c561ebe710d6e6a43aa4afc6c2036a215378ce87 (patch) | |
| tree | 7d4a56233ef53a0457646c47895ac5c6e7a65d31 | |
| download | patchwork-c561ebe710d6e6a43aa4afc6c2036a215378ce87.tar.bz2 patchwork-c561ebe710d6e6a43aa4afc6c2036a215378ce87.tar.xz | |
Inital commit
Signed-off-by: Jeremy Kerr <jk@ozlabs.org>
67 files changed, 5738 insertions, 0 deletions
| diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/apps/__init__.py diff --git a/apps/manage.py b/apps/manage.py new file mode 100755 index 0000000..1f6f0ed --- /dev/null +++ b/apps/manage.py @@ -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 index 0000000..e69de29 --- /dev/null +++ b/apps/patchwork/__init__.py diff --git a/apps/patchwork/bin/parsemail-batch.sh b/apps/patchwork/bin/parsemail-batch.sh new file mode 100644 index 0000000..dbf81cc --- /dev/null +++ b/apps/patchwork/bin/parsemail-batch.sh @@ -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 index 0000000..d41bd92 --- /dev/null +++ b/apps/patchwork/bin/parsemail.py @@ -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 index 0000000..0178e18 --- /dev/null +++ b/apps/patchwork/bin/parsemail.sh @@ -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 index 0000000..16d1de4 --- /dev/null +++ b/apps/patchwork/bin/patchparser.py @@ -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 index 0000000..7d55815 --- /dev/null +++ b/apps/patchwork/bin/setup.py @@ -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 index 0000000..c774d63 --- /dev/null +++ b/apps/patchwork/bin/update-patchwork-status.py @@ -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 index 0000000..f4ab5a9 --- /dev/null +++ b/apps/patchwork/context_processors.py @@ -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 index 0000000..f7fb652 --- /dev/null +++ b/apps/patchwork/filters.py @@ -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"> ' % 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' + \ +                   '    ') % \ +                    {'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 index 0000000..ed55c4f --- /dev/null +++ b/apps/patchwork/forms.py @@ -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 index 0000000..f6943fc --- /dev/null +++ b/apps/patchwork/models.py @@ -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 index 0000000..8d8be64 --- /dev/null +++ b/apps/patchwork/paginator.py @@ -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 index 0000000..ecc1d4b --- /dev/null +++ b/apps/patchwork/parser.py @@ -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 index 0000000..cb9a782 --- /dev/null +++ b/apps/patchwork/requestcontext.py @@ -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 index 0000000..f0db525 --- /dev/null +++ b/apps/patchwork/sql/project.sql @@ -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 index 0000000..c673fd8 --- /dev/null +++ b/apps/patchwork/sql/state.sql @@ -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 index 0000000..e69de29 --- /dev/null +++ b/apps/patchwork/templatetags/__init__.py diff --git a/apps/patchwork/templatetags/filter.py b/apps/patchwork/templatetags/filter.py new file mode 100644 index 0000000..b940599 --- /dev/null +++ b/apps/patchwork/templatetags/filter.py @@ -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 index 0000000..22e2a1b --- /dev/null +++ b/apps/patchwork/templatetags/listurl.py @@ -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 index 0000000..e392f03 --- /dev/null +++ b/apps/patchwork/templatetags/order.py @@ -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 index 0000000..bec0cab --- /dev/null +++ b/apps/patchwork/templatetags/patch.py @@ -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 index 0000000..6a6a6af --- /dev/null +++ b/apps/patchwork/templatetags/person.py @@ -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 index 0000000..98bc1ca --- /dev/null +++ b/apps/patchwork/templatetags/pwurl.py @@ -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 index 0000000..a538062 --- /dev/null +++ b/apps/patchwork/templatetags/syntax.py @@ -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*>.*$', '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 index 0000000..4a7ccb1 --- /dev/null +++ b/apps/patchwork/urls.py @@ -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 index 0000000..7cf88bc --- /dev/null +++ b/apps/patchwork/utils.py @@ -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 index 0000000..2636d29 --- /dev/null +++ b/apps/patchwork/views/__init__.py @@ -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 index 0000000..16fa5db --- /dev/null +++ b/apps/patchwork/views/base.py @@ -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 index 0000000..be6a937 --- /dev/null +++ b/apps/patchwork/views/bundle.py @@ -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 index 0000000..d509e28 --- /dev/null +++ b/apps/patchwork/views/patch.py @@ -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 index 0000000..223cfc6 --- /dev/null +++ b/apps/patchwork/views/user.py @@ -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 index 0000000..0d74b10 --- /dev/null +++ b/apps/settings.py @@ -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 index 0000000..e11cbd9 --- /dev/null +++ b/apps/urls.py @@ -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 index 0000000..da8dd54 --- /dev/null +++ b/docs/INSTALL @@ -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 index 0000000..2d8d628 --- /dev/null +++ b/htdocs/css/style.css @@ -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.pngBinary files differ new file mode 100644 index 0000000..3992342 --- /dev/null +++ b/htdocs/images/filter-add.png diff --git a/htdocs/images/filter-remove.png b/htdocs/images/filter-remove.pngBinary files differ new file mode 100644 index 0000000..82f2a32 --- /dev/null +++ b/htdocs/images/filter-remove.png diff --git a/htdocs/images/title-background.png b/htdocs/images/title-background.pngBinary files differ new file mode 100644 index 0000000..d850ad7 --- /dev/null +++ b/htdocs/images/title-background.png diff --git a/htdocs/js/autocomplete.js b/htdocs/js/autocomplete.js new file mode 100644 index 0000000..115ffba --- /dev/null +++ b/htdocs/js/autocomplete.js @@ -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 index 0000000..d8596ea --- /dev/null +++ b/htdocs/js/filters.js @@ -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 index 0000000..7fb4e9f --- /dev/null +++ b/htdocs/js/people.js @@ -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 index 0000000..78d8147 --- /dev/null +++ b/lib/apache2/patchwork.fastcgi.conf @@ -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 index 0000000..a84a9e2 --- /dev/null +++ b/lib/apache2/patchwork.mod_python.conf @@ -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 index 0000000..4b8a43b --- /dev/null +++ b/lib/sql/grant-all.sql @@ -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 index 0000000..c3a2206 --- /dev/null +++ b/templates/patchwork/base.html @@ -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">α</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 index 0000000..0ee57da --- /dev/null +++ b/templates/patchwork/bundle-public.html @@ -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 index 0000000..8fa694a --- /dev/null +++ b/templates/patchwork/bundle.html @@ -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 index 0000000..482bc98 --- /dev/null +++ b/templates/patchwork/filters.html @@ -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 %}   |   {% 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 index 0000000..755c047 --- /dev/null +++ b/templates/patchwork/list.html @@ -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 index 0000000..4706dda --- /dev/null +++ b/templates/patchwork/login.html @@ -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 index 0000000..737f1ce --- /dev/null +++ b/templates/patchwork/logout.html @@ -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 index 0000000..3e95126 --- /dev/null +++ b/templates/patchwork/pagination.html @@ -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">« Previous</a></span> +{% else %} + <span class="prev-na">« 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 »</a> +  </span> +{% else %} + <span class="next-na">Next »</span> +{% endif %} +</div>  +{% endifnotequal %} diff --git a/templates/patchwork/patch-form.html b/templates/patchwork/patch-form.html new file mode 100644 index 0000000..9d2c954 --- /dev/null +++ b/templates/patchwork/patch-form.html @@ -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 index 0000000..0a15e9c --- /dev/null +++ b/templates/patchwork/patch-list.html @@ -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%">▾</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 index 0000000..6ca6761 --- /dev/null +++ b/templates/patchwork/patch.html @@ -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 index 0000000..1bcd2c1 --- /dev/null +++ b/templates/patchwork/patchlist.html @@ -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 index 0000000..35f3d4f --- /dev/null +++ b/templates/patchwork/profile.html @@ -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 index 0000000..4ea1009 --- /dev/null +++ b/templates/patchwork/project.html @@ -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 }} +     <<a href="mailto:{{maintainer.email}}">{{maintainer.email}}</a>> +     <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 index 0000000..349f314 --- /dev/null +++ b/templates/patchwork/projects.html @@ -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 index 0000000..2af5744 --- /dev/null +++ b/templates/patchwork/register-confirm.html @@ -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 index 0000000..8bd422e --- /dev/null +++ b/templates/patchwork/register.html @@ -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 index 0000000..8a5ab7a --- /dev/null +++ b/templates/patchwork/todo-list.html @@ -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 index 0000000..8eb10cc --- /dev/null +++ b/templates/patchwork/todo-lists.html @@ -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 index 0000000..61979cf --- /dev/null +++ b/templates/patchwork/user-link-confirm.html @@ -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 index 0000000..3eeb527 --- /dev/null +++ b/templates/patchwork/user-link.html @@ -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 %} | 
