1
# Copyright 2009 Canonical Ltd. This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
4
# pylint: disable-msg=E0611,W0212
5
"""Milestone model classes."""
18
from lazr.restful.declarations import webservice_error
19
from sqlobject import (
27
from storm.locals import (
31
from storm.zope import IResultSet
32
from zope.component import getUtility
33
from zope.interface import implements
35
from canonical.database.sqlbase import (
39
from canonical.launchpad.interfaces.lpstorm import IStore
40
from canonical.launchpad.webapp.sorting import expand_numbers
41
from lp.app.errors import NotFoundError
42
from lp.blueprints.model.specification import Specification
43
from lp.bugs.interfaces.bugtarget import IHasBugs
44
from lp.bugs.interfaces.bugtask import (
49
from lp.bugs.model.bugtarget import HasBugsBase
50
from lp.bugs.model.structuralsubscription import (
51
StructuralSubscriptionTargetMixin,
53
from lp.registry.interfaces.milestone import (
57
IProjectGroupMilestone,
59
from lp.registry.model.productrelease import ProductRelease
62
FUTURE_NONE = datetime.date(datetime.MAXYEAR, 1, 1)
65
def milestone_sort_key(milestone):
66
"""Enable sorting by the Milestone dateexpected and name."""
67
if milestone.dateexpected is None:
68
# A datetime.datetime object cannot be compared with None.
69
# Milestones with dateexpected=None are sorted as being
72
elif isinstance(milestone.dateexpected, datetime.datetime):
73
# XXX: EdwinGrubbs 2009-02-06 bug=326384:
74
# The Milestone.dateexpected should be changed into a date column,
75
# since the class defines the field as a DateCol, so that a list
76
# of milestones can't have some dateexpected attributes that are
77
# datetimes and others that are dates, which can't be compared.
78
date = milestone.dateexpected.date()
80
date = milestone.dateexpected
81
return (date, expand_numbers(milestone.name))
84
class HasMilestonesMixin:
85
implements(IHasMilestones)
88
'milestone_sort_key(Milestone.dateexpected, Milestone.name) DESC')
90
def _getMilestoneCondition(self):
91
"""Provides condition for milestones and all_milestones properties.
93
Subclasses need to override this method.
95
:return: Storm ComparableExpr object.
97
raise NotImplementedError(
98
"Unexpected class for mixin: %r" % self)
101
def has_milestones(self):
102
return not self.all_milestones.is_empty()
105
def all_milestones(self):
106
"""See `IHasMilestones`."""
107
store = Store.of(self)
108
result = store.find(Milestone, self._getMilestoneCondition())
109
return result.order_by(self._milestone_order)
111
def _get_milestones(self):
112
"""See `IHasMilestones`."""
113
store = Store.of(self)
114
result = store.find(Milestone,
115
And(self._getMilestoneCondition(),
116
Milestone.active == True))
117
return result.order_by(self._milestone_order)
119
milestones = property(_get_milestones)
122
class MultipleProductReleases(Exception):
123
"""Raised when a second ProductRelease is created for a milestone."""
124
webservice_error(400)
126
def __init__(self, msg='A milestone can only have one ProductRelease.'):
127
super(MultipleProductReleases, self).__init__(msg)
130
class Milestone(SQLBase, StructuralSubscriptionTargetMixin, HasBugsBase):
131
implements(IHasBugs, IMilestone)
133
# XXX: Guilherme Salgado 2007-03-27 bug=40978:
134
# Milestones should be associated with productseries/distroseriess
135
# so these columns are not needed.
136
product = ForeignKey(dbName='product',
137
foreignKey='Product', default=None)
138
distribution = ForeignKey(dbName='distribution',
139
foreignKey='Distribution', default=None)
141
productseries = ForeignKey(dbName='productseries',
142
foreignKey='ProductSeries', default=None)
143
distroseries = ForeignKey(dbName='distroseries',
144
foreignKey='DistroSeries', default=None)
145
name = StringCol(notNull=True)
146
# XXX: EdwinGrubbs 2009-02-06 bug=326384:
147
# The Milestone.dateexpected should be changed into a date column,
148
# since the class defines the field as a DateCol, so that a list of
149
# milestones can't have some dateexpected attributes that are
150
# datetimes and others that are dates, which can't be compared.
151
dateexpected = DateCol(notNull=False, default=None)
152
active = BoolCol(notNull=True, default=True)
153
summary = StringCol(notNull=False, default=None)
154
code_name = StringCol(dbName='codename', notNull=False, default=None)
157
specifications = SQLMultipleJoin('Specification', joinColumn='milestone',
158
orderBy=['-priority', 'definition_status',
159
'implementation_status', 'title'],
160
prejoins=['assignee'])
163
def product_release(self):
164
store = Store.of(self)
165
result = store.find(ProductRelease,
166
ProductRelease.milestone == self.id)
167
releases = list(result)
168
if len(releases) == 0:
175
"""See IMilestone."""
178
elif self.distribution:
179
return self.distribution
182
def series_target(self):
183
"""See IMilestone."""
184
if self.productseries:
185
return self.productseries
186
elif self.distroseries:
187
return self.distroseries
190
def displayname(self):
191
"""See IMilestone."""
192
return "%s %s" % (self.target.displayname, self.name)
196
"""See IMilestone."""
197
if not self.code_name:
198
# XXX sinzui 2009-07-16 bug=400477: code_name may be None or ''.
199
return self.displayname
200
return ('%s "%s"') % (self.displayname, self.code_name)
202
def _customizeSearchParams(self, search_params):
203
"""Customize `search_params` for this milestone."""
204
search_params.milestone = self
207
def official_bug_tags(self):
208
"""See `IHasBugs`."""
209
return self.target.official_bug_tags
211
def createProductRelease(self, owner, datereleased,
212
changelog=None, release_notes=None):
213
"""See `IMilestone`."""
214
if self.product_release is not None:
215
raise MultipleProductReleases()
216
release = ProductRelease(
219
release_notes=release_notes,
220
datereleased=datereleased,
224
def closeBugsAndBlueprints(self, user):
225
"""See `IMilestone`."""
226
for bugtask in self.open_bugtasks:
227
if bugtask.status == BugTaskStatus.FIXCOMMITTED:
228
bugtask.bug.setStatus(
229
bugtask.target, BugTaskStatus.FIXRELEASED, user)
231
def destroySelf(self):
232
"""See `IMilestone`."""
233
params = BugTaskSearchParams(milestone=self, user=None)
234
bugtasks = getUtility(IBugTaskSet).search(params)
235
subscriptions = IResultSet(self.getSubscriptions())
236
assert subscriptions.is_empty(), (
237
"You cannot delete a milestone which has structural "
239
assert bugtasks.count() == 0, (
240
"You cannot delete a milestone which has bugtasks targeted "
242
assert self.specifications.count() == 0, (
243
"You cannot delete a milestone which has specifications targeted "
245
assert self.product_release is None, (
246
"You cannot delete a milestone which has a product release "
247
"associated with it.")
248
SQLBase.destroySelf(self)
252
implements(IMilestoneSet)
255
"""See lp.registry.interfaces.milestone.IMilestoneSet."""
256
for ms in Milestone.select():
259
def get(self, milestoneid):
260
"""See lp.registry.interfaces.milestone.IMilestoneSet."""
261
result = list(self.getByIds([milestoneid]))
264
"Milestone with ID %d does not exist" % milestoneid)
267
def getByIds(self, milestoneids):
268
"""See `IMilestoneSet`."""
269
return IStore(Milestone).find(Milestone,
270
Milestone.id.is_in(milestoneids))
272
def getByNameAndProduct(self, name, product, default=None):
273
"""See lp.registry.interfaces.milestone.IMilestoneSet."""
274
query = AND(Milestone.q.name==name,
275
Milestone.q.productID==product.id)
276
milestone = Milestone.selectOne(query)
277
if milestone is None:
281
def getByNameAndDistribution(self, name, distribution, default=None):
282
"""See lp.registry.interfaces.milestone.IMilestoneSet."""
283
query = AND(Milestone.q.name==name,
284
Milestone.q.distributionID==distribution.id)
285
milestone = Milestone.selectOne(query)
286
if milestone is None:
290
def getVisibleMilestones(self):
291
"""See lp.registry.interfaces.milestone.IMilestoneSet."""
292
return Milestone.selectBy(active=True, orderBy='id')
295
class ProjectMilestone(HasBugsBase):
296
"""A virtual milestone implementation for project.
298
The current database schema has no formal concept of milestones related to
299
projects. A milestone named `milestone` is considererd to belong to
300
a project if the project contains at least one product with a milestone
301
of the same name. A project milestone is considered to be active if at
302
least one product milestone with the same name is active. The
303
`dateexpected` attribute of a project milestone is set to the minimum of
304
the `dateexpected` values of the product milestones.
307
implements(IProjectGroupMilestone)
309
def __init__(self, target, name, dateexpected, active):
311
self.code_name = None
312
# The id is necessary for generating a unique memcache key
313
# in a page template loop. The ProjectMilestone.id is passed
314
# in as the third argument to the "cache" TALes.
315
self.id = 'ProjectGroup:%s/Milestone:%s' % (target.name, name)
316
self.code_name = None
318
self.distribution = None
319
self.productseries = None
320
self.distroseries = None
321
self.product_release = None
322
self.dateexpected = dateexpected
325
self.series_target = None
329
def specifications(self):
330
"""See `IMilestone`."""
331
return Specification.select(
334
FROM Milestone, Product
335
WHERE Milestone.Product = Product.id
336
AND Milestone.name = %s
337
AND Product.project = %s)
338
""" % sqlvalues(self.name, self.target),
339
orderBy=['-priority', 'definition_status',
340
'implementation_status', 'title'],
341
prejoins=['assignee'])
344
def displayname(self):
345
"""See IMilestone."""
346
return "%s %s" % (self.target.displayname, self.name)
350
"""See IMilestone."""
351
return self.displayname
353
def _customizeSearchParams(self, search_params):
354
"""Customize `search_params` for this milestone."""
355
search_params.milestone = self
358
def official_bug_tags(self):
359
"""See `IHasBugs`."""
360
return self.target.official_bug_tags
362
def userHasBugSubscriptions(self, user):
363
"""See `IStructuralSubscriptionTarget`."""