~launchpad-pqm/launchpad/devel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# Copyright 2009 Canonical Ltd.  This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

"""Common registry browser helpers and mixins."""

__metaclass__ = type

__all__ = [
    'add_subscribe_link',
    'BaseRdfView',
    'get_status_counts',
    'MilestoneOverlayMixin',
    'RDFIndexView',
    'RegistryEditFormView',
    'RegistryDeleteViewMixin',
    'StatusCount',
    ]


from operator import attrgetter
import os

from storm.store import Store
from zope.component import getUtility

from lp.app.browser.folder import ExportedFolder
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.bugs.interfaces.bugtask import (
    BugTaskSearchParams,
    IBugTaskSet,
    )
from lp.registry.interfaces.productseries import IProductSeries
from lp.registry.interfaces.series import SeriesStatus
from lp.services.webapp.launchpadform import (
    action,
    LaunchpadEditFormView,
    )
from lp.services.webapp.publisher import (
    canonical_url,
    LaunchpadView,
    )


class StatusCount:
    """A helper that stores the count of status for a list of items.

    Items such as `IBugTask` and `ISpecification` can be summarised by
    their status.
    """

    def __init__(self, status, count):
        """Set the status and count."""
        self.status = status
        self.count = count


def get_status_counts(workitems, status_attr, key='sortkey'):
    """Return a list StatusCounts summarising the workitem."""
    statuses = {}
    for workitem in workitems:
        status = getattr(workitem, status_attr)
        if status is None:
            # This is not something we want to count.
            continue
        if status not in statuses:
            statuses[status] = 0
        statuses[status] += 1
    return [
        StatusCount(status, statuses[status])
        for status in sorted(statuses, key=attrgetter(key))]


def add_subscribe_link(links):
    """Add the subscription-related links."""
    links.extend(['subscribe_to_bug_mail', 'edit_bug_mail'])


class MilestoneOverlayMixin:
    """A mixin that provides the data for the milestoneoverlay script."""

    milestone_can_release = True

    @property
    def milestone_form_uri(self):
        """URI for form displayed by the formoverlay widget."""
        return canonical_url(self.context) + '/+addmilestone/++form++'

    @property
    def series_api_uri(self):
        """The series URL for API access."""
        return canonical_url(self.context, path_only_if_possible=True)

    @property
    def milestone_table_class(self):
        """The milestone table will be unseen if there are no milestones."""
        if self.context.has_milestones:
            return 'listing'
        else:
            # The page can remove the 'unseen' class to make the table
            # visible.
            return 'listing unseen'

    @property
    def milestone_row_uri_template(self):
        if IProductSeries.providedBy(self.context):
            pillar = self.context.product
        else:
            pillar = self.context.distribution
        uri = canonical_url(pillar, path_only_if_possible=True)
        return '%s/+milestone/{name}/+productseries-table-row' % uri

    @property
    def register_milestone_script(self):
        """Return the script to enable milestone creation via AJAX."""
        uris = {
            'series_api_uri': self.series_api_uri,
            'milestone_form_uri': self.milestone_form_uri,
            'milestone_row_uri': self.milestone_row_uri_template,
            }
        return """
            LPS.use(
                'node', 'lp.registry.milestoneoverlay',
                'lp.registry.milestonetable',
                function (Y) {

                var series_uri = '%(series_api_uri)s';
                var milestone_form_uri = '%(milestone_form_uri)s';
                var milestone_row_uri = '%(milestone_row_uri)s';
                var milestone_rows_id = '#milestone-rows';

                Y.on('domready', function () {
                    var create_milestone_link = Y.one(
                        '.menu-link-create_milestone');
                    create_milestone_link.addClass('js-action');
                    var milestone_table = Y.lp.registry.milestonetable;
                    var config = {
                        milestone_form_uri: milestone_form_uri,
                        series_uri: series_uri,
                        next_step: milestone_table.get_milestone_row,
                        activate_node: create_milestone_link
                        };
                    Y.lp.registry.milestoneoverlay.attach_widget(config);
                    var table_config = {
                        milestone_row_uri_template: milestone_row_uri,
                        milestone_rows_id: milestone_rows_id
                        }
                    Y.lp.registry.milestonetable.setup(table_config);
                });
            });
            """ % uris


class RegistryDeleteViewMixin:
    """A mixin class that provides common behavior for registry deletions."""

    @property
    def cancel_url(self):
        """The context's URL."""
        return canonical_url(self.context)

    def _getBugtasks(self, target):
        """Return the list `IBugTask`s associated with the target."""
        if IProductSeries.providedBy(target):
            params = BugTaskSearchParams(user=self.user)
            params.setProductSeries(target)
        else:
            params = BugTaskSearchParams(milestone=target, user=self.user)
        bugtasks = getUtility(IBugTaskSet).search(params)
        return list(bugtasks)

    def _getSpecifications(self, target):
        """Return the list `ISpecification`s associated to the target."""
        if IProductSeries.providedBy(target):
            return list(target.all_specifications)
        else:
            return list(target.specifications)

    def _getProductRelease(self, milestone):
        """The `IProductRelease` associated with the milestone."""
        return milestone.product_release

    def _getProductReleaseFiles(self, milestone):
        """The list of `IProductReleaseFile`s related to the milestone."""
        product_release = self._getProductRelease(milestone)
        if product_release is not None:
            return list(product_release.files)
        else:
            return []

    def _unsubscribe_structure(self, structure):
        """Removed the subscriptions from structure."""
        for subscription in structure.getSubscriptions():
            # The owner of the subscription or an admin are the only users
            # that can destroy a subscription, but this rule cannot prevent
            # the owner from removing the structure.
            subscription.delete()

    def _remove_series_bugs_and_specifications(self, series):
        """Untarget the associated bugs and subscriptions."""
        for spec in self._getSpecifications(series):
            spec.proposeGoal(None, self.user)
        for bugtask in self._getBugtasks(series):
            # Bugtasks cannot be deleted directly. In this case, the bugtask
            # is already reported on the product, so the series bugtask has
            # no purpose without a series.
            Store.of(bugtask).remove(bugtask)

    def _deleteProductSeries(self, series):
        """Remove the series and delete/unlink related objects.

        All subordinate milestones, releases, and files will be deleted.
        Milestone bugs and blueprints will be untargeted.
        Series bugs and blueprints will be untargeted.
        Series and milestone structural subscriptions are unsubscribed.
        Series branches are unlinked.
        """
        self._unsubscribe_structure(series)
        self._remove_series_bugs_and_specifications(series)
        series.branch = None

        for milestone in series.all_milestones:
            self._deleteMilestone(milestone)
        # Series are not deleted because some objects like translations are
        # problematic. The series is assigned to obsolete-junk. They must be
        # renamed to avoid name collision.
        date_time = series.datecreated.strftime('%Y%m%d-%H%M%S')
        series.name = '%s-%s-%s' % (
            series.product.name, series.name, date_time)
        series.status = SeriesStatus.OBSOLETE
        series.releasefileglob = None
        series.product = getUtility(ILaunchpadCelebrities).obsolete_junk

    def _deleteMilestone(self, milestone):
        """Delete a milestone and unlink related objects."""
        self._unsubscribe_structure(milestone)
        for bugtask in self._getBugtasks(milestone):
            if bugtask.conjoined_master is not None:
                Store.of(bugtask).remove(bugtask.conjoined_master)
            else:
                bugtask.milestone = None
        for spec in self._getSpecifications(milestone):
            spec.milestone = None
        self._deleteRelease(milestone.product_release)
        milestone.destroySelf()

    def _deleteRelease(self, release):
        """Delete a release and it's files."""
        if release is not None:
            for release_file in release.files:
                release_file.destroySelf()
            release.destroySelf()


class RegistryEditFormView(LaunchpadEditFormView):
    """A base class that provides consistent edit form behaviour."""

    @property
    def page_title(self):
        """The page title."""
        return self.label

    @property
    def cancel_url(self):
        """See `LaunchpadFormView`."""
        return canonical_url(self.context)

    next_url = cancel_url

    @action("Change", name='change')
    def change_action(self, action, data):
        self.updateContextFromData(data)


class BaseRdfView:
    """A view that sets its mime-type to application/rdf+xml."""

    template = None
    filename = None

    def __init__(self, context, request):
        self.context = context
        self.request = request

    def __call__(self):
        """Render RDF output, and return it as a string encoded in UTF-8.

        Render the page template to produce RDF output.
        The return value is string data encoded in UTF-8.

        As a side-effect, HTTP headers are set for the mime type
        and filename for download."""
        self.request.response.setHeader('Content-Type', 'application/rdf+xml')
        self.request.response.setHeader(
            'Content-Disposition', 'attachment; filename=%s.rdf' % (
             self.filename))
        unicodedata = self.template()
        encodeddata = unicodedata.encode('utf-8')
        return encodeddata


class RDFIndexView(LaunchpadView):
    """View for /rdf page."""
    page_title = label = "Launchpad RDF"


class RDFFolder(ExportedFolder):
    """Export the Launchpad RDF schemas."""
    folder = os.path.join(
        os.path.dirname(os.path.realpath(__file__)), '../rdfspec/')