Esempio n. 1
0
def generateMailsForAssignmentsTransaction(db, transaction_id):
    cursor = db.cursor()
    cursor.execute(
        "SELECT review, assigner, note FROM reviewassignmentstransactions WHERE id=%s",
        (transaction_id, ))

    review_id, assigner_id, note = cursor.fetchone()

    review = dbutils.Review.fromId(db, review_id)
    assigner = dbutils.User.fromId(db, assigner_id)

    cursor.execute(
        """SELECT uid, directory, file, type, created
                        FROM reviewfilterchanges
                       WHERE transaction=%s""", (transaction_id, ))

    by_user = {}

    for reviewer_id, directory_id, file_id, filter_type, created in cursor:
        added_filters, removed_filters, unassigned, assigned = by_user.setdefault(
            reviewer_id, ([], [], [], []))

        if file_id: path = dbutils.describe_file(db, file_id)
        elif directory_id: path = dbutils.describe_directory(db, directory_id)
        else: path = "/"

        if created: added_filters.append((filter_type, path))
        else: removed_filters.append((filter_type, path))

    cursor.execute(
        """SELECT reviewassignmentchanges.uid, reviewassignmentchanges.assigned, reviewfiles.file, SUM(reviewfiles.deleted), SUM(reviewfiles.inserted)
                        FROM reviewfiles
                        JOIN reviewassignmentchanges ON (reviewassignmentchanges.file=reviewfiles.id)
                       WHERE reviewassignmentchanges.transaction=%s
                    GROUP BY reviewassignmentchanges.uid, reviewassignmentchanges.assigned, reviewfiles.file""",
        (transaction_id, ))

    for reviewer_id, was_assigned, file_id, deleted, inserted in cursor:
        added_filters, removed_filters, unassigned, assigned = by_user.setdefault(
            reviewer_id, (None, None, [], []))

        if was_assigned: assigned.append((file_id, deleted, inserted))
        else: unassigned.append((file_id, deleted, inserted))

    pending_mails = []

    for reviewer_id, (added_filters, removed_filters, unassigned,
                      assigned) in by_user.items():
        reviewer = dbutils.User.fromId(db, reviewer_id)
        if assigner != reviewer:
            pending_mails.extend(
                mail.sendAssignmentsChanged(db, assigner, reviewer, review,
                                            added_filters, removed_filters,
                                            unassigned, assigned))

    return pending_mails
Esempio n. 2
0
def generateMailsForAssignmentsTransaction(db, transaction_id):
    cursor = db.cursor()
    cursor.execute("SELECT review, assigner, note FROM reviewassignmentstransactions WHERE id=%s", (transaction_id,))

    review_id, assigner_id, note = cursor.fetchone()

    review = dbutils.Review.fromId(db, review_id)
    assigner = dbutils.User.fromId(db, assigner_id)

    cursor.execute("""SELECT uid, directory, file, type, created
                        FROM reviewfilterchanges
                       WHERE transaction=%s""",
                   (transaction_id,))

    by_user = {}

    for reviewer_id, directory_id, file_id, filter_type, created in cursor:
        added_filters, removed_filters, unassigned, assigned = by_user.setdefault(reviewer_id, ([], [], [], []))

        if file_id: path = dbutils.describe_file(db, file_id)
        elif directory_id: path = dbutils.describe_directory(db, directory_id)
        else: path = "/"

        if created: added_filters.append((filter_type, path))
        else: removed_filters.append((filter_type, path))

    cursor.execute("""SELECT reviewassignmentchanges.uid, reviewassignmentchanges.assigned, reviewfiles.file, SUM(reviewfiles.deleted), SUM(reviewfiles.inserted)
                        FROM reviewfiles
                        JOIN reviewassignmentchanges ON (reviewassignmentchanges.file=reviewfiles.id)
                       WHERE reviewassignmentchanges.transaction=%s
                    GROUP BY reviewassignmentchanges.uid, reviewassignmentchanges.assigned, reviewfiles.file""",
                   (transaction_id,))

    for reviewer_id, was_assigned, file_id, deleted, inserted in cursor:
        added_filters, removed_filters, unassigned, assigned = by_user.setdefault(reviewer_id, (None, None, [], []))

        if was_assigned: assigned.append((file_id, deleted, inserted))
        else: unassigned.append((file_id, deleted, inserted))

    pending_mails = []

    for reviewer_id, (added_filters, removed_filters, unassigned, assigned) in by_user.items():
        reviewer = dbutils.User.fromId(db, reviewer_id)
        if assigner != reviewer:
            pending_mails.extend(mail.sendAssignmentsChanged(db, assigner, reviewer, review, added_filters, removed_filters, unassigned, assigned))

    return pending_mails
Esempio n. 3
0
def renderHome(req, db, user):
    if user.isAnonymous():
        raise page.utils.NeedLogin, req

    cursor = db.cursor()

    readonly = req.getParameter("readonly", "yes" if user.name != req.user else "no") == "yes"
    repository = req.getParameter("repository", None, gitutils.Repository.FromParameter(db))

    if not repository:
        repository = user.getDefaultRepository(db)

    title_fullname = user.fullname

    if title_fullname[-1] == "s":
        title_fullname += "'"
    else:
        title_fullname += "'s"

    cursor.execute("SELECT email FROM usergitemails WHERE uid=%s ORDER BY email ASC", (user.id,))
    gitemails = ", ".join([email for (email,) in cursor])

    document = htmlutils.Document(req)

    html = document.html()
    head = html.head()
    body = html.body()

    page.utils.generateHeader(body, db, user, current_page="home")

    document.addExternalStylesheet("resource/home.css")
    document.addExternalScript("resource/home.js")
    if repository:
        document.addInternalScript(repository.getJS())
    else:
        document.addInternalScript("var repository = null;")
    if user.name != req.user and req.getUser(db).hasRole(db, "administrator"):
        document.addInternalScript("var administrator = true;")
    else:
        document.addInternalScript("var administrator = false;")
    document.addInternalScript(user.getJS())
    document.addInternalScript("user.gitEmails = %s;" % htmlutils.jsify(gitemails))
    document.setTitle("%s Home" % title_fullname)

    target = body.div("main")

    basic = target.table("paleyellow basic", align="center")
    basic.tr().td("h1", colspan=3).h1().text("%s Home" % title_fullname)

    def row(heading, value, help=None, status_id=None):
        main_row = basic.tr("line")
        main_row.td("heading").text("%s:" % heading)
        value_cell = main_row.td("value", colspan=2)
        if callable(value):
            value(value_cell)
        else:
            value_cell.text(value)
        basic.tr("help").td("help", colspan=3).text(help)

    def renderFullname(target):
        if readonly:
            target.text(user.fullname)
        else:
            target.input("value", id="user_fullname", value=user.fullname)
            target.span("status", id="status_fullname")
            target.button(onclick="saveFullname();").text("Save")
            target.button(onclick="resetFullname();").text("Reset")

    def renderEmail(target):
        if readonly:
            target.text(user.email)
        else:
            target.input("value", id="user_email", value=user.email)
            target.span("status", id="status_email")
            target.button(onclick="saveEmail();").text("Save")
            target.button(onclick="resetEmail();").text("Reset")

    def renderGitEmails(target):
        if readonly:
            target.text(gitemails)
        else:
            target.input("value", id="user_gitemails", value=gitemails)
            target.span("status", id="status_gitemails")
            target.button(onclick="saveGitEmails();").text("Save")
            target.button(onclick="resetGitEmails();").text("Reset")

    def renderPassword(target):
        target.text("****")
        if not readonly:
            target.button(onclick="changePassword();").text("Change")

    row("User ID", str(user.id))
    row("User Name", user.name)
    row(
        "Display Name",
        renderFullname,
        "This is the name used when displaying commits or comments.",
        status_id="status_fullname",
    )
    row("Email", renderEmail, "This is the primary email address, to which emails are sent.", status_id="status_email")
    row(
        "Git Emails",
        renderGitEmails,
        "These email addresses are used to map Git commits to the user.",
        status_id="status_gitemails",
    )

    if configuration.base.AUTHENTICATION_MODE == "critic":
        row("Password", renderPassword)

    filters = target.table("paleyellow filters", align="center")
    row = filters.tr()
    row.td("h1", colspan=2).h1().text("Filters")
    repositories = row.td("repositories", colspan=2).select()

    if not repository:
        repositories.option(value="-", selected="selected", disabled="disabled").text("Select a repository")

    cursor.execute("SELECT id, path FROM repositories ORDER BY id")
    for id, path in cursor:
        repositories.option(value=id, selected="selected" if repository and id == repository.id else None).text(
            "%s:%s" % (configuration.base.HOSTNAME, path)
        )

    help = filters.tr().td("help", colspan=4)

    help.p().text(
        "Filters is the system's mechanism to connect reviews to users.  When a review is created or updated, a set of users to associate with the review is calculated by matching the files modified by each commit in the review to the filters set up by users.  Each filter selects one file or one directory (and affects all sub-directories and files,) and only the most specific filter per file and user is used when associating users with reviews."
    )

    p = help.p()
    p.text("There are two types of filters: ")
    p.code().text("reviewer")
    p.text(" and ")
    p.code().text("watcher")
    p.text(".  All files matched by a ")
    p.code().text("reviewer")
    p.text(
        " filter for a user are added to the user's to-do list, meaning the user needs to review all changes made to that file before the review is finished.  However, if more than one user is matched as a reviewer for a file, only one of them needs to review the changes.  A user associated with a review only by "
    )
    p.code().text("watcher")
    p.text(" filters will simply receive notifications relating to the review, but isn't required to do anything.")

    p = help.p()
    p.text("For a ")
    p.code().text("reviewer")
    p.text(
        ' type filter, a set of "delegates" can also be defined.  The delegate field should be a comma-separated list of user names.  Delegates are automatically made reviewers of changes by you in the filtered files (since you can\'t review them yourself) regardless of their own filters.'
    )

    p = help.p()
    p.strong().text("Note: A filter names a directory only if the path ends with a slash ('/').")
    p.text(
        "  If the path doesn't end with a slash, the filter would name a specific file even if the path is a directory in some or all versions of the actual tree.  However, you'll get a warning if you try to add a filter for a file whose path is registered as a directory in the database."
    )

    if repository:
        headings = filters.tr("headings")
        headings.td("heading type").text("Type")
        headings.td("heading path").text("Path")
        headings.td("heading delegate").text("Delegate")
        headings.td("heading buttons")

        cursor.execute(
            "SELECT directory, file, type, delegate FROM filters WHERE uid=%s AND repository=%s",
            [user.id, repository.id],
        )

        all_filters = []

        for directory_id, file_id, filter_type, delegate in cursor.fetchall():
            if file_id == 0:
                path = dbutils.describe_directory(db, directory_id) + "/"
            else:
                path = dbutils.describe_file(db, file_id)
            all_filters.append((path, directory_id, file_id, filter_type, delegate))

        all_filters.sort()

        empty = filters.tr("empty").td("empty", colspan=4).span(id="empty").text("No filters configured")
        if filters:
            empty.setAttribute("style", "display: none")

        for path, directory_id, file_id, filter_type, delegate in all_filters:
            row = filters.tr("filter")
            row.td("filter type").text(filter_type.capitalize())
            row.td("filter path").text(path)
            row.td("filter delegate").text(delegate)

            buttons = row.td("filter buttons")
            if readonly:
                buttons.text()
            else:
                buttons.button(onclick="editFilter(this, %d, %d, false);" % (directory_id, file_id)).text("Edit")
                buttons.button(onclick="deleteFilter(this, %d, %d);" % (directory_id, file_id)).text("Delete")

        if not readonly:
            filters.tr("buttons").td("buttons", colspan=4).button(onclick="addFilter(this);").text("Add Filter")

    return document
Esempio n. 4
0
    def renderWatchers(target):
        if review.watchers:
            for index, watcher in enumerate(review.watchers):
                if index != 0: target.text(", ")
                span = target.span("user %s" % watcher.status)
                span.span("name").text(watcher.fullname)
                if watcher.status == 'absent':
                    span.span("status").text(" (%s)" % watcher.getAbsence(db))
                elif watcher.status == 'retired':
                    span.span("status").text(" (retired)")
        else:
            target.i().text("No watchers.")

        cursor.execute("""SELECT reviewfilters.id, reviewfilters.uid, reviewfilters.directory, reviewfilters.file
                            FROM reviewfilters
                            JOIN users ON (reviewfilters.uid=users.id)
                           WHERE reviewfilters.review=%s
                             AND reviewfilters.type='watcher'
                             AND users.status!='retired'""",
                       (review.id,))

        rows = cursor.fetchall()
        watcher_filters_hidden = []

        if rows:
            table = target.table("reviewfilters watchers")

            row = table.thead().tr("h1")
            row.th("h1", colspan=4).text("Custom filters:")

            filter_data = {}
            reviewfilters = {}

            for filter_id, user_id, directory_id, file_id in rows:
                filter_user = dbutils.User.fromId(db, user_id)

                if file_id: path = dbutils.describe_file(db, file_id)
                else: path = dbutils.describe_directory(db, directory_id) + "/"

                reviewfilters.setdefault(filter_user.fullname, []).append(path)
                filter_data[(filter_user.fullname, path)] = (filter_id, filter_user)

            count = 0
            tbody = table.tbody()

            for fullname in sorted(reviewfilters.keys()):
                original_paths = sorted(reviewfilters[fullname])
                trimmed_paths = diff.File.eliminateCommonPrefixes(original_paths[:])

                first = True

                for original_path, trimmed_path in zip(original_paths, trimmed_paths):
                    row = tbody.tr("filter")

                    if first:
                        row.td("username", rowspan=len(original_paths)).text(fullname)
                        row.td("reviews", rowspan=len(original_paths)).text("watches")
                        first = False

                    row.td("path").span().innerHTML(trimmed_path)

                    filter_id, filter_user = filter_data[(fullname, original_path)]

                    href = "javascript:removeReviewFilter(%d, %s, 'watcher', %s, %s);" % (filter_id, filter_user.getJSConstructor(), htmlutils.jsify(original_path), "true" if filter_user != user else "false")
                    row.td("remove").a(href=href).text("[remove]")

                    count += 1

            tfoot = table.tfoot()
            tfoot.tr().td(colspan=4).text("%d line%s hidden" % (count, "s" if count > 1 else ""))

            if count > 10:
                tbody.setAttribute("class", "hidden")
                watcher_filters_hidden.append(True)
            else:
                tfoot.setAttribute("class", "hidden")
                watcher_filters_hidden.append(False)

        buttons = target.div("buttons")

        if watcher_filters_hidden:
            buttons.button("showfilters", onclick="toggleReviewFilters('watchers', $(this));").text("%s Custom Filters" % ("Show" if watcher_filters_hidden[0] else "Hide"))

        buttons.button("addwatcher", onclick="addWatcher();").text("Add Watcher")

        if user not in review.reviewers and user not in review.owners:
            if user not in review.watchers:
                buttons.button("watch", onclick="watchReview();").text("Watch Review")
            elif review.watchers[user] == "manual":
                buttons.button("watch", onclick="unwatchReview();").text("Stop Watching Review")
Esempio n. 5
0
    def renderWatchers(target):
        if review.watchers:
            for index, watcher in enumerate(review.watchers):
                if index != 0: target.text(", ")
                span = target.span("user %s" % watcher.status)
                span.span("name").text(watcher.fullname)
                if watcher.status == 'absent':
                    span.span("status").text(" (%s)" % watcher.getAbsence(db))
                elif watcher.status == 'retired':
                    span.span("status").text(" (retired)")
        else:
            target.i().text("No watchers.")

        cursor.execute("""SELECT reviewfilters.id, reviewfilters.uid, reviewfilters.directory, reviewfilters.file
                            FROM reviewfilters
                            JOIN users ON (reviewfilters.uid=users.id)
                           WHERE reviewfilters.review=%s
                             AND reviewfilters.type='watcher'
                             AND users.status!='retired'""",
                       (review.id,))

        rows = cursor.fetchall()
        watcher_filters_hidden = []

        if rows:
            table = target.table("reviewfilters watchers")

            row = table.thead().tr("h1")
            row.th("h1", colspan=4).text("Custom filters:")

            filter_data = {}
            reviewfilters = {}

            for filter_id, user_id, directory_id, file_id in rows:
                filter_user = dbutils.User.fromId(db, user_id)

                if file_id: path = dbutils.describe_file(db, file_id)
                else: path = dbutils.describe_directory(db, directory_id) + "/"

                reviewfilters.setdefault(filter_user.fullname, []).append(path)
                filter_data[(filter_user.fullname, path)] = (filter_id, filter_user)

            count = 0
            tbody = table.tbody()

            for fullname in sorted(reviewfilters.keys()):
                original_paths = sorted(reviewfilters[fullname])
                trimmed_paths = diff.File.eliminateCommonPrefixes(original_paths[:])

                first = True

                for original_path, trimmed_path in zip(original_paths, trimmed_paths):
                    row = tbody.tr("filter")

                    if first:
                        row.td("username", rowspan=len(original_paths)).text(fullname)
                        row.td("reviews", rowspan=len(original_paths)).text("watches")
                        first = False

                    row.td("path").span().innerHTML(trimmed_path)

                    filter_id, filter_user = filter_data[(fullname, original_path)]

                    href = "javascript:removeReviewFilter(%d, %s, 'watcher', %s, %s);" % (filter_id, filter_user.getJSConstructor(), htmlutils.jsify(original_path), "true" if filter_user != user else "false")
                    row.td("remove").a(href=href).text("[remove]")

                    count += 1

            tfoot = table.tfoot()
            tfoot.tr().td(colspan=4).text("%d line%s hidden" % (count, "s" if count > 1 else ""))

            if count > 10:
                tbody.setAttribute("class", "hidden")
                watcher_filters_hidden.append(True)
            else:
                tfoot.setAttribute("class", "hidden")
                watcher_filters_hidden.append(False)

        buttons = target.div("buttons")

        if watcher_filters_hidden:
            buttons.button("showfilters", onclick="toggleReviewFilters('watchers', $(this));").text("%s Custom Filters" % ("Show" if watcher_filters_hidden[0] else "Hide"))

        buttons.button("addwatcher", onclick="addWatcher();").text("Add Watcher")

        if user not in review.reviewers and user not in review.owners:
            if user not in review.watchers:
                buttons.button("watch", onclick="watchReview();").text("Watch Review")
            elif review.watchers[user] == "manual":
                buttons.button("watch", onclick="unwatchReview();").text("Stop Watching Review")
Esempio n. 6
0
def renderHome(req, db, user):
    if user.isAnonymous(): raise page.utils.NeedLogin, req

    cursor = db.cursor()

    readonly = req.getParameter(
        "readonly", "yes" if user.name != req.user else "no") == "yes"
    repository = req.getParameter("repository", None,
                                  gitutils.Repository.FromParameter(db))

    if not repository:
        repository = user.getDefaultRepository(db)

    title_fullname = user.fullname

    if title_fullname[-1] == 's': title_fullname += "'"
    else: title_fullname += "'s"

    cursor.execute(
        "SELECT email FROM usergitemails WHERE uid=%s ORDER BY email ASC",
        (user.id, ))
    gitemails = ", ".join([email for (email, ) in cursor])

    document = htmlutils.Document(req)

    html = document.html()
    head = html.head()
    body = html.body()

    page.utils.generateHeader(body, db, user, current_page="home")

    document.addExternalStylesheet("resource/home.css")
    document.addExternalScript("resource/home.js")
    if repository: document.addInternalScript(repository.getJS())
    else: document.addInternalScript("var repository = null;")
    if user.name != req.user and req.getUser(db).hasRole(db, "administrator"):
        document.addInternalScript("var administrator = true;")
    else:
        document.addInternalScript("var administrator = false;")
    document.addInternalScript(user.getJS())
    document.addInternalScript("user.gitEmails = %s;" %
                               htmlutils.jsify(gitemails))
    document.setTitle("%s Home" % title_fullname)

    target = body.div("main")

    basic = target.table('paleyellow basic', align='center')
    basic.tr().td('h1', colspan=3).h1().text("%s Home" % title_fullname)

    def row(heading, value, help=None, status_id=None):
        main_row = basic.tr('line')
        main_row.td('heading').text("%s:" % heading)
        value_cell = main_row.td('value', colspan=2)
        if callable(value): value(value_cell)
        else: value_cell.text(value)
        basic.tr('help').td('help', colspan=3).text(help)

    def renderFullname(target):
        if readonly: target.text(user.fullname)
        else:
            target.input("value", id="user_fullname", value=user.fullname)
            target.span("status", id="status_fullname")
            target.button(onclick="saveFullname();").text("Save")
            target.button(onclick="resetFullname();").text("Reset")

    def renderEmail(target):
        if readonly: target.text(user.email)
        else:
            target.input("value", id="user_email", value=user.email)
            target.span("status", id="status_email")
            target.button(onclick="saveEmail();").text("Save")
            target.button(onclick="resetEmail();").text("Reset")

    def renderGitEmails(target):
        if readonly: target.text(gitemails)
        else:
            target.input("value", id="user_gitemails", value=gitemails)
            target.span("status", id="status_gitemails")
            target.button(onclick="saveGitEmails();").text("Save")
            target.button(onclick="resetGitEmails();").text("Reset")

    def renderPassword(target):
        target.text("****")
        if not readonly:
            target.button(onclick="changePassword();").text("Change")

    row("User ID", str(user.id))
    row("User Name", user.name)
    row("Display Name",
        renderFullname,
        "This is the name used when displaying commits or comments.",
        status_id="status_fullname")
    row("Email",
        renderEmail,
        "This is the primary email address, to which emails are sent.",
        status_id="status_email")
    row("Git Emails",
        renderGitEmails,
        "These email addresses are used to map Git commits to the user.",
        status_id="status_gitemails")

    if configuration.base.AUTHENTICATION_MODE == "critic":
        row("Password", renderPassword)

    filters = target.table('paleyellow filters', align='center')
    row = filters.tr()
    row.td("h1", colspan=2).h1().text("Filters")
    repositories = row.td("repositories", colspan=2).select()

    if not repository:
        repositories.option(value="-",
                            selected="selected",
                            disabled="disabled").text("Select a repository")

    cursor.execute("SELECT id, path FROM repositories ORDER BY id")
    for id, path in cursor:
        repositories.option(value=id,
                            selected="selected" if repository
                            and id == repository.id else None).text(
                                "%s:%s" % (configuration.base.HOSTNAME, path))

    help = filters.tr().td("help", colspan=4)

    help.p().text(
        "Filters is the system's mechanism to connect reviews to users.  When a review is created or updated, a set of users to associate with the review is calculated by matching the files modified by each commit in the review to the filters set up by users.  Each filter selects one file or one directory (and affects all sub-directories and files,) and only the most specific filter per file and user is used when associating users with reviews."
    )

    p = help.p()
    p.text("There are two types of filters: ")
    p.code().text("reviewer")
    p.text(" and ")
    p.code().text("watcher")
    p.text(".  All files matched by a ")
    p.code().text("reviewer")
    p.text(
        " filter for a user are added to the user's to-do list, meaning the user needs to review all changes made to that file before the review is finished.  However, if more than one user is matched as a reviewer for a file, only one of them needs to review the changes.  A user associated with a review only by "
    )
    p.code().text("watcher")
    p.text(
        " filters will simply receive notifications relating to the review, but isn't required to do anything."
    )

    p = help.p()
    p.text("For a ")
    p.code().text("reviewer")
    p.text(
        " type filter, a set of \"delegates\" can also be defined.  The delegate field should be a comma-separated list of user names.  Delegates are automatically made reviewers of changes by you in the filtered files (since you can't review them yourself) regardless of their own filters."
    )

    p = help.p()
    p.strong().text(
        "Note: A filter names a directory only if the path ends with a slash ('/')."
    )
    p.text(
        "  If the path doesn't end with a slash, the filter would name a specific file even if the path is a directory in some or all versions of the actual tree.  However, you'll get a warning if you try to add a filter for a file whose path is registered as a directory in the database."
    )

    if repository:
        headings = filters.tr("headings")
        headings.td("heading type").text("Type")
        headings.td("heading path").text("Path")
        headings.td("heading delegate").text("Delegate")
        headings.td("heading buttons")

        cursor.execute(
            "SELECT directory, file, type, delegate FROM filters WHERE uid=%s AND repository=%s",
            [user.id, repository.id])

        all_filters = []

        for directory_id, file_id, filter_type, delegate in cursor.fetchall():
            if file_id == 0:
                path = dbutils.describe_directory(db, directory_id) + "/"
            else:
                path = dbutils.describe_file(db, file_id)
            all_filters.append(
                (path, directory_id, file_id, filter_type, delegate))

        all_filters.sort()

        empty = filters.tr("empty").td(
            "empty", colspan=4).span(id="empty").text("No filters configured")
        if filters: empty.setAttribute("style", "display: none")

        for path, directory_id, file_id, filter_type, delegate in all_filters:
            row = filters.tr("filter")
            row.td("filter type").text(filter_type.capitalize())
            row.td("filter path").text(path)
            row.td("filter delegate").text(delegate)

            buttons = row.td("filter buttons")
            if readonly: buttons.text()
            else:
                buttons.button(onclick="editFilter(this, %d, %d, false);" %
                               (directory_id, file_id)).text("Edit")
                buttons.button(onclick="deleteFilter(this, %d, %d);" %
                               (directory_id, file_id)).text("Delete")

        if not readonly:
            filters.tr("buttons").td("buttons", colspan=4).button(
                onclick="addFilter(this);").text("Add Filter")

    return document