#       Licensed to the Apache Software Foundation (ASF) under one
#       or more contributor license agreements.  See the NOTICE file
#       distributed with this work for additional information
#       regarding copyright ownership.  The ASF licenses this file
#       to you under the Apache License, Version 2.0 (the
#       "License"); you may not use this file except in compliance
#       with the License.  You may obtain a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#       Unless required by applicable law or agreed to in writing,
#       software distributed under the License is distributed on an
#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
#       KIND, either express or implied.  See the License for the
#       specific language governing permissions and limitations
#       under the License.
from datetime import datetime
import os
import time
import json
from io import BytesIO
import allura
import mock

import PIL
from bs4 import BeautifulSoup
from mock import patch
from formencode.variabledecode import variable_encode
from tg import tmpl_context as c
from tg import app_globals as g
from tg import config

from allura.lib.mail_util import email_policy
from allura.tests.decorators import assert_equivalent_urls
from allura.tests.test_globals import squish_spaces
from alluratest.controller import TestController, setup_basic_test
from allura import model as M
from forgewiki import model as wm
from forgetracker import model as tm

from allura.lib.security import has_access, Credentials
from allura.lib import helpers as h
from allura.lib.search import SearchError
from allura.lib.utils import urlencode
from allura.tests import decorators as td
from allura.tasks import mail_tasks
from ming.odm.odmsession import ThreadLocalODMSession


def find(d, pred):
    for n, v in d.items():
        if pred(v):
            return n, v


class TrackerTestController(TestController):
    def setup_method(self, method):
        super().setup_method(method)
        self.setup_with_tools()

    @td.with_tracker
    def setup_with_tools(self):
        pass

    def _find_new_ticket_form(self, resp):
        def cond(f):
            return f.action.endswith('/save_ticket')
        return self.find_form(resp, cond)

    def _find_update_ticket_form(self, resp):
        def cond(f):
            return f.action.endswith('/update_ticket_from_widget')
        return self.find_form(resp, cond)

    def new_ticket(self, mount_point='/bugs/', extra_environ=None, **kw):
        extra_environ = extra_environ or {}
        response = self.app.get(mount_point + 'new/',
                                extra_environ=extra_environ)
        form = self._find_new_ticket_form(response)
        for k, v in kw.items():
            if isinstance(v, bool):
                form['ticket_form.%s' % k] = v
            else:
                form['ticket_form.%s' % k].force_value(v)
        resp = form.submit(extra_environ=extra_environ)
        assert resp.status_int != 200, resp
        return resp


class TestMilestones(TrackerTestController):
    def test_milestone_list(self):
        r = self.app.get('/bugs/milestones')
        assert '1.0' in r

    def test_milestone_list_progress(self):
        self.new_ticket(summary='foo', _milestone='1.0')
        self.new_ticket(summary='bar', _milestone='1.0', status='closed')
        r = self.app.get('/bugs/milestones')
        assert '1 / 2' in r

    def test_default_milestone_created_if_missing(self):
        p = M.Project.query.get(shortname='test')
        app = p.app_instance('bugs')
        app.globals.custom_fields = []
        ThreadLocalODMSession.flush_all()
        d = {
            'field_name': '_milestone',
            'milestones-0.old_name': '',
            'milestones-0.new_name': '1.0',
            'milestones-0.description': 'Version 1',
            'milestones-0.complete': 'Open',
            'milestones-0.due_date': ''
        }
        r = self.app.post('/bugs/update_milestones', d)
        r = self.app.get('/bugs/milestones')
        assert 'Version 1' in r
        # make sure _milestone doesn't get created again if it already exists
        r = self.app.post('/bugs/update_milestones', d)
        p = M.Project.query.get(shortname='test')
        app = p.app_instance('bugs')
        assert len(app.globals.custom_fields) == 1, len(
            app.globals.custom_fields)

    def test_closed_milestone(self):
        self.new_ticket(summary='bar', _milestone='1.0', status='closed')
        d = {
            'field_name': '_milestone',
            'milestones-0.old_name': '1.0',
            'milestones-0.new_name': '1.0',
            'milestones-0.description': '',
            'milestones-0.complete': 'Closed',
            'milestones-0.due_date': ''
        }
        self.app.post('/bugs/update_milestones', d)
        d = {
            'field_name': '_milestone',
            'milestones-9.old_name': '',
            'milestones-9.new_name': '3.0',
            'milestones-9.description': '',
            'milestones-9.complete': 'Closed',
            'milestones-9.due_date': ''
        }
        self.app.post('/bugs/update_milestones', d)
        d = {
            'field_name': '_milestone',
            'milestones-9.old_name': '',
            'milestones-9.new_name': '4.0',
            'milestones-9.description': '',
            'milestones-9.complete': 'Closed',
            'milestones-9.due_date': ''
        }
        self.app.post('/bugs/update_milestones', d)
        r = self.app.get('/bugs/1/')
        closed = r.html.find('optgroup', label='Closed')
        assert closed
        closed = str(closed)
        assert '<option selected value="1.0">1.0</option>' in r
        assert '<option selected value="1.0">1.0</option>' not in closed
        assert '<option value="2.0">2.0</option>' in r
        assert '<option value="2.0">2.0</option>' not in closed
        assert '<option value="3.0">3.0</option>' in closed
        assert '<option value="4.0">4.0</option>' in closed
        r = self.app.get('/bugs/new/')
        closed = r.html.find('optgroup', label='Closed')
        assert closed
        closed = str(closed)
        assert '<option selected value="1.0">1.0</option>' not in r
        assert '<option value="1.0">1.0</option>' in closed
        assert '<option value="2.0">2.0</option>' in r
        assert '<option value="2.0">2.0</option>' not in closed
        assert '<option value="3.0">3.0</option>' in closed
        assert '<option value="4.0">4.0</option>' in closed

    def test_duplicate_milestone(self):
        self.new_ticket(summary='bar', _milestone='1.0', status='closed')
        d = {
            'field_name': '_milestone',
            'milestones-0.old_name': '',
            'milestones-0.new_name': '1.0',
            'milestones-0.description': '',
            'milestones-0.complete': 'Closed',
            'milestones-0.due_date': ''
        }
        r = self.app.post('/bugs/update_milestones', d)
        assert 'error' in self.webflash(r)

        p = M.Project.query.get(shortname='test')
        app = p.app_instance('bugs')
        assert len(app.globals.milestone_fields[0]['milestones']) == 2

        d = {
            'field_name': '_milestone',
            'milestones-0.old_name': '2.0',
            'milestones-0.new_name': '1.0',
            'milestones-0.description': '',
            'milestones-0.complete': 'Closed',
            'milestones-0.due_date': ''
        }
        r = self.app.post('/bugs/update_milestones', d)
        assert 'error' in self.webflash(r)
        assert app.globals.milestone_fields[0]['milestones'][1]['name'] == '2.0'

    def test_default_milestone(self):
        self.new_ticket(summary='bar', _milestone='1.0', status='closed')
        d = {
            'field_name': '_milestone',
            'milestones-0.old_name': '2.0',
            'milestones-0.new_name': '2.0',
            'milestones-0.description': '',
            'milestones-0.complete': 'Open',
            'milestones-0.default': 'on',
            'milestones-0.due_date': ''
        }
        self.app.post('/bugs/update_milestones', d)
        r = self.app.get('/bugs/new/')
        assert '<option selected value="2.0">2.0</option>' in r


def post_install_create_ticket_permission(app):
    """Set to authenticated permission to create tickets but not update"""
    role = M.ProjectRole.by_name('*authenticated')._id
    create_permission = M.ACE.allow(role, 'create')
    update_permission = M.ACE.allow(role, 'update')
    acl = app.config.acl
    acl.append(create_permission)
    if update_permission in acl:
        acl.remove(update_permission)


def post_install_update_ticket_permission(app):
    """Set to anonymous permission to create and update tickets"""
    role = M.ProjectRole.by_name('*anonymous')._id
    app.config.acl.append(M.ACE.allow(role, 'create'))
    app.config.acl.append(M.ACE.allow(role, 'update'))


class TestSubprojectTrackerController(TrackerTestController):
    @td.with_tool('test/sub1', 'Tickets', 'tickets')
    def test_index_page_ticket_visibility(self):
        """Test that non-admin users can see tickets created by admins."""
        self.new_ticket(summary="my ticket", mount_point="/sub1/tickets/")
        response = self.app.get('/p/test/sub1/tickets/',
                                extra_environ=dict(username='*anonymous'))
        assert 'my ticket' in response

    @td.with_tool('test/sub1', 'Tickets', 'tickets')
    def test_search_page_ticket_visibility(self):
        """Test that non-admin users can see tickets created by admins."""
        self.new_ticket(summary="my ticket", mount_point="/sub1/tickets/")
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/sub1/tickets/search/?q=my',
                                extra_environ=dict(username='*anonymous'))
        assert 'my ticket' in response

    @td.with_tool('test/sub1', 'Tickets', 'tickets')
    def test_deleted_ticket_visibility(self):
        """Test that admins can see deleted tickets in a subproject tracker."""
        self.new_ticket(summary='test', mount_point="/sub1/tickets/")
        self.app.post('/sub1/tickets/1/delete')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        r = self.app.get('/p/test/sub1/tickets/search/',
                         params=dict(q='test', deleted='True'))
        assert '<td><a href="/p/test/sub1/tickets/1/">test' in r
        assert '<tr class=" deleted">' in r


class TestFunctionalController(TrackerTestController):
    def test_bad_ticket_number(self):
        self.app.get('/bugs/not-a-ticket', status=404)

    def test_invalid_ticket(self):
        self.app.get('/bugs/2/', status=404)

    @patch('forgetracker.tracker_main.g.director.create_activity')
    def test_activity(self, create_activity):
        self.new_ticket(summary='my ticket', description='my description')
        assert create_activity.call_count == 1
        assert create_activity.call_args[0][1] == 'created'
        create_activity.reset_mock()
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'my ticket',
            'description': 'new description',
        })
        assert create_activity.call_count == 1
        assert create_activity.call_args[0][1] == 'modified'

    def test_new_ticket(self):
        summary = 'test new ticket'
        ticket_view = self.new_ticket(summary=summary).follow()
        assert summary in ticket_view
        opts = self.subscription_options(ticket_view)
        assert opts['subscribed'] is False

    def test_ticket_get_markdown(self):
        self.new_ticket(summary='my ticket', description='my description')
        response = self.app.get('/bugs/1/get_markdown')
        assert response.content_type == 'text/plain'
        assert 'my description' in response

    def test_ticket_update_markdown(self):
        self.new_ticket(summary='my ticket', description='my description')
        response = self.app.get('/bugs/1/get_markdown')
        response = self.app.post(
            '/bugs/1/update_markdown',
            params={
                'text': '- [x] checkbox'})
        assert response.json['status'] == 'success'
        # anon users can't edit markdown
        response = self.app.post(
            '/bugs/1/update_markdown',
            params={
                'text': '- [x] checkbox'},
            extra_environ=dict(username='*anonymous'))
        assert response.json['status'] == 'no_permission'

    def test_labels(self):
        ticket_view = self.new_ticket(summary='summary', labels="test label").follow()
        assert '''<a href="../search?q=labels:%22test+label%22">test label (1)</a>''' in ticket_view

    def test_new_with_milestone(self):
        ticket_view = self.new_ticket(
            summary='test new with milestone', **{'_milestone': '1.0'}).follow()
        assert 'Milestone' in ticket_view
        assert '1.0' in ticket_view

    def test_milestone_count(self):
        self.new_ticket(summary='test new with milestone',
                        **{'_milestone': '1.0'})
        self.new_ticket(
            summary='test new with milestone', **{'_milestone': '1.0',
                                                  'private': True})
        r = self.app.get('/bugs/milestone_counts')
        counts = {
            'milestone_counts': [
                {'name': '1.0', 'count': 2},
                {'name': '2.0', 'count': 0}
            ]}
        assert r.text == json.dumps(counts)
        # Private tickets shouldn't be included in counts if user doesn't
        # have read access to private tickets.
        r = self.app.get('/bugs/milestone_counts',
                         extra_environ=dict(username='*anonymous'))
        counts['milestone_counts'][0]['count'] = 1
        assert r.text == json.dumps(counts)

        self.app.post('/bugs/1/delete')
        r = self.app.get('/bugs/milestone_counts')
        assert r.text == json.dumps(counts)

    def test_bin_counts(self):
        self.new_ticket(summary='test new')
        self.new_ticket(summary='test new private', private=True)
        M.MonQTask.run_ready()

        r = self.app.get('/bugs/bin_counts')
        assert r.json == {"bin_counts": [{"count": 2, "label": "Changes"},
                                         {"count": 0, "label": "Closed Tickets"},
                                         {"count": 2, "label": "Open Tickets"}]}

        """
        forgetracker.model.ticket.Globals.bin_count doesn't do a permission check like corresponding milestone_count

        # Private tickets shouldn't be included in counts if user doesn't
        # have read access to private tickets.
        r = self.app.get('/bugs/bin_counts', extra_environ=dict(username='*anonymous'))
        assert_equal(r.json, {"bin_counts": [{"count": 1, "label": "Changes"},
                                             {"count": 0, "label": "Closed Tickets"},
                                             {"count": 1, "label": "Open Tickets"}]})
        """

    def test_milestone_progress(self):
        self.new_ticket(summary='Ticket 1', **{'_milestone': '1.0'})
        self.new_ticket(summary='Ticket 2', **{'_milestone': '1.0',
                                               'status': 'closed',
                                               'private': True}).follow()
        r = self.app.get('/bugs/milestone/1.0/')
        assert '1 / 2' in r
        # Private tickets shouldn't be included in counts if user doesn't
        # have read access to private tickets.
        r = self.app.get('/bugs/milestone/1.0/',
                         extra_environ=dict(username='*anonymous'))
        assert '0 / 1' in r

    def test_new_ticket_form(self):
        response = self.app.get('/bugs/new/')
        form = self._find_new_ticket_form(response)
        form['ticket_form.summary'] = 'test new ticket form'
        form['ticket_form.description'] = 'test new ticket form description'
        response = form.submit().follow()
        assert 'test new ticket form' in response
        assert 'test new ticket form description' in response

    def test_new_ticket_prepop_from_url(self):
        response = self.app.get('/bugs/new/?summary=very buggy&description=descr&labels=label1,label2&private=true'
                                '&assigned_to=test-user&_milestone=2.0&status=pending')
        form = self._find_new_ticket_form(response)
        assert form['ticket_form.summary'].value == 'very buggy'
        assert form['ticket_form.description'].value == 'descr'
        assert form['ticket_form.labels'].value == 'label1,label2'
        assert form['ticket_form.assigned_to'].value == 'test-user'
        assert form['ticket_form._milestone'].value == '2.0'
        assert form['ticket_form.status'].value == 'pending'
        assert form['ticket_form.private'].checked is True

    def test_mass_edit(self):
        self.new_ticket(summary='First Ticket').follow()
        self.new_ticket(summary='Second Ticket').follow()
        M.MonQTask.run_ready()
        first_ticket = tm.Ticket.query.find({'summary': 'First Ticket'}).first()
        second_ticket = tm.Ticket.query.find({'summary': 'Second Ticket'}).first()
        r = self.app.get('/p/test/bugs/edit/?q=ticket')
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': [str(first_ticket._id)],
            '_milestone': '2.0',
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        assert '<li><strong>Milestone</strong>: 1.0 --&gt; 2.0</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Milestone</strong>: 1.0 --&gt; 2.0</li>' not in r
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(first_ticket._id),
                str(second_ticket._id)),
            '_milestone': '1.0',
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        assert '<li><strong>Milestone</strong>: 2.0 --&gt; 1.0</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Milestone</strong>: 2.0 --&gt; 1.0</li>' not in r

        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(first_ticket._id),
                str(second_ticket._id)),
            'status': 'accepted',
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        assert '<li><strong>Status</strong>: open --&gt; accepted</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Status</strong>: open --&gt; accepted</li>' in r

    def test_label_for_mass_edit(self):
        self.new_ticket(summary='Ticket1')
        self.new_ticket(summary='Ticket2', labels='tag1')
        self.new_ticket(summary='Ticket3', labels='tag1,tag2')
        M.MonQTask.run_ready()
        ticket1 = tm.Ticket.query.get(summary='Ticket1')
        ticket2 = tm.Ticket.query.get(summary='Ticket2')
        ticket3 = tm.Ticket.query.get(summary='Ticket3')
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),
                str(ticket3._id)),
            'labels': 'tag2, tag3',
        })
        M.MonQTask.run_ready()

        ticket1 = tm.Ticket.query.get(summary='Ticket1')
        ticket2 = tm.Ticket.query.get(summary='Ticket2')
        ticket3 = tm.Ticket.query.get(summary='Ticket3')
        assert ticket1.labels == ['tag2', 'tag3']
        assert ticket2.labels == ['tag1', 'tag2', 'tag3']
        assert ticket3.labels == ['tag1', 'tag2', 'tag3']
        r = self.app.get('/p/test/bugs/3/')
        assert '<li><strong>Labels</strong>: tag1, tag2 --&gt; tag1, tag2, tag3</li>' in r

    def test_mass_edit_custom_fields(self):
        params = dict(
            custom_fields=[
                dict(name='_major', label='Major', type='boolean'), ],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))
        kw = {'custom_fields._major': True}
        self.new_ticket(summary='First Custom', **kw)
        self.new_ticket(summary='Second Custom')
        M.MonQTask.run_ready()

        ticket1 = tm.Ticket.query.find({'summary': 'First Custom'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second Custom'}).first()
        ticket2.custom_fields._major = None
        ticket2.commit()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),),
            'status': 'accepted',
            '_major': 'False'
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        r.mustcontain('<li><strong>Major</strong>: True --&gt; False</li>')
        assert '<li><strong>Major</strong>: True --&gt; False</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Major</strong>: --&gt; False</li>' in r
        ticket1 = tm.Ticket.query.find({
            'summary': 'First Custom'}).first()
        ticket2 = tm.Ticket.query.find({
            'summary': 'Second Custom'}).first()
        assert ticket1.custom_fields._major is False
        assert ticket2.custom_fields._major is False

        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),),
            'status': 'accepted',
            '_major': 'True'
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        assert '<li><strong>Major</strong>: False --&gt; True</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Major</strong>: False --&gt; True</li>' in r
        ticket1 = tm.Ticket.query.find({'summary': 'First Custom'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second Custom'}).first()
        assert ticket1.custom_fields._major is True
        assert ticket2.custom_fields._major is True

        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket2._id),),
            '_major': 'False'
        })
        M.MonQTask.run_ready()
        ticket2 = tm.Ticket.query.find({
            'summary': 'Second Custom'}).first()
        assert ticket2.custom_fields._major is False
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),),
            'status': 'accepted',
            '_major': ''
        })
        M.MonQTask.run_ready()
        ticket1 = tm.Ticket.query.find({'summary': 'First Custom'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second Custom'}).first()
        assert ticket1.custom_fields._major is True
        assert ticket2.custom_fields._major is False

    def test_mass_edit_select_options_split(self):
        params = dict(
            custom_fields=[
                dict(name='_type',
                     label='Type',
                     type='select',
                     options='Bug "Feature Request"')],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))
        r = self.app.get('/p/test/bugs/edit/')
        opts = r.html.find('select', attrs={'name': '_type'})
        opts = opts.find_all('option')
        assert opts[0].get('value') == ''
        assert opts[0].getText() == 'no change'
        assert opts[1].get('value') == 'Bug'
        assert opts[1].getText() == 'Bug'
        assert opts[2].get('value') == 'Feature Request'
        assert opts[2].getText() == 'Feature Request'

    def test_mass_edit_private_field(self):
        kw = {'private': True}
        self.new_ticket(summary='First', **kw)
        self.new_ticket(summary='Second')
        M.MonQTask.run_ready()
        ticket1 = tm.Ticket.query.find({'summary': 'First'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second'}).first()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),),
            'private': 'False',
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        assert '<li><strong>Private</strong>: Yes --&gt; No</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Private</strong>: No --&gt; Yes</li>' not in r
        ticket1 = tm.Ticket.query.find({'summary': 'First'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second'}).first()
        assert ticket1.private is False
        assert ticket2.private is False

        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),),
            'private': 'True'
        })
        M.MonQTask.run_ready()
        r = self.app.get('/p/test/bugs/1/')
        assert '<li><strong>Private</strong>: No --&gt; Yes</li>' in r
        r = self.app.get('/p/test/bugs/2/')
        assert '<li><strong>Private</strong>: No --&gt; Yes</li>' in r
        ticket1 = tm.Ticket.query.find({'summary': 'First'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second'}).first()
        assert ticket1.private is True
        assert ticket2.private is True

        ticket2.private = False
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(ticket1._id),
                str(ticket2._id),),
            'private': ''
        })
        M.MonQTask.run_ready()
        ticket1 = tm.Ticket.query.find({'summary': 'First'}).first()
        ticket2 = tm.Ticket.query.find({'summary': 'Second'}).first()
        assert ticket1.private is True
        assert ticket2.private is False

    def test_private_ticket(self):
        ticket_view = self.new_ticket(summary='Public Ticket').follow()
        assert '<label class="simple">Private:</label> No' in squish_spaces(ticket_view.text)
        ticket_view = self.new_ticket(summary='Private Ticket',
                                      private=True).follow()
        assert '<label class="simple">Private:</label> Yes' in squish_spaces(ticket_view.text)
        M.MonQTask.run_ready()
        # Creator sees private ticket on list page...
        index_response = self.app.get('/p/test/bugs/')
        assert '2 results' in index_response
        assert 'Public Ticket' in index_response
        assert 'Private Ticket' in index_response
        # ...and in search results.
        search_response = self.app.get('/p/test/bugs/search/?q=open')
        assert '2 results' in search_response
        assert 'Private Ticket' in search_response
        # Unauthorized user doesn't see private ticket on list page...
        env = dict(username='*anonymous')
        r = self.app.get('/p/test/bugs/', extra_environ=env)
        assert '1 results' in r
        assert 'Private Ticket' not in r
        # ...or in search results...
        r = self.app.get('/p/test/bugs/search/?q=open', extra_environ=env)
        assert '1 results' in r
        assert 'Private Ticket' not in r
        # ... or in search feed...
        r = self.app.get('/p/test/bugs/search_feed?q=open',
                         extra_environ=env)
        assert 'Private Ticket' not in r
        # ...and can't get to the private ticket directly.
        r = self.app.get(ticket_view.request.url, extra_environ=env)
        assert 'Private Ticket' not in r
        # ... and it doesn't appear in the feed
        r = self.app.get('/p/test/bugs/feed.atom', extra_environ=env)
        assert 'Private Ticket' not in r
        # ... or in the API ...
        self.app.get('/rest/p/test/bugs/2/', extra_environ=env, status=401)
        r = self.app.get('/rest/p/test/bugs/', extra_environ=env)
        assert 'Private Ticket' not in r

        # update private ticket
        self.app.post('/bugs/1/update_ticket_from_widget', {
            'ticket_form.summary': 'Public Ticket',
            'ticket_form.description': '',
            'ticket_form.status': 'open',
            'ticket_form._milestone': '1.0',
            'ticket_form.assigned_to': '',
            'ticket_form.labels': '',
            'ticket_form.comment': 'gotta be secret about this now',
            'ticket_form.private': 'on',
        })
        response = self.app.get('/bugs/1/')
        assert '<li><strong>private</strong>: No --&gt; Yes</li>' in response

    def test_discussion_disabled_ticket(self):
        response = self.new_ticket(summary='test discussion disabled ticket').follow()
        # New tickets will not show discussion disabled
        assert '<span class="closed">Discussion Disabled</span>' not in response

        ticket_params = {
            'ticket_form.summary': 'test discussion disabled ticket',
            'ticket_form.description': '',
            'ticket_form.status': 'open',
            'ticket_form._milestone': '1.0',
            'ticket_form.assigned_to': '',
            'ticket_form.labels': '',
            'ticket_form.comment': 'no more comments allowed',
            'ticket_form.discussion_disabled': 'on',
        }

        # Disable Discussion
        response = self.app.post('/bugs/1/update_ticket_from_widget', ticket_params).follow()
        assert '<li><strong>discussion</strong>: enabled --&gt; disabled</li>' in response
        assert '<span class="closed">Discussion Disabled</span>' in response
        assert 'edit_post_form reply' in response  # Make sure admin can still comment

        # Unauthorized user cannot comment or even see form fields
        env = dict(username='*anonymous')
        r = self.app.get('/p/test/bugs/1', extra_environ=env)
        assert 'edit_post_form reply' not in r

        # Test re-enabling discussions
        ticket_params['ticket_form.discussion_disabled'] = 'off'
        response = self.app.post('/bugs/1/update_ticket_from_widget', ticket_params).follow()
        assert '<li><strong>discussion</strong>: disabled --&gt; enabled</li>' in response
        assert '<span class="closed">Discussion Disabled</span>' not in response

        # Test solr search
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        # At this point, there is one ticket and it has discussion_disabled set to False
        r = self.app.get('/bugs/search/?q=discussion_disabled_b:False')
        assert '1 results' in r
        assert 'test discussion disabled ticket' in r

        # Set discussion_disabled to True and search again
        ticket_params['ticket_form.discussion_disabled'] = 'on'
        self.app.post('/bugs/1/update_ticket_from_widget', ticket_params)
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        r = self.app.get('/bugs/search/?q=discussion_disabled_b:True')
        assert '1 results' in r
        assert 'test discussion disabled ticket' in r

        # Make sure there are no other tickets or false positives for good measure.
        r = self.app.get('/bugs/search/?q=discussion_disabled_b:False')
        assert '0 results' in r

    @td.with_tool('test', 'Tickets', 'doc-bugs')
    def test_two_trackers(self):
        summary = 'test two trackers'
        ticket_view = self.new_ticket(
            '/doc-bugs/', summary=summary, _milestone='1.0').follow()
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        assert summary in ticket_view
        index_view = self.app.get('/doc-bugs/')
        assert summary in index_view
        assert sidebar_contains(index_view, '<span>1.0</span>')
        index_view = self.app.get('/bugs/')
        assert sidebar_contains(index_view, '<span>1.0</span>')
        assert summary not in index_view

    def test_render_ticket(self):
        summary = 'test render ticket'
        ticket_view = self.new_ticket(summary=summary).follow()
        ticket_view.mustcontain(summary, 'Discussion')

    def test_render_index(self):
        admin = M.User.query.get(username='test-admin')
        anon = M.User.query.get(username="*anonymous")
        for app in M.AppConfig.query.find({'options.mount_point': 'bugs'}):
            assert has_access(app, 'create', admin)
            assert not has_access(app, 'create', anon)

        index_view = self.app.get('/bugs/')
        assert 'No open tickets found.' in index_view
        assert 'Create Ticket' in index_view

        # Make sure the 'Create Ticket' button is disabled for user without 'create' perm
        r = self.app.get('/bugs/', extra_environ=dict(username='*anonymous'))
        create_button = r.html.find('a', attrs={'href': '/p/test/bugs/new/'})
        assert create_button['class'] == ['icon', 'sidebar-disabled']

    @patch.dict('allura.lib.app_globals.config', markdown_cache_threshold='0')
    def test_cached_convert(self):
        from allura.model.session import artifact_orm_session
        # Create ticket
        params = dict(ticket_num=1,
                      app_config_id=c.app.config._id,
                      summary='test md cache',
                      description='# Test markdown cached_convert',
                      mod_date=datetime(2010, 1, 1, 1, 1, 1))
        ticket = tm.Ticket(**params)

        # Enable skip_mod_date to prevent mod_date from being set automatically when the ticket is saved.
        session = artifact_orm_session._get()
        session.skip_mod_date = True

        # This visit will cause cache to be stored on the artifact.
        # We want to make sure the 'last_updated' field isn't updated by the cache creation
        r = self.app.get('/bugs/1').follow()
        last_updated = r.html.find("span", {"id": "updated_id"}).text.strip()
        assert last_updated == '2010-01-01'

        # Make sure the cache has been saved.
        t = tm.Ticket.query.find({'_id': ticket._id}).first()
        assert '<h1 id="h-test-markdown-cached_convert">Test markdown cached_convert</h1>' in t.description_cache.html

    def test_ticket_diffs(self):
        self.new_ticket(summary='difftest', description='1\n2\n3\n')
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'difftest',
            'description': '1\n3\n4\n',
        })
        r = self.app.get('/bugs/1/')
        assert '<span class="gd">-2</span>' in r
        assert '<span class="gi">+4</span>' in r

    def test_meta_comment(self):
        self.new_ticket(summary="foo")
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'bar',
            'comment': 'user comment',
        })
        t = tm.Ticket.query.get(ticket_num=1)
        assert t.discussion_thread.first_post.is_meta
        assert not t.discussion_thread.last_post.is_meta

    def test_ticket_label_unlabel(self):
        summary = 'test labeling and unlabeling a ticket'
        self.new_ticket(summary=summary)
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'aaa',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': '',
            'assigned_to': '',
            'labels': 'yellow,greén'.encode(),
            'comment': ''
        })
        response = self.app.get('/bugs/1/')
        assert 'yellow' in response
        assert 'greén' in response
        assert '<li><strong>labels</strong>:  --&gt; yellow, greén</li>' in response
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': '',
            'assigned_to': '',
            'labels': 'yellow',
            'comment': ''
        })
        response = self.app.get('/bugs/1/')
        assert 'yellow' in response
        assert '<li><strong>labels</strong>: yellow, greén --&gt; yellow</li>' in response
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': '',
            'assigned_to': '',
            'labels': '',
            'comment': ''
        })
        response = self.app.get('/bugs/1/')
        assert '<li><strong>labels</strong>: yellow --&gt; </li>' in response

    def test_new_attachment(self):
        file_name = 'test_root.py'
        file_data = open(__file__, 'rb').read()
        upload = ('attachment', file_name, file_data)
        self.new_ticket(summary='test new attachment')
        ticket_editor = self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz'
        }, upload_files=[upload]).follow()
        assert file_name in ticket_editor
        assert '<span>py</span>' not in ticket_editor
        ticket_page = self.app.get('/bugs/1/')
        diff = ticket_page.html.find_all('div', attrs={'class': 'codehilite'})
        added = diff[-1].find_all('span', attrs={'class': 'gi'})[-1]
        assert '+test_root.py' in added.getText()

    def test_delete_attachment(self):
        file_name = 'test_root.py'
        file_data = open(__file__, 'rb').read()
        upload = ('attachment', file_name, file_data)
        self.new_ticket(summary='test new attachment')
        ticket_editor = self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz'
        }, upload_files=[upload]).follow()
        assert file_name in ticket_editor
        req = self.app.get('/bugs/1/')
        form = self._find_update_ticket_form(req)
        file_link = BeautifulSoup(form.text, features="html5lib").find_all('a')[2]
        assert file_link.string == file_name
        self.app.post(str(file_link['href']), {
            'delete': 'True'
        })
        ticket_page = self.app.get('/bugs/1/')
        assert '/p/test/bugs/1/attachment/test_root.py' not in ticket_page
        diff = ticket_page.html.find_all('div', attrs={'class': 'codehilite'})
        removed = diff[-1].find_all('span', attrs={'class': 'gd'})[-1]
        assert '-test_root.py' in removed.getText()

    def test_delete_attachment_from_comments(self):
        ticket_view = self.new_ticket(summary='test ticket').follow()
        for f in ticket_view.html.find_all('form'):
            if f.get('action', '').endswith('/post'):
                break
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[f.find('textarea')['name']] = 'test comment'
        self.app.post(f['action'], params=params,
                      headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/1/', dict(page='1'))
        post_link = str(r.html.find('div', {'class': 'edit_post_form reply'}).find('form')['action'])
        self.app.post(post_link + 'attach',
                      upload_files=[('file_info', 'test.txt', b'HiThere!')])
        r = self.app.get('/bugs/1/', dict(page='1'))
        assert '<i class="fa fa-trash-o" aria-hidden="true"></i>' in r
        r.forms[5].submit()
        r = self.app.get('/bugs/1/', dict(page='1'))
        assert '<i class="fa fa-trash-o" aria-hidden="true"></i>' not in r

    def test_new_text_attachment_content(self):
        file_name = 'test_root.py'
        file_data = open(__file__, 'rb').read()
        upload = ('attachment', file_name, file_data)
        self.new_ticket(summary='test new attachment')
        ticket_editor = self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz'
        }, upload_files=[upload]).follow()
        form = self._find_update_ticket_form(ticket_editor)
        download = self.app.get(str(BeautifulSoup(form.text, features="html5lib").find_all('a')[2]['href']))
        assert download.body == file_data

    def test_two_attachments(self):
        file_name1 = 'test_root1.py'
        file_name2 = 'test_root2.py'
        file_data = open(__file__, 'rb').read()
        self.new_ticket(summary='test new attachment')
        ticket_editor = self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz'
        }, upload_files=[('attachment', file_name1, file_data), ('attachment', file_name2, file_data)]).follow()

        assert 'test_root1.py' in ticket_editor
        assert 'test_root2.py' in ticket_editor

    def test_new_image_attachment_content(self):
        h.set_context('test', 'bugs', neighborhood='Projects')
        file_name = 'neo-icon-set-454545-256x350.png'
        file_path = os.path.join(
            allura.__path__[0], 'nf', 'allura', 'images', file_name)
        file_data = open(file_path, 'rb').read()
        upload = ('attachment', file_name, file_data)
        self.new_ticket(summary='test new attachment')
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz'
        }, upload_files=[upload]).follow()
        ticket = tm.Ticket.query.find({'ticket_num': 1}).first()
        filename = ticket.attachments[0].filename

        uploaded = PIL.Image.open(file_path)
        r = self.app.get('/bugs/1/attachment/' + filename)
        downloaded = PIL.Image.open(BytesIO(r.body))
        assert uploaded.size == downloaded.size
        r = self.app.get('/bugs/1/attachment/' + filename + '/thumb')

        thumbnail = PIL.Image.open(BytesIO(r.body))
        assert thumbnail.size == (100, 100)

    def test_sidebar_static_page(self):
        admin = M.User.query.get(username='test-admin')
        for app in M.AppConfig.query.find({'options.mount_point': 'bugs'}):
            assert has_access(app, 'create', admin)

        response = self.app.get('/bugs/search/')
        assert 'Create Ticket' in response
        assert 'Related Pages' not in response

    def test_related_artifacts(self):
        summary = 'test sidebar logic for a ticket page'
        self.new_ticket(summary=summary)
        response = self.app.get('/p/test/bugs/1/')
        assert 'Related Pages' not in response
        self.app.post('/wiki/aaa/update', params={
            'title': 'aaa',
            'text': '',
            'labels': '',
        })
        self.new_ticket(summary='bbb')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()

        h.set_context('test', 'wiki', neighborhood='Projects')
        a = wm.Page.query.find(dict(title='aaa')).first()
        a.text = '\n[bugs:#1]\n[bugs:#2]\n'
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        b = tm.Ticket.query.find(dict(ticket_num=2)).first()
        b.description = '\n[#1]\n'
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()

        response = self.app.get('/p/test/bugs/1/')
        assert 'Related' in response
        assert 'Wiki: aaa' in response
        assert 'Tickets: #2' in response

        b = tm.Ticket.query.find(dict(ticket_num=2)).first()
        b.deleted = True
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/1/')
        assert 'Tickets: #2' not in response
        response = self.app.get('/wiki/aaa/')
        assert 'alink notfound' in response

    def test_related_artifacts_closed_tickets(self):
        self.new_ticket(summary='Ticket 1')
        self.new_ticket(summary='Ticket 2', status='closed')
        self.new_ticket(summary='Ticket 3', description='[#1]\n\n[#2]')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        r = self.app.get('/p/test/bugs/3/')
        assert 'Tickets: #1' in r
        assert 'Tickets: <s>#1</s>' not in r
        assert 'Tickets: <s>#2</s>' in r

        assert '<a class="alink" href="/p/test/bugs/1/">[#1]</a>' in r.text
        assert '<a class="alink strikethrough" href="/p/test/bugs/2/">[#2]</a>' in r.text

    def test_ticket_view_editable(self):
        summary = 'test ticket view page can be edited'
        self.new_ticket(summary=summary)
        response = self.app.get('/p/test/bugs/1/')
        assert response.html.find('input', {'name': 'ticket_form.summary'})
        assert response.html.find('select', {'name': 'ticket_form.assigned_to'})
        assert response.html.find('textarea', {'name': 'ticket_form.description'})
        assert response.html.find('select', {'name': 'ticket_form.status'})
        assert response.html.find('select', {'name': 'ticket_form._milestone'})
        assert response.html.find('input', {'name': 'ticket_form.labels'})
        assert response.html.find('textarea', {'name': 'ticket_form.comment'})

    def test_assigned_to_nobody(self):
        summary = 'test default assignment'
        self.new_ticket(summary=summary)
        response = self.app.get('/p/test/bugs/1/')
        assert 'nobody' in str(response.html.find('div', {'class': 'grid-5 ticket-assigned-to'}))

    def test_assign_ticket(self):
        summary = 'test assign ticket'
        self.new_ticket(summary=summary)
        response = self.app.get('/p/test/bugs/1/')
        assert 'nobody' in str(response.html.find('div', {'class': 'grid-5 ticket-assigned-to'}))
        response = self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': '',
            'assigned_to': 'test-admin',
            'labels': '',
            'comment': ''
        }).follow()
        assert 'test-admin' in str(response.html.find('div',
                                                      {'class': 'grid-5 ticket-assigned-to'}))
        assert '<li><strong>summary</strong>: test assign ticket --&gt; zzz' in response
        assert '<li><strong>status</strong>: open --&gt; ccc' in response

    def test_custom_fields(self):
        params = dict(
            custom_fields=[
                dict(name='_priority', label='Priority', type='select',
                     options='normal urgent critical'),
                dict(name='_category', label='Category', type='string',
                     options=''),
                dict(name='_code_review', label='Code Review', type='user')],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))
        kw = {'custom_fields._priority': 'normal',
              'custom_fields._category': 'helloworld',
              'custom_fields._code_review': 'test-admin'}
        ticket_view = self.new_ticket(summary='test custom fields', **kw).follow()
        assert 'Priority:' in ticket_view
        assert 'normal' in ticket_view
        assert 'Test Admin' in ticket_view

    def test_select_custom_field(self):
        params = dict(
            custom_fields=[
                dict(name='_testselect', label='Test', type='select',
                     options='"test select"'),
            ],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))
        r = self.app.get('/bugs/new/')
        assert '<option value="test select">test select</option>' in r
        kw = {'custom_fields._testselect': 'test select'}
        ticket_view = self.new_ticket(summary='test select custom fields', **kw).follow()
        assert '<option selected value="test select">test select</option>' in ticket_view

    def test_select_custom_field_unicode(self):
        params = dict(
            custom_fields=[
                dict(name='_testselect', label='Test', type='select',
                     options='oné "one and á half" two'.encode()),
            ],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))
        r = self.app.get('/bugs/new/')

        r.mustcontain('<option value="oné">oné</option>')
        assert '<option value="oné">oné</option>' in r.text
        assert '<option value="one and á half">one and á half</option>' in r.text
        assert '<option value="two">two</option>' in r.text

    def test_select_custom_field_invalid_quotes(self):
        params = dict(
            custom_fields=[
                dict(name='_testselect', label='Test', type='select',
                     options='closéd "quote missing'.encode()),
            ],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))
        r = self.app.get('/bugs/new/')
        assert '<option value="closéd">closéd</option>'.encode() in r
        assert '<option value="quote">quote</option>' in r
        assert '<option value="missing">missing</option>' in r

    def test_custom_field_update_comments(self):
        params = dict(
            custom_fields=[
                dict(label='Number', type='number', options='')],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post('/admin/bugs/set_custom_fields',
                      params=variable_encode(params))
        kw = {'custom_fields._number': ''}
        ticket_view = self.new_ticket(summary='test custom fields', **kw).follow()
        assert '<strong>Number</strong>:  --&gt;' not in ticket_view
        ticket_view = self.app.post('/bugs/1/update_ticket', params={
            'summary': 'zzz',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': 'aaa',
            'assigned_to': '',
            'labels': '',
            'custom_fields._number': '',
            'comment': ''
        }).follow()
        assert '<strong>Number</strong>:  --&gt;' not in ticket_view
        ticket_view = self.app.post('/bugs/1/update_ticket', params={
            'summary': 'zzz',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': 'aaa',
            'assigned_to': '',
            'labels': '',
            'custom_fields._number': '4',
            'comment': ''
        }).follow()
        assert '<strong>Number</strong>:  --&gt;' in ticket_view

    def test_milestone_names(self):
        params = {
            'open_status_names': 'aa bb',
            'closed_status_names': 'cc',
            'custom_fields': [dict(
                label='Milestone',
                show_in_search='on',
                type='milestone',
                milestones=[
                    dict(name='aaaé'.encode()),
                    dict(name='bbb'),
                    dict(name='ccc')])]}
        self.app.post('/admin/bugs/set_custom_fields',
                      variable_encode(params),
                      status=302)
        self.new_ticket(summary='test milestone names')
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'zzz',
            'description': 'bbb',
            'status': 'ccc',
            '_milestone': 'aaaé'.encode(),
            'assigned_to': '',
            'labels': '',
            'comment': ''
        })
        ticket_view = self.app.get('/p/test/bugs/1/')
        assert 'Milestone' in ticket_view
        assert 'aaaé' in ticket_view

    def test_milestone_rename(self):
        self.new_ticket(summary='test milestone rename')
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'test milestone rename',
            'description': '',
            'status': '',
            '_milestone': '1.0',
            'assigned_to': '',
            'labels': '',
            'comment': ''
        })
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        ticket_view = self.app.get('/p/test/bugs/1/')
        assert 'Milestone' in ticket_view
        assert '1.0' in ticket_view
        assert 'zzzé' not in ticket_view
        self.app.post('/bugs/update_milestones', {
            'field_name': '_milestone',
            'milestones-0.old_name': '1.0',
            'milestones-0.new_name': 'zzzé'.encode(),
            'milestones-0.description': '',
            'milestones-0.complete': 'Open',
            'milestones-0.due_date': ''
        })
        ticket_view = self.app.get('/p/test/bugs/1/')
        assert '1.0' not in ticket_view
        assert 'zzzé' in ticket_view

    def test_milestone_close(self):
        self.new_ticket(summary='test milestone close')
        r = self.app.get('/bugs/milestones')
        assert 'view closed' not in r
        r = self.app.post('/bugs/update_milestones', {
            'field_name': '_milestone',
            'milestones-0.old_name': '1.0',
            'milestones-0.new_name': '1.0',
            'milestones-0.description': '',
            'milestones-0.complete': 'Closed',
            'milestones-0.due_date': ''
        })
        r = self.app.get('/bugs/milestones')
        assert 'view closed' in r

    def test_edit_all_button(self):
        admin = M.User.query.get(username='test-admin')
        for app in M.AppConfig.query.find({'options.mount_point': 'bugs'}):
            assert has_access(app, 'update', admin)

        response = self.app.get('/p/test/bugs/search/')
        assert 'Edit All' not in response

    def test_custom_fields_preserve_user_input_on_form_errors(self):
        params = dict(
            custom_fields=[
                dict(name='_priority', label='Priority', type='select',
                     options='normal urgent critical'),
                dict(name='_category', label='Category', type='string',
                     options='')],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields', params=variable_encode(params))
        # Test new ticket form
        r = self.app.get('/bugs/new/')
        form = self._find_new_ticket_form(r)
        form['ticket_form.custom_fields._priority'] = 'urgent'
        form['ticket_form.custom_fields._category'] = 'bugs'
        error_form = form.submit()
        form = self._find_new_ticket_form(error_form)
        assert form['ticket_form.custom_fields._priority'].value == 'urgent'
        assert form['ticket_form.custom_fields._category'].value == 'bugs'
        # Test edit ticket form
        self.new_ticket(summary='Test ticket')
        response = self.app.get('/bugs/1/')
        form = self._find_update_ticket_form(response)
        assert (
            form['ticket_form.custom_fields._priority'].value == 'normal')
        assert form['ticket_form.custom_fields._category'].value == ''
        form['ticket_form.summary'] = ''
        form['ticket_form.custom_fields._priority'] = 'urgent'
        form['ticket_form.custom_fields._category'] = 'bugs'
        error_form = form.submit()
        form = self._find_update_ticket_form(error_form)
        assert form['ticket_form.custom_fields._priority'].value == 'urgent'
        assert form['ticket_form.custom_fields._category'].value == 'bugs'

    def test_new_ticket_validation(self):
        summary = 'ticket summary'
        response = self.app.get('/bugs/new/')
        assert not response.html.find('div', {'class': 'error'})
        form = self._find_new_ticket_form(response)
        form['ticket_form.labels'] = 'foo'
        # try submitting with no summary set and check for error message
        error_form = form.submit()
        form = self._find_new_ticket_form(error_form)
        assert form['ticket_form.labels'].value == 'foo'
        error_message = BeautifulSoup(form.text, features="html5lib").find('div', {'class': 'error'})
        assert error_message
        assert (error_message.string == 'You must provide a Title' or
                error_message.string == 'Missing value')
        assert error_message.find_previous_sibling('input').get('name') == 'ticket_form.summary'
        # set a summary, submit, and check for success
        form['ticket_form.summary'] = summary
        success = form.submit().follow().html
        assert success.find_all('form', {'action': '/p/test/bugs/1/update_ticket_from_widget'}) is not None
        assert success.find('input', {'name': 'ticket_form.summary'})['value'] == summary

    def test_edit_ticket_validation(self):
        old_summary = 'edit ticket test'
        new_summary = "new summary"
        self.new_ticket(summary=old_summary)
        response = self.app.get('/bugs/1/')
        # check that existing form is valid
        assert response.html.find('input', {'name': 'ticket_form.summary'})['value'] == old_summary
        assert not response.html.find('div', {'class': 'error'})
        form = self._find_update_ticket_form(response)
        # try submitting with no summary set and check for error message
        form['ticket_form.summary'] = ""
        error_form = form.submit()
        error_message = error_form.html.find('div', {'class': 'error'})
        assert error_message
        assert error_message.string == 'You must provide a Title'
        assert error_message.find_previous_sibling('input').get('name') == 'ticket_form.summary'
        # set a summary, submit, and check for success
        form = self._find_update_ticket_form(error_form)
        form['ticket_form.summary'] = new_summary
        r = form.submit()
        assert r.status_int == 302
        success = r.follow().html
        assert success.find_all('form', {'action': '/p/test/bugs/1/update_ticket_from_widget'}) is not None
        assert success.find('input', {'name': 'ticket_form.summary'})['value'] == new_summary

    def test_home(self):
        self.new_ticket(summary='test first ticket')
        self.new_ticket(summary='test second ticket')
        self.new_ticket(summary='test third ticket')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/')
        assert 'test third ticket' in response

    def test_search(self):
        self.new_ticket(summary='test first ticket')
        self.new_ticket(summary='test second ticket')
        self.new_ticket(summary='test third ticket')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/search/?q=test&limit=2')
        response.mustcontain('canonical')
        response.mustcontain('results of 3')
        response.mustcontain('test second ticket')
        next_page_link = response.html.select('.page_list a')[0]
        assert next_page_link.text == '2'
        # keep 'q' and zero-based page nums:
        assert next_page_link['href'] == '/p/test/bugs/search/?q=test&limit=2&page=1'

        # 'filter' is special kwarg, don't let it cause problems
        r = self.app.get('/p/test/bugs/search/?q=test&filter=blah')
        r = self.app.get('/p/test/bugs/search/?q=test&defType=asdf')

    def test_search_canonical(self):
        self.new_ticket(summary='test first ticket')
        self.new_ticket(summary='test second ticket')
        self.new_ticket(summary='test third ticket')
        self.new_ticket(summary='test fourth ticket')
        self.new_ticket(summary='test fifth ticket')
        self.new_ticket(summary='test sixth ticket')
        self.new_ticket(summary='test seventh ticket')
        self.new_ticket(summary='test eighth ticket')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/search/?q=test&limit=1')
        canonical = response.html.select_one('link[rel=canonical]')
        assert 'limit=2' not in canonical['href']
        response = self.app.get('/p/test/bugs/search/?q=test&limit=2&page=2')
        next = response.html.select_one('link[rel=next]')
        assert 'page=3' in next['href']
        prev = response.html.select_one('link[rel=prev]')
        assert 'page=1' in prev['href']
        response = self.app.get('/p/test/bugs/search/?q=test&limit=2&page=0')
        canonical = response.html.select_one('link[rel=canonical]')
        assert 'page=' not in canonical

    def test_search_with_strange_chars(self):
        r = self.app.get('/p/test/bugs/search/?' +
                         urlencode({'q': 'tést'}))
        assert 'Search bugs: tést' in r

    def test_saved_search_with_strange_chars(self):
        '''Sidebar must be visible even with a strange characters in saved search terms'''
        r = self.app.post('/admin/bugs/bins/save_bin', {
            'summary': 'Strange chars in terms here',
            'terms': 'labels:tést'.encode(),
            'old_summary': '',
            'sort': ''}).follow()
        r = self.app.get('/bugs/')
        assert sidebar_contains(r, 'Strange chars in terms here')

    def test_search_feed(self):
        self.new_ticket(summary='test first ticket')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        for ext in ['', '.rss', '.atom']:
            assert '<title>test first ticket</title>' in \
                   self.app.get('/p/test/bugs/search_feed%s/?q=test' % ext)

    def test_search_current_user(self):
        self.new_ticket(summary='test first ticket')
        self.new_ticket(summary='test second ticket')
        p = M.Project.query.get(shortname='test')
        p.app_instance('bugs')
        t = tm.Ticket.query.get(summary='test first ticket')
        t.reported_by_id = M.User.by_username('test-user-0')._id
        t = tm.Ticket.query.get(summary='test second ticket')
        t.reported_by_id = M.User.by_username('test-user-1')._id
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/search/?q=reported_by_s:$USER',
                                extra_environ={'username': 'test-user-0'})
        assert '1 result' in response
        assert 'test first ticket' in response
        response = self.app.get('/p/test/bugs/search/?q=reported_by_s:$USER',
                                extra_environ={'username': 'test-user-1'})
        assert '1 result' in response
        assert 'test second ticket' in response

    def test_feed(self):
        self.new_ticket(
            summary='test first ticket',
            description='test description')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/feed')
        assert 'test first ticket' in response

    def test_touch(self):
        self.new_ticket(summary='test touch')
        h.set_context('test', 'bugs', neighborhood='Projects')
        ticket = tm.Ticket.query.get(ticket_num=1)
        old_date = ticket.mod_date
        ticket.summary = 'changing the summary'
        time.sleep(1)
        ThreadLocalODMSession.flush_all()
        ThreadLocalODMSession.close_all()
        ticket = tm.Ticket.query.get(ticket_num=1)
        new_date = ticket.mod_date
        assert new_date > old_date

    @patch('forgetracker.tracker_main.search_artifact')
    def test_save_invalid_search(self, search_artifact):
        err = 'Error running search query: [Reason: undefined field label]'
        search_artifact.side_effect = SearchError(err)
        r = self.app.post('/admin/bugs/bins/save_bin', {
            'summary': 'This is not too long.',
            'terms': 'label:foo',
            'old_summary': '',
            'sort': ''})
        assert err in r
        r = self.app.get('/admin/bugs/bins/')
        edit_form = r.forms['saved-search-form']
        edit_form['bins-2.summary'] = 'Original'
        edit_form['bins-2.terms'] = 'label:foo'
        r = edit_form.submit()
        assert err in r

    def test_saved_search_labels_truncated(self):
        r = self.app.post('/admin/bugs/bins/save_bin', {
            'summary': 'This is not too long.',
            'terms': 'aaa',
            'old_summary': '',
            'sort': ''}).follow()
        r = self.app.get('/bugs/')
        assert sidebar_contains(r, 'This is not too long.')
        self.app.post('/admin/bugs/bins/save_bin', {
            'summary': 'This will be truncated because it is too long to show in the sidebar without being ridiculous',
            'terms': 'aaa',
            'old_summary': '',
            'sort': ''}).follow()
        r = self.app.get('/bugs/')
        assert sidebar_contains(r, 'This will be truncated because it is too long to show in the sidebar ...')

    def test_edit_saved_search(self):
        r = self.app.get('/admin/bugs/bins/')
        edit_form = r.forms['saved-search-form']
        edit_form['bins-2.summary'] = 'Original'
        edit_form['bins-2.terms'] = 'aaa'
        edit_form.submit()
        r = self.app.get('/bugs/')
        assert sidebar_contains(r, 'Original')
        assert not sidebar_contains(r, 'New')
        r = self.app.get('/admin/bugs/bins/')
        edit_form = r.forms['saved-search-form']
        edit_form['bins-2.summary'] = 'New'
        edit_form.submit()
        r = self.app.get('/bugs/')
        assert sidebar_contains(r, 'New')
        assert not sidebar_contains(r, 'Original')

    def test_comment_split(self):
        summary = 'new ticket'
        ticket_view = self.new_ticket(summary=summary).follow()
        for f in ticket_view.html.find_all('form'):
            if f.get('action', '').endswith('/post'):
                break
        post_content = 'ticket discussion post content'
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[f.find('textarea')['name']] = post_content
        r = self.app.post(f['action'], params=params,
                          headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/1/', dict(page='1'))
        assert post_content in r
        assert len(r.html.find_all(attrs={'class': 'discussion-post'})) == 1

        new_summary = 'old ticket'
        for f in ticket_view.html.find_all('form'):
            if f.get('action', '').endswith('update_ticket_from_widget'):
                break
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params['ticket_form.summary'] = new_summary
        r = self.app.post(f['action'], params=params,
                          headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/1/', dict(page='1'))
        assert summary + ' --&gt; ' + new_summary in r
        assert len(r.html.find_all(attrs={'class': 'discussion-post meta_post'})) == 1

    def test_discussion_paging(self):
        summary = 'test discussion paging'
        ticket_view = self.new_ticket(summary=summary).follow()
        for f in ticket_view.html.find_all('form'):
            if f.get('action', '').endswith('/post'):
                break
        post_content = 'ticket discussion post content'
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[f.find('textarea')['name']] = post_content
        r = self.app.post(f['action'], params=params,
                          headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/1/', dict(page='-1'))
        assert summary in r
        r = self.app.get('/bugs/1/', dict(page='1'))
        assert post_content in r
        # no pager if just one page
        assert 'Page 1 of 1' not in r
        # add some more posts and check for pager
        for i in range(2):
            r = self.app.post(f['action'], params=params,
                              headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/1/', dict(page='1', limit='2'))
        assert 'Page 2 of 2' in r

    def test_discussion_feed(self):
        summary = 'test discussion paging'
        ticket_view = self.new_ticket(summary=summary).follow()
        for f in ticket_view.html.find_all('form'):
            if f.get('action', '').endswith('/post'):
                break
        post_content = 'ticket discussion post content'
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[f.find('textarea')['name']] = post_content
        self.app.post(f['action'], params=params,
                      headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/feed.rss')
        post = M.Post.query.find().first()
        assert '/p/test/bugs/1/?limit=25#' + post.slug in r
        r = self.app.get('/bugs/1/')
        post_link = str(r.html.find('div', {'class': 'edit_post_form reply'}).find('form')['action'])
        post_form = r.html.find('form', {'action': post_link + 'reply'})
        params = dict()
        inputs = post_form.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[post_form.find('textarea')['name']] = 'Tis a reply'
        r = self.app.post(post_link + 'reply',
                          params=params,
                          headers={'Referer': post_link.encode("utf-8")})
        r = self.app.get('/bugs/feed.rss')
        assert 'Tis a reply' in r
        assert 'ticket discussion post content' in r
        r = self.app.get('/bugs/1/feed.rss')
        assert 'Tis a reply' in r
        assert 'ticket discussion post content' in r

        self.app.get('/bugs/9999/feed.rss', status=404)

    def test_bulk_edit_index(self):
        self.new_ticket(summary='test first ticket', status='open')
        self.new_ticket(summary='test second ticket', status='accepted')
        self.new_ticket(summary='test third ticket', status='closed')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/?sort=summary+asc')
        ticket_rows = response.html.find('table', {'class': 'ticket-list'}).find('tbody')
        assert 'test first ticket' in ticket_rows.text
        assert 'test second ticket' in ticket_rows.text
        edit_link = response.html.find('a', {'title': 'Bulk Edit'})
        expected_link = "/p/test/bugs/edit/?q=%21status%3Aclosed+%26%26+%21status%3Awont-fix"\
                        "&sort=snippet_s+asc&limit=25&filter=&page=0"
        assert_equivalent_urls(expected_link, edit_link['href'])
        response = self.app.get(edit_link['href'])
        ticket_rows = response.html.find('tbody', {'class': 'ticket-list'})
        assert 'test first ticket' in ticket_rows.text
        assert 'test second ticket' in ticket_rows.text

    def test_bulk_edit_milestone(self):
        self.new_ticket(summary='test first ticket',
                        status='open', _milestone='1.0')
        self.new_ticket(summary='test second ticket',
                        status='accepted', _milestone='1.0')
        self.new_ticket(summary='test third ticket',
                        status='closed', _milestone='1.0')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/milestone/1.0/?sort=ticket_num+asc')
        ticket_rows = response.html.find('table', {'class': 'ticket-list'}).find('tbody')
        assert 'test first ticket' in ticket_rows.text
        assert 'test second ticket' in ticket_rows.text
        assert 'test third ticket' in ticket_rows.text
        edit_link = response.html.find('a', {'title': 'Bulk Edit'})
        expected_link = "/p/test/bugs/edit/?q=_milestone%3A1.0&sort=ticket_num_i+asc&limit=25&filter=&page=0"
        assert_equivalent_urls(expected_link, edit_link['href'])
        response = self.app.get(edit_link['href'])
        ticket_rows = response.html.find('tbody', {'class': 'ticket-list'})
        assert 'test first ticket' in ticket_rows.text
        assert 'test second ticket' in ticket_rows.text
        assert 'test third ticket' in ticket_rows.text

    def test_bulk_edit_search(self):
        self.new_ticket(summary='test first ticket', status='open')
        self.new_ticket(summary='test second ticket', status='open')
        self.new_ticket(summary='test third ticket', status='closed', _milestone='1.0')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        response = self.app.get('/p/test/bugs/search/?q=status%3Aopen')
        ticket_rows = response.html.find('table', {'class': 'ticket-list'}).find('tbody')
        assert 'test first ticket' in ticket_rows.text
        assert 'test second ticket' in ticket_rows.text
        assert 'test third ticket' not in ticket_rows.text
        edit_link = response.html.find('a', {'title': 'Bulk Edit'})
        expected_link = "/p/test/bugs/edit/?q=status%3Aopen&limit=25&filter=%7B%7D&page=0"
        assert_equivalent_urls(expected_link, edit_link['href'])
        response = self.app.get(edit_link['href'])
        ticket_rows = response.html.find('tbody', {'class': 'ticket-list'})
        assert 'test first ticket' in ticket_rows.text
        assert 'test second ticket' in ticket_rows.text
        assert 'test third ticket' not in ticket_rows.text

    def test_bulk_edit_after_filtering(self):
        self.new_ticket(summary='test first ticket', status='open')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        self.app.get("/p/test/bugs/edit/?q=test&limit=25&sort=&page=0&filter={'status'%3A+['open']}")

    def test_new_ticket_notification_contains_attachments(self):
        file_name = 'tést_root.py'.encode()
        file_data = open(__file__, 'rb').read()
        upload = ('ticket_form.attachment', file_name, file_data)
        r = self.app.post('/bugs/save_ticket', {
            'ticket_form.summary': 'new ticket with attachment'
        }, upload_files=[upload]).follow()
        assert file_name in r
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        email = M.MonQTask.query.find(
            dict(task_name='allura.tasks.mail_tasks.sendmail')
        ).first()
        expected_text = (
            '**Attachments:**\n\n'
            '- [tést\\_root.py]'
            '(http://localhost/p/test/bugs/1/attachment/t%C3%A9st_root.py)')
        assert expected_text in email.kwargs['text']

    def test_ticket_notification_contains_milestones(self):
        params = dict(
            custom_fields=[
                dict(
                    name='_releases',
                    label='Releases',
                    type='milestone',
                    milestones=[{'name': '0.9.3'}, {'name': '1.0-beta'}],
                    show_in_search='on',
                ),
                dict(
                    name='_milestone',
                    label='Milestone',
                    type='milestone',
                    milestones=[{'name': '1.0'}, {'name': '2.0'}],
                    show_in_search='on',
                ),
            ],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post('/admin/bugs/set_custom_fields',
                      params=variable_encode(params))
        self.new_ticket(summary='test new milestone', _milestone='2.0',
                        **{'custom_fields._releases': '1.0-beta'})
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        email = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).first()
        assert '**Releases:** 1.0-beta' in email.kwargs.text
        assert '**Milestone:** 2.0' in email.kwargs.text

    def test_bulk_edit_notifications(self):
        self.new_ticket(summary='test first ticket',
                        status='open', _milestone='2.0')
        self.new_ticket(summary='test second ticket',
                        status='accepted', _milestone='1.0')
        self.new_ticket(summary='test third ticket', status='unread')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        first_ticket = tm.Ticket.query.get(summary='test first ticket')
        second_ticket = tm.Ticket.query.get(summary='test second ticket')
        third_ticket = tm.Ticket.query.get(summary='test third ticket')
        first_user = M.User.by_username('test-user-0')
        second_user = M.User.by_username('test-user-1')
        admin = M.User.by_username('test-admin')
        first_ticket.subscribe(user=first_user)
        second_ticket.subscribe(user=second_user)
        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(first_ticket._id),
                str(second_ticket._id),
                str(third_ticket._id)),
            'status': 'accepted',
            '_milestone': '2.0',
            'assigned_to': 'test-admin'})
        M.MonQTask.run_ready()

        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        assert len(emails) == 3
        for email in emails:
            assert email.kwargs.subject == '[test:bugs] Mass edit changes by Test Admin'
        first_user_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(first_user._id)
        }).all()
        assert len(first_user_email) == 1
        first_user_email = first_user_email[0]
        second_user_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(second_user._id)
        }).all()
        assert len(second_user_email) == 1
        second_user_email = second_user_email[0]
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        assert len(admin_email) == 1
        admin_email = admin_email[0]

        # Expected data
        email_header = '''Mass edit changing:

- **Owner**: Test Admin
- **Status**: accepted
- **Milestone**: 2.0

'''
        first_ticket_changes = '''ticket: bugs:#1 test first ticket

- **Owner**: Anonymous --> Test Admin
- **Status**: open --> accepted
'''
        second_ticket_changes = '''ticket: bugs:#2 test second ticket

- **Owner**: Anonymous --> Test Admin
- **Milestone**: 1.0 --> 2.0
'''
        third_ticket_changes = '''ticket: bugs:#3 test third ticket

- **Owner**: Anonymous --> Test Admin
- **Status**: unread --> accepted
- **Milestone**: 1.0 --> 2.0
'''
        email = '\n'.join([email_header, first_ticket_changes, ''])
        assert email == first_user_email.kwargs.text
        email = '\n'.join([email_header, second_ticket_changes, ''])
        assert email == second_user_email.kwargs.text
        assert email_header in admin_email.kwargs.text
        assert first_ticket_changes in admin_email.kwargs.text
        assert second_ticket_changes in admin_email.kwargs.text
        assert third_ticket_changes in admin_email.kwargs.text

    def test_bulk_edit_notifications_monitoring_email(self):
        self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': 'monitoring@email.com',
            'TicketMonitoringType': 'AllTicketChanges',
        })
        self.new_ticket(summary='test first ticket',
                        status='open', _milestone='2.0', private=True)
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        ticket = tm.Ticket.query.get(summary='test first ticket')
        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': [str(ticket._id)],
            'status': 'accepted'})
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        # one for admin and one for monitoring email
        assert len(emails) == 2
        for email in emails:
            assert email.kwargs.subject == '[test:bugs] Mass edit changes by Test Admin'
        admin = M.User.by_username('test-admin')
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        monitoring_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': 'monitoring@email.com'
        }).all()
        assert len(admin_email) == 1
        assert len(monitoring_email) == 1
        admin_email_text = admin_email[0].kwargs.text
        monitoring_email_text = monitoring_email[0].kwargs.text
        assert admin_email_text == monitoring_email_text

    def test_bulk_edit_notifications_monitoring_email_public_only(self):
        """Test that private tickets are not included in bulk edit
        notifications if the "public only" option is selected.
        """
        self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': 'monitoring@email.com',
            'TicketMonitoringType': 'AllPublicTicketChanges',
        })
        self.new_ticket(summary='test first ticket', status='open', _milestone='2.0')
        self.new_ticket(summary='test second ticket', status='open', private=True)
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        tickets = tm.Ticket.query.find(dict(status='open')).all()
        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': [str(t._id) for t in tickets],
            'status': 'accepted'})
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        # one for admin and one for monitoring email
        assert len(emails) == 2
        for email in emails:
            assert email.kwargs.subject == '[test:bugs] Mass edit changes by Test Admin'
        admin = M.User.by_username('test-admin')
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        monitoring_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': 'monitoring@email.com'
        }).all()
        assert len(admin_email) == 1
        assert len(monitoring_email) == 1
        admin_email_text = admin_email[0].kwargs.text
        monitoring_email_text = monitoring_email[0].kwargs.text
        assert 'second ticket' in admin_email_text
        assert 'second ticket' not in monitoring_email_text

    def test_bulk_edit_monitoring_email_all_private_edits(self):
        """Test that no monitoring email is sent if the "public only"
        option is selected, and only private tickets were updated.
        """
        self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': 'monitoring@email.com',
            'TicketMonitoringType': 'AllPublicTicketChanges',
        })
        self.new_ticket(summary='test first ticket', status='open', private=True)
        self.new_ticket(summary='test second ticket', status='open', private=True)
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        tickets = tm.Ticket.query.find(dict(status='open')).all()
        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': [str(t._id) for t in tickets],
            'status': 'accepted'})
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        assert len(emails) == 1  # only admin email sent
        for email in emails:
            assert email.kwargs.subject == '[test:bugs] Mass edit changes by Test Admin'
        admin = M.User.by_username('test-admin')
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        monitoring_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': 'monitoring@email.com'
        }).all()
        assert len(admin_email) == 1
        assert len(monitoring_email) == 0

    def test_filtered_by_subscription(self):
        self.new_ticket(summary='test first ticket', status='open')
        self.new_ticket(summary='test second ticket', status='open')
        self.new_ticket(summary='test third ticket', status='open')
        tickets = []
        users = []
        tickets.append(tm.Ticket.query.get(summary='test first ticket'))
        tickets.append(tm.Ticket.query.get(summary='test second ticket'))
        tickets.append(tm.Ticket.query.get(summary='test third ticket'))
        users.append(M.User.by_username('test-user-0'))
        users.append(M.User.by_username('test-user-1'))
        users.append(M.User.by_username('test-user-2'))
        admin = M.User.by_username('test-admin')
        tickets[0].subscribe(user=users[0])
        tickets[0].subscribe(user=users[1])
        tickets[1].subscribe(user=users[1])
        tickets[2].subscribe(user=users[2])
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()

        # Pretend we're changing first and second ticket.
        # Then we should notify test-user-0, test-user-1 and admin.
        # test-user-2 shoudn't be notified
        # (he has subscription to third ticket, but it didn't change).
        # test-user-0 should see changes only for first ticket.
        # test-user-1 - for both (separate subscription for both tickets).
        # admin - for both (tool subscription).
        changes = {
            tickets[0]._id: tickets[0],
            tickets[1]._id: tickets[1],
        }
        filtered_changes = c.app.globals.filtered_by_subscription(changes)
        filtered_users = [uid for uid, data in filtered_changes.items()]
        assert (sorted(filtered_users) ==
                sorted(u._id for u in users[:-1] + [admin]))
        ticket_ids = [t._id for t in tickets]
        assert filtered_changes[users[0]._id] == set(ticket_ids[0:1])
        assert filtered_changes[users[1]._id] == set(ticket_ids[:-1])
        assert filtered_changes[admin._id] == set(ticket_ids[:-1])

    def test_filtered_by_subscription_respects_private_read_access(self):
        self.new_ticket(summary='public ticket', status='open')
        self.new_ticket(summary='private ticket', status='open')
        public = tm.Ticket.query.get(summary='public ticket')
        private = tm.Ticket.query.get(summary='private ticket')
        private.private = True
        user = M.User.by_username('test-user-0')
        admin = M.User.by_username('test-admin')
        # tool-wide subscription: would otherwise receive every ticket change
        M.Mailbox.subscribe(user_id=user._id, artifact=None)
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()

        changes = {public._id: public, private._id: private}
        filtered = c.app.globals.filtered_by_subscription(changes)
        # user can't read the private ticket, so it's excluded from their fanout
        assert filtered[user._id] == {public._id}
        # admin (developer/creator) can read both
        assert filtered[admin._id] == {public._id, private._id}

    def test_vote(self):
        r = self.new_ticket(summary='test vote').follow()
        assert r.html.find('div', {'id': 'vote'})

        # test vote form not visible to anon user
        r = self.app.get('/bugs/1/', extra_environ=dict(username='*anonymous'))
        assert not r.html.find('div', {'id': 'vote'})

        r = self.app.get('/bugs/1/')
        votes_up = r.html.find('span', {'class': 'votes-up'})
        votes_down = r.html.find('span', {'class': 'votes-down'})
        assert '0' in str(votes_up)
        assert '0' in str(votes_down)

        # invalid vote
        r = self.app.post('/bugs/1/vote', dict(vote='invalid'))
        expected_resp = json.dumps(dict(status='error', votes_up=0, votes_down=0, votes_percent=0))
        assert r.response.text == expected_resp

        # vote up
        r = self.app.post('/bugs/1/vote', dict(vote='u'))
        expected_resp = json.dumps(dict(status='ok', votes_up=1, votes_down=0, votes_percent=100))
        assert r.response.text == expected_resp

        # vote down by another user
        r = self.app.post('/bugs/1/vote', dict(vote='d'),
                          extra_environ=dict(username='test-user-0'))

        expected_resp = json.dumps(dict(status='ok', votes_up=1, votes_down=1, votes_percent=50))
        assert r.response.text == expected_resp

        # make sure that on the page we see the same result
        r = self.app.get('/bugs/1/')
        votes_up = r.html.find('span', {'class': 'votes-up'})
        votes_down = r.html.find('span', {'class': 'votes-down'})
        assert '1' in str(votes_up)
        assert '1' in str(votes_down)

        r = self.app.get('/bugs/')
        assert "Votes" in r
        self.app.post(
            '/admin/bugs/set_options',
            params={'EnableVoting': 'false'})
        r = self.app.get('/bugs/')
        assert "Votes" not in r

    @td.with_tool('test', 'Tickets', 'tracker',
                  post_install_hook=post_install_create_ticket_permission)
    def test_create_permission(self):
        """Test that user with `create` permission can create ticket,
        but can't edit it without `update` permission.
        """
        response = self.app.get('/p/test/tracker/',
                                extra_environ=dict(username='test-user-0'))
        assert 'Create Ticket' in response

        response = self.new_ticket(summary='test create, not update',
                                   mount_point='/tracker/',
                                   extra_environ=dict(username='test-user-0'))
        ticket_url = response.headers['Location']
        response = self.app.get(ticket_url,
                                extra_environ=dict(username='test-user-0'))
        assert not response.html.find('div', {'class': 'error'})
        assert not response.html.find('a', {'class': 'edit_ticket'})

    @td.with_tool('test', 'Tickets', 'tracker',
                  post_install_hook=post_install_update_ticket_permission)
    def test_update_permission(self):
        r = self.app.get('/p/test/tracker/',
                         extra_environ=dict(username='*anonymous'))
        assert 'Create Ticket' in r

        r = self.new_ticket(summary='test', mount_point='/tracker/',
                            extra_environ=dict(username='*anonymous'))
        ticket_url = r.headers['Location']
        r = self.app.get(ticket_url, extra_environ=dict(username='*anonymous'))
        a = r.html.find('a', {'class': 'icon edit_ticket'})
        assert a.text == '\xa0Edit'

    def test_ticket_creator_cant_edit_private_ticket_without_update_perm(self):
        p = M.Project.query.get(shortname='test')
        tracker = p.app_instance('bugs')
        # authenticated user has 'create' permission, but not 'update'
        role = M.ProjectRole.by_name('*authenticated')._id
        create_permission = M.ACE.allow(role, 'create')
        update_permission = M.ACE.allow(role, 'update')
        acl = tracker.config.acl
        acl.append(create_permission)
        if update_permission in acl:
            acl.remove(update_permission)
        # test-user creates private ticket
        env = {'username': 'test-user'}
        post_data = {
            'ticket_form.summary': 'Private ticket title',
            'ticket_form.private': 'True'
        }
        self.app.post('/bugs/save_ticket', post_data, extra_environ=env)
        # ... and can see it
        r = self.app.get('/bugs/1/', extra_environ=env)
        assert 'Private ticket title' in r
        assert '<label class="simple">Private:</label> Yes' in r, 'Ticket is not private'
        # ... and can't see 'Edit' link
        assert r.html.find('a', {'class': 'edit_ticket'}) is None, "Found 'Edit' link"
        # ... and can't actually edit it
        self.app.post('/bugs/1/update_ticket', {'summary': 'should fail'},
                      extra_environ=env, status=403)

    def test_imported_tickets_redirect(self):
        self.new_ticket(summary='Imported ticket')
        ticket = tm.Ticket.query.get(ticket_num=1)
        ticket.import_id = {'source_id': '42000'}
        ThreadLocalODMSession.flush_all()

        # expect permanent redirect to /p/test/bugs/1/
        r = self.app.get('/p/test/bugs/42000/', status=301).follow()
        assert r.request.path == '/p/test/bugs/1/', r.request.path

        # not found and has not import_id
        self.app.get('/p/test/bugs/42042/', status=404)

    def test_ticket_delete(self):
        self.new_ticket(summary='Test ticket')
        self.app.post('/bugs/1/delete')
        r = self.app.get('/bugs/')
        assert 'No open tickets found.' in r
        assert tm.Ticket.query.get(ticket_num=1).summary != 'Test ticket'
        self.app.post('/bugs/1/undelete')
        r = self.app.get('/bugs/')
        assert 'No open tickets found.' not in r
        assert tm.Ticket.query.get(ticket_num=1).summary == 'Test ticket'

    def test_ticket_delete_without_permission(self):
        self.new_ticket(summary='Test ticket')
        self.app.post('/bugs/1/delete',
                      extra_environ=dict(username='*anonymous'))
        r = self.app.get('/bugs/')
        assert '<a href="/p/test/bugs/1/">Test ticket</a>' in r
        self.app.post('/bugs/1/delete')
        self.app.post('/bugs/1/undelete',
                      extra_environ=dict(username='*anonymous'))
        r = self.app.get('/bugs/')
        assert 'No open tickets found.' in r

    def test_deleted_ticket_visible(self):
        self.new_ticket(summary='test')
        self.app.post('/bugs/1/delete')
        r = self.app.get('/p/test/bugs/1/')
        assert '#1 test' in r
        self.app.get('/p/test/bugs/1/',
                     extra_environ=dict(username='*anonymous'), status=404)
        r = self.app.get('/p/test/bugs/',
                         params=dict(q='test', deleted='True'))
        assert '<td><a href="/p/test/bugs/1/">test' in r
        assert '<tr class=" deleted">' in r
        r = self.app.get(
            '/p/test/bugs/', params=dict(q='test', deleted='True'),
            extra_environ=dict(username='*anonymous'))
        assert 'No open tickets found.' in r

    def test_show_hide_deleted_tickets(self):
        self.new_ticket(summary='Test ticket')
        r = self.app.get('/p/test/bugs/')
        assert 'Show deleted tickets' not in r
        self.app.post('/bugs/1/delete')
        r = self.app.get('/p/test/bugs/')
        assert 'Show deleted tickets' in r
        assert 'No open tickets found' in r
        r = self.app.get('/bugs/?deleted=True')
        assert '<a href="/p/test/bugs/1/">Test ticket' in r
        assert 'Hide deleted tickets' in r

    @td.with_tool('test', 'Tickets', 'bugs2')
    @td.with_tool('test2', 'Tickets', 'bugs')
    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_move_ticket(self):
        self.new_ticket(summary='test')
        r = self.app.get('/p/test/bugs/1/move')
        trackers = r.html.find('select', {'name': 'tracker'}).find_all('option')
        trackers = {t.text for t in trackers}
        expected = {'test/bugs', 'test/bugs2', 'test2/bugs', 'test2/bugs2'}
        assert trackers == expected, trackers

        p = M.Project.query.get(shortname='test2')
        tracker = p.app_instance('bugs2')
        r = self.app.post('/p/test/bugs/1/move/',
                          params={'tracker': str(tracker.config._id)}).follow()
        assert r.request.path == '/p/test2/bugs2/1/'
        summary = r.html.find_all('h2', {'class': 'dark title'})[0].find('span').contents[0].strip()
        assert summary == '#1 test'
        ac_id = tracker.config._id
        ticket = tm.Ticket.query.find({
            'app_config_id': ac_id,
            'ticket_num': 1}).first()
        assert ticket is not None, "Can't find moved ticket"
        assert ticket.discussion_thread.app_config_id == ac_id
        assert ticket.discussion_thread.discussion.app_config_id == ac_id
        post = ticket.discussion_thread.last_post
        assert post.text == 'Ticket moved from /p/test/bugs/1/'

    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_move_ticket_feed(self):
        self.new_ticket(summary='test')
        p = M.Project.query.get(shortname='test2')
        ac_id = p.app_instance('bugs2').config._id
        r = self.app.post('/p/test/bugs/1/move/',
                          params={'tracker': str(ac_id)}).follow()

        ticket = tm.Ticket.query.find({
            'app_config_id': ac_id,
            'ticket_num': 1}).first()
        post = ticket.discussion_thread.last_post
        ticket_link = '/p/test2/bugs2/1/?limit=25#' + post.slug
        msg = 'Ticket moved from /p/test/bugs/1/'
        assert post.text == msg
        # auto comment content and link to it should be in a ticket's feed
        r = self.app.get('/p/test2/bugs2/1/feed')
        assert msg in r, r
        assert ticket_link in r, r
        # auto comment content and link to it should be in a tracker's feed
        r = self.app.get('/p/test2/bugs2/1/feed')
        assert msg in r, r
        assert ticket_link in r, r

        # post comment and make sure that it appears on the feeds
        r = self.app.get('/p/test2/bugs2/1/')
        for f in r.html.find_all('form'):
            if f.get('action', '').endswith('/post'):
                break
        post_content = 'ticket discussion post content'
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[f.find('textarea')['name']] = post_content
        r = self.app.post(f['action'], params=params,
                          headers={'Referer': b'/p/test2/bugs2/1/'})
        r = self.app.get('/p/test2/bugs2/1/', dict(page='1'))
        assert post_content in r
        comments_cnt = len(r.html.find_all(attrs={'class': 'discussion-post'}))
        assert comments_cnt == 2  # moved auto comment + new comment
        post = ticket.discussion_thread.last_post
        # content and link to the ticket should be in a tracker's feed
        ticket_link = '/p/test2/bugs2/1/?limit=25#' + post.slug
        r = self.app.get('/p/test2/bugs2/feed')
        assert post_content in r, r
        assert ticket_link in r, r
        # content and link to the ticket should be in a ticket's feed
        r = self.app.get('/p/test2/bugs2/1/feed')
        assert post_content in r, r
        assert ticket_link in r, r

    def test_move_ticket_bad_data(self):
        self.new_ticket(summary='test')
        r = self.app.post('/p/test/bugs/1/move', extra_environ={'HTTP_REFERER': '/p/test/bugs/1/'}).follow()  # empty POST
        assert 'Select valid tracker' in r, r
        r = self.app.post('/p/test/bugs/1/move',
                          params={'tracker': 'invalid tracker id'},
                          extra_environ={'HTTP_REFERER': '/p/test/bugs/1/'}).follow()
        assert 'Select valid tracker' in r, r
        p = M.Project.query.get(shortname='test')
        tracker = p.app_instance('bugs')
        r = self.app.post('/p/test/bugs/1/move',
                          params={'tracker': str(tracker.config._id)},
                          extra_environ={'HTTP_REFERER': '/p/test/bugs/1/'}).follow()
        assert 'Ticket already in a selected tracker' in r, r

    def test_move_ticket_access(self):
        self.new_ticket(summary='test')
        self.app.get('/p/test/bugs/1/move',
                     extra_environ={'username': 'test-user'},
                     status=403)
        self.app.post('/p/test/bugs/1/move',
                      extra_environ={'username': 'test-user'},
                      status=403)

    @td.with_tool('test', 'Tickets', 'dummy')
    def test_move_ticket_redirect(self):
        self.new_ticket(summary='test 1')
        self.app.get('/p/test/bugs/1/', status=200)  # shouldn't fail

        # move ticket 1 to 'dummy' tracker
        p = M.Project.query.get(shortname='test')
        dummy_tracker = p.app_instance('dummy')
        r = self.app.post('/p/test/bugs/1/move',
                          params={'tracker': str(dummy_tracker.config._id)}).follow()
        assert r.request.path == '/p/test/dummy/1/'

        # test that old url redirects to moved ticket
        self.app.get('/p/test/bugs/1/', status=301).follow()
        assert r.request.path == '/p/test/dummy/1/'

    @td.with_tool('test', 'Tickets', 'dummy')
    def test_move_ticket_and_delete_tool(self):
        """See [#5708] for details."""
        # create two tickets and ensure they are viewable
        self.new_ticket(summary='test 1')
        self.new_ticket(summary='test 2')
        self.app.get('/p/test/bugs/1/', status=200)  # shouldn't fail
        self.app.get('/p/test/bugs/2/', status=200)  # shouldn't fail

        # move ticket 1 to 'dummy' tracker
        p = M.Project.query.get(shortname='test')
        dummy_tracker = p.app_instance('dummy')
        r = self.app.post('/p/test/bugs/1/move',
                          params={'tracker': str(dummy_tracker.config._id)}).follow()
        assert r.request.path == '/p/test/dummy/1/'

        # delete 'dummy' tracker
        p.uninstall_app('dummy')

        # remaining tickets in 'bugs' tracker should still be viewable
        self.app.get('/p/test/bugs/2/', status=200)  # shouldn't fail
        # ticket counts as moved
        r = self.app.get('/p/test/bugs/1/', status=301)
        r.follow(status=301)  # and found 'cause we don't want it to hard 404, but use default logic

    @td.with_tool('test', 'Tickets', 'dummy')
    def test_move_ticket_email_notifications(self):
        """See [#5691] for details"""
        # create two tickets and ensure they are viewable
        self.new_ticket(summary='test 1')
        self.new_ticket(summary='test 2')
        self.app.get('/p/test/bugs/1/', status=200)  # shouldn't fail
        self.app.get('/p/test/bugs/2/', status=200)  # shouldn't fail

        # move ticket 1 to 'dummy' tracker
        p = M.Project.query.get(shortname='test')
        dummy_tracker = p.app_instance('dummy')
        r = self.app.post('/p/test/bugs/1/move',
                          params={'tracker': str(dummy_tracker.config._id)}).follow()
        assert r.request.path == '/p/test/dummy/1/'

        # comment ticket 2
        M.Notification.query.remove()
        r = self.app.get('/p/test/bugs/2/')
        field_name = None  # comment text textarea name
        form = r.forms['ticket-form']
        for name, field in form.fields.items():
            if field[0].tag == 'textarea':
                field_name = name
        assert field_name, "Can't find comment field"
        form.fields[field_name][0].value = 'Hi there'
        form.submit()

        # notification for ticket 2 should reference [test:bugs], not
        # [test:dummy]
        n = M.Notification.query.find().all()[0]
        assert '[test:bugs]' in n.subject
        assert '[test:bugs]' in n.reply_to_address

    @td.with_tool('test2', 'Tickets', 'features')
    def test_move_ticket_subscriptions(self):
        """Subscriptions should move along with the ticket"""
        self.new_ticket(summary='test ticket')
        self.new_ticket(summary='another test ticket')
        p = M.Project.query.get(shortname='test')
        bugs = p.app_instance('bugs')
        p2 = M.Project.query.get(shortname='test2')
        features = p2.app_instance('features')
        admin = M.User.query.get(username='test-admin')
        user = M.User.query.get(username='test-user')

        # subscribe test-user to ticket #2
        self.app.post('/p/test/bugs/2/subscribe', {'subscribe': 'True'},
                      extra_environ={'username': 'test-user'})
        assert M.Mailbox.query.get(user_id=user._id,
                                   project_id=p._id,
                                   app_config_id=bugs.config._id,
                                   artifact_title='Ticket #2: another test ticket',
                                   artifact_url='/p/test/bugs/2/')

        # remove test-admin's tool-wide subscription to test2/features so he can get a new individual one
        M.Mailbox.query.remove({'user_id': admin._id,
                                'project_id': p2._id,
                                'app_config_id': features.config._id,
                                'artifact_index_id': None,
                                })

        # move ticket to new project & tool: test/bugs/2 => test2/features/1
        r = self.app.post('/p/test/bugs/2/move',
                          params={'tracker': str(features.config._id)}).follow()
        assert r.request.path == '/p/test2/features/1/'

        # test-user should be subscribed to it
        assert M.Mailbox.query.get(user_id=user._id,
                                   project_id=p2._id,
                                   app_config_id=features.config._id,
                                   artifact_title='Ticket #1: another test ticket',
                                   artifact_url='/p/test2/features/1/'), \
            "Could not find moved subscription.  User's record is %s" % M.Mailbox.query.get(user_id=user._id)
        # test-admin (who had a tool-level subscription) should be too
        assert M.Mailbox.query.get(user_id=admin._id,
                                   project_id=p2._id,
                                   app_config_id=features.config._id,
                                   artifact_title='Ticket #1: another test ticket',
                                   artifact_url='/p/test2/features/1/'), \
            "Could not find moved subscription.  Admin's record is %s" % M.Mailbox.query.get(user_id=admin._id)

    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_move_attachment(self):
        file_name = 'neo-icon-set-454545-256x350.png'
        file_path = os.path.join(
            allura.__path__[0], 'nf', 'allura', 'images', file_name)
        file_data = open(file_path, 'rb').read()
        upload = ('attachment', file_name, file_data)
        self.new_ticket(summary='test move attachment')

        # attach an image to the ticket
        self.app.post('/bugs/1/update_ticket',
                      {'summary': 'test'},
                      upload_files=[upload])
        # attach a txt file to the comment
        r = self.app.get('/p/test/bugs/1/')
        post_link = str(r.html.find('div', {'class': 'edit_post_form reply'}).find('form')['action'])
        r = self.app.post(post_link + 'attach',
                          upload_files=[('file_info', 'test.txt', b'test')])
        # move ticket
        p = M.Project.query.get(shortname='test2')
        bugs2 = p.app_instance('bugs2')
        r = self.app.post('/p/test/bugs/1/move/',
                          params={'tracker': str(bugs2.config._id)}).follow()

        attach_tickets = r.html.find_all('div', attrs={'class': 'attachment_thumb'})
        attach_comments = r.html.find_all('div', attrs={'class': 'attachment_item'})
        ta = str(attach_tickets)  # ticket's attachments
        ca = str(attach_comments)  # comment's attachments
        assert '<a href="/p/test2/bugs2/1/attachment/neo-icon-set-454545-256x350.png"' in ta
        assert '<img alt="Thumbnail" src="/p/test2/bugs2/1/attachment/neo-icon-set-454545-256x350.png/thumb"' in ta
        p = M.Post.query.find().sort('timestamp', 1).first()
        assert (
            '<a href="/p/test2/bugs2/_discuss/thread/%s/%s/attachment/test.txt"' %
            (p.thread_id, p.slug) in ca)
        for attach in M.BaseAttachment.query.find():
            assert attach.app_config_id == bugs2.config._id
            if attach.attachment_type == 'DiscussionAttachment':
                assert attach.discussion_id == bugs2.config.discussion_id

    @td.with_tool('test', 'Tickets', 'dummy')
    def test_move_ticket_comments(self):
        """Comments should move along with the ticket"""
        self.new_ticket(summary='test ticket')
        r = self.app.get('/p/test/bugs/1/')
        field_name = None  # comment text textarea name
        form = r.forms['ticket-form']
        for name, field in form.fields.items():
            if field[0].tag == 'textarea':
                field_name = name
        assert field_name, "Can't find comment field"
        form.fields[field_name][0].value = 'I am comment'
        form.submit()
        r = self.app.get('/p/test/bugs/1/')
        assert 'I am comment' in r

        p = M.Project.query.get(shortname='test')
        dummy_tracker = p.app_instance('dummy')
        r = self.app.post('/p/test/bugs/1/move',
                          params={'tracker': str(dummy_tracker.config._id)}).follow()
        assert r.request.path == '/p/test/dummy/1/'
        assert 'I am comment' in r

    def test_tags(self):
        p = M.Project.query.get(shortname='test')
        p.app_instance('bugs')
        self.new_ticket(summary='a', labels='tag1,tag2')
        self.new_ticket(summary='b', labels='tag2')
        self.new_ticket(summary='c', labels='42cc,test')
        # Testing only empty 'term', because mim doesn't support aggregation
        # calls
        r = self.app.get('/p/test/bugs/tags')
        assert json.loads(r.text) == []
        r = self.app.get('/p/test/bugs/tags?term=')
        assert json.loads(r.text) == []

    def test_rest_tickets(self):
        ticket_view = self.new_ticket(summary='test').follow()
        for f in ticket_view.html.find_all('form'):
            if f.get('action', '').endswith('/post'):
                break
        params = dict()
        inputs = f.find_all('input')
        for field in inputs:
            if field.has_attr('name'):
                params[field['name']] = field.get('value') or ''
        params[f.find('textarea')['name']] = 'test comment'
        self.app.post(f['action'], params=params,
                      headers={'Referer': b'/bugs/1/'})
        r = self.app.get('/bugs/1/', dict(page='1'))
        post_link = str(r.html.find('div', {'class': 'edit_post_form reply'}).find('form')['action'])
        self.app.post(post_link + 'attach',
                      upload_files=[('file_info', 'test.txt', b'test attach')])
        r = self.app.get('/p/test/bugs/1/')
        discussion_url = r.html.find_all('form')[-1]['action'][:-4]
        r = self.app.get('/rest/p/test/bugs/1/')
        r = json.loads(r.text)
        assert (r['ticket']['discussion_thread_url'] ==
                'http://localhost/rest%s' % discussion_url)
        slug = r['ticket']['discussion_thread']['posts'][0]['slug']
        assert (r['ticket']['discussion_thread']['posts'][0]['attachments'][0]['url'] ==
                f'http://localhost{discussion_url}{slug}/attachment/test.txt')
        assert (r['ticket']['discussion_thread']['posts'][0]['attachments'][0]['bytes'] ==
                11)

        file_name = 'test_root.py'
        file_data = open(__file__, 'rb').read()
        upload = ('attachment', file_name, file_data)
        r = self.app.post('/bugs/1/update_ticket', {
            'summary': 'test rest attach'
        }, upload_files=[upload]).follow()
        r = self.app.get('/rest/p/test/bugs/1/')
        r = json.loads(r.text)
        assert (r['ticket']['attachments'][0]['url'] ==
                'http://localhost/p/test/bugs/1/attachment/test_root.py')

    def test_html_escaping(self):
        with mock.patch.object(mail_tasks.smtp_client, '_client') as _client:
            self.new_ticket(summary='test <h2> ticket',
                            status='open', _milestone='2.0')
            ThreadLocalODMSession.flush_all()
            M.MonQTask.run_ready()
            ThreadLocalODMSession.flush_all()
            email = M.MonQTask.query.find(
                dict(task_name='allura.tasks.mail_tasks.sendmail')).first()
            assert (email.kwargs.subject ==
                    '[test:bugs] #1 test <h2> ticket')
            text = email.kwargs.text
            assert '**[bugs:#1] test &lt;h2&gt; ticket**' in text
            mail_tasks.sendmail(
                fromaddr=str(c.user._id),
                destinations=[str(c.user._id)],
                text=text,
                reply_to=g.noreply,
                subject=email.kwargs.subject,
                message_id=h.gen_message_id())
            assert _client.sendmail.call_count == 1
            return_path, rcpts, body = _client.sendmail.call_args[0]
            body = body.split(email_policy.linesep)
            # check subject
            assert 'Subject: [test:bugs] #1 test <h2> ticket' in body
            # check html, need tags escaped
            assert ('<p><strong><a class="alink" href="http://localhost/p/test/bugs/1/">[bugs:#1]</a>'
                    ' test &lt;h2&gt; ticket</strong></p>' in
                    body)
            # check plaintext (ok to have "html" tags)
            assert '**[bugs:#1] test <h2> ticket**' in body

    @patch('forgetracker.search.query_filter_choices', autospec=True)
    def test_multiselect(self, query_filter_choices):
        self.new_ticket(summary='test')
        self.new_ticket(summary='test2')
        query_filter_choices.return_value = {'status': [('open', 2)], }
        r = self.app.get('/bugs/')
        assert '<option value="open">open (2)</option>' in r
        query_filter_choices.assert_called_once_with('!status_s:closed && !status_s:wont-fix', fq=['deleted_b:False'])

    def test_rate_limit_new(self):
        self.new_ticket(summary='First ticket')
        # Set rate limit to unlimit
        with h.push_config(config, **{'forgetracker.rate_limits': '{}'}):
            r = self.app.get('/bugs/new/')
            assert r.status_int == 200
        # Set rate limit to 1 in first hour of project
        with h.push_config(config, **{'forgetracker.rate_limits': '{"3600": 1}'}):
            r = self.app.get('/bugs/new/')
            assert r.status_int == 302
            assert r.location == 'http://localhost/bugs/'
            wf = json.loads(self.webflash(r))
            assert wf['status'] == 'error'
            assert (
                wf['message'] ==
                'Ticket creation rate limit exceeded. Please try again later.')

    def test_rate_limit_save_ticket(self):
        # Set rate limit to unlimit
        with h.push_config(config, **{'forgetracker.rate_limits': '{}'}):
            summary = 'Ticket w/o limit'
            post_data = {'ticket_form.summary': summary}
            r = self.app.post('/bugs/save_ticket', post_data).follow()
            assert summary in r
            t = tm.Ticket.query.get(summary=summary)
            assert t is not None
        # Set rate limit to 1 in first hour of project
        with h.push_config(config, **{'forgetracker.rate_limits': '{"3600": 1}'}):
            summary = 'Ticket with limit'
            post_data = {'ticket_form.summary': summary}
            r = self.app.post('/bugs/save_ticket', post_data)
            assert r.status_int == 302
            assert r.location == 'http://localhost/bugs/'
            wf = json.loads(self.webflash(r))
            assert wf['status'] == 'error'
            assert (
                wf['message'] ==
                'Ticket creation rate limit exceeded. Please try again later.')
            assert summary not in r.follow()
            t = tm.Ticket.query.get(summary=summary)
            assert t is None

    def test_user_missing(self):
        # add test-user to project so it can be assigned the ticket
        testuser = M.User.by_username('test-user')
        c.project.add_user(testuser, ['Developer'])
        Credentials.get().clear()
        # make a ticket created by & assigned to test-user
        self.new_ticket(summary='foo bar', assigned_to='test-user', private=True,
                        extra_environ={'username': 'test-user'})
        # but then remove the user
        M.User.query.remove({'username': 'test-user'})

        self.app.get('/p/test/bugs/1/', status=200)

        r = self.app.post('/p/test/bugs/1/update_ticket', {
            'summary': 'new summary',
            'description': 'new description',
            'status': 'closed',
            'assigned_to': '',
            'labels': '',
            'private': 'True',
            'comment': 'closing ticket of a user that is gone'
        })
        self.app.get('/p/test/bugs/1/', status=200)

    def test_bulk_delete(self):
        self.new_ticket(summary='test first ticket')
        self.new_ticket(summary='test second ticket')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        first_ticket = tm.Ticket.query.get(summary='test first ticket')
        second_ticket = tm.Ticket.query.get(summary='test second ticket')

        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/update_tickets', {
            '__search': '',
            '__ticket_ids': (
                str(first_ticket._id),
                str(second_ticket._id)),
            'deleted': 'True'})
        M.MonQTask.run_ready()

        r = self.app.get('/bugs/')
        assert 'No open tickets found.' in r
        assert tm.Ticket.query.get(ticket_num=1).summary != 'test first ticket'
        assert tm.Ticket.query.get(ticket_num=2).summary != 'test second ticket'


class TestMilestoneAdmin(TrackerTestController):
    def _post(self, params, **kw):
        params['open_status_names'] = 'aa bb'
        params['closed_status_names'] = 'cc'
        self.app.post('/admin/bugs/set_custom_fields',
                      params=variable_encode(params), **kw)
        return self.app.get('/admin/bugs/fields')

    def _post_milestones(self, milestones):
        params = {'custom_fields': [
            dict(label=mf['label'],
                 show_in_search='on',
                 type='milestone',
                 milestones=[
                     {k: v for k, v in d.items()} for d in mf['milestones']])
            for mf in milestones]}
        return self._post(params)

    def test_create_milestone_field(self):
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0/beta')])
        ])
        assert 'releases' in r
        assert '1.0-beta' in r

    def test_delete_milestone_field(self):
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0/beta')])
        ])
        self.new_ticket(summary='test new milestone',
                        **{'custom_fields._releases': '1.0-beta'})
        assert tm.Ticket.query.find({
            'custom_fields._releases': '1.0-beta'}).count() == 1
        r = self._post_milestones([])
        assert 'Releases' not in r
        assert '1.0-beta' not in r
        assert tm.Ticket.query.find({
            'custom_fields._releases': '1.0-beta'}).count() == 0

    def test_rename_milestone_field(self):
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0/beta')])
        ])
        self.new_ticket(summary='test new milestone',
                        **{'custom_fields._releases': '1.0-beta'})
        r = self._post_milestones([
            dict(label='versions', milestones=[dict(name='1.0/beta')])
        ])
        assert 'Releases' not in r
        assert 'versions' in r
        assert '1.0-beta' in r
        # TODO: This doesn't work - need to make milestone custom fields
        #       renameable.
        # assert tm.Ticket.query.find({
        #    'custom_fields._versions': '1.0-beta'}).count() == 1

    def test_create_milestone(self):
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0/beta')])
        ])
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0/beta'),
                                               dict(name='2.0')])
        ])
        assert '1.0-beta' in r
        assert '2.0' in r

    def test_delete_milestone(self):
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0/beta')])
        ])
        self.new_ticket(summary='test new milestone',
                        **{'custom_fields._releases': '1.0-beta'})
        assert tm.Ticket.query.find({
            'custom_fields._releases': '1.0-beta'}).count() == 1
        r = self._post_milestones([
            dict(label='releases', milestones=[])
        ])
        assert 'releases' in r
        assert '1.0-beta' not in r
        assert tm.Ticket.query.find({
            'custom_fields._releases': '1.0-beta'}).count() == 0

    def test_rename_milestone(self):
        r = self._post_milestones([
            dict(label='releases', milestones=[dict(name='1.0')])
        ])
        self.new_ticket(summary='test new milestone',
                        **{'custom_fields._releases': '1.0'})
        r = self._post_milestones([
            dict(label='releases', milestones=[
                dict(name='1.1', old_name='1.0')])
        ])
        assert 'releases' in r
        assert '1.0' not in r
        assert '1.1' in r
        assert tm.Ticket.query.find({
            'custom_fields._releases': '1.0'}).count() == 0
        assert tm.Ticket.query.find({
            'custom_fields._releases': '1.1'}).count() == 1


def post_install_hook(app):
    role_anon = M.ProjectRole.by_name('*anonymous')._id
    app.config.acl.append(M.ACE.allow(role_anon, 'post'))
    app.config.acl.append(M.ACE.allow(role_anon, 'create'))
    app.config.acl.append(M.ACE.allow(role_anon, 'update'))


class TestEmailMonitoring(TrackerTestController):

    @classmethod
    def setup_class(cls):
        cls.test_email = 'mailinglist@example.com'

    def _set_options(self, monitoring_type='AllTicketChanges'):
        r = self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': self.test_email,
            'TicketMonitoringType': monitoring_type,
        })
        return r

    def test_set_options(self):
        r = self._set_options()
        r = self.app.get('/admin/bugs/options')
        email = r.html.find_all(attrs=dict(name='TicketMonitoringEmail'))
        mtype = r.html.find_all('option', attrs=dict(value='AllTicketChanges'))
        assert email[0]['value'] == self.test_email
        assert mtype[0].has_attr('selected')

    @td.with_tool('test', 'Tickets', 'doc-bugs', post_install_hook=post_install_hook)
    @patch('forgetracker.model.ticket.Notification.send_direct')
    @patch('allura.model.discuss.Thread.is_spam')
    def test_notifications_moderators(self, is_spam, send_direct):
        is_spam.return_value = False
        ticket_view = self.new_ticket(summary='test moderation', mount_point='/doc-bugs/').follow()
        _, form = find(ticket_view.forms, lambda f: f.action.endswith('/post'))
        field, _ = find(form.fields, lambda f: f[0].tag == 'textarea')
        form.set(field, 'this is an anonymous comment')
        form.submit(extra_environ=dict(username='*anonymous'))
        send_direct.assert_called_with(
            str(M.User.query.get(username='test-admin')._id))

    @td.with_tool('test', 'Tickets', 'doc-bugs', post_install_hook=post_install_hook)
    @patch('forgetracker.model.ticket.Notification.send_direct')
    @patch('allura.model.discuss.Thread.is_spam')
    def test_notifications_off_spam(self, is_spam, send_direct):
        # like test_notifications_moderators but no notification because it goes straight to spam status
        is_spam.return_value = True
        ticket_view = self.new_ticket(summary='test moderation', mount_point='/doc-bugs/').follow()
        _, form = find(ticket_view.forms, lambda f: f.action.endswith('/post'))
        field, _ = find(form.fields, lambda f: f[0].tag == 'textarea')
        form.set(field, 'this is an anonymous comment')
        form.submit(extra_environ=dict(username='*anonymous'))
        assert not send_direct.called

    @patch('forgetracker.model.ticket.Notification.send_simple')
    def test_notifications_new(self, send_simple):
        self._set_options('NewTicketsOnly')
        self.new_ticket(summary='test', private=True)
        send_simple.assert_called_once_with(self.test_email)

    @patch('forgetracker.model.ticket.Notification.send_simple')
    def test_notifications_new_public_only(self, send_simple):
        """Test that notification not sent for new private ticket
        if "public only" option selected.
        """
        self._set_options('NewPublicTicketsOnly')
        self.new_ticket(summary='test', private=True)
        assert not send_simple.called

    @patch('forgetracker.tracker_main.M.Notification.send_simple')
    def test_notifications_all(self, send_simple):
        self._set_options()
        self.new_ticket(summary='test')
        send_simple.assert_called_once_with(self.test_email)
        send_simple.reset_mock()
        response = self.app.post('/bugs/1/update_ticket', {
            'summary': 'test',
            'description': 'update',
            'private': '1'})
        assert send_simple.call_count == 1, send_simple.call_count
        send_simple.assert_called_with(self.test_email)
        send_simple.reset_mock()
        response = response.follow()
        for f in response.html.find_all('form'):
            # Dirty way to find comment form
            if (('thread' in f['action']) and ('post' in f['action'])):
                params = {i['name']: i.get('value', '')
                          for i in f.find_all('input')
                          if i.has_attr('name')}
                params[f.find('textarea')['name']] = 'foobar'
                self.app.post(str(f['action']), params)
                break  # Do it only once if many forms met
        assert send_simple.call_count == 1, send_simple.call_count
        send_simple.assert_called_with(self.test_email)

    @patch('forgetracker.tracker_main.M.Notification.send_simple')
    def test_notifications_all_public_only(self, send_simple):
        """Test that notifications are not sent for private tickets
        if "public only" option selected.
        """
        self._set_options('AllPublicTicketChanges')
        self.new_ticket(summary='test')
        send_simple.assert_called_once_with(self.test_email)
        send_simple.reset_mock()
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'test',
            'description': 'update 1'})
        send_simple.assert_called_once_with(self.test_email)
        send_simple.reset_mock()
        self.app.post('/bugs/1/update_ticket', {
            'summary': 'test',
            'description': 'update 2',
            'private': '1'})
        assert not send_simple.called

    @patch('forgetracker.tracker_main.M.Notification.send_simple')
    def test_notifications_off(self, send_simple):
        """Test that tracker notification email is not sent if notifications
        are disabled at the project level.
        """
        p = M.Project.query.get(shortname='test')
        p.notifications_disabled = True
        self._set_options()
        with patch.object(M.Project.query, 'get') as get:
            get.side_effect = lambda *a, **k: None if 'bugs' in k.get('shortname', '') else p
            self.new_ticket(summary='test')
        assert send_simple.call_count == 0, send_simple.call_count

    def test_footer(self):
        self._set_options(monitoring_type='AllTicketChanges')
        M.MonQTask.query.remove()
        self.new_ticket(summary='test')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        email_tasks = M.MonQTask.query.find(
            dict(task_name='allura.tasks.mail_tasks.sendsimplemail')).all()
        assert 'Sent from localhost because mailinglist@example.com is subscribed to http://localhost/p/test/bugs/' in \
               email_tasks[0].kwargs['text']
        assert 'a project admin can change settings at http://localhost/p/test/admin/bugs/options' in \
               email_tasks[0].kwargs['text']


class TestCustomUserField(TrackerTestController):
    def setup_method(self, method):
        super().setup_method(method)
        params = dict(
            custom_fields=[
                dict(name='_code_review', label='Code Review', type='user',
                     show_in_search='on')],
            open_status_names='aa bb',
            closed_status_names='cc',
        )
        self.app.post(
            '/admin/bugs/set_custom_fields',
            params=variable_encode(params))

    def test_blank_user(self):
        kw = {'custom_fields._code_review': ''}
        ticket_view = self.new_ticket(summary='test custom fields', **kw).follow()
        # summary header shows 'nobody'
        assert (squish_spaces(ticket_view.html.find_all('label', 'simple', string='Code Review:')[0].parent.text) ==
                ' Code Review: nobody ')
        # form input is blank
        select = ticket_view.html.find('select',
                                       dict(name='ticket_form.custom_fields._code_review'))
        selected = None
        for option in select.find_all():
            if option.has_attr('selected'):
                selected = option
        assert selected is None

    def test_project_member(self):
        kw = {'custom_fields._code_review': 'test-admin'}
        ticket_view = self.new_ticket(summary='test custom fields', **kw).follow()
        # summary header shows 'Test Admin'
        assert (squish_spaces(ticket_view.html.find_all('label', 'simple', string='Code Review:')[0].parent.text) ==
                ' Code Review: Test Admin ')
        # form input is blank
        select = ticket_view.html.find('select',
                                       dict(name='ticket_form.custom_fields._code_review'))
        selected = None
        for option in select.find_all():
            if option.has_attr('selected'):
                selected = option
        assert selected['value'] == 'test-admin'

    def test_change_user_field(self):
        kw = {'custom_fields._code_review': ''}
        r = self.new_ticket(summary='test custom fields', **kw).follow()
        f = self._find_update_ticket_form(r)
        f['ticket_form.custom_fields._code_review'].force_value('test-admin')
        r = f.submit().follow()
        assert '<li><strong>Code Review</strong>: Test Admin' in r

    def test_search_results(self):
        kw = {'custom_fields._code_review': 'test-admin'}
        self.new_ticket(summary='test custom fields', **kw)
        r = self.app.get('/bugs/')
        assert r.html.find('table', 'ticket-list').find_all('th')[7].text.strip()[:11] == 'Code Review'
        assert r.html.find('table', 'ticket-list').tbody.tr.find_all('td')[7].text == 'Test Admin'


class TestHelpTextOptions(TrackerTestController):
    def _set_options(self, new_txt='', search_txt=''):
        r = self.app.post('/admin/bugs/set_options', params={
            'TicketHelpNew': new_txt,
            'TicketHelpSearch': search_txt,
        })
        return r

    def test_help_text(self):
        self._set_options(
            new_txt='**foo**',
            search_txt='*bar*')
        r = self.app.get('/bugs/')
        assert '<em>bar</em>' in r
        r = self.app.get('/bugs/search/', params=dict(q='test'))
        assert '<em>bar</em>' in r
        r = self.app.get('/bugs/milestone/1.0/')
        assert '<em>bar</em>' in r
        r = self.app.get('/bugs/new/')
        assert '<strong>foo</strong>' in r

        self._set_options()
        r = self.app.get('/bugs/')
        assert len(
            r.html.find_all(attrs=dict(id='search-ticket-help-msg'))) == 0
        r = self.app.get('/bugs/search/', params=dict(q='test'))
        assert len(
            r.html.find_all(attrs=dict(id='search-ticket-help-msg'))) == 0
        r = self.app.get('/bugs/milestone/1.0/')
        assert len(
            r.html.find_all(attrs=dict(id='search-ticket-help-msg'))) == 0
        r = self.app.get('/bugs/new/')
        assert len(r.html.find_all(attrs=dict(id='new-ticket-help-msg'))) == 0


class TestShowDefaultFields(TrackerTestController):

    def test_show_default_fields(self):
        r = self.app.get('/admin/bugs/fields')
        assert '<td>Ticket Number</td> <td><input type="checkbox" name="ticket_num" checked ></td>' in r
        assert '<td>Summary</td> <td><input type="checkbox" name="summary" checked ></td>' in r
        assert '<td>Milestone</td> <td><input type="checkbox" name="_milestone" checked ></td>' in r
        assert '<td>Status</td> <td><input type="checkbox" name="status" checked ></td>' in r
        assert '<td>Owner</td> <td><input type="checkbox" name="assigned_to" checked ></td>' in r
        assert '<td>Creator</td> <td><input type="checkbox" name="reported_by" ></td>' in r
        assert '<td>Created</td> <td><input type="checkbox" name="created_date" checked ></td>' in r
        assert '<td>Updated</td> <td><input type="checkbox" name="mod_date" checked ></td>' in r
        assert '<td>Labels</td> <td><input type="checkbox" name="labels" ></td>' in r
        self.new_ticket(summary='test')
        M.MonQTask.run_ready()
        r = self.app.get('/bugs/search/', params=dict(q='test'))
        assert '<td><a href="/p/test/bugs/1/">1</a></td>' in r
        p = M.Project.query.get(shortname='test')
        app = p.app_instance('bugs')
        app.globals.show_in_search['ticket_num'] = False
        r = self.app.get('/bugs/search/', params=dict(q='test'))
        assert '<td><a href="/p/test/bugs/1/">1</a></td>' not in r
        self.app.post('/admin/bugs/allow_default_field',
                      params={'status': 'on'})
        r = self.app.get('/admin/bugs/fields')
        assert '<td>Ticket Number</td> <td><input type="checkbox" name="ticket_num" ></td>' in r
        assert '<td>Summary</td> <td><input type="checkbox" name="summary" ></td>' in r
        assert '<td>Milestone</td> <td><input type="checkbox" name="_milestone" ></td>' in r
        assert '<td>Status</td> <td><input type="checkbox" name="status" checked ></td>' in r
        assert '<td>Owner</td> <td><input type="checkbox" name="assigned_to" ></td>' in r
        assert '<td>Creator</td> <td><input type="checkbox" name="reported_by" ></td>' in r
        assert '<td>Created</td> <td><input type="checkbox" name="created_date" ></td>' in r
        assert '<td>Updated</td> <td><input type="checkbox" name="mod_date" ></td>' in r
        assert '<td>Labels</td> <td><input type="checkbox" name="labels" ></td>' in r


class TestBulkMove(TrackerTestController):
    def setup_method(self, method):
        super().setup_method(method)
        self.new_ticket(summary='A New Hope')
        self.new_ticket(summary='The Empire Strikes Back')
        self.new_ticket(summary='Return Of The Jedi')
        M.MonQTask.run_ready()

    def test_access_restriction(self):
        self.app.get('/bugs/move/', status=200)
        self.app.get('/bugs/move/',
                     extra_environ={'username': 'test-user-0'},
                     status=403)
        self.app.get('/bugs/move/',
                     extra_environ={'username': '*anonymous'},
                     status=302)
        self.app.post('/bugs/move_tickets',
                      extra_environ={'username': 'test-user-0'},
                      status=403)

    def test_ticket_list(self):
        r = self.app.get('/bugs/move/?q=The')
        tickets_table = r.html.find('tbody', attrs={'class': 'ticket-list'})
        tickets = tickets_table.find_all('tr')
        assert len(tickets) == 2
        assert 'The Empire Strikes Back' in tickets_table.text
        assert 'Return Of The Jedi' in tickets_table.text

    @td.with_tool('test', 'Tickets', 'bugs2')
    @td.with_tool('test2', 'Tickets', 'bugs')
    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_controls_present(self):
        r = self.app.get('/bugs/move/')
        trackers = r.html.find('select', {'name': 'tracker'}).find_all('option')
        trackers = {t.text for t in trackers}
        expected = {'test/bugs', 'test/bugs2', 'test2/bugs', 'test2/bugs2'}
        assert trackers == expected
        move_btn = r.html.find('input', attrs={'type': 'submit', 'value': 'Move'})
        assert move_btn is not None

    @td.with_tool('test2', 'Tickets', 'bugs')
    def test_move(self):
        tickets = [
            tm.Ticket.query.find(
                {'summary': 'The Empire Strikes Back'}).first(),
            tm.Ticket.query.find({'summary': 'Return Of The Jedi'}).first()]
        p = M.Project.query.get(shortname='test2')
        original_p = M.Project.query.get(shortname='test')
        tracker = p.app_instance('bugs')
        original_tracker = original_p.app_instance('bugs')
        self.app.post('/p/test/bugs/move_tickets', {
            'tracker': str(tracker.config._id),
            '__ticket_ids': [str(t._id) for t in tickets],
            '__search': '',
        })
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        ac_id = tracker.config._id
        original_ac_id = original_tracker.config._id
        moved_tickets = tm.Ticket.query.find({'app_config_id': ac_id}).all()
        original_tickets = tm.Ticket.query.find({'app_config_id': original_ac_id}).all()
        assert len(moved_tickets) == 2
        assert len(original_tickets) == 1
        for ticket in moved_tickets:
            assert ticket.discussion_thread.app_config_id == ac_id
            assert ticket.discussion_thread.discussion.app_config_id == ac_id
            post = ticket.discussion_thread.last_post
            assert 'Ticket moved from /p/test/bugs/' in post.text
        for t in original_tickets:
            assert t.discussion_thread.app_config_id == original_ac_id
            assert t.discussion_thread.discussion.app_config_id == original_ac_id
            assert t.discussion_thread.last_post is None

    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_notifications(self):
        tickets = [
            tm.Ticket.query.find({'summary': 'A New Hope'}).first(),
            tm.Ticket.query.find(
                {'summary': 'The Empire Strikes Back'}).first(),
            tm.Ticket.query.find({'summary': 'Return Of The Jedi'}).first()]
        p = M.Project.query.get(shortname='test2')
        tracker = p.app_instance('bugs2')
        first_user = M.User.by_username('test-user-0')
        second_user = M.User.by_username('test-user-1')
        admin = M.User.by_username('test-admin')
        tickets[0].subscribe(user=first_user)
        tickets[1].subscribe(user=second_user)
        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/move_tickets', {
            'tracker': str(tracker.config._id),
            '__ticket_ids': [str(t._id) for t in tickets],
            '__search': '',
        })
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        assert len(emails) == 3
        for email in emails:
            assert (email.kwargs.subject ==
                    '[test:bugs] Mass ticket moving by Test Admin')
        first_user_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(first_user._id)
        }).all()
        assert len(first_user_email) == 1
        first_user_email = first_user_email[0]
        second_user_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(second_user._id)
        }).all()
        assert len(second_user_email) == 1
        second_user_email = second_user_email[0]
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        assert len(admin_email) == 1
        admin_email = admin_email[0]

        email_header = 'Tickets were moved from [test:bugs] to [test2:bugs2]\n'
        first_ticket_changes = 'A New Hope'
        second_ticket_changes = 'The Empire Strikes Back'
        third_ticket_changes = 'Return Of The Jedi'
        assert email_header in first_user_email.kwargs.text
        assert first_ticket_changes in first_user_email.kwargs.text
        assert email_header in second_user_email.kwargs.text
        assert second_ticket_changes in second_user_email.kwargs.text
        assert email_header in admin_email.kwargs.text
        assert first_ticket_changes in admin_email.kwargs.text
        assert second_ticket_changes in admin_email.kwargs.text
        assert third_ticket_changes in admin_email.kwargs.text
        # After tickets moved, user should see a flash
        mbox = M.Mailbox.query.get(user_id=admin._id, is_flash=True)
        notification_id = mbox.queue[-1]
        notification = M.Notification.query.get(_id=notification_id)
        assert (notification.text ==
                'Tickets moved from test/bugs to test2/bugs2')

    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_monitoring_email(self):
        self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': 'monitoring@email.com',
            'TicketMonitoringType': 'AllTicketChanges',
        })
        tickets = [
            tm.Ticket.query.find({'summary': 'A New Hope'}).first(),
            tm.Ticket.query.find(
                {'summary': 'The Empire Strikes Back'}).first(),
            tm.Ticket.query.find({'summary': 'Return Of The Jedi'}).first()]
        p = M.Project.query.get(shortname='test2')
        tracker = p.app_instance('bugs2')
        M.MonQTask.query.remove()
        self.app.post('/p/test/bugs/move_tickets', {
            'tracker': str(tracker.config._id),
            '__ticket_ids': [str(t._id) for t in tickets],
            '__search': '',
        })
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        assert len(emails) == 2
        for email in emails:
            assert (email.kwargs.subject ==
                    '[test:bugs] Mass ticket moving by Test Admin')
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(M.User.by_username('test-admin')._id)
        }).all()
        monitoring_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': 'monitoring@email.com'
        }).all()
        assert len(admin_email) == 1
        assert len(monitoring_email) == 1
        admin_email_text = admin_email[0].kwargs.text
        assert ('test:bugs:#1 --> test2:bugs2:#1 A New Hope' in
                admin_email_text)
        assert ('test:bugs:#2 --> test2:bugs2:#2 The Empire Strikes Back' in
                admin_email_text)
        assert ('test:bugs:#3 --> test2:bugs2:#3 Return Of The Jedi' in
                admin_email_text)
        monitoring_email_text = monitoring_email[0].kwargs.text
        assert ('test:bugs:#1 --> test2:bugs2:#1 A New Hope' in
                monitoring_email_text)
        assert ('test:bugs:#2 --> test2:bugs2:#2 The Empire Strikes Back' in
                monitoring_email_text)
        assert ('test:bugs:#3 --> test2:bugs2:#3 Return Of The Jedi' in
                monitoring_email_text)

    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_monitoring_email_public_only(self):
        """Test that private tickets are not included in bulk move
        notifications if the "public only" option is selected.
        """
        self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': 'monitoring@email.com',
            'TicketMonitoringType': 'AllPublicTicketChanges',
        })
        self.new_ticket(summary='test first ticket', status='open')
        self.new_ticket(summary='test second ticket', status='open', private=True)
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        tickets = [
            tm.Ticket.query.find({'summary': 'test first ticket'}).first(),
            tm.Ticket.query.find({'summary': 'test second ticket'}).first()]
        M.MonQTask.query.remove()
        p = M.Project.query.get(shortname='test2')
        tracker = p.app_instance('bugs2')
        self.app.post('/p/test/bugs/move_tickets', {
            'tracker': str(tracker.config._id),
            '__ticket_ids': [str(t._id) for t in tickets],
            '__search': '',
        })
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        # one for admin and one for monitoring email
        assert len(emails) == 2
        for email in emails:
            assert (email.kwargs.subject ==
                    '[test:bugs] Mass ticket moving by Test Admin')
        admin = M.User.by_username('test-admin')
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        monitoring_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': 'monitoring@email.com'
        }).all()
        assert len(admin_email) == 1
        assert len(monitoring_email) == 1
        admin_email_text = admin_email[0].kwargs.text
        monitoring_email_text = monitoring_email[0].kwargs.text
        assert 'second ticket' in admin_email_text
        assert 'second ticket' not in monitoring_email_text

    @td.with_tool('test2', 'Tickets', 'bugs2')
    def test_monitoring_email_all_private_moved(self):
        """Test that no monitoring email is sent if the "public only"
        option is selected, and only private tickets were moved.
        """
        self.app.post('/admin/bugs/set_options', params={
            'TicketMonitoringEmail': 'monitoring@email.com',
            'TicketMonitoringType': 'AllPublicTicketChanges',
        })
        self.new_ticket(summary='test first ticket', status='open', private=True)
        self.new_ticket(summary='test second ticket', status='open', private=True)
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        tickets = [
            tm.Ticket.query.find({'summary': 'test first ticket'}).first(),
            tm.Ticket.query.find({'summary': 'test second ticket'}).first()]
        M.MonQTask.query.remove()
        p = M.Project.query.get(shortname='test2')
        tracker = p.app_instance('bugs2')
        self.app.post('/p/test/bugs/move_tickets', {
            'tracker': str(tracker.config._id),
            '__ticket_ids': [str(t._id) for t in tickets],
            '__search': '',
        })
        M.MonQTask.run_ready()
        emails = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).all()
        assert len(emails) == 1  # only admin email sent
        for email in emails:
            assert (email.kwargs.subject ==
                    '[test:bugs] Mass ticket moving by Test Admin')
        admin = M.User.by_username('test-admin')
        admin_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': str(admin._id)
        }).all()
        monitoring_email = M.MonQTask.query.find({
            'task_name': 'allura.tasks.mail_tasks.sendmail',
            'kwargs.destinations': 'monitoring@email.com'
        }).all()
        assert len(admin_email) == 1
        assert len(monitoring_email) == 0


def sidebar_contains(response, text):
    sidebar_menu = response.html.find('div', attrs={'id': 'sidebar'})
    return text in str(sidebar_menu)


class TestStats(TrackerTestController):
    def test_stats(self):
        r = self.app.get('/bugs/stats/', status=200)
        assert '# tickets: 0' in r.text


class TestNotificationEmailGrouping(TrackerTestController):
    def test_new_ticket_message_id(self):
        self.new_ticket(summary='Test Ticket')
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        email = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).first()
        ticket = tm.Ticket.query.get(ticket_num=1)
        assert email.kwargs.message_id == ticket.message_id()
        assert email.kwargs.in_reply_to is None
        assert email.kwargs.references == []

    def test_comments(self):
        ticket_view = self.new_ticket(summary='Test Ticket').follow()
        ThreadLocalODMSession.flush_all()
        M.MonQTask.query.remove()
        ThreadLocalODMSession.flush_all()
        _, form = find(ticket_view.forms, lambda f: f.action.endswith('/post'))
        field, _ = find(form.fields, lambda f: f[0].tag == 'textarea')
        form.set(field, 'top-level comment')
        r = form.submit()
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        # Check that comment notification refers ticket's message id
        email = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).first()
        ticket = tm.Ticket.query.get(ticket_num=1)
        top_level_comment = ticket.discussion_thread.posts[0]
        top_level_comment_msg_id = ticket.url() + top_level_comment._id
        assert email.kwargs.message_id == top_level_comment_msg_id
        assert email.kwargs.in_reply_to == ticket.message_id()
        assert email.kwargs.references == [ticket.message_id()]

        ThreadLocalODMSession.flush_all()
        M.MonQTask.query.remove()
        ThreadLocalODMSession.flush_all()
        r = self.app.get('/bugs/1/')
        _, form = find(r.forms, lambda f: f.action.endswith('/reply'))
        field, _ = find(form.fields, lambda f: f[0].tag == 'textarea')
        reply_text = 'Reply to top-level-comment'
        form.set(field, reply_text)
        r = form.submit()
        ThreadLocalODMSession.flush_all()
        M.MonQTask.run_ready()
        ThreadLocalODMSession.flush_all()
        # Check that reply notification refers top-level comment's message id
        email = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendmail')).first()
        ticket = tm.Ticket.query.get(ticket_num=1)
        reply = [post for post in ticket.discussion_thread.posts if post.text == reply_text][0]
        assert email.kwargs.message_id == ticket.url() + reply._id
        assert email.kwargs.in_reply_to == top_level_comment_msg_id
        assert (email.kwargs.references ==
                [ticket.message_id(), top_level_comment_msg_id])


def test_status_passthru():
    setup_basic_test()
    c.project = M.Project.query.get(shortname='test')
    c.user = M.User.by_username('test-admin')
    c.project.install_app('tickets', mount_point='tsp',
                          open_status_names='foo bar', closed_status_names='qux baz')
    ThreadLocalODMSession.flush_all()
    app = c.project.app_instance('tsp')
    assert app.globals.set_of_open_status_names == {'foo', 'bar'}
    assert app.globals.set_of_closed_status_names == {'qux', 'baz'}
    assert 'open_status_names' not in app.config.options
    assert 'closed_status_names' not in app.config.options


class TestArtifactLinks(TrackerTestController):
    @td.with_tool('test', 'Tickets', 'features')
    def test_ambiguous_shortlinks(self):
        # Problem:
        # Two 'ticket' tools in one projects. Both have ticket #1.
        # When creating a link from within one of the tools to ticket #1 (e.g. [#1])
        # you may end up linking to another tool's ticket #1.
        # It depends on what shortlink was encountered first.
        # This test ensures this does not happening.

        project = M.Project.query.get(shortname='test')
        bugs = project.app_instance('bugs')
        features = project.app_instance('features')
        self.new_ticket('/bugs/', summary='Ticket 1 in bugs', _milestone='1.0').follow()
        self.new_ticket('/features/', summary='Ticket 1 in features', _milestone='1.0').follow()
        ticket_bugs = tm.Ticket.query.get(summary='Ticket 1 in bugs')
        ticket_features = tm.Ticket.query.get(summary='Ticket 1 in features')
        assert ticket_bugs.ticket_num == 1
        assert ticket_bugs.app.config._id == bugs.config._id
        assert ticket_features.ticket_num == 1
        assert ticket_features.app.config._id == features.config._id

        c.app = bugs
        link = '<div class="markdown_content"><p><a class="alink" href="/p/test/bugs/1/">[#1]</a></p></div>'
        assert g.markdown.convert('[#1]') == link

        c.app = features
        link = '<div class="markdown_content"><p><a class="alink" href="/p/test/features/1/">[#1]</a></p></div>'
        assert g.markdown.convert('[#1]') == link
