You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bloodhound.apache.org by ah...@apache.org on 2013/07/28 19:22:24 UTC

svn commit: r1507818 [1/3] - in /bloodhound/branches/bep_0007_embeddable_objects: ./ bloodhound_dashboard/ bloodhound_dashboard/bhdashboard/ bloodhound_dashboard/bhdashboard/widgets/ bloodhound_dashboard/bhdashboard/widgets/templates/ bloodhound_multip...

Author: ahorincar
Date: Sun Jul 28 17:22:23 2013
New Revision: 1507818

URL: http://svn.apache.org/r1507818
Log:
Merged trunk to bep_007_embeddable_objects branch

Added:
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/hooks.py
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_multiproduct/tests/hooks.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/tests/base.py
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_relations/bhrelations/tests/base.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/utils.py
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_relations/bhrelations/utils.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_register.html
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_register.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_reset_password.html
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_reset_password.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_user_table.html
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_user_table.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_verify_email.html
      - copied unchanged from r1507814, bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_verify_email.html
Removed:
    bloodhound/branches/bep_0007_embeddable_objects/DISCLAIMER
Modified:
    bloodhound/branches/bep_0007_embeddable_objects/   (props changed)
    bloodhound/branches/bep_0007_embeddable_objects/NOTICE
    bloodhound/branches/bep_0007_embeddable_objects/RELEASE_NOTES
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/product.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_product.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_relations.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/timeline.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/setup.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/dbcursor.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/env.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/hooks.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/model.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/ticket/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/setup.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/__init__.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/db/cursor.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/env.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/model.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/upgrade.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/api.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/templates/manage.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/tests/api.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/tests/notification.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/tests/search.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/tests/validation.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/tests/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/widgets/relations.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/setup.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/__init__.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/api.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/base.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/index_with_whoosh.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/query_parser.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/query_suggestion.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/real_index_view.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/search_resources/base.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/search_resources/changeset_search.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/search_resources/milestone_search.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/search_resources/ticket_search.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/search_resources/wiki_search.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/security.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/web_ui.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/bhsearch/tests/whoosh_backend.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_search/setup.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/htdocs/bloodhound.css
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/htdocs/js/theme.js
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_about.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_account_details.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_admin_products.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_admin_users.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_browser.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_login.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_milestone_edit.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_prefs_account.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_ticket.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_timeline.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bh_wiki_edit.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/templates/bloodhound_theme.html
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/bhtheme/theme.py
    bloodhound/branches/bep_0007_embeddable_objects/bloodhound_theme/setup.py
    bloodhound/branches/bep_0007_embeddable_objects/doc/Bloodhound logo.svg
    bloodhound/branches/bep_0007_embeddable_objects/installer/bloodhound_setup.py
    bloodhound/branches/bep_0007_embeddable_objects/trac/   (props changed)
    bloodhound/branches/bep_0007_embeddable_objects/trac/trac/wiki/tests/wiki-tests.txt

Propchange: bloodhound/branches/bep_0007_embeddable_objects/
------------------------------------------------------------------------------
  Merged /bloodhound/trunk:r1497828-1507814

Modified: bloodhound/branches/bep_0007_embeddable_objects/NOTICE
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/NOTICE?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/NOTICE (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/NOTICE Sun Jul 28 17:22:23 2013
@@ -27,4 +27,5 @@ developed by Trent Richardson
 
 LRU and LFU cache decorators - licensed under the PSF License
 This product includes code (LRU and LFU cache decorators) developed by
-Raymond Hettinger (http://code.activestate.com/recipes/498245-lru-and-lfu-cache-decorators/)
+Raymond Hettinger
+(http://code.activestate.com/recipes/498245-lru-and-lfu-cache-decorators/)

Modified: bloodhound/branches/bep_0007_embeddable_objects/RELEASE_NOTES
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/RELEASE_NOTES?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/RELEASE_NOTES (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/RELEASE_NOTES Sun Jul 28 17:22:23 2013
@@ -1,3 +1,23 @@
+0.6
+
+ * Added support for multiple products.
+ * Added support for ticket relations.
+ * Numerous improvements to the BloodhoundSearch module.
+ * Redesigned the Ticket page.
+ * Implemented Bootstrap templates for Timeline and AccountManagerPlugin.
+
+ * Not fixed for this release:
+  * No major outstanding issues
+
+0.5.3
+
+ * Removed reference to BloodhoundSearch docs in setup script, which was causing installation to fail.
+ * Updated installation document so that site-packages are inherited in the virtualenv.
+
+ * Not fixed for this release:
+   * No major outstanding issues
+
+
 0.5.2
 
  * Updated this file with 0.5.1 changes.

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/web_ui.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/web_ui.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/web_ui.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/web_ui.py Sun Jul 28 17:22:23 2013
@@ -224,7 +224,8 @@ class DashboardModule(Component):
                             'args': ['Timeline', None, {'args': {}}]
                         },
                     'products': {
-                            'args': ['Product', None, {'args': {'max': 3}}]
+                            'args': ['Product', None, {'args': {'max': 3, 
+                                                                'cols': 2}}]
                         },
                }
         }

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/product.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/product.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/product.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/product.py Sun Jul 28 17:22:23 2013
@@ -48,12 +48,11 @@ class ProductWidget(WidgetBase):
         """Return a dictionary containing arguments specification for
         the widget with specified name.
         """
-        return {
-                'max' : {
-                        'desc' : """Limit the number of products displayed""",
-                        'type' : int
-                    },
-            }
+        return {'max' : {'desc' : """Limit the number of products displayed""",
+                         'type' : int},
+                'cols' : {'desc' : """Number of columns""",
+                          'type' : int}
+                }
 
     get_widget_params = pretty_wrapper(get_widget_params, check_widget_name)
 
@@ -116,8 +115,8 @@ class ProductWidget(WidgetBase):
         data = {}
         req = context.req
         title = ''
-        params = ('max', )
-        max_, = self.bind_params(name, options, *params)
+        params = ('max', 'cols')
+        max_, cols = self.bind_params(name, options, *params)
 
         if not isinstance(req.perm.env, ProductEnvironment):
             for p in Product.select(self.env):
@@ -136,6 +135,9 @@ class ProductWidget(WidgetBase):
                     data.setdefault('product_list', []).append(p)
             title = _('Products')
 
+        data['colseq'] = itertools.cycle(xrange(cols - 1, -1, -1)) if cols \
+                         else itertools.repeat(1)
+
         return 'widget_product.html', \
             {
                 'title': title,

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html Sun Jul 28 17:22:23 2013
@@ -92,7 +92,7 @@
                       <!--! for the ticket listing -->
                       <py:when test="col in ('ticket', 'id')">
                         <td class="ticket" py:attrs="td_attrs">
-                          <a title="View ${row.resource.realm}" href="$row.href">#$cell.value</a>
+                          <a title="View ${row.resource.realm}" href="${row.href if row.href else url_of(row.resource)}">${shortname_of(row.resource)}</a>
                         </td>
                       </py:when>
   

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_product.html
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_product.html?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_product.html (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_product.html Sun Jul 28 17:22:23 2013
@@ -22,60 +22,63 @@
   xmlns:py="http://genshi.edgewall.org/"
   xmlns:xi="http://www.w3.org/2001/XInclude">
   <div py:if="product_list" class="row" id="products">
-    <div py:for="p in product_list" class="span4">
-      <div class="well product-well">
-        <h4>
-          &#9734; <a href="${req.href.products(p.prefix)}">$p.name ($p.prefix)</a>
-          <py:if test="p.owner_link">
-            <br />
-            <small>owned by
-              <a href="$p.owner_link">${authorinfo(p._data['owner']) if p._data['owner'] else _('(nobody)')}</a>
-            </small>
-          </py:if>
-        </h4>
-
-        <h5>Milestones</h5>
-        <py:choose test="">
-          <py:when test="p.milestones">
-            <ul class="subset">
-              <li py:for="m in p.milestones">
-                <a href="$m.url">$m.name<py:if test="m.ticket_count is not None"> ($m.ticket_count)</py:if></a>
-              </li>
-            </ul>
-          </py:when>
-          <py:otherwise>
-            (No milestones for this product)
-          </py:otherwise>
-        </py:choose>
-
-        <h5>Components</h5>
-        <py:choose test="">
-          <py:when test="p.components">
-            <ul class="subset">
-              <li py:for="c in p.components">
-                <a href="$c.url">$c.name<py:if test="c.ticket_count is not None"> ($c.ticket_count)</py:if></a>
-              </li>
-            </ul>
-          </py:when>
-          <py:otherwise>
-            (No components for this product)
-          </py:otherwise>
-        </py:choose>
-
-        <h5>Versions</h5>
-        <py:choose test="">
-          <py:when test="p.versions">
-            <ul class="subset">
-              <li py:for="v in p.versions">
-                <a href="$v.url">$v.name<py:if test="v.ticket_count is not None"> ($v.ticket_count)</py:if></a>
-              </li>
-            </ul>
-          </py:when>
-          <py:otherwise>
-            (No versions for this product)
-          </py:otherwise>
-        </py:choose>
+    <py:for each="i, p in zip(colseq, product_list)">
+      <div class="span4">
+        <div class="well product-well">
+          <h4>
+            &#9734; <a href="${req.href.products(p.prefix)}">$p.name ($p.prefix)</a>
+            <py:if test="p.owner_link">
+              <br />
+              <small>owned by
+                <a href="$p.owner_link">${authorinfo(p._data['owner']) if p._data['owner'] else _('(nobody)')}</a>
+              </small>
+            </py:if>
+          </h4>
+  
+          <h5>Milestones</h5>
+          <py:choose test="">
+            <py:when test="p.milestones">
+              <ul class="subset">
+                <li py:for="m in p.milestones">
+                  <a href="$m.url">$m.name<py:if test="m.ticket_count is not None"> ($m.ticket_count)</py:if></a>
+                </li>
+              </ul>
+            </py:when>
+            <py:otherwise>
+              (No milestones for this product)
+            </py:otherwise>
+          </py:choose>
+  
+          <h5>Components</h5>
+          <py:choose test="">
+            <py:when test="p.components">
+              <ul class="subset">
+                <li py:for="c in p.components">
+                  <a href="$c.url">$c.name<py:if test="c.ticket_count is not None"> ($c.ticket_count)</py:if></a>
+                </li>
+              </ul>
+            </py:when>
+            <py:otherwise>
+              (No components for this product)
+            </py:otherwise>
+          </py:choose>
+  
+          <h5>Versions</h5>
+          <py:choose test="">
+            <py:when test="p.versions">
+              <ul class="subset">
+                <li py:for="v in p.versions">
+                  <a href="$v.url">$v.name<py:if test="v.ticket_count is not None"> ($v.ticket_count)</py:if></a>
+                </li>
+              </ul>
+            </py:when>
+            <py:otherwise>
+              (No versions for this product)
+            </py:otherwise>
+          </py:choose>
+        </div>
       </div>
-    </div>
+      <div class="clearfix" py:if="i == 0" />
+    </py:for>
   </div>
 </div>

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_relations.html
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_relations.html?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_relations.html (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_relations.html Sun Jul 28 17:22:23 2013
@@ -1,64 +1,64 @@
-<!--!
-  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.
--->
-
-<div
-  xmlns="http://www.w3.org/1999/xhtml"
-  xmlns:py="http://genshi.edgewall.org/"
-  xmlns:xi="http://www.w3.org/2001/XInclude">
-
-  <py:choose test="">
-    <py:when test="relations">
-      <table class="table table-condensed table-bordered">
-        <thead>
-          <tr>
-            <th>Type</th><th>Product</th><th>Ticket</th><th>Comment</th><th>Author</th><th class="hidden-phone">Changed</th>
-          </tr>
-        </thead>
-
-        <tbody py:for="relgroup,items in relations.iteritems()">
-          <tr py:for="item in items" class="relation">
-            <td>${relgroup if items.index(item) == 0 else None}</td>
-            <td>
-              <a href="${href.products(item['destticket'].env.product.prefix)}">
-                <span class="hidden-phone">${item['destticket'].env.product.name} (${item['destticket'].env.product.prefix})</span>
-                <span class="visible-phone">${item['destticket'].env.product.prefix}</span>
-              </a>
-            </td>
-            <td><a href="${item['desthref']}">#${item['destticket'].id}</a> - ${item['destticket'].summary}</td>
-            <td>$item.comment</td>
-            <td>$item.author</td>
-            <td class="hidden-phone">${pretty_dateinfo(item.when)}</td>
-          </tr>
-        </tbody>
-      </table>
-    </py:when>
-    <py:otherwise>
-      <div class="alert alert-info">
-        No defined relations for this ticket.
-      </div>
-    </py:otherwise>
-  </py:choose>
-
-  <div class="btn-group">
-    <form method="get" action="${href.ticket(ticket.id, 'relations')}">
-      <button type="submit" class="btn" id="manage-relations"><i class="icon-retweet"></i> Manage relations</button>
-    </form>
-  </div>
-</div>
-
+<!--!
+  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.
+-->
+
+<div
+  xmlns="http://www.w3.org/1999/xhtml"
+  xmlns:py="http://genshi.edgewall.org/"
+  xmlns:xi="http://www.w3.org/2001/XInclude">
+
+  <py:choose test="">
+    <py:when test="relations">
+      <table class="table table-condensed table-bordered">
+        <thead>
+          <tr>
+            <th>Type</th><th>Product</th><th>Ticket</th><th>Comment</th><th>Author</th><th class="hidden-phone">Changed</th>
+          </tr>
+        </thead>
+
+        <tbody py:for="relgroup,items in relations.iteritems()">
+          <tr py:for="item in items" class="relation">
+            <td>${relgroup if items.index(item) == 0 else None}</td>
+            <td>
+              <a href="${href.products(item['destticket'].env.product.prefix)}">
+                <span class="hidden-phone">${item['destticket'].env.product.name} (${item['destticket'].env.product.prefix})</span>
+                <span class="visible-phone">${item['destticket'].env.product.prefix}</span>
+              </a>
+            </td>
+            <td><a href="${item['desthref']}">#${item['destticket'].id}</a> - ${item['destticket'].summary}</td>
+            <td>$item.comment</td>
+            <td>$item.author</td>
+            <td class="hidden-phone">${pretty_dateinfo(item.when)}</td>
+          </tr>
+        </tbody>
+      </table>
+    </py:when>
+    <py:otherwise>
+      <div class="alert alert-info">
+        No defined relations for this ticket.
+      </div>
+    </py:otherwise>
+  </py:choose>
+
+  <div class="btn-group">
+    <form method="get" action="${href.ticket(ticket.id, 'relations')}">
+      <button type="submit" class="btn" id="manage-relations"><i class="icon-retweet"></i> Manage relations</button>
+    </form>
+  </div>
+</div>
+

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html Sun Jul 28 17:22:23 2013
@@ -21,7 +21,9 @@
   xmlns="http://www.w3.org/1999/xhtml"
   xmlns:py="http://genshi.edgewall.org/"
   xmlns:xi="http://www.w3.org/2001/XInclude"
-  py:with="today = format_date(today); yesterday = format_date(yesterday)"
+  py:with="now = datetime.now(req.tz);
+           today = format_date(now);
+           yesterday = format_date(now - timedelta(days=1))"
   py:choose="">
 
   <table py:when="events"

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/timeline.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/timeline.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/timeline.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/bhdashboard/widgets/timeline.py Sun Jul 28 17:22:23 2013
@@ -39,7 +39,7 @@ from trac.ticket.api import TicketSystem
 from trac.ticket.model import Ticket
 from trac.ticket.web_ui import TicketModule
 from trac.util.datefmt import utc
-from trac.util.translation import _
+from trac.util.translation import _, tag_
 from trac.web.chrome import add_stylesheet
 
 from bhdashboard.api import DateField, EnumField, ListField
@@ -50,6 +50,7 @@ from bhdashboard.util import WidgetBase,
 
 __metaclass__ = type
 
+
 class ITimelineEventsFilter(Interface):
     """Filter timeline events displayed in a rendering context
     """
@@ -71,11 +72,12 @@ class ITimelineEventsFilter(Interface):
                   `NotImplemented` if the filter doesn't care about it.
         """
 
+
 class TimelineWidget(WidgetBase):
     """Display activity feed.
     """
     default_count = IntOption('widget_activity', 'limit', 25, 
-                        """Maximum number of items displayed by default""")
+        """Maximum number of items displayed by default""")
 
     event_filters = ExtensionPoint(ITimelineEventsFilter)
 
@@ -101,36 +103,36 @@ class TimelineWidget(WidgetBase):
         the widget with specified name.
         """
         return {
-                'from' : {
-                        'desc' : """Display events before this date""",
-                        'type' : DateField(), # TODO: Custom datetime format
-                    },
-                'daysback' : {
-                        'desc' : """Event time window""",
-                        'type' : int, 
-                    },
-                'precision' : {
-                        'desc' : """Time precision""",
-                        'type' : EnumField('second', 'minute', 'hour')
-                    },
-                'doneby' : {
-                        'desc' : """Filter events related to user""",
-                    },
-                'filters' : {
-                        'desc' : """Event filters""",
-                        'type' : ListField()
-                    },
-                'max' : {
-                        'desc' : """Limit the number of events displayed""",
-                        'type' : int
-                    },
-                'realm' : {
-                        'desc' : """Resource realm. Used to filter events""",
-                    },
-                'id' : {
-                        'desc' : """Resource ID. Used to filter events""",
-                    },
-            }
+            'from': {
+                'desc': """Display events before this date""",
+                'type': DateField(),  # TODO: Custom datetime format
+            },
+            'daysback': {
+                'desc': """Event time window""",
+                'type': int,
+            },
+            'precision': {
+                'desc': """Time precision""",
+                'type': EnumField('second', 'minute', 'hour')
+            },
+            'doneby': {
+                'desc': """Filter events related to user""",
+            },
+            'filters': {
+                'desc': """Event filters""",
+                'type': ListField()
+            },
+            'max': {
+                'desc': """Limit the number of events displayed""",
+                'type': int
+            },
+            'realm': {
+                'desc': """Resource realm. Used to filter events""",
+            },
+            'id': {
+                'desc': """Resource ID. Used to filter events""",
+            },
+        }
     get_widget_params = pretty_wrapper(get_widget_params, check_widget_name)
 
     def render_widget(self, name, context, options):
@@ -140,28 +142,34 @@ class TimelineWidget(WidgetBase):
         req = context.req
         try:
             timemdl = self.env[TimelineModule]
-            if timemdl is None :
+            admin_page = tag.a(_("administration page."),
+                               title=_("Plugin Administration Page"),
+                               href=req.href.admin('general/plugin'))
+            if timemdl is None:
                 return 'widget_alert.html', {
                     'title':  _("Activity"),
                     'data': {
                         'msglabel': "Warning",
-                        'msgbody': _("TimelineWidget is disabled because the "
-                                     "Timeline component is not available. "
-                                     "Is the component disabled?"),
+                        'msgbody':
+                            tag_("The TimelineWidget is disabled because the "
+                                 "Timeline component is not available. "
+                                  "Is the component disabled? "
+                                  "You can enable from the %(page)s",
+                                  page=admin_page),
                         'dismiss': False,
                     }
                 }, context
 
-            params = ('from', 'daysback', 'doneby', 'precision', 'filters', \
-                        'max', 'realm', 'id')
+            params = ('from', 'daysback', 'doneby', 'precision', 'filters',
+                      'max', 'realm', 'id')
             start, days, user, precision, filters, count, realm, rid = \
-                    self.bind_params(name, options, *params)
+                self.bind_params(name, options, *params)
             if context.resource.realm == 'ticket':
                 if days is None:
                     # calculate a long enough time daysback
                     ticket = Ticket(self.env, context.resource.id)
-                    ticketage = datetime.now(utc) - ticket.time_created
-                    days = ticketage.days + 1
+                    ticket_age = datetime.now(utc) - ticket.time_created
+                    days = ticket_age.days + 1
                 if count is None:
                     # ignore short count for ticket feeds
                     count = 0
@@ -170,12 +178,12 @@ class TimelineWidget(WidgetBase):
 
             fakereq = dummy_request(self.env, req.authname)
             fakereq.args = {
-                    'author' : user or '',
-                    'daysback' : days or '',
-                    'max' : count,
-                    'precision' : precision,
-                    'user' : user
-                }
+                'author': user or '',
+                'daysback': days or '',
+                'max': count,
+                'precision': precision,
+                'user': user
+            }
             if filters:
                 fakereq.args.update(dict((k, True) for k in filters))
             if start is not None:
@@ -191,12 +199,12 @@ class TimelineWidget(WidgetBase):
                     wcontext.req = req
                 else:
                     self.log.warning("TimelineWidget: Resource %s not found",
-                            resource)
+                                     resource)
             # FIXME: Filter also if existence check is not conclusive ?
             if resource_exists(self.env, wcontext.resource):
                 module = FilteredTimeline(self.env, wcontext)
-                self.log.debug('Filtering timeline events for %s', \
-                        wcontext.resource)
+                self.log.debug('Filtering timeline events for %s',
+                               wcontext.resource)
             else:
                 module = timemdl
             data = module.process_request(fakereq)[1]
@@ -207,23 +215,19 @@ class TimelineWidget(WidgetBase):
         else:
             merge_links(srcreq=fakereq, dstreq=req,
                         exclude=["stylesheet", "alternate"])
-            data['today'] = today = datetime.now(req.tz)
-            data['yesterday'] = today - timedelta(days=1)
             if 'context' in data:
                 # Needed for abbreviated messages in widget events (#340)
                 wcontext.set_hints(**(data['context']._hints or {}))
             data['context'] = wcontext
-            return 'widget_timeline.html', \
-                    {
-                        'title' : _('Activity'),
-                        'data' : data, 
-                        'altlinks' : fakereq.chrome.get('links',
-                                                        {}).get('alternate')
-                    }, \
-                    context
+            return 'widget_timeline.html', {
+                'title': _('Activity'),
+                'data': data,
+                'altlinks': fakereq.chrome.get('links', {}).get('alternate')
+            }, context
 
     render_widget = pretty_wrapper(render_widget, check_widget_name)
 
+
 class FilteredTimeline:
     """This is a class (not a component ;) aimed at overriding some parts of
     TimelineModule without patching it in order to inject code needed to filter
@@ -250,7 +254,7 @@ class FilteredTimeline:
     @property
     def max_daysback(self):
         return (-1 if self.context.resource.realm == 'ticket'
-                   else self._max_daysback)
+                else self._max_daysback)
 
     @property
     def event_providers(self):
@@ -268,11 +272,12 @@ class FilteredTimeline:
             if isinstance(value, MethodType):
                 raise AttributeError()
         except AttributeError:
-            raise AttributeError("'%s' object has no attribute '%s'" % \
-                    (self.__class__.__name__, attrnm))
+            raise AttributeError("'%s' object has no attribute '%s'"
+                                 % (self.__class__.__name__, attrnm))
         else:
             return value
 
+
 class TimelineFilterAdapter:
     """Wrapper class used to filter timeline event streams transparently.
     Therefore it is compatible with `ITimelineEventProvider` interface 
@@ -289,9 +294,9 @@ class TimelineFilterAdapter:
 
     def get_timeline_filters(self, req):
         gen = self.provider.get_timeline_filters(req)
-        if (self.context.resource.realm == 'ticket' and
-            isinstance(self.provider, TicketModule) and
-            'TICKET_VIEW' in req.perm):
+        if self.context.resource.realm == 'ticket' and \
+                isinstance(self.provider, TicketModule) and \
+                'TICKET_VIEW' in req.perm:
             # ensure ticket_details appears once if this is a query on a ticket
             gen = list(gen)
             if not [g for g in gen if g[0] == 'ticket_details']:
@@ -305,16 +310,16 @@ class TimelineFilterAdapter:
         """
         filters_map = TimelineWidget(self.env).filters_map
         evfilters = filters_map.get(self.provider.__class__.__name__, []) + \
-                filters_map.get(None, [])
+            filters_map.get(None, [])
         self.log.debug('Applying filters %s for %s against %s', evfilters, 
-                self.context.resource, self.provider)
+                       self.context.resource, self.provider)
         if evfilters:
             for event in self.provider.get_timeline_events(
                     req, start, stop, filters):
                 match = False
                 for f in evfilters:
                     new_event = f.filter_event(self.context, self.provider,
-                            event, filters)
+                                               event, filters)
                     if new_event is None:
                         event = None
                         match = True
@@ -338,11 +343,12 @@ class TimelineFilterAdapter:
         try:
             value = getattr(self.provider, attrnm)
         except AttributeError:
-            raise AttributeError("'%s' object has no attribute '%s'" % \
-                    (self.__class__.__name__, attrnm))
+            raise AttributeError("'%s' object has no attribute '%s'"
+                                 % (self.__class__.__name__, attrnm))
         else:
             return value
 
+
 class TicketFieldTimelineFilter(Component):
     """A class filtering ticket events related to a given resource
     associated via ticket fields.
@@ -355,8 +361,8 @@ class TicketFieldTimelineFilter(Componen
         """
         field_names = getattr(self, '_fields', None)
         if field_names is None:
-            self._fields = set(f['name'] \
-                    for f in TicketSystem(self.env).get_ticket_fields())
+            self._fields = set(f['name'] for f in
+                               TicketSystem(self.env).get_ticket_fields())
         return self._fields
 
     # ITimelineEventsFilter methods
@@ -379,7 +385,7 @@ class TicketFieldTimelineFilter(Componen
                     ticket_ids = event[3][0]
                 except:
                     self.log.exception('Unknown ticket event %s ... [SKIP]',
-                            event)
+                                       event)
                     return None
 
                 if not isinstance(ticket_ids, list):

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/setup.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/setup.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/setup.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_dashboard/setup.py Sun Jul 28 17:22:23 2013
@@ -35,6 +35,7 @@ versions = [
     (0, 4, 0),
     (0, 5, 0),
     (0, 6, 0),
+    (0, 7, 0),
     ]
     
 latest = '.'.join(str(x) for x in versions[-1])
@@ -75,13 +76,6 @@ cats = [
       "Topic :: Software Development :: Widget Sets"
     ]
 
-# Be compatible with older versions of Python
-from sys import version
-if version < '2.2.3':
-    from distutils.dist import DistributionMetadata
-    DistributionMetadata.classifiers = None
-    DistributionMetadata.download_url = None
-
 # Add the change log to the package description.
 chglog = None
 try:
@@ -136,7 +130,7 @@ setup(
     description=DESC.split('\n', 1)[0],
     author = "Apache Bloodhound",
     license = "Apache License v2",
-    url = "http://incubator.apache.org/bloodhound/",
+    url = "https://bloodhound.apache.org/",
     requires = ['trac'],
     tests_require = ['dutest>=0.2.4', 'TracXMLRPC'],
     install_requires = [

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/dbcursor.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/dbcursor.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/dbcursor.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/dbcursor.py Sun Jul 28 17:22:23 2013
@@ -41,7 +41,7 @@ TRANSLATE_TABLES = ['system',
                     'permission',
                     'wiki',
                     'report',
-                   ]
+                    ]
 PRODUCT_COLUMN = 'product'
 GLOBAL_PRODUCT = ''
 
@@ -533,6 +533,11 @@ class BloodhoundProductSQLTranslate(obje
                 ptoken = self._token_next(columns_token, ptoken)
                 last_token = ptoken
                 while ptoken:
+                    if isinstance(ptoken, Types.IdentifierList):
+                        if any(i.get_name() == 'product'
+                               for i in ptoken.get_identifiers()
+                               if isinstance(i, Types.Identifier)):
+                            return True
                     last_token = ptoken
                     ptoken = self._token_next(columns_token, ptoken)
                 if not last_token or \
@@ -540,7 +545,7 @@ class BloodhoundProductSQLTranslate(obje
                     raise Exception("Invalid INSERT statement, unable to find column parenthesis end")
                 for keyword in [',', ' ', self._product_column]:
                     self._token_insert_before(columns_token, last_token, Types.Token(Tokens.Keyword, keyword))
-            return
+            return False
         def insert_extra_column_value(tablename, ptoken, before_token):
             if tablename in self._translate_tables:
                 for keyword in [',', "'", self._product_prefix, "'"]:
@@ -548,6 +553,7 @@ class BloodhoundProductSQLTranslate(obje
             return
         tablename = None
         table_name_token = self._token_next(parent, token)
+        has_product_column = False
         if isinstance(table_name_token, Types.Function):
             token = self._token_first(table_name_token)
             if isinstance(token, Types.Identifier):
@@ -556,7 +562,7 @@ class BloodhoundProductSQLTranslate(obje
                 if columns_token.match(Tokens.Keyword, 'VALUES'):
                     token = columns_token
                 else:
-                    insert_extra_column(tablename, columns_token)
+                    has_product_column = insert_extra_column(tablename, columns_token)
                     token = self._token_next(parent, table_name_token)
         else:
             tablename = table_name_token.value
@@ -564,9 +570,11 @@ class BloodhoundProductSQLTranslate(obje
             if columns_token.match(Tokens.Keyword, 'VALUES'):
                 token = columns_token
             else:
-                insert_extra_column(tablename, columns_token)
+                has_product_column = insert_extra_column(tablename, columns_token)
                 token = self._token_next(parent, columns_token)
-        if token.match(Tokens.Keyword, 'VALUES'):
+        if has_product_column:
+            pass  # INSERT already has product, no translation needed
+        elif token.match(Tokens.Keyword, 'VALUES'):
             separators = [',', '(', ')']
             token = self._token_next(parent, token)
             while token:

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/env.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/env.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/env.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/env.py Sun Jul 28 17:22:23 2013
@@ -405,7 +405,7 @@ class ProductEnvironment(Component, Comp
         """
         return ''
 
-    base_url = Option('trac', 'base_url', '',
+    _base_url = Option('trac', 'base_url', '',
         """Reference URL for the Trac deployment.
         
         This is the base URL that will be used when producing
@@ -413,7 +413,14 @@ class ProductEnvironment(Component, Comp
         context, like for example when inserting URLs pointing to Trac
         resources in notification e-mails.""")
 
-    base_url_for_redirect = BoolOption('trac', 'use_base_url_for_redirect',
+    @property
+    def base_url(self):
+        base_url = self._base_url
+        if base_url == self.parent.base_url:
+            return ''
+        return base_url
+
+    _base_url_for_redirect = BoolOption('trac', 'use_base_url_for_redirect',
             False, 
         """Optionally use `[trac] base_url` for redirects.
         

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/hooks.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/hooks.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/hooks.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/hooks.py Sun Jul 28 17:22:23 2013
@@ -30,7 +30,10 @@ from trac.web.href import Href
 from trac.web.main import RequestWithSession
 
 PRODUCT_RE = re.compile(r'^/products(?:/(?P<pid>[^/]*)(?P<pathinfo>.*))?')
-REDIRECT_DEFAULT_RE = re.compile(r'^/(?P<section>milestone|roadmap|query|report|newticket|ticket|qct|timeline|(raw-|zip-)?attachment|diff|batchmodify|search)(?P<pathinfo>.*)')
+REDIRECT_DEFAULT_RE = \
+    re.compile(r'^/(?P<section>milestone|roadmap|query|report|newticket|'
+               r'ticket|qct|timeline|diff|batchmodify|search|'
+               r'(raw-|zip-)?attachment/(ticket|milestone))(?P<pathinfo>.*)')
 
 
 class MultiProductEnvironmentFactory(EnvironmentFactoryBase):
@@ -100,10 +103,11 @@ class ProductizedHref(Href):
         self._global_href = global_href
 
     def __call__(self, *args, **kwargs):
-        if args:
+        if args and isinstance(args[0], basestring):
             if args[0] in self.PATHS_NO_TRANSFORM or \
-               (len(args) == 1 and args[0] == 'admin') or \
-               filter(lambda x: args[0].startswith(x), self.STATIC_PREFIXES):
+                    (len(args) == 1 and args[0] == 'admin') or \
+                    filter(lambda x: args[0].startswith(x),
+                           self.STATIC_PREFIXES):
                 return self._global_href(*args, **kwargs)
         return self.super.__call__(*args, **kwargs)
 

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/model.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/model.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/model.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/model.py Sun Jul 28 17:22:23 2013
@@ -81,7 +81,11 @@ class Product(ModelBase):
     @classmethod
     def get_tickets(cls, env, product=''):
         """Retrieve all tickets associated with the product."""
-        q = Query.from_string(env, 'product=%s' % product)
+        from multiproduct.ticket.query import ProductQuery
+        from multiproduct.env import ProductEnvironment
+        if not product and isinstance(env, ProductEnvironment):
+            product = env.product.prefix
+        q = ProductQuery.from_string(env, 'product=%s' % product)
         return q.execute()
 
 class ProductResourceMap(ModelBase):

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/ticket/web_ui.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/ticket/web_ui.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/ticket/web_ui.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/ticket/web_ui.py Sun Jul 28 17:22:23 2013
@@ -54,8 +54,7 @@ class ProductTicketModule(TicketModule):
         productid = req.args.get('productid','')
         
         if ticketid:
-            if (req.path_info == '/products/' + productid + '/newticket' or
-                req.path_info == '/products'):
+            if req.path_info in ('/newticket', '/products'):
                 raise TracError(_("id can't be set for a new ticket request."))
             ticket = Ticket(self.env, ticketid)
             if productid and ticket['product'] != productid:

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/web_ui.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/web_ui.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/web_ui.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/multiproduct/web_ui.py Sun Jul 28 17:22:23 2013
@@ -99,7 +99,6 @@ class ProductModule(Component):
 
     def _render_list(self, req):
         """products list"""
-        print "Rendering list"
         products = [p for p in Product.select(self.env)
                     if 'PRODUCT_VIEW' in req.perm(Neighborhood('product',
                                                                p.prefix))]

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/setup.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/setup.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/setup.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/setup.py Sun Jul 28 17:22:23 2013
@@ -17,15 +17,17 @@
 #  under the License.
 
 """setup for multi product plugin"""
+import sys
+from pkg_resources import parse_version
 from setuptools import setup
 
 setup(
     name = 'BloodhoundMultiProduct',
-    version = '0.6.0',
+    version = '0.7.0',
     description = "Multiproduct support for Apache(TM) Bloodhound.",
     author = "Apache Bloodhound",
     license = "Apache License v2",
-    url = "http://incubator.apache.org/bloodhound/",
+    url = "https://bloodhound.apache.org/",
     packages = ['multiproduct', 'multiproduct.ticket', 'tests',],
     package_data = {'multiproduct' : ['templates/*.html',]},
     entry_points = {'trac.plugins': [
@@ -37,5 +39,6 @@ setup(
             'multiproduct.web_ui = multiproduct.web_ui',
         ],},
     test_suite='tests.test_suite',
+    tests_require=['unittest2' if parse_version(sys.version) < parse_version('2.7') else '']
 )
 

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/__init__.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/__init__.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/__init__.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/__init__.py Sun Jul 28 17:22:23 2013
@@ -79,4 +79,5 @@ class TestLoader(unittest.TestLoader):
 def test_suite():
     return TestLoader().discover_package('tests', pattern='*.py')
 
-
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/db/cursor.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/db/cursor.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/db/cursor.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/db/cursor.py Sun Jul 28 17:22:23 2013
@@ -987,7 +987,14 @@ data = {
 """create temporary table table_old as select * from table""",
 """create temporary table "PRODUCT_table_old" as select * from (SELECT * FROM "PRODUCT_table") AS table""",
         )
-    ]
+    ],
+    # insert with specified product (#601)
+    'insert_with_product': [
+        (
+"""INSERT INTO ticket (summary, product) VALUES ('S', 'swlcu')""",
+"""INSERT INTO ticket (summary, product) VALUES ('S', 'swlcu')"""
+        ),
+    ],
 
 }
 
@@ -1045,6 +1052,9 @@ class DbCursorTestCase(unittest.TestCase
     def test_lowercase_tokens(self):
         self._run_test('lowercase_tokens')
 
+    def test_insert_with_product(self):
+        self._run_test('insert_with_product')
+
 if __name__ == '__main__':
     unittest.main()
 

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/env.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/env.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/env.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/env.py Sun Jul 28 17:22:23 2013
@@ -42,6 +42,7 @@ from trac.tests.env import EnvironmentTe
 from trac.ticket.report import ReportModule
 from trac.ticket.web_ui import TicketModule
 from trac.util.text import to_unicode
+from trac.web.href import Href
 
 from multiproduct.api import MultiProductSystem
 from multiproduct.env import ProductEnvironment
@@ -208,35 +209,38 @@ class MultiproductTestCase(unittest.Test
             env._log_handler.close()
             del env._log_handler
 
-    def _load_product_from_data(self, env, prefix):
+    @classmethod
+    def _load_product_from_data(cls, env, prefix):
         r"""Ensure test product with prefix is loaded
         """
         # TODO: Use fixtures implemented in #314
-        product_data = self.PRODUCT_DATA[prefix]
+        product_data = cls.PRODUCT_DATA[prefix]
         prefix = to_unicode(prefix)
         product = Product(env)
         product._data.update(product_data)
         product.insert()
 
-    def _upgrade_mp(self, env):
+    @classmethod
+    def _upgrade_mp(cls, env):
         r"""Apply multi product upgrades
         """
         # Do not break wiki parser ( see #373 )
         env.disable_component(TicketModule)
         env.disable_component(ReportModule)
 
-        self.mpsystem = MultiProductSystem(env)
+        mpsystem = MultiProductSystem(env)
         try:
-            self.mpsystem.upgrade_environment(env.db_transaction)
+            mpsystem.upgrade_environment(env.db_transaction)
         except OperationalError:
             # Database is upgraded, but database version was deleted.
             # Complete the upgrade by inserting default product.
-            self.mpsystem._insert_default_product(env.db_transaction)
+            mpsystem._insert_default_product(env.db_transaction)
         # assume that the database schema has been upgraded, enable
         # multi-product schema support in environment
         env.enable_multiproduct_schema(True)
 
-    def _load_default_data(self, env):
+    @classmethod
+    def _load_default_data(cls, env):
         r"""Initialize environment with default data by respecting
         values set in system table.
         """
@@ -555,9 +559,11 @@ class ProductEnvHrefTestCase(Multiproduc
     def setUp(self):
         self._mp_setup()
         self.env.path = '/path/to/env'
+        self.env.abs_href = Href('http://globalenv.com/trac.cgi')
         url_pattern = getattr(getattr(self, self._testMethodName).im_func,
                               'product_base_url', '')
         self.env.config.set('multiproduct', 'product_base_url', url_pattern)
+        self.env.config.set('trac', 'base_url', 'http://globalenv.com/trac.cgi')
         self.product_env = ProductEnvironment(self.env, self.default_product)
 
     def tearDown(self):
@@ -582,21 +588,28 @@ class ProductEnvHrefTestCase(Multiproduc
     def test_href_sibling_paths(self):
         """Test product base URL at sibling paths
         """
-        self.assertEqual('http://example.org/trac.cgi/path/to/bloodhound/tp1', 
+        self.assertEqual('http://globalenv.com/trac.cgi/path/to/bloodhound/tp1',
                          self.product_env.abs_href())
 
     @product_base_url('/$(envname)s/$(prefix)s')
     def test_href_inherit_sibling_paths(self):
         """Test product base URL at sibling paths inheriting configuration.
         """
-        self.assertEqual('http://example.org/trac.cgi/env/tp1', 
+        self.assertEqual('http://globalenv.com/trac.cgi/env/tp1',
+                         self.product_env.abs_href())
+
+    @product_base_url('')
+    def test_href_default(self):
+        """Test product base URL is to a default
+        """
+        self.assertEqual('http://globalenv.com/trac.cgi/products/tp1',
                          self.product_env.abs_href())
 
     @product_base_url('/products/$(prefix)s')
     def test_href_embed(self):
         """Test default product base URL /products/prefix
         """
-        self.assertEqual('http://example.org/trac.cgi/products/tp1', 
+        self.assertEqual('http://globalenv.com/trac.cgi/products/tp1',
                          self.product_env.abs_href())
 
     @product_base_url('http://$(envname)s.tld/bh/$(prefix)s')
@@ -605,6 +618,46 @@ class ProductEnvHrefTestCase(Multiproduc
         """
         self.assertEqual('http://env.tld/bh/tp1', self.product_env.abs_href())
 
+    @product_base_url('http://$(prefix)s.$(envname)s.tld/')
+    def test_product_href_uses_multiproduct_product_base_url(self):
+        """Test that [multiproduct] product_base_url is used to compute
+        abs_href for the product environment when [trac] base_url for
+        the product environment is an empty string (the default).
+        """
+        # Global URLs
+        self.assertEqual('http://globalenv.com/trac.cgi', self.env.base_url)
+        self.assertEqual('http://globalenv.com/trac.cgi', self.env.abs_href())
+
+        # Product URLs
+        self.assertEqual('', self.product_env.base_url)
+        self.assertEqual('http://tp1.env.tld', self.product_env.abs_href())
+
+    @product_base_url('http://$(prefix)s.$(envname)s.tld/')
+    def test_product_href_uses_products_base_url(self):
+        """Test that [trac] base_url for the product environment is used to
+        compute abs_href for the product environment when [trac] base_url
+        for the product environment is different than [trac] base_url for
+        the global environment.
+        """
+        self.product_env.config.set('trac', 'base_url', 'http://productenv.com')
+        self.product_env.config.save()
+
+        self.assertEqual('http://productenv.com', self.product_env.base_url)
+        self.assertEqual('http://productenv.com', self.product_env.abs_href())
+
+    @product_base_url('http://$(prefix)s.$(envname)s.tld/')
+    def test_product_href_global_and_product_base_urls_same(self):
+        """Test that [multiproduct] product_base_url is used to compute
+        abs_href for the product environment when [trac] base_url is the same
+        for the product and global environment.
+        """
+        self.product_env.config.set('trac', 'base_url',
+                                    self.env.config.get('trac', 'base_url'))
+        self.product_env.config.save()
+
+        self.assertEqual('', self.product_env.base_url)
+        self.assertEqual('http://tp1.env.tld', self.product_env.abs_href())
+
     product_base_url = staticmethod(product_base_url)
 
 

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/model.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/model.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/model.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/model.py Sun Jul 28 17:22:23 2013
@@ -30,11 +30,16 @@ from sqlite3 import OperationalError
 
 from trac.test import EnvironmentStub
 from trac.core import TracError
+from trac.ticket.model import Ticket
 
+from multiproduct.env import ProductEnvironment
 from multiproduct.model import Product
+from bhdashboard.model import ModelBase
+
 from multiproduct.api import MultiProductSystem
 from trac.tests.resource import TestResourceChangeListener
 
+
 class ProductTestCase(unittest.TestCase):
     """Unit tests covering the Product model"""
     INITIAL_PREFIX = 'tp'
@@ -56,7 +61,8 @@ class ProductTestCase(unittest.TestCase)
         self.default_data = {'prefix':self.INITIAL_PREFIX,
                              'name':self.INITIAL_NAME,
                              'description':self.INITIAL_DESCRIPTION}
-        
+
+        self.global_env = self.env
         self.product = Product(self.env)
         self.product._data.update(self.default_data)
         self.product.insert()
@@ -205,7 +211,6 @@ class ProductTestCase(unittest.TestCase)
         """ensure that that insert method works when _meta does not specify
         unique fields when inserting more than one ProductResourceMap instances
         """
-        from multiproduct.model import ModelBase
         class TestModel(ModelBase):
             """A test model with no unique_fields"""
             _meta = {'table_name': 'bloodhound_testmodel',
@@ -253,6 +258,33 @@ class ProductTestCase(unittest.TestCase)
         self.assertIsInstance(self.listener.resource, Product)
         self.assertEqual(self.INITIAL_PREFIX, self.prefix)
 
+    def test_get_tickets(self):
+        for pdata in (
+            {'prefix': 'p2', 'name':'product, too', 'description': ''},
+            {'prefix': 'p3', 'name':'strike three', 'description': ''},
+        ):
+            num_tickets = 5
+            product = Product(self.global_env)
+            product._data.update(pdata)
+            product.insert()
+            self.env = ProductEnvironment(self.global_env, product)
+            for i in range(num_tickets):
+                ticket = Ticket(self.env)
+                ticket['summary'] = 'hello ticket #%s-%d' % (product.prefix, i)
+                ticket['reporter'] = 'admin'
+                tid = ticket.insert()
+
+            # retrieve tickets using both global and product scope
+            tickets_from_global = [(t['product'], t['id']) for t in
+                Product.get_tickets(self.global_env, product.prefix)]
+            self.assertEqual(len(tickets_from_global), num_tickets)
+            tickets_from_product = [(t['product'], t['id']) for t in
+                Product.get_tickets(self.env)]
+            self.assertEqual(len(tickets_from_product), num_tickets)
+            # both lists should contain same elements
+            intersection = set(tickets_from_global) & set(tickets_from_product)
+            self.assertEqual(len(intersection), num_tickets)
+
 def suite():
     test_suite = unittest.TestSuite()
     test_suite.addTest(unittest.makeSuite(ProductTestCase, 'test'))

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/upgrade.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/upgrade.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/upgrade.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/upgrade.py Sun Jul 28 17:22:23 2013
@@ -190,7 +190,7 @@ class EnvironmentUpgradeTestCase(unittes
                     % (table, len(rows), 6, rows))
             for table in ('permission',):
                 # Permissions also hold rows for global product.
-                rows = db("SELECT * FROM %s" % table)
+                rows = db("SELECT * FROM %s WHERE username='x'" % table)
                 self.assertEqual(
                     len(rows), 7,
                     "Wrong number of lines in %s (%d instead of %d)\n%s"

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/web_ui.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/web_ui.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/web_ui.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_multiproduct/tests/web_ui.py Sun Jul 28 17:22:23 2013
@@ -148,23 +148,6 @@ class ProductModuleTestCase(RequestHandl
                          "Unexpected product prefix")
         self.assertEqual(self.expectedPathInfo, req.args['pathinfo'],
                          "Unexpected sub path")
- 
-    def test_product_pathinfo_warning(self):
-        spy = self.global_env[TestRequestSpy]
-        self.assertIsNot(None, spy)
-
-        req = self._get_request_obj(self.global_env)
-        req.authname = 'testuser'
-        req.environ['PATH_INFO'] = '/products/PREFIX/some/path'
-        self.expectedPrefix = 'PREFIX'
-        self.expectedPathInfo = '/some/path'
-        spy.testProcessing = lambda *args, **kwargs: None
-
-        with self.assertRaises(HTTPNotFound) as test_cm:
-            self._dispatch(req, self.global_env)
-
-        self.assertEqual('Unable to render product page. Wrong setup ?',
-                         test_cm.exception.detail)
 
     def test_product_list(self):
         spy = self.global_env[TestRequestSpy]
@@ -216,13 +199,6 @@ class ProductModuleTestCase(RequestHandl
         spy = self.global_env[TestRequestSpy]
         self.assertIsNot(None, spy)
 
-        # Missing product
-        req = self._get_request_obj(self.global_env)
-        req.authname = 'testuser'
-        req.environ['PATH_INFO'] = '/products/missing'
-
-        real_prefix = None
-
         def assert_product_view(req, template, data, content_type):
             self.assertEquals('product_view.html', template)
             self.assertIs(None, content_type)
@@ -238,11 +214,6 @@ class ProductModuleTestCase(RequestHandl
 
         spy.testProcessing = assert_product_view
 
-        self.expectedPrefix = 'missing'
-        self.expectedPathInfo = ''
-        with self.assertRaises(RequestDone):
-            self._dispatch(req, self.global_env)
-
         # Existing product
         req = self._get_request_obj(self.global_env)
         req.authname = 'testuser'
@@ -254,6 +225,36 @@ class ProductModuleTestCase(RequestHandl
         with self.assertRaises(RequestDone):
             self._dispatch(req, self.global_env)
 
+    def test_missing_product(self):
+        spy = self.global_env[TestRequestSpy]
+        self.assertIsNot(None, spy)
+
+        mps = MultiProductSystem(self.global_env)
+        def assert_product_list(req, template, data, content_type):
+            self.assertEquals('product_list.html', template)
+            self.assertIs(None, content_type)
+            self.assertEquals([mps.default_product_prefix,
+                               self.default_product],
+                              [p.prefix for p in data.get('products')])
+            self.assertTrue('context' in data)
+            ctx = data['context']
+            self.assertEquals('product', ctx.resource.realm)
+            self.assertEquals(None, ctx.resource.id)
+
+        spy.testProcessing = assert_product_list
+
+        # Missing product
+        req = self._get_request_obj(self.global_env)
+        req.authname = 'testuser'
+        req.environ['PATH_INFO'] = '/products/missing'
+
+        self.expectedPrefix = 'missing'
+        self.expectedPathInfo = ''
+        with self.assertRaises(RequestDone):
+            self._dispatch(req, self.global_env)
+        self.assertEqual(1, len(req.chrome['warnings']))
+        self.assertEqual('Product missing not found',
+                         req.chrome['warnings'][0].unescape())
 
     def test_product_edit(self):
         spy = self.global_env[TestRequestSpy]

Modified: bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/api.py
URL: http://svn.apache.org/viewvc/bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/api.py?rev=1507818&r1=1507817&r2=1507818&view=diff
==============================================================================
--- bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/api.py (original)
+++ bloodhound/branches/bep_0007_embeddable_objects/bloodhound_relations/bhrelations/api.py Sun Jul 28 17:22:23 2013
@@ -17,17 +17,24 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
+import itertools
+
+import re
 from datetime import datetime
 from pkg_resources import resource_filename
 from bhrelations import db_default
 from bhrelations.model import Relation
+from bhrelations.utils import unique
 from multiproduct.api import ISupportMultiProductEnvironment
-from trac.config import OrderedExtensionsOption
+from multiproduct.model import Product
+from multiproduct.env import ProductEnvironment
+
+from trac.config import OrderedExtensionsOption, Option
 from trac.core import (Component, implements, TracError, Interface,
                        ExtensionPoint)
 from trac.env import IEnvironmentSetupParticipant
 from trac.db import DatabaseManager
-from trac.resource import (ResourceSystem, Resource,
+from trac.resource import (ResourceSystem, Resource, ResourceNotFound,
                            get_resource_shortname, Neighborhood)
 from trac.ticket import Ticket, ITicketManipulator, ITicketChangeListener
 from trac.util.datefmt import utc, to_utimestamp
@@ -167,6 +174,12 @@ class RelationsSystem(Component):
         regardless of their type."""
     )
 
+    duplicate_relation_type = Option(
+        'bhrelations',
+        'duplicate_relation',
+        '',
+        "Relation type to be used with the resolve as duplicate workflow.")
+
     def __init__(self):
         links, labels, validators, blockers, copy_fields, exclusive = \
             self._parse_config()
@@ -432,6 +445,9 @@ class ResourceIdSerializer(object):
         #TODO: temporary workaround for the ticket specific behavior
         #change it to generic resource behaviour
         ticket = resource_instance
+        if ticket.id is None:
+            raise ValueError("Cannot get resource id for ticket "
+                             "that does not exist yet.")
         nbhprefix = ticket["product"]
 
         resource_full_id = cls.RESOURCE_ID_DELIMITER.join(
@@ -443,28 +459,46 @@ class ResourceIdSerializer(object):
 class TicketRelationsSpecifics(Component):
     implements(ITicketManipulator, ITicketChangeListener)
 
-    #ITicketChangeListener methods
+    def __init__(self):
+        self.rls = RelationsSystem(self.env)
 
+    #ITicketChangeListener methods
     def ticket_created(self, ticket):
         pass
 
     def ticket_changed(self, ticket, comment, author, old_values):
-        pass
+        if (
+            self._closed_as_duplicate(ticket) and
+            self.rls.duplicate_relation_type
+        ):
+            try:
+                self.rls.add(ticket, ticket.duplicate,
+                             self.rls.duplicate_relation_type,
+                             comment, author)
+            except TracError:
+                pass
+
+    def _closed_as_duplicate(self, ticket):
+        return (ticket['status'] == 'closed' and
+                ticket['resolution'] == 'duplicate')
 
     def ticket_deleted(self, ticket):
-        RelationsSystem(self.env).delete_resource_relations(ticket)
+        self.rls.delete_resource_relations(ticket)
 
     #ITicketManipulator methods
-
     def prepare_ticket(self, req, ticket, fields, actions):
         pass
 
     def validate_ticket(self, req, ticket):
-        action = req.args.get('action')
-        if action == 'resolve':
-            rls = RelationsSystem(self.env)
-            blockers = rls.find_blockers(
-                ticket, self.is_blocker)
+        return itertools.chain(
+            self._check_blockers(req, ticket),
+            self._check_open_children(req, ticket),
+            self._check_duplicate_id(req, ticket),
+        )
+
+    def _check_blockers(self, req, ticket):
+        if req.args.get('action') == 'resolve':
+            blockers = self.rls.find_blockers(ticket, self.is_blocker)
             if blockers:
                 blockers_str = ', '.join(
                     get_resource_shortname(self.env, blocker_ticket.resource)
@@ -474,14 +508,61 @@ class TicketRelationsSpecifics(Component
                        % blockers_str)
                 yield None, msg
 
-            for relation in [r for r in rls.get_relations(ticket)
-                             if r['type'] == rls.CHILDREN_RELATION_TYPE]:
+    def _check_open_children(self, req, ticket):
+        if req.args.get('action') == 'resolve':
+            for relation in [r for r in self.rls.get_relations(ticket)
+                             if r['type'] == self.rls.CHILDREN_RELATION_TYPE]:
                 ticket = self._create_ticket_by_full_id(relation['destination'])
                 if ticket['status'] != 'closed':
                     msg = ("Cannot resolve this ticket because it has open"
                            "child tickets.")
                     yield None, msg
 
+    def _check_duplicate_id(self, req, ticket):
+        if req.args.get('action') == 'resolve':
+            resolution = req.args.get('action_resolve_resolve_resolution')
+            if resolution == 'duplicate':
+                duplicate_id = req.args.get('duplicate_id')
+                if not duplicate_id:
+                    yield None, "Duplicate ticket ID must be provided."
+
+                try:
+                    duplicate_ticket = self.find_ticket(duplicate_id)
+                    req.perm.require('TICKET_MODIFY',
+                                     Resource(duplicate_ticket.id))
+                    ticket.duplicate = duplicate_ticket
+                except NoSuchTicketError:
+                    yield None, "Invalid duplicate ticket ID."
+
+    def find_ticket(self, ticket_spec):
+        ticket = None
+        m = re.match(r'#?(?P<tid>\d+)', ticket_spec)
+        if m:
+            tid = m.group('tid')
+            try:
+                ticket = Ticket(self.env, tid)
+            except ResourceNotFound:
+                # ticket not found in current product, try all other products
+                for p in Product.select(self.env):
+                    if p.prefix != self.env.product.prefix:
+                        # TODO: check for PRODUCT_VIEW permissions
+                        penv = ProductEnvironment(self.env.parent, p.prefix)
+                        try:
+                            ticket = Ticket(penv, tid)
+                        except ResourceNotFound:
+                            pass
+                        else:
+                            break
+
+        # ticket still not found, use fallback for <prefix>:ticket:<id> syntax
+        if ticket is None:
+            try:
+                resource = ResourceIdSerializer.get_resource_by_id(ticket_spec)
+                ticket = self._create_ticket_by_full_id(resource)
+            except:
+                raise NoSuchTicketError
+        return ticket
+
     def is_blocker(self, resource):
         ticket = self._create_ticket_by_full_id(resource)
         if ticket['status'] != 'closed':
@@ -573,14 +654,10 @@ class TicketChangeRecordUpdater(Componen
             new_value,
             product))
 
-# Copied from trac/utils.py, ticket-links-trunk branch
-def unique(seq):
-    """Yield unique elements from sequence of hashables, preserving order.
-    (New in 0.13)
-    """
-    seen = set()
-    return (x for x in seq if x not in seen and not seen.add(x))
-
 
 class UnknownRelationType(ValueError):
     pass
+
+
+class NoSuchTicketError(ValueError):
+    pass