Merge lp:~lifeless/launchpad/bug-759467 into lp:launchpad

Proposed by Robert Collins
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 14106
Proposed branch: lp:~lifeless/launchpad/bug-759467
Merge into: lp:launchpad
Diff against target: 917 lines (+268/-120)
19 files modified
database/schema/security.cfg (+1/-0)
lib/canonical/config/schema-lazr.conf (+1/-1)
lib/canonical/database/enumcol.py (+29/-12)
lib/canonical/launchpad/doc/enumcol.txt (+35/-3)
lib/lp/bugs/browser/bugtask.py (+1/-52)
lib/lp/bugs/browser/tests/bugs-views.txt (+1/-1)
lib/lp/bugs/configure.zcml (+1/-0)
lib/lp/bugs/doc/bugtask-status-workflow.txt (+1/-1)
lib/lp/bugs/interfaces/bugtask.py (+19/-11)
lib/lp/bugs/model/bug.py (+10/-2)
lib/lp/bugs/model/bugtarget.py (+14/-1)
lib/lp/bugs/model/bugtask.py (+69/-30)
lib/lp/bugs/model/tests/test_bugtask.py (+2/-0)
lib/lp/bugs/model/tests/test_bugtask_status.py (+1/-1)
lib/lp/bugs/scripts/tests/test_bugimport.py (+1/-1)
lib/lp/registry/model/distribution.py (+2/-2)
lib/lp/registry/model/distributionsourcepackage.py (+2/-2)
lib/lp/scripts/garbo.py (+41/-0)
lib/lp/scripts/tests/test_garbo.py (+37/-0)
To merge this branch: bzr merge lp:~lifeless/launchpad/bug-759467
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+58262@code.launchpad.net

Commit message

[r=allenap][bug=759467] Change from storing INCOMPLETE to storing INCOMPLETE_WITH_RESPONSE and INCOMPLETE_WITHOUT_RESPONSE to permit more efficient future queries.

Description of the change

Change from storing INCOMPLETE to storing INCOMPLETE_WITH_RESPONSE and INCOMPLETE_WITHOUT_RESPONSE to permit more efficient future queries.

This required:
 - querying on both the current model and the new
 - extending enumcol to permit using both schemas
 - changing the BugTaskStatusSearch enum values to be intrinsically in the right sort order.
 - adding a migration task to the garbo
 - making BugTask.status a property
 - translating INCOMPLETE into INCOMPLETE_WITH[OUT]_RESPONSE in transitionToStatus
 - changing any INCOMPLETE[_WITHOUT_RESPONSE] into INCOMPLETE_WITH_RESPONSE in Bug.linkMessage.

We don't appear to filter on INCOMPLETE in non-nodowntime services so this can go directly into devel.

As a bonus I found some dead code to delete.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

I haven't [yet] checked for test fallout beyond the migration and interop stuff; I could possibly be convinced to add extra tests for this behaviour but I figure the current tests are sufficient as the contract isn't altered.

Revision history for this message
Gavin Panella (allenap) wrote :

Looks good.

- status = EnumCol(
+ _status = EnumCol(

Did you consider subclassing EnumCol instead of doing this?

Also, any other places that want to select with BugTask.status are
going to access an underscore property. It might have been clearer to
call it db_status or something like that.

review: Approve
Revision history for this message
Robert Collins (lifeless) wrote :

On the subclassing - I would have needed to change many things;
 - the ability to check N schemas
 - the policy about which schemas to return (the way we fold things onto INCOMPLETE)
 - add a backdoor to get the unmapped value (for code that cares e.g. the migrator)
 - add a policy for setting to decide when to permit and when to fold

The N schemas thing could be pulled up as I did into the base class; having done that a property neatly solves all the other issues with ~3 lines of code.

As for the name, We do try pretty hard to only query using BugTaskSet.search because of all the preloading complexities, so I'm not to worried. I'll happily land a s/_/db_/ in future if it starts to gross folk out :).

Thanks for the review!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2011-04-19 12:20:36 +0000
3+++ database/schema/security.cfg 2011-04-21 04:03:32 +0000
4@@ -2238,6 +2238,7 @@
5 public.bugnotificationfilter = SELECT, DELETE
6 public.bugnotificationrecipientarchive = SELECT
7 public.bugtag = SELECT
8+public.bugtask = SELECT, UPDATE
9 public.bugwatch = SELECT, UPDATE
10 public.bugwatchactivity = SELECT, DELETE
11 public.codeimportevent = SELECT, DELETE
12
13=== modified file 'lib/canonical/config/schema-lazr.conf'
14--- lib/canonical/config/schema-lazr.conf 2011-04-13 05:03:04 +0000
15+++ lib/canonical/config/schema-lazr.conf 2011-04-21 04:03:32 +0000
16@@ -1489,7 +1489,7 @@
17 max_comment_size: 3200
18
19 # The number of days of inactivity required before an unassigned
20-# bugtask with the status of INCOMPLETE is expired.
21+# bugtask with the status of INCOMPLETE_WITHOUT_RESPONSE is expired.
22 # datatype: integer
23 days_before_expiration: 60
24
25
26=== modified file 'lib/canonical/database/enumcol.py'
27--- lib/canonical/database/enumcol.py 2009-06-25 05:30:52 +0000
28+++ lib/canonical/database/enumcol.py 2011-04-21 04:03:32 +0000
29@@ -16,27 +16,46 @@
30 ]
31
32
33+def check_enum_type(enum):
34+ if not issubclass(enum, DBEnumeratedType):
35+ raise TypeError(
36+ '%r must be a DBEnumeratedType: %r' % (enum, type(enum)))
37+
38+
39+def check_type(enum):
40+ if type(enum) in (list, tuple):
41+ map(check_enum_type, enum)
42+ else:
43+ check_enum_type(enum)
44+
45+
46 class DBEnumVariable(Variable):
47 """A Storm variable class representing a DBEnumeratedType."""
48 __slots__ = ("_enum",)
49
50 def __init__(self, *args, **kwargs):
51- self._enum = kwargs.pop("enum")
52- if not issubclass(self._enum, DBEnumeratedType):
53- raise TypeError(
54- '%r must be a DBEnumeratedType: %r'
55- % (self._enum, type(self._enum)))
56+ enum = kwargs.pop("enum")
57+ if type(enum) not in (list, tuple):
58+ enum = (enum,)
59+ self._enum = enum
60+ check_type(self._enum)
61 super(DBEnumVariable, self).__init__(*args, **kwargs)
62
63 def parse_set(self, value, from_db):
64 if from_db:
65- return self._enum.items[value]
66+ for enum in self._enum:
67+ try:
68+ return enum.items[value]
69+ except KeyError:
70+ pass
71+ raise KeyError('%r not in present in any of %r' % (
72+ value, self._enum))
73 else:
74 if not zope_isinstance(value, DBItem):
75 raise TypeError("Not a DBItem: %r" % (value,))
76- if self._enum != value.enum:
77- raise TypeError("DBItem from wrong type, %r != %r" % (
78- self._enum.name, value.enum.name))
79+ if value.enum not in self._enum:
80+ raise TypeError("DBItem from unknown enum, %r not in %r" % (
81+ value.enum.name, self._enum))
82 return value
83
84 def parse_get(self, value, to_db):
85@@ -56,9 +75,7 @@
86 enum = kw.pop('enum')
87 except KeyError:
88 enum = kw.pop('schema')
89- if not issubclass(enum, DBEnumeratedType):
90- raise TypeError(
91- '%r must be a DBEnumeratedType: %r' % (enum, type(enum)))
92+ check_type(enum)
93 self._kwargs = {
94 'enum': enum
95 }
96
97=== modified file 'lib/canonical/launchpad/doc/enumcol.txt'
98--- lib/canonical/launchpad/doc/enumcol.txt 2009-04-17 10:32:16 +0000
99+++ lib/canonical/launchpad/doc/enumcol.txt 2011-04-21 04:03:32 +0000
100@@ -25,7 +25,7 @@
101 Attempting to use a normal enumerated type for an enumcol will
102 result in an error.
103
104- >>> from lazr.enum import EnumeratedType, Item
105+ >>> from lazr.enum import EnumeratedType, Item, use_template
106 >>> class PlainFooType(EnumeratedType):
107 ... """Enumerated type for the foo column."""
108 ... ONE = Item("One")
109@@ -100,7 +100,7 @@
110 >>> t.foo = AnotherType.ONE
111 Traceback (most recent call last):
112 ...
113- TypeError: DBItem from wrong type, 'FooType' != 'AnotherType'
114+ TypeError: DBItem from unknown enum, 'AnotherType' not in (<DBEnumeratedType 'FooType'>,)
115
116 The type assigned in must be the exact type, not a derived types.
117
118@@ -111,9 +111,41 @@
119 >>> t.foo = item
120 Traceback (most recent call last):
121 ...
122- TypeError: DBItem from wrong type, 'FooType' != 'DerivedType'
123+ TypeError: DBItem from unknown enum, 'DerivedType' not in (<DBEnumeratedType 'FooType'>,)
124
125 A simple way to assign in the correct item is to use the name of the derived
126 item to access the correct item from the base type.
127
128 >>> t.foo = FooType.items[item.name]
129+
130+Sometimes its useful to serialise things from two different (but related)
131+schemas into one table. This works if you tell the column about both enums:
132+
133+ >>> class BarType(DBEnumeratedType):
134+ ... use_template(FooType, exclude=('TWO'))
135+ ... THREE = DBItem(3, "Three")
136+
137+Redefine the table with awareness of BarType:
138+
139+ >>> class FooTest(SQLBase):
140+ ... foo = EnumCol(schema=[FooType, BarType], default=DEFAULT)
141+
142+We can assign items from either schema to the table;
143+
144+ >>> t = FooTest()
145+ >>> t.foo = FooType.ONE
146+ >>> t_id = t.id
147+ >>> b = FooTest()
148+ >>> b.foo = BarType.THREE
149+ >>> b_id = b.id
150+
151+And reading back from the database correctly finds things from the schemas in
152+the order given.
153+
154+ >>> from storm.store import AutoReload
155+ >>> b.foo = AutoReload
156+ >>> t.foo = AutoReload
157+ >>> b.foo == BarType.THREE
158+ True
159+ >>> t.foo == FooType.ONE
160+ True
161
162=== modified file 'lib/lp/bugs/browser/bugtask.py'
163--- lib/lp/bugs/browser/bugtask.py 2011-04-13 11:05:45 +0000
164+++ lib/lp/bugs/browser/bugtask.py 2011-04-21 04:03:32 +0000
165@@ -1812,7 +1812,7 @@
166 bug_task_set.buildUpstreamClause(upstream_open_bugs))
167 open_bugs = bug_task_set.open_bugtask_search
168 open_bugs.setTarget(self.context)
169- groups = (BugTask.status, BugTask.importance,
170+ groups = (BugTask._status, BugTask.importance,
171 Bug.latest_patch_uploaded != None, fixed_upstream_clause)
172 counts = bug_task_set.countBugs(open_bugs, groups)
173 # Sum the split out aggregates.
174@@ -1975,57 +1975,6 @@
175 return search_filter_url
176
177
178-def getInitialValuesFromSearchParams(search_params, form_schema):
179- """Build a dictionary that can be given as initial values to
180- setUpWidgets, based on the given search params.
181-
182- >>> initial = getInitialValuesFromSearchParams(
183- ... {'status': any(*UNRESOLVED_BUGTASK_STATUSES)}, IBugTaskSearch)
184- >>> for status in initial['status']:
185- ... print status.name
186- NEW
187- INCOMPLETE
188- CONFIRMED
189- TRIAGED
190- INPROGRESS
191- FIXCOMMITTED
192-
193- >>> initial = getInitialValuesFromSearchParams(
194- ... {'status': BugTaskStatus.INVALID}, IBugTaskSearch)
195- >>> [status.name for status in initial['status']]
196- ['INVALID']
197-
198- >>> initial = getInitialValuesFromSearchParams(
199- ... {'importance': [BugTaskImportance.CRITICAL,
200- ... BugTaskImportance.HIGH]}, IBugTaskSearch)
201- >>> [importance.name for importance in initial['importance']]
202- ['CRITICAL', 'HIGH']
203-
204- >>> getInitialValuesFromSearchParams(
205- ... {'assignee': NULL}, IBugTaskSearch)
206- {'assignee': None}
207- """
208- initial = {}
209- for key, value in search_params.items():
210- if IList.providedBy(form_schema[key]):
211- if isinstance(value, any):
212- value = value.query_values
213- elif isinstance(value, (list, tuple)):
214- value = value
215- else:
216- value = [value]
217- elif value == NULL:
218- value = None
219- else:
220- # Should be safe to pass value as it is to setUpWidgets, no need
221- # to worry
222- pass
223-
224- initial[key] = value
225-
226- return initial
227-
228-
229 class BugTaskListingItem:
230 """A decorated bug task.
231
232
233=== modified file 'lib/lp/bugs/browser/tests/bugs-views.txt'
234--- lib/lp/bugs/browser/tests/bugs-views.txt 2010-11-01 15:46:48 +0000
235+++ lib/lp/bugs/browser/tests/bugs-views.txt 2011-04-21 04:03:32 +0000
236@@ -21,7 +21,7 @@
237 >>> from lp.bugs.interfaces.bugtask import BugTaskStatus
238 >>> from lp.bugs.model.bugtask import BugTask
239 >>> [bugtask.bug.id
240- ... for bugtask in BugTask.selectBy(status=BugTaskStatus.FIXRELEASED)]
241+ ... for bugtask in BugTask.selectBy(_status=BugTaskStatus.FIXRELEASED)]
242 [8]
243 >>> for bug in bugs_view.getMostRecentlyFixedBugs():
244 ... print "%s: %s" % (bug.id, bug.title)
245
246=== modified file 'lib/lp/bugs/configure.zcml'
247--- lib/lp/bugs/configure.zcml 2011-04-11 12:52:27 +0000
248+++ lib/lp/bugs/configure.zcml 2011-04-21 04:03:32 +0000
249@@ -221,6 +221,7 @@
250 distribution
251 distroseries
252 milestone
253+ _status
254 status
255 statusexplanation
256 importance
257
258=== modified file 'lib/lp/bugs/doc/bugtask-status-workflow.txt'
259--- lib/lp/bugs/doc/bugtask-status-workflow.txt 2010-10-18 22:24:59 +0000
260+++ lib/lp/bugs/doc/bugtask-status-workflow.txt 2011-04-21 04:03:32 +0000
261@@ -57,7 +57,7 @@
262 >>> ubuntu_firefox_task.status = BugTaskStatus.CONFIRMED
263 Traceback (most recent call last):
264 ...
265- ForbiddenAttribute: ...
266+ AttributeError: ...
267
268 >>> print ubuntu_firefox_task.status.title
269 New
270
271=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
272--- lib/lp/bugs/interfaces/bugtask.py 2011-04-12 12:16:03 +0000
273+++ lib/lp/bugs/interfaces/bugtask.py 2011-04-21 04:03:32 +0000
274@@ -18,6 +18,7 @@
275 'BugTaskStatusSearch',
276 'BugTaskStatusSearchDisplay',
277 'ConjoinedBugTaskEditError',
278+ 'DB_UNRESOLVED_BUGTASK_STATUSES',
279 'DEFAULT_SEARCH_BUGTASK_STATUSES_FOR_DISPLAY',
280 'IAddBugTaskForm',
281 'IAddBugTaskWithProductCreationForm',
282@@ -201,6 +202,11 @@
283 this product or source package.
284 """)
285
286+ # INCOMPLETE is never actually stored now: INCOMPLETE_WITH_RESPONSE and
287+ # INCOMPLETE_WITHOUT_RESPONSE are mapped to INCOMPLETE on read, and on
288+ # write INCOMPLETE is mapped to INCOMPLETE_WITHOUT_RESPONSE. This permits
289+ # An index on the INCOMPLETE_WITH*_RESPONSE queries that the webapp
290+ # generates.
291 INCOMPLETE = DBItem(15, """
292 Incomplete
293
294@@ -274,10 +280,6 @@
295 affected software.
296 """)
297
298- # DBItem values 35 and 40 are used by
299- # BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE and
300- # BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
301-
302 UNKNOWN = DBItem(999, """
303 Unknown
304
305@@ -292,19 +294,14 @@
306 """
307 use_template(BugTaskStatus, exclude=('UNKNOWN'))
308
309- sort_order = (
310- 'NEW', 'INCOMPLETE_WITH_RESPONSE', 'INCOMPLETE_WITHOUT_RESPONSE',
311- 'INCOMPLETE', 'OPINION', 'INVALID', 'WONTFIX', 'EXPIRED',
312- 'CONFIRMED', 'TRIAGED', 'INPROGRESS', 'FIXCOMMITTED', 'FIXRELEASED')
313-
314- INCOMPLETE_WITH_RESPONSE = DBItem(35, """
315+ INCOMPLETE_WITH_RESPONSE = DBItem(13, """
316 Incomplete (with response)
317
318 This bug has new information since it was last marked
319 as requiring a response.
320 """)
321
322- INCOMPLETE_WITHOUT_RESPONSE = DBItem(40, """
323+ INCOMPLETE_WITHOUT_RESPONSE = DBItem(14, """
324 Incomplete (without response)
325
326 This bug requires more information, but no additional
327@@ -312,6 +309,7 @@
328 """)
329
330
331+
332 class BugTagsSearchCombinator(EnumeratedType):
333 """Bug Tags Search Combinator
334
335@@ -376,6 +374,12 @@
336 BugTaskStatus.INPROGRESS,
337 BugTaskStatus.FIXCOMMITTED)
338
339+# Actual values stored in the DB:
340+DB_UNRESOLVED_BUGTASK_STATUSES = UNRESOLVED_BUGTASK_STATUSES + (
341+ BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
342+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
343+ )
344+
345 RESOLVED_BUGTASK_STATUSES = (
346 BugTaskStatus.FIXRELEASED,
347 BugTaskStatus.OPINION,
348@@ -492,9 +496,13 @@
349 # bugwatch; this would be better described in a separate interface,
350 # but adding a marker interface during initialization is expensive,
351 # and adding it post-initialization is not trivial.
352+ # Note that status is a property because the model only exposes INCOMPLETE
353+ # but the DB stores INCOMPLETE_WITH_RESPONSE and
354+ # INCOMPLETE_WITHOUT_RESPONSE for query efficiency.
355 status = exported(
356 Choice(title=_('Status'), vocabulary=BugTaskStatus,
357 default=BugTaskStatus.NEW, readonly=True))
358+ _status = Attribute('The actual status DB column used in queries.')
359 importance = exported(
360 Choice(title=_('Importance'), vocabulary=BugTaskImportance,
361 default=BugTaskImportance.UNDECIDED, readonly=True))
362
363=== modified file 'lib/lp/bugs/model/bug.py'
364--- lib/lp/bugs/model/bug.py 2011-04-13 20:55:31 +0000
365+++ lib/lp/bugs/model/bug.py 2011-04-21 04:03:32 +0000
366@@ -154,9 +154,10 @@
367 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
368 from lp.bugs.interfaces.bugtask import (
369 BugTaskStatus,
370+ BugTaskStatusSearch,
371+ DB_UNRESOLVED_BUGTASK_STATUSES,
372 IBugTask,
373 IBugTaskSet,
374- UNRESOLVED_BUGTASK_STATUSES,
375 )
376 from lp.bugs.interfaces.bugtracker import BugTrackerType
377 from lp.bugs.interfaces.bugwatch import IBugWatchSet
378@@ -272,7 +273,7 @@
379 Join(BugTask, BugTask.bugID == BugTag.bugID),
380 )
381 where_conditions = [
382- BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES),
383+ BugTask._status.is_in(DB_UNRESOLVED_BUGTASK_STATUSES),
384 context_condition,
385 ]
386 if wanted_tags is not None:
387@@ -1136,6 +1137,13 @@
388 getUtility(IBugWatchSet).fromText(
389 message.text_contents, self, user)
390 self.findCvesInText(message.text_contents, user)
391+ for bugtask in self.bugtasks:
392+ # Check the stored value so we don't write to unaltered tasks.
393+ if (bugtask._status == BugTaskStatus.INCOMPLETE or
394+ bugtask._status == BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE):
395+ # This is not a semantic change, so we don't update date
396+ # records or send email.
397+ bugtask._status = BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
398 # XXX 2008-05-27 jamesh:
399 # Ensure that BugMessages get flushed in same order as
400 # they are created.
401
402=== modified file 'lib/lp/bugs/model/bugtarget.py'
403--- lib/lp/bugs/model/bugtarget.py 2011-03-29 15:59:22 +0000
404+++ lib/lp/bugs/model/bugtarget.py 2011-04-21 04:03:32 +0000
405@@ -48,6 +48,7 @@
406 BugTaskImportance,
407 BugTaskSearchParams,
408 BugTaskStatus,
409+ BugTaskStatusSearch,
410 RESOLVED_BUGTASK_STATUSES,
411 UNRESOLVED_BUGTASK_STATUSES,
412 )
413@@ -213,6 +214,9 @@
414 if statuses is None:
415 statuses = BugTaskStatus.items
416 statuses = list(statuses)
417+ if BugTaskStatus.INCOMPLETE in statuses:
418+ statuses.extend([BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
419+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE])
420
421 count_column = """
422 COUNT (CASE WHEN BugTask.status = %s
423@@ -232,7 +236,16 @@
424 "SELECT %s FROM BugTask, Bug WHERE %s" % (
425 ', '.join(select_columns), ' AND '.join(conditions)))
426 counts = cur.fetchone()
427- return dict(zip(statuses, counts))
428+ result = dict(zip(statuses, counts))
429+ def fold_status(from_st, to_st):
430+ if from_st in result:
431+ result[to_st] = result.get(to_st, 0) + result[from_st]
432+ del result[from_st]
433+ fold_status(BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
434+ BugTaskStatus.INCOMPLETE)
435+ fold_status(BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
436+ BugTaskStatus.INCOMPLETE)
437+ return result
438
439 def getBugTaskWeightFunction(self):
440 """Default weight function is the simple one."""
441
442=== modified file 'lib/lp/bugs/model/bugtask.py'
443--- lib/lp/bugs/model/bugtask.py 2011-04-11 12:52:27 +0000
444+++ lib/lp/bugs/model/bugtask.py 2011-04-21 04:03:32 +0000
445@@ -113,6 +113,7 @@
446 BugTaskStatus,
447 BugTaskStatusSearch,
448 ConjoinedBugTaskEditError,
449+ DB_UNRESOLVED_BUGTASK_STATUSES,
450 IBugTask,
451 IBugTaskDelta,
452 IBugTaskSet,
453@@ -475,7 +476,7 @@
454 _defaultOrder = ['distribution', 'product', 'productseries',
455 'distroseries', 'milestone', 'sourcepackagename']
456 _CONJOINED_ATTRIBUTES = (
457- "status", "importance", "assigneeID", "milestoneID",
458+ "_status", "importance", "assigneeID", "milestoneID",
459 "date_assigned", "date_confirmed", "date_inprogress",
460 "date_closed", "date_incomplete", "date_left_new",
461 "date_triaged", "date_fix_committed", "date_fix_released",
462@@ -507,9 +508,9 @@
463 dbName='milestone', foreignKey='Milestone',
464 notNull=False, default=None,
465 storm_validator=validate_conjoined_attribute)
466- status = EnumCol(
467+ _status = EnumCol(
468 dbName='status', notNull=True,
469- schema=BugTaskStatus,
470+ schema=(BugTaskStatus, BugTaskStatusSearch),
471 default=BugTaskStatus.NEW,
472 storm_validator=validate_status)
473 statusexplanation = StringCol(dbName='statusexplanation', default=None)
474@@ -561,6 +562,13 @@
475 dbName='targetnamecache', notNull=False, default=None)
476
477 @property
478+ def status(self):
479+ if (self._status == BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE or
480+ self._status == BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE):
481+ return BugTaskStatus.INCOMPLETE
482+ return self._status
483+
484+ @property
485 def title(self):
486 """See `IBugTask`."""
487 return 'Bug #%s in %s: "%s"' % (
488@@ -843,12 +851,21 @@
489 "Only Bug Supervisors may change status to %s." % (
490 new_status.title,))
491
492- if self.status == new_status:
493+ if new_status == BugTaskStatus.INCOMPLETE:
494+ # We store INCOMPLETE as INCOMPLETE_WITHOUT_RESPONSE so that it can
495+ # be queried on efficiently.
496+ if (when is None or self.bug.date_last_message is None or
497+ when > self.bug.date_last_message):
498+ new_status = BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
499+ else:
500+ new_status = BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
501+
502+ if self._status == new_status:
503 # No change in the status, so nothing to do.
504 return
505
506 old_status = self.status
507- self.status = new_status
508+ self._status = new_status
509
510 if new_status == BugTaskStatus.UNKNOWN:
511 # Ensure that all status-related dates are cleared,
512@@ -922,17 +939,18 @@
513 # Bugs can jump in and out of 'incomplete' status
514 # and for just as long as they're marked incomplete
515 # we keep a date_incomplete recorded for them.
516- if new_status == BugTaskStatus.INCOMPLETE:
517+ if new_status in (BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
518+ BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE):
519 self.date_incomplete = when
520 else:
521 self.date_incomplete = None
522
523- if ((old_status in UNRESOLVED_BUGTASK_STATUSES) and
524+ if ((old_status in DB_UNRESOLVED_BUGTASK_STATUSES) and
525 (new_status in RESOLVED_BUGTASK_STATUSES)):
526 self.date_closed = when
527
528 if ((old_status in RESOLVED_BUGTASK_STATUSES) and
529- (new_status in UNRESOLVED_BUGTASK_STATUSES)):
530+ (new_status in DB_UNRESOLVED_BUGTASK_STATUSES)):
531 self.date_left_closed = when
532
533 # Ensure that we don't have dates recorded for state
534@@ -940,7 +958,7 @@
535 # workflow state. We want to ensure that, for example, a
536 # bugtask that went New => Confirmed => New
537 # has a dateconfirmed value of None.
538- if new_status in UNRESOLVED_BUGTASK_STATUSES:
539+ if new_status in DB_UNRESOLVED_BUGTASK_STATUSES:
540 self.date_closed = None
541
542 if new_status < BugTaskStatus.CONFIRMED:
543@@ -1601,29 +1619,45 @@
544 elif zope_isinstance(status, not_equals):
545 return '(NOT %s)' % self._buildStatusClause(status.value)
546 elif zope_isinstance(status, BaseItem):
547+ incomplete_response = (
548+ status == BugTaskStatus.INCOMPLETE)
549 with_response = (
550 status == BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE)
551 without_response = (
552 status == BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE)
553+ # TODO: bug 759467 tracks the migration of INCOMPLETE in the db to
554+ # INCOMPLETE_WITH_RESPONSE and INCOMPLETE_WITHOUT_RESPONSE. When
555+ # the migration is complete, we can convert status lookups to a
556+ # simple IN clause.
557 if with_response or without_response:
558- status_clause = (
559- '(BugTask.status = %s) ' %
560- sqlvalues(BugTaskStatus.INCOMPLETE))
561 if with_response:
562- status_clause += ("""
563+ return """(
564+ BugTask.status = %s OR
565+ (BugTask.status = %s
566 AND (Bug.date_last_message IS NOT NULL
567 AND BugTask.date_incomplete <=
568- Bug.date_last_message)
569- """)
570+ Bug.date_last_message)))
571+ """ % sqlvalues(
572+ BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
573+ BugTaskStatus.INCOMPLETE)
574 elif without_response:
575- status_clause += ("""
576+ return """(
577+ BugTask.status = %s OR
578+ (BugTask.status = %s
579 AND (Bug.date_last_message IS NULL
580 OR BugTask.date_incomplete >
581- Bug.date_last_message)
582- """)
583- else:
584- assert with_response != without_response
585- return status_clause
586+ Bug.date_last_message)))
587+ """ % sqlvalues(
588+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
589+ BugTaskStatus.INCOMPLETE)
590+ assert with_response != without_response
591+ elif incomplete_response:
592+ # search for any of INCOMPLETE (being migrated from),
593+ # INCOMPLETE_WITH_RESPONSE or INCOMPLETE_WITHOUT_RESPONSE
594+ return 'BugTask.status %s' % search_value_to_where_condition(
595+ any(BugTaskStatus.INCOMPLETE,
596+ BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
597+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE))
598 else:
599 return '(BugTask.status = %s)' % sqlvalues(status)
600 else:
601@@ -1660,7 +1694,7 @@
602 And(ConjoinedMaster.bugID == BugTask.bugID,
603 BugTask.distributionID == milestone.distribution.id,
604 ConjoinedMaster.distroseriesID == current_series.id,
605- Not(ConjoinedMaster.status.is_in(
606+ Not(ConjoinedMaster._status.is_in(
607 BugTask._NON_CONJOINED_STATUSES))))
608 join_tables = [(ConjoinedMaster, join)]
609 else:
610@@ -1680,7 +1714,7 @@
611 And(ConjoinedMaster.bugID == BugTask.bugID,
612 ConjoinedMaster.productseriesID
613 == Product.development_focusID,
614- Not(ConjoinedMaster.status.is_in(
615+ Not(ConjoinedMaster._status.is_in(
616 BugTask._NON_CONJOINED_STATUSES)))),
617 ]
618 # join.right is the table name.
619@@ -1693,7 +1727,7 @@
620 And(ConjoinedMaster.bugID == BugTask.bugID,
621 BugTask.productID == milestone.product.id,
622 ConjoinedMaster.productseriesID == dev_focus_id,
623- Not(ConjoinedMaster.status.is_in(
624+ Not(ConjoinedMaster._status.is_in(
625 BugTask._NON_CONJOINED_STATUSES))))
626 join_tables = [(ConjoinedMaster, join)]
627 else:
628@@ -2190,6 +2224,8 @@
629 statuses_for_open_tasks = [
630 BugTaskStatus.NEW,
631 BugTaskStatus.INCOMPLETE,
632+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
633+ BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
634 BugTaskStatus.CONFIRMED,
635 BugTaskStatus.INPROGRESS,
636 BugTaskStatus.UNKNOWN]
637@@ -2601,7 +2637,7 @@
638
639 non_target_create_params = dict(
640 bug=bug,
641- status=status,
642+ _status=status,
643 importance=importance,
644 assignee=assignee,
645 owner=owner,
646@@ -2736,14 +2772,16 @@
647 """ + target_clause + """
648 """ + bug_clause + """
649 """ + bug_privacy_filter + """
650- AND BugTask.status = %s
651+ AND BugTask.status in (%s, %s, %s)
652 AND BugTask.assignee IS NULL
653 AND BugTask.milestone IS NULL
654 AND Bug.duplicateof IS NULL
655 AND Bug.date_last_updated < CURRENT_TIMESTAMP
656 AT TIME ZONE 'UTC' - interval '%s days'
657 AND BugWatch.id IS NULL
658- )""" % sqlvalues(BugTaskStatus.INCOMPLETE, min_days_old)
659+ )""" % sqlvalues(BugTaskStatus.INCOMPLETE,
660+ BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
661+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE, min_days_old)
662 expirable_bugtasks = BugTask.select(
663 query + unconfirmed_bug_condition,
664 clauseTables=['Bug'],
665@@ -2761,6 +2799,7 @@
666 """
667 statuses_not_preventing_expiration = [
668 BugTaskStatus.INVALID, BugTaskStatus.INCOMPLETE,
669+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
670 BugTaskStatus.WONTFIX]
671
672 unexpirable_status_list = [
673@@ -2906,7 +2945,7 @@
674 ]
675
676 product_ids = [product.id for product in products]
677- conditions = And(BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES),
678+ conditions = And(BugTask._status.is_in(DB_UNRESOLVED_BUGTASK_STATUSES),
679 Bug.duplicateof == None,
680 BugTask.productID.is_in(product_ids))
681
682@@ -2934,7 +2973,7 @@
683 # TODO: sort by their name?
684 "assignee": BugTask.assigneeID,
685 "targetname": BugTask.targetnamecache,
686- "status": BugTask.status,
687+ "status": BugTask._status,
688 "title": Bug.title,
689 "milestone": BugTask.milestoneID,
690 "dateassigned": BugTask.date_assigned,
691@@ -3041,7 +3080,7 @@
692
693 open_bugs_cond = (
694 'BugTask.status %s' % search_value_to_where_condition(
695- any(*UNRESOLVED_BUGTASK_STATUSES)))
696+ any(*DB_UNRESOLVED_BUGTASK_STATUSES)))
697
698 sum_template = "SUM(CASE WHEN %s THEN 1 ELSE 0 END) AS %s"
699 sums = [
700
701=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
702--- lib/lp/bugs/model/tests/test_bugtask.py 2011-04-11 12:52:27 +0000
703+++ lib/lp/bugs/model/tests/test_bugtask.py 2011-04-21 04:03:32 +0000
704@@ -30,6 +30,7 @@
705 BugTaskImportance,
706 BugTaskSearchParams,
707 BugTaskStatus,
708+ DB_UNRESOLVED_BUGTASK_STATUSES,
709 IBugTaskSet,
710 IUpstreamBugTask,
711 RESOLVED_BUGTASK_STATUSES,
712@@ -1353,6 +1354,7 @@
713 """
714 self.assertNotIn(BugTaskStatus.UNKNOWN, RESOLVED_BUGTASK_STATUSES)
715 self.assertNotIn(BugTaskStatus.UNKNOWN, UNRESOLVED_BUGTASK_STATUSES)
716+ self.assertNotIn(BugTaskStatus.UNKNOWN, DB_UNRESOLVED_BUGTASK_STATUSES)
717
718
719 class TestBugTaskContributor(TestCaseWithFactory):
720
721=== modified file 'lib/lp/bugs/model/tests/test_bugtask_status.py'
722--- lib/lp/bugs/model/tests/test_bugtask_status.py 2011-03-23 16:28:51 +0000
723+++ lib/lp/bugs/model/tests/test_bugtask_status.py 2011-04-21 04:03:32 +0000
724@@ -181,7 +181,7 @@
725 team = self.factory.makeTeam(members=[self.reporter])
726 team_bug = self.factory.makeBug(owner=team)
727 naked_task = removeSecurityProxy(team_bug.default_bugtask)
728- naked_task.status = BugTaskStatus.FIXRELEASED
729+ naked_task._status = BugTaskStatus.FIXRELEASED
730 with person_logged_in(self.reporter):
731 team_bug.default_bugtask.transitionToStatus(
732 BugTaskStatus.CONFIRMED, self.reporter)
733
734=== modified file 'lib/lp/bugs/scripts/tests/test_bugimport.py'
735--- lib/lp/bugs/scripts/tests/test_bugimport.py 2010-12-16 17:01:06 +0000
736+++ lib/lp/bugs/scripts/tests/test_bugimport.py 2011-04-21 04:03:32 +0000
737@@ -845,7 +845,7 @@
738 if bugtask.conjoined_master is not None:
739 continue
740 bugtask = removeSecurityProxy(bugtask)
741- bugtask.status = new_malone_status
742+ bugtask._status = new_malone_status
743 if self.failing:
744 cur = cursor()
745 cur.execute("""
746
747=== modified file 'lib/lp/registry/model/distribution.py'
748--- lib/lp/registry/model/distribution.py 2011-04-15 15:08:20 +0000
749+++ lib/lp/registry/model/distribution.py 2011-04-21 04:03:32 +0000
750@@ -106,7 +106,7 @@
751 from lp.bugs.interfaces.bugtarget import IHasBugHeat
752 from lp.bugs.interfaces.bugtask import (
753 BugTaskStatus,
754- UNRESOLVED_BUGTASK_STATUSES,
755+ DB_UNRESOLVED_BUGTASK_STATUSES,
756 )
757 from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
758 from lp.bugs.model.bug import (
759@@ -1720,7 +1720,7 @@
760 'triaged': quote(BugTaskStatus.TRIAGED),
761 'limit': limit,
762 'distro': self.id,
763- 'unresolved': quote(UNRESOLVED_BUGTASK_STATUSES),
764+ 'unresolved': quote(DB_UNRESOLVED_BUGTASK_STATUSES),
765 'excluded_packages': quote(exclude_packages),
766 })
767 counts = cur.fetchall()
768
769=== modified file 'lib/lp/registry/model/distributionsourcepackage.py'
770--- lib/lp/registry/model/distributionsourcepackage.py 2011-04-12 06:21:39 +0000
771+++ lib/lp/registry/model/distributionsourcepackage.py 2011-04-21 04:03:32 +0000
772@@ -40,7 +40,7 @@
773 from canonical.lazr.utils import smartquote
774 from lp.answers.interfaces.questiontarget import IQuestionTarget
775 from lp.bugs.interfaces.bugtarget import IHasBugHeat
776-from lp.bugs.interfaces.bugtask import UNRESOLVED_BUGTASK_STATUSES
777+from lp.bugs.interfaces.bugtask import DB_UNRESOLVED_BUGTASK_STATUSES
778 from lp.bugs.model.bug import (
779 Bug,
780 BugSet,
781@@ -219,7 +219,7 @@
782 BugTask.distributionID == self.distribution.id,
783 BugTask.sourcepackagenameID == self.sourcepackagename.id,
784 Bug.duplicateof == None,
785- BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES)).one()
786+ BugTask._status.is_in(DB_UNRESOLVED_BUGTASK_STATUSES)).one()
787
788 # Aggregate functions return NULL if zero rows match.
789 row = list(row)
790
791=== modified file 'lib/lp/scripts/garbo.py'
792--- lib/lp/scripts/garbo.py 2011-04-18 15:40:02 +0000
793+++ lib/lp/scripts/garbo.py 2011-04-21 04:03:32 +0000
794@@ -54,9 +54,14 @@
795 MASTER_FLAVOR,
796 )
797 from lp.bugs.interfaces.bug import IBugSet
798+from lp.bugs.interfaces.bugtask import (
799+ BugTaskStatus,
800+ BugTaskStatusSearch,
801+ )
802 from lp.bugs.model.bug import Bug
803 from lp.bugs.model.bugattachment import BugAttachment
804 from lp.bugs.model.bugnotification import BugNotification
805+from lp.bugs.model.bugtask import BugTask
806 from lp.bugs.model.bugwatch import BugWatchActivity
807 from lp.bugs.scripts.checkwatches.scheduler import (
808 BugWatchScheduler,
809@@ -730,6 +735,41 @@
810 transaction.commit()
811
812
813+class BugTaskIncompleteMigrator(TunableLoop):
814+ """Migrate BugTaskStatus 'INCOMPLETE' to a concrete WITH/WITHOUT value."""
815+
816+ maximum_chunk_size = 20000
817+ minimum_chunk_size = 100
818+
819+ def __init__(self, log, abort_time=None, max_heat_age=None):
820+ super(BugTaskIncompleteMigrator, self).__init__(log, abort_time)
821+ self.transaction = transaction
822+ self.total_processed = 0
823+ self.is_done = False
824+ self.offset = 0
825+ self.store = IMasterStore(BugTask)
826+ self.query = self.store.find((BugTask, Bug),
827+ BugTask._status==BugTaskStatus.INCOMPLETE,
828+ BugTask.bugID==Bug.id)
829+
830+ def isDone(self):
831+ """See `ITunableLoop`."""
832+ return self.query.is_empty()
833+
834+ def __call__(self, chunk_size):
835+ """See `ITunableLoop`."""
836+ transaction.begin()
837+ tasks = list(self.query[:chunk_size])
838+ for (task, bug) in tasks:
839+ if (bug.date_last_message is None or
840+ task.date_incomplete > bug.date_last_message):
841+ task._status = BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
842+ else:
843+ task._status = BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
844+ self.log.debug("Updated status on %d tasks" % len(tasks))
845+ transaction.commit()
846+
847+
848 class BugWatchActivityPruner(BulkPruner):
849 """A TunableLoop to prune BugWatchActivity entries."""
850 target_table_class = BugWatchActivity
851@@ -1050,6 +1090,7 @@
852 UnusedSessionPruner,
853 DuplicateSessionPruner,
854 BugHeatUpdater,
855+ BugTaskIncompleteMigrator,
856 ]
857 experimental_tunable_loops = []
858
859
860=== modified file 'lib/lp/scripts/tests/test_garbo.py'
861--- lib/lp/scripts/tests/test_garbo.py 2011-04-15 04:56:44 +0000
862+++ lib/lp/scripts/tests/test_garbo.py 2011-04-21 04:03:32 +0000
863@@ -52,10 +52,15 @@
864 LaunchpadZopelessLayer,
865 ZopelessDatabaseLayer,
866 )
867+from lp.bugs.interfaces.bugtask import (
868+ BugTaskStatus,
869+ BugTaskStatusSearch,
870+ )
871 from lp.bugs.model.bugnotification import (
872 BugNotification,
873 BugNotificationRecipient,
874 )
875+from lp.bugs.model.bugtask import BugTask
876 from lp.code.bzr import (
877 BranchFormat,
878 RepositoryFormat,
879@@ -768,6 +773,38 @@
880 BugNotification.date_emailed < THIRTY_DAYS_AGO).count(),
881 0)
882
883+ def test_BugTaskIncompleteMigrator(self):
884+ # BugTasks with status INCOMPLETE should be either
885+ # INCOMPLETE_WITHOUT_RESPONSE or INCOMPLETE_WITH_RESPONSE.
886+ # Create a bug with two tasks set to INCOMPLETE and a comment between
887+ # them.
888+ LaunchpadZopelessLayer.switchDbUser('testadmin')
889+ store = IMasterStore(BugTask)
890+ bug = self.factory.makeBug()
891+ with_response = bug.bugtasks[0]
892+ with_response.transitionToStatus(BugTaskStatus.INCOMPLETE, bug.owner)
893+ removeSecurityProxy(with_response)._status = BugTaskStatus.INCOMPLETE
894+ store.flush()
895+ transaction.commit()
896+ self.factory.makeBugComment(bug=bug)
897+ transaction.commit()
898+ without_response = self.factory.makeBugTask(bug=bug)
899+ without_response.transitionToStatus(BugTaskStatus.INCOMPLETE, bug.owner)
900+ removeSecurityProxy(without_response
901+ )._status = BugTaskStatus.INCOMPLETE
902+ transaction.commit()
903+ self.runHourly()
904+ self.assertEqual(1,
905+ store.find(BugTask.id,
906+ BugTask.id==with_response.id,
907+ BugTask._status==BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
908+ ).count())
909+ self.assertEqual(1,
910+ store.find(BugTask.id,
911+ BugTask.id==without_response.id,
912+ BugTask._status==
913+ BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE).count())
914+
915 def test_BranchJobPruner(self):
916 # Garbo should remove jobs completed over 30 days ago.
917 LaunchpadZopelessLayer.switchDbUser('testadmin')