launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #04392
[Merge] lp:~wallyworld/launchpad/question-portlet-subscribers-details into lp:launchpad
Ian Booth has proposed merging lp:~wallyworld/launchpad/question-portlet-subscribers-details into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/question-portlet-subscribers-details/+merge/69387
There's a need to allow users to unsubscribe people from questions without needed to ask an admin to run SQL. See bug 134577.
This branch adds the server side support to provide the json data which will be consumed by the javascript subscription portlet. The portlet code is already landed. The next branches will add the glue to wire up the portlet to this new back end code.
Note that event through questions subscribe() doesn't support "subscribed_by", it's been added as a parameter because the generic subscriptions portlet expects it. If we ever decide to add support for this (it's used by bugs for already), it can be done without a subsequent api change.
The question-portlet-subscribers.pt tales will no longer be used - the subscription portlet is/will be fully javascript.
NB This branch will not be landed, only the final one will be.
== Implementation ==
Add new class lp.answers.browser.questionsubscription.QuestionPortletSubscribersWithDetails plus associated tests. This new view calls a new method om IQuestion - getDirectSubscribersWithDetails()
== Tests ==
Add tests:
lp.answers.model.tests.test_question - tests for IQuestion.getDirectSubscribersWithDetails()
lp.answers.browser.tests.test_questionsubscription_views - tests for the QuestionPortletSubscribersWithDetails view
== Lint ==
Linting changed files:
lib/lp/answers/configure.zcml
lib/lp/answers/browser/configure.zcml
lib/lp/answers/browser/questionsubscription.py
lib/lp/answers/browser/tests/test_questionsubscription_views.py
lib/lp/answers/interfaces/question.py
lib/lp/answers/model/question.py
lib/lp/answers/model/questionsubscription.py
lib/lp/answers/model/tests/test_question.py
--
https://code.launchpad.net/~wallyworld/launchpad/question-portlet-subscribers-details/+merge/69387
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/question-portlet-subscribers-details into lp:launchpad.
=== modified file 'lib/lp/answers/browser/configure.zcml'
--- lib/lp/answers/browser/configure.zcml 2011-05-17 18:45:12 +0000
+++ lib/lp/answers/browser/configure.zcml 2011-07-27 03:13:28 +0000
@@ -150,12 +150,7 @@
name="+portlet-reopenings"
template="../templates/question-portlet-reopenings.pt"
/>
- <browser:page
- name="+portlet-subscribers"
- template="../templates/question-portlet-subscribers.pt"
- />
</browser:pages>
-
<browser:pages
for="lp.answers.interfaces.question.IQuestion"
class="canonical.launchpad.webapp.LaunchpadView"
@@ -172,6 +167,13 @@
</browser:pages>
<browser:page
+ for="lp.answers.interfaces.question.IQuestion"
+ name="+portlet-subscribers-details"
+ class="
+ lp.answers.browser.questionsubscription.QuestionPortletSubscribersWithDetails"
+ permission="zope.Public"/>
+
+ <browser:page
name="+index"
for="lp.answers.interfaces.question.IQuestion"
class=".question.QuestionWorkflowView"
=== added file 'lib/lp/answers/browser/questionsubscription.py'
--- lib/lp/answers/browser/questionsubscription.py 1970-01-01 00:00:00 +0000
+++ lib/lp/answers/browser/questionsubscription.py 2011-07-27 03:13:28 +0000
@@ -0,0 +1,105 @@
+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Views for QuestionSubscription."""
+
+__metaclass__ = type
+__all__ = [
+ 'QuestionPortletSubscribersWithDetails',
+ ]
+
+from lazr.delegates import delegates
+from lazr.restful.interfaces import (
+ IWebServiceClientRequest,
+)
+from simplejson import dumps
+from zope.traversing.browser import absoluteURL
+
+from canonical.launchpad.webapp import (
+ canonical_url,
+ LaunchpadView,
+ )
+from lp.answers.interfaces.question import IQuestion
+from lp.answers.interfaces.questionsubscription import IQuestionSubscription
+from lp.services.propertycache import cachedproperty
+
+
+class QuestionPortletSubscribersWithDetails(LaunchpadView):
+ """A view that returns a JSON dump of the subscriber details for a bug."""
+
+ @cachedproperty
+ def api_request(self):
+ return IWebServiceClientRequest(self.request)
+
+ def direct_subscriber_data(self, bug):
+ """Get the direct subscriber data.
+
+ This method is isolated from the subscriber_data_js so that query
+ count testing can be done accurately and robustly.
+ """
+ data = []
+ details = list(bug.getDirectSubscribersWithDetails())
+ for person, subscription in details:
+ can_edit = subscription.canBeUnsubscribedByUser(self.user)
+ if person == self.user or (person.private and not can_edit):
+ # Skip the current user viewing the page,
+ # and private teams user is not a member of.
+ continue
+
+ subscriber = {
+ 'name': person.name,
+ 'display_name': person.displayname,
+ 'web_link': canonical_url(person, rootsite='mainsite'),
+ 'self_link': absoluteURL(person, self.api_request),
+ 'is_team': person.is_team,
+ 'can_edit': can_edit
+ }
+ record = {
+ 'subscriber': subscriber,
+ 'subscription_level': 'Direct',
+ }
+ data.append(record)
+ return data
+
+ @property
+ def subscriber_data_js(self):
+ """Return subscriber_ids in a form suitable for JavaScript use."""
+ question = IQuestion(self.context)
+ data = self.direct_subscriber_data(question)
+
+ others = list(question.getIndirectSubscribers())
+ for person in others:
+ if person == self.user:
+ # Skip the current user viewing the page.
+ continue
+ subscriber = {
+ 'name': person.name,
+ 'display_name': person.displayname,
+ 'web_link': canonical_url(person, rootsite='mainsite'),
+ 'self_link': absoluteURL(person, self.api_request),
+ 'is_team': person.is_team,
+ 'can_edit': False,
+ }
+ record = {
+ 'subscriber': subscriber,
+ 'subscription_level': 'Indirect',
+ }
+ data.append(record)
+ return dumps(data)
+
+ def render(self):
+ """Override the default render() to return only JSON."""
+ self.request.response.setHeader('content-type', 'application/json')
+ return self.subscriber_data_js
+
+
+class SubscriptionAttrDecorator:
+ """A QuestionSubscription with added attributes for HTML/JS."""
+ delegates(IQuestionSubscription, 'subscription')
+
+ def __init__(self, subscription):
+ self.subscription = subscription
+
+ @property
+ def css_name(self):
+ return 'subscriber-%s' % self.subscription.person.id
=== added file 'lib/lp/answers/browser/tests/test_questionsubscription_views.py'
--- lib/lp/answers/browser/tests/test_questionsubscription_views.py 1970-01-01 00:00:00 +0000
+++ lib/lp/answers/browser/tests/test_questionsubscription_views.py 2011-07-27 03:13:28 +0000
@@ -0,0 +1,266 @@
+# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for QuestionSubscription views."""
+
+__metaclass__ = type
+
+from simplejson import dumps
+from storm.store import Store
+from testtools.matchers import Equals
+from zope.component import getUtility
+from zope.traversing.browser import absoluteURL
+
+from canonical.launchpad.ftests import LaunchpadFormHarness
+from canonical.launchpad.webapp import canonical_url
+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
+from canonical.testing.layers import LaunchpadFunctionalLayer
+from lazr.restful.interfaces import IWebServiceClientRequest
+from lp.answers.browser.questionsubscription import (
+ QuestionPortletSubscribersWithDetails,
+)
+from lp.registry.interfaces.person import IPersonSet
+from lp.testing import (
+ person_logged_in,
+ StormStatementRecorder,
+ TestCaseWithFactory,
+ )
+from lp.testing.matchers import HasQueryCount
+from lp.testing.sampledata import ADMIN_EMAIL
+
+
+class QuestionPortletSubscribersWithDetailsTests(TestCaseWithFactory):
+ """Tests for IQuestion:+portlet-subscribers-details view."""
+ layer = LaunchpadFunctionalLayer
+
+ def test_content_type(self):
+ question = self.factory.makeQuestion()
+
+ # It works even for anonymous users, so no log-in is needed.
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ harness.view.render()
+
+ self.assertEqual(
+ harness.request.response.getHeader('content-type'),
+ 'application/json')
+
+ def test_view_url(self):
+ # Test that IQuestion:+portlet-subscribers-details maps through to
+ # correct view via the zcml configuration.
+ question = self._makeQuestionWithNoSubscribers()
+ subscriber = self.factory.makePerson(
+ name='user', displayname='Subscriber Name')
+ subscriber_web_link = canonical_url(subscriber)
+ api_request = LaunchpadTestRequest(
+ SERVER_URL='http://answers.launchpad.dev/api/devel')
+ with person_logged_in(subscriber):
+ question.subscribe(subscriber, subscriber)
+ subscriber_self_link = absoluteURL(subscriber, api_request)
+
+ browser = self.getUserBrowser(
+ user=question.owner,
+ url=canonical_url(
+ question, view_name='+portlet-subscribers-details'))
+ expected_result = {
+ 'subscriber': {
+ 'name': 'user',
+ 'display_name': 'Subscriber Name',
+ 'is_team': False,
+ 'can_edit': True,
+ 'web_link': subscriber_web_link,
+ 'self_link': subscriber_self_link
+ },
+ 'subscription_level': "Direct",
+ }
+ self.assertEqual(
+ dumps([expected_result]), browser.contents)
+
+ def _makeQuestionWithNoSubscribers(self):
+ question = self.factory.makeQuestion()
+ with person_logged_in(question.owner):
+ # Unsubscribe the question owner to ensure we have no subscribers.
+ question.unsubscribe(question.owner, question.owner)
+ return question
+
+ def test_data_no_subscriptions(self):
+ question = self._makeQuestionWithNoSubscribers()
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ self.assertEqual(dumps([]), harness.view.subscriber_data_js)
+
+ def test_data_person_subscription(self):
+ # subscriber_data_js returns JSON string of a list
+ # containing all subscriber information needed for
+ # subscribers_list.js subscribers loading.
+ question = self._makeQuestionWithNoSubscribers()
+ subscriber = self.factory.makePerson(
+ name='user', displayname='Subscriber Name')
+ with person_logged_in(subscriber):
+ question.subscribe(subscriber, subscriber)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ api_request = IWebServiceClientRequest(harness.request)
+
+ expected_result = {
+ 'subscriber': {
+ 'name': 'user',
+ 'display_name': 'Subscriber Name',
+ 'is_team': False,
+ 'can_edit': False,
+ 'web_link': canonical_url(subscriber),
+ 'self_link': absoluteURL(subscriber, api_request)
+ },
+ 'subscription_level': "Direct",
+ }
+ self.assertEqual(
+ dumps([expected_result]), harness.view.subscriber_data_js)
+
+ def test_data_person_subscription_other_subscriber_query_count(self):
+ # All subscriber data should be retrieved with a single query.
+ question = self._makeQuestionWithNoSubscribers()
+ subscribed_by = self.factory.makePerson(
+ name="someone", displayname='Someone')
+ subscriber = self.factory.makePerson(
+ name='user', displayname='Subscriber Name')
+ with person_logged_in(subscriber):
+ question.subscribe(person=subscriber,
+ subscribed_by=subscribed_by)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ # Invoke the view method, ignoring the results.
+ Store.of(question).invalidate()
+ with StormStatementRecorder() as recorder:
+ harness.view.direct_subscriber_data(question)
+ self.assertThat(recorder, HasQueryCount(Equals(1)))
+
+ def test_data_team_subscription(self):
+ # For a team subscription, subscriber_data_js has is_team set
+ # to true.
+ question = self._makeQuestionWithNoSubscribers()
+ teamowner = self.factory.makePerson(
+ name="team-owner", displayname="Team Owner")
+ subscriber = self.factory.makeTeam(
+ name='team', displayname='Team Name', owner=teamowner)
+ with person_logged_in(subscriber.teamowner):
+ question.subscribe(subscriber, subscriber.teamowner)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ api_request = IWebServiceClientRequest(harness.request)
+
+ expected_result = {
+ 'subscriber': {
+ 'name': 'team',
+ 'display_name': 'Team Name',
+ 'is_team': True,
+ 'can_edit': False,
+ 'web_link': canonical_url(subscriber),
+ 'self_link': absoluteURL(subscriber, api_request)
+ },
+ 'subscription_level': "Direct",
+ }
+ self.assertEqual(
+ dumps([expected_result]), harness.view.subscriber_data_js)
+
+ def test_data_team_subscription_owner_looks(self):
+ # For a team subscription, subscriber_data_js has can_edit
+ # set to true for team owner.
+ question = self._makeQuestionWithNoSubscribers()
+ teamowner = self.factory.makePerson(
+ name="team-owner", displayname="Team Owner")
+ subscriber = self.factory.makeTeam(
+ name='team', displayname='Team Name', owner=teamowner)
+ with person_logged_in(subscriber.teamowner):
+ question.subscribe(subscriber, subscriber.teamowner)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ api_request = IWebServiceClientRequest(harness.request)
+
+ expected_result = {
+ 'subscriber': {
+ 'name': 'team',
+ 'display_name': 'Team Name',
+ 'is_team': True,
+ 'can_edit': True,
+ 'web_link': canonical_url(subscriber),
+ 'self_link': absoluteURL(subscriber, api_request)
+ },
+ 'subscription_level': "Direct",
+ }
+ with person_logged_in(subscriber.teamowner):
+ self.assertEqual(
+ dumps([expected_result]), harness.view.subscriber_data_js)
+
+ def test_data_team_subscription_member_looks(self):
+ # For a team subscription, subscriber_data_js has can_edit
+ # set to true for team member.
+ question = self._makeQuestionWithNoSubscribers()
+ member = self.factory.makePerson()
+ teamowner = self.factory.makePerson(
+ name="team-owner", displayname="Team Owner")
+ subscriber = self.factory.makeTeam(
+ name='team', displayname='Team Name', owner=teamowner,
+ members=[member])
+ with person_logged_in(subscriber.teamowner):
+ question.subscribe(subscriber, subscriber.teamowner)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ api_request = IWebServiceClientRequest(harness.request)
+
+ expected_result = {
+ 'subscriber': {
+ 'name': 'team',
+ 'display_name': 'Team Name',
+ 'is_team': True,
+ 'can_edit': True,
+ 'web_link': canonical_url(subscriber),
+ 'self_link': absoluteURL(subscriber, api_request)
+ },
+ 'subscription_level': "Direct",
+ }
+ with person_logged_in(subscriber.teamowner):
+ self.assertEqual(
+ dumps([expected_result]), harness.view.subscriber_data_js)
+
+ def test_data_subscription_lp_admin(self):
+ # For a subscription, subscriber_data_js has can_edit
+ # set to true for a Launchpad admin.
+ question = self._makeQuestionWithNoSubscribers()
+ member = self.factory.makePerson()
+ subscriber = self.factory.makePerson(
+ name='user', displayname='Subscriber Name')
+ with person_logged_in(member):
+ question.subscribe(subscriber, subscriber)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ api_request = IWebServiceClientRequest(harness.request)
+
+ expected_result = {
+ 'subscriber': {
+ 'name': 'user',
+ 'display_name': 'Subscriber Name',
+ 'is_team': False,
+ 'can_edit': True,
+ 'web_link': canonical_url(subscriber),
+ 'self_link': absoluteURL(subscriber, api_request)
+ },
+ 'subscription_level': "Direct",
+ }
+
+ # Login as admin
+ admin = getUtility(IPersonSet).find(ADMIN_EMAIL).any()
+ with person_logged_in(admin):
+ self.assertEqual(
+ dumps([expected_result]), harness.view.subscriber_data_js)
+
+ def test_data_person_subscription_user_excluded(self):
+ # With the subscriber logged in, he is not included in the results.
+ question = self._makeQuestionWithNoSubscribers()
+ subscriber = self.factory.makePerson(
+ name='a-person', displayname='Subscriber Name')
+
+ with person_logged_in(subscriber):
+ question.subscribe(subscriber, subscriber)
+ harness = LaunchpadFormHarness(
+ question, QuestionPortletSubscribersWithDetails)
+ self.assertEqual(dumps([]), harness.view.subscriber_data_js)
=== modified file 'lib/lp/answers/configure.zcml'
--- lib/lp/answers/configure.zcml 2011-05-18 03:36:29 +0000
+++ lib/lp/answers/configure.zcml 2011-07-27 03:13:28 +0000
@@ -100,7 +100,8 @@
can_confirm_answer can_reopen
subscriptions isSubscribed getRecipients
direct_recipients indirect_recipients
- getDirectSubscribers getIndirectSubscribers"
+ getDirectSubscribers getIndirectSubscribers
+ getDirectSubscribersWithDetails"
/>
<require
permission="launchpad.Owner"
=== modified file 'lib/lp/answers/interfaces/question.py'
--- lib/lp/answers/interfaces/question.py 2011-07-22 07:13:16 +0000
+++ lib/lp/answers/interfaces/question.py 2011-07-27 03:13:28 +0000
@@ -434,7 +434,7 @@
"""
# subscription-related methods
- def subscribe(person):
+ def subscribe(person, subscribed_by=None):
"""Subscribe this person to the question."""
def isSubscribed(person):
@@ -449,6 +449,13 @@
:return: A list of persons sorted by displayname.
"""
+ def getDirectSubscribersWithDetails():
+ """Get direct subscribers and their subscriptions for the question.
+
+ :returns: A ResultSet of tuples (Person, QuestionSubscription)
+ representing a subscriber and their question subscription.
+ """
+
def getIndirectSubscribers():
"""Return the persons who are implicitly subscribed to this question.
=== modified file 'lib/lp/answers/model/question.py'
--- lib/lp/answers/model/question.py 2011-07-22 07:13:16 +0000
+++ lib/lp/answers/model/question.py 2011-07-27 03:13:28 +0000
@@ -517,7 +517,7 @@
return msg
# subscriptions
- def subscribe(self, person):
+ def subscribe(self, person, subscribed_by=None):
"""See `IQuestion`."""
# First see if a relevant subscription exists, and if so, update it.
for sub in self.subscriptions:
@@ -551,6 +551,18 @@
return sorted(
self.subscribers, key=operator.attrgetter('displayname'))
+ def getDirectSubscribersWithDetails(self):
+ """See `IQuestion`."""
+
+ # Avoid circular imports
+ from lp.registry.model.person import Person
+ results = Store.of(self).find(
+ (Person, QuestionSubscription),
+ QuestionSubscription.person_id == Person.id,
+ QuestionSubscription.question_id == self.id,
+ ).order_by(Person.displayname)
+ return results
+
def getIndirectSubscribers(self):
"""See `IQuestion`.
=== modified file 'lib/lp/answers/model/questionsubscription.py'
--- lib/lp/answers/model/questionsubscription.py 2011-07-22 07:13:16 +0000
+++ lib/lp/answers/model/questionsubscription.py 2011-07-27 03:13:28 +0000
@@ -10,6 +10,7 @@
__all__ = ['QuestionSubscription']
from sqlobject import ForeignKey
+from storm.locals import Int
from zope.interface import implements
from canonical.database.sqlbase import SQLBase
@@ -25,9 +26,12 @@
_table = 'QuestionSubscription'
+ question_id = Int("question", allow_none=False)
question = ForeignKey(
dbName='question', foreignKey='Question', notNull=True)
+ person_id = Int(
+ "person", allow_none=False, validator=validate_public_person)
person = ForeignKey(
dbName='person', foreignKey='Person',
storm_validator=validate_public_person, notNull=True)
=== added file 'lib/lp/answers/model/tests/test_question.py'
--- lib/lp/answers/model/tests/test_question.py 1970-01-01 00:00:00 +0000
+++ lib/lp/answers/model/tests/test_question.py 2011-07-27 03:13:28 +0000
@@ -0,0 +1,56 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.testing import (
+ person_logged_in,
+ TestCaseWithFactory,
+ )
+
+
+class TestQuestionDirectSubscribers(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_get_direct_subscribers(self):
+ question = self.factory.makeQuestion()
+ subscriber = self.factory.makePerson()
+ subscribers = [question.owner, subscriber]
+ with person_logged_in(subscriber):
+ question.subscribe(subscriber, subscriber)
+
+ direct_subscribers = question.getDirectSubscribers()
+ self.assertEqual(
+ set(subscribers), set(direct_subscribers),
+ "Subscribers did not match expected value.")
+
+ def test_get_direct_subscribers_with_details_other_subscriber(self):
+ # getDirectSubscribersWithDetails() returns
+ # Person and QuestionSubscription records in one go.
+ question = self.factory.makeQuestion()
+ with person_logged_in(question.owner):
+ # Unsubscribe question owner so it doesn't taint the result.
+ question.unsubscribe(question.owner, question.owner)
+ subscriber = self.factory.makePerson()
+ subscribee = self.factory.makePerson()
+ with person_logged_in(subscriber):
+ subscription = question.subscribe(subscribee, subscriber)
+ self.assertContentEqual(
+ [(subscribee, subscription)],
+ question.getDirectSubscribersWithDetails())
+
+ def test_get_direct_subscribers_with_details_self_subscribed(self):
+ # getDirectSubscribersWithDetails() returns
+ # Person and QuestionSubscription records in one go.
+ question = self.factory.makeQuestion()
+ with person_logged_in(question.owner):
+ # Unsubscribe question owner so it doesn't taint the result.
+ question.unsubscribe(question.owner, question.owner)
+ subscriber = self.factory.makePerson()
+ with person_logged_in(subscriber):
+ subscription = question.subscribe(subscriber, subscriber)
+ self.assertContentEqual(
+ [(subscriber, subscription)],
+ question.getDirectSubscribersWithDetails())
=== modified file 'lib/lp/answers/templates/question-index.pt'
--- lib/lp/answers/templates/question-index.pt 2011-06-27 17:21:35 +0000
+++ lib/lp/answers/templates/question-index.pt 2011-07-27 03:13:28 +0000
@@ -50,7 +50,7 @@
</ul>
</div>
<div tal:replace="structure context/@@+global-actions" />
- <div tal:replace="structure context/@@+portlet-subscribers" />
+ <!-- TODO - wire up new javascript subscribers portlet -->
</metal:portlets>
<div metal:fill-slot="main">
=== removed file 'lib/lp/answers/templates/question-portlet-subscribers.pt'
--- lib/lp/answers/templates/question-portlet-subscribers.pt 2009-08-24 14:31:37 +0000
+++ lib/lp/answers/templates/question-portlet-subscribers.pt 1970-01-01 00:00:00 +0000
@@ -1,46 +0,0 @@
-<tal:root
- xmlns:tal="http://xml.zope.org/namespaces/tal"
- xmlns:metal="http://xml.zope.org/namespaces/metal"
- xmlns:i18n="http://xml.zope.org/namespaces/i18n"
- omit-tag="">
-
-<div class="portlet" id="subscribers">
- <h2>Subscribers</h2>
-
- <div class="portletBody portletContent"
- tal:define="subscribers context/getDirectSubscribers;
- indirect_subscribers context/getIndirectSubscribers">
-
- <ul tal:condition="subscribers">
- <li tal:repeat="subscriber subscribers">
- <a
- tal:condition="subscriber/name|nothing"
- tal:attributes="href subscriber/fmt:url">
-
- <tal:block replace="structure subscriber/fmt:icon" />
- <tal:block replace="subscriber/fmt:displayname/fmt:shorten/20" />
- </a>
- </li>
- </ul>
- <p tal:condition="not: subscribers">
- <i>No subscribers.</i>
- </p>
- <div style="margin-top: 1em;" tal:condition="indirect_subscribers">
-
- <b>Also notified:</b><br />
- <ul>
- <li tal:repeat="subscriber indirect_subscribers">
- <a
- tal:condition="subscriber/name|nothing"
- tal:attributes="href subscriber/fmt:url">
-
- <tal:block replace="structure subscriber/fmt:icon" />
- <tal:block replace="subscriber/fmt:displayname/fmt:shorten/20" />
- </a>
- </li>
- </ul>
-
- </div>
- </div>
-</div>
-</tal:root>