Merge ~pappacena/launchpad:storm_select_related into launchpad:master

Proposed by Thiago F. Pappacena
Status: Needs review
Proposed branch: ~pappacena/launchpad:storm_select_related
Merge into: launchpad:master
Diff against target: 902 lines (+525/-20)
12 files modified
database/schema/security.cfg (+4/-1)
lib/lp/services/database/configure.zcml (+60/-0)
lib/lp/services/database/select_related.py (+145/-0)
lib/lp/services/database/tests/test_select_related.py (+57/-0)
lib/lp/soyuz/browser/queue.py (+28/-4)
lib/lp/soyuz/browser/tests/test_queue.py (+39/-4)
lib/lp/soyuz/configure.zcml (+7/-1)
lib/lp/soyuz/interfaces/queue.py (+32/-1)
lib/lp/soyuz/model/queue.py (+79/-5)
lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt (+25/-1)
lib/lp/soyuz/templates/distroseries-queue.pt (+10/-0)
lib/lp/soyuz/tests/test_packageupload.py (+39/-3)
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+377968@code.launchpad.net

Commit message

[DONT MERGE] Adding an easy way to select related objects on Storm (discussion only)

Description of the change

I'm creating this MP in order to have a place to discuss an initial idea. Please don't merge it, since it's probably a better idea to propose this change on Storm, if others would think this could be a good idea.

One problem we have today in LP is that, in order to prefetch related objects (reviewer Person from PackageUploadLog, for example), we are first fetching the list of the first objects (PackageUploadLog), getting the related IDs and issuing another query to fetch the second class of objects (reviewers/Person).

This way is obviously better than doing one query per PackageUploadLog, but it's still one query worst than it would be if we just LEFT JOIN PackageUploadLog with Person in this simple scenario (ignoring the other complexities of Person entity here).

Django has an easy way to solve this with something like `PackageUploadLog.objects.all().select_related('reviewer')`. I couldn't find an easy way to do it on Storm ORM without manually joining the table and setting the condition. Way more verbose than Django requires.

The idea here is to implement something similar to what we have in Django:
`resultset = store.find(PackageUploadLog).select_related(PackageUploadLog.reviewer)` would join both tables with a left join, fill the store with all objects in a way that, when doing `[p.reviwer for p in resultset]` after, it would fetch from local store and no extra query would be needed.

This branch already works and doesn't introduce regressions on LP (although the code is terribly ugly and not well tested; I would need to work the implementation details with Storm maintainers - if any!)

Implementing something similar to Django's `prefetch_related` (to fetch multiple objects) could be a future possibility.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

I think I see where you're going with this, and in principle it seems like a reasonable thing to add. This is almost a worst case for a third-party extension to Storm though; you're having to contort things a bit to cope with being in a subclass, and I'd find it a *lot* easier to review as an MP against lp:storm itself. (The people more or less active in maintaining Storm at the moment seem to be me and Simon Poirier.)

I'd also suggest talking with Free Ekanayaka, who's still around (though not working on Storm much) and you should be able to get hold of him. It was a long time ago and I'm not quite sure where it went, but https://bazaar.launchpad.net/~free.ekanayaka/storm/eager-loading/revision/361 seems like it's trying to do some of the same things that you're trying to do, and to my mind looks rather cleaner and better-integrated with Storm's reference mechanisms; so it would be worth asking him whether there were any fundamental obstacles there. I haven't reviewed either approach in a lot of fine detail, though, and both would need lots of testing.

Unmerged commits

661606c... by Thiago F. Pappacena

comments

f32f75a... by Thiago F. Pappacena

doing the queries on the resultset

6f96dc5... by Thiago F. Pappacena

checkpoint

768adab... by Thiago F. Pappacena

kind of working with left join, bug failing test

81c973d... by Thiago F. Pappacena

working for flat joins but only when there are objects

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 06da0d4..2844a30 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -1,4 +1,4 @@
1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
3#3#
4# Possible permissions: SELECT, INSERT, UPDATE, EXECUTE4# Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
@@ -1080,6 +1080,7 @@ public.packagesetgroup = SELECT, INSERT
1080public.packagesetinclusion = SELECT, INSERT1080public.packagesetinclusion = SELECT, INSERT
1081public.packagesetsources = SELECT, INSERT1081public.packagesetsources = SELECT, INSERT
1082public.packageupload = SELECT, INSERT, UPDATE1082public.packageupload = SELECT, INSERT, UPDATE
1083public.packageuploadlog = SELECT
1083public.packageuploadsource = SELECT1084public.packageuploadsource = SELECT
1084public.packageuploadbuild = SELECT1085public.packageuploadbuild = SELECT
1085public.packageuploadcustom = SELECT, INSERT1086public.packageuploadcustom = SELECT, INSERT
@@ -1242,6 +1243,7 @@ public.ociprojectname = SELECT, INSERT, UPDATE
1242public.ociprojectseries = SELECT, INSERT, UPDATE, DELETE1243public.ociprojectseries = SELECT, INSERT, UPDATE, DELETE
1243public.openididentifier = SELECT1244public.openididentifier = SELECT
1244public.packageupload = SELECT, INSERT, UPDATE1245public.packageupload = SELECT, INSERT, UPDATE
1246public.packageuploadlog = SELECT, INSERT
1245public.packageuploadbuild = SELECT, INSERT, UPDATE1247public.packageuploadbuild = SELECT, INSERT, UPDATE
1246public.packageuploadcustom = SELECT, INSERT, UPDATE1248public.packageuploadcustom = SELECT, INSERT, UPDATE
1247public.packageuploadsource = SELECT, INSERT, UPDATE1249public.packageuploadsource = SELECT, INSERT, UPDATE
@@ -2293,6 +2295,7 @@ public.packagediff = SELECT, UPDATE
2293public.packageset = SELECT, UPDATE2295public.packageset = SELECT, UPDATE
2294public.packagesetgroup = SELECT, UPDATE2296public.packagesetgroup = SELECT, UPDATE
2295public.packageupload = SELECT, UPDATE2297public.packageupload = SELECT, UPDATE
2298public.packageuploadlog = SELECT, UPDATE
2296public.packaging = SELECT, UPDATE2299public.packaging = SELECT, UPDATE
2297public.person = SELECT, UPDATE2300public.person = SELECT, UPDATE
2298public.personlanguage = SELECT, UPDATE2301public.personlanguage = SELECT, UPDATE
diff --git a/lib/lp/services/database/configure.zcml b/lib/lp/services/database/configure.zcml
index 3795b28..d21caea 100644
--- a/lib/lp/services/database/configure.zcml
+++ b/lib/lp/services/database/configure.zcml
@@ -17,4 +17,64 @@
17 <allow attributes="__getslice__ get_plain_result_set" />17 <allow attributes="__getslice__ get_plain_result_set" />
18 </class>18 </class>
1919
20 <class class="lp.services.database.select_related.ResultSet">
21 <allow attributes="
22 __class__
23 __contains__
24 __delattr__
25 __dict__
26 __doc__
27 __format__
28 __getattribute__
29 __getitem__
30 __getslice__
31 __hash__
32 __implemented__
33 __init__
34 __iter__
35 __module__
36 __new__
37 __providedBy__
38 __provides__
39 __reduce__
40 __reduce_ex__
41 __repr__
42 __setattr__
43 __sizeof__
44 __str__
45 __subclasshook__
46 __weakref__
47 _aggregate
48 _any
49 _get_select
50 _load_objects
51 _set_expr
52 any
53 avg
54 cached
55 config
56 copy
57 count
58 difference
59 find
60 first
61 get_select_expr
62 group_by
63 having
64 intersection
65 is_empty
66 last
67 max
68 min
69 one
70 order_by
71 remove
72 set
73 sum
74 union
75 values
76 select_related"
77 />
78 </class>
79
20</configure>80</configure>
diff --git a/lib/lp/services/database/select_related.py b/lib/lp/services/database/select_related.py
21new file mode 10064481new file mode 100644
index 0000000..eabf546
--- /dev/null
+++ b/lib/lp/services/database/select_related.py
@@ -0,0 +1,145 @@
1from __future__ import absolute_import, print_function, unicode_literals
2
3__metaclass__ = type
4
5from storm import Undef
6from storm.expr import LeftJoin, compare_columns
7from storm.info import get_cls_info, ClassAlias
8from storm.properties import PropertyColumn
9from storm.store import (
10 ResultSet as StormResultSet, FindSpec as StormFindSpec, Store)
11
12
13class FindSpec(StormFindSpec):
14 def __init__(self, cls_spec, *args, **kwargs):
15 super(FindSpec, self).__init__(cls_spec, *args, **kwargs)
16 self.cls_spec = cls_spec
17 self._to_select_related = {}
18
19 @staticmethod
20 def tuplify(obj):
21 """Transform a given object in a tuple, if it's not yet one"""
22 if not isinstance(obj, tuple):
23 return (obj, )
24 return obj
25
26 def add_select_related(self, reference, cls_alias):
27 self._to_select_related[reference] = cls_alias
28
29 def filter_values_for_related(self, values, reference):
30 """From the given row, filter only the values refering to the given
31 reference
32
33 In summary, this method returns a tuple in the same format that
34 would be returned by selecting a row for only the given Reference
35 attribute (or None, for the main cls_spec given)
36 """
37 # If called with store.find(Column), do not try to filter anything
38 skip_filter = isinstance(self.cls_spec, PropertyColumn)
39 # If no select_related was asked, do not try to filter also
40 skip_filter = skip_filter or not len(self._to_select_related)
41 if skip_filter:
42 return values
43
44 cols, tables = self.get_columns_and_tables()
45 if reference is None:
46 table_alias = self.tuplify(self.cls_spec)
47 else:
48 table_alias = [self._to_select_related[reference]]
49
50 target_cols = set([i for i in cols if i.table in table_alias])
51 return tuple(
52 [value for col, value in zip(cols, values)
53 if col in target_cols])
54
55 def load_related_objects(self, store, result, values):
56 """Load into the store the objects fetched through
57 ResultSet.select_related (stored here as self._to_select_related)
58 """
59 for ref, cls_alias in self._to_select_related.items():
60 cls_info = get_cls_info(ref._relation.remote_cls)
61 ref_values = self.filter_values_for_related(values, ref)
62 try:
63 store._load_object(cls_info, result, ref_values)
64 except Exception, e:
65 import ipdb; ipdb.set_trace()
66
67 def load_objects(self, store, result, values):
68 """Load into the store the objects from this FindSpec, and also the
69 """
70 self.load_related_objects(store, result, values)
71 cls_values = self.filter_values_for_related(values, None)
72 result = super(FindSpec, self).load_objects(
73 store, result, cls_values)
74
75 # If the cls_spec contains spec of select_related things, exclude
76 # them from the final result
77 related_slice = len(self._to_select_related)
78 if related_slice:
79 result = result[:-related_slice]
80
81 # If it only got back a tuple because of select_related, returns
82 # the only object remaining (rather then a tuple)
83 if len(self.cls_spec) - 1 == related_slice:
84 return result[0]
85 else:
86 return result
87 return result
88
89
90class ResultSet(StormResultSet):
91 def __init__(self, *args, **kwargs):
92 super(ResultSet, self).__init__(*args, **kwargs)
93 self._to_select_related = []
94
95 def _get_left_join_condition(self, rel, cls_alias):
96 # Rebuild the foreign key condition using cls_alias instead of the
97 # original class name
98 remote_alias_key = tuple(
99 getattr(cls_alias, r.name) for r in rel.remote_key)
100 return compare_columns(rel.local_key, remote_alias_key)
101
102 def select_related(self, *args):
103 if len(args) == 0:
104 return self
105 rs = self.copy()
106
107 cls_spec = self._find_spec.cls_spec
108 if not isinstance(cls_spec, tuple):
109 cls_spec = (cls_spec,)
110 cls_spec = list(cls_spec)
111
112 if rs._tables == Undef:
113 rs._tables = [i for i in cls_spec]
114
115 cls_alias_per_ref = {}
116 for ref in args:
117 rel = ref._relation
118 cls_alias = ClassAlias(rel.remote_cls)
119 left_condition = self._get_left_join_condition(rel, cls_alias)
120 rs._tables.append(LeftJoin(cls_alias, left_condition))
121 cls_spec.append(cls_alias)
122 cls_alias_per_ref[ref] = cls_alias
123
124 # Rebuild find spec
125 rs._find_spec = FindSpec(tuple(cls_spec))
126 rs._find_spec._to_select_related = cls_alias_per_ref
127 return rs
128
129
130# Replace resultset by mine
131Store._result_set_factory = ResultSet
132
133# Replace FindSpec by mine on module level
134import storm.store
135storm.store.FindSpec = FindSpec
136
137# Replace the references from storm.Store that uses FindSpec
138original_find = Store.find
139
140def _find(self, cls_spec, *args, **kwargs):
141 resultset = original_find(self, cls_spec, *args, **kwargs)
142 resultset._find_spec = FindSpec(cls_spec)
143 return resultset
144
145Store.find = _find
diff --git a/lib/lp/services/database/tests/test_select_related.py b/lib/lp/services/database/tests/test_select_related.py
0new file mode 100644146new file mode 100644
index 0000000..55860f9
--- /dev/null
+++ b/lib/lp/services/database/tests/test_select_related.py
@@ -0,0 +1,57 @@
1from lp.bugs.model.bug import Bug
2from lp.services.database.select_related import ResultSet
3from lp.testing import TestCaseWithFactory, StormStatementRecorder, login
4from lp.testing.layers import DatabaseFunctionalLayer
5from lp.testing.matchers import HasQueryCount
6from lp.testing.sampledata import ADMIN_EMAIL
7from storm.store import Store
8from testtools.matchers import Equals
9from zope.security.proxy import removeSecurityProxy
10
11
12class TestLoaders(TestCaseWithFactory):
13 layer = DatabaseFunctionalLayer
14
15 def test_select_related_object_does_left_join(self):
16 # we have 11 bugs of the same owner
17 duplicate = self.factory.makeBug()
18 store = Store.of(duplicate)
19 bug1 = self.factory.makeBug() # this bug doesnt have dup
20 for i in range(10):
21 b = removeSecurityProxy(self.factory.makeBug(owner=bug1.owner))
22 # XXX: Test with left join
23 b.duplicateof = duplicate
24 store.add(duplicate)
25
26 # and 2 other random bugs
27 self.factory.makeBug()
28 self.factory.makeBug()
29
30 store.flush()
31 store.commit()
32
33 bugs_filter = Bug.owner == bug1.owner
34 store.invalidate()
35 # fetch bug1 again outside recorder, to do the match agains it bellow
36 bug1.owner
37 with StormStatementRecorder() as recorder:
38 rs = store.find(Bug, bugs_filter)
39 bugs = rs.select_related(
40 Bug.owner, Bug.who_made_private, Bug.duplicateof)
41
42 # One query for the count
43 self.assertEqual(11, bugs.count())
44
45 # This should trigger only one query to database
46 # (even if we are fetching related objects)
47 for bug in bugs:
48 bug = removeSecurityProxy(bug)
49 self.assertIsNotNone(bug.date_last_message)
50 self.assertEqual(bug.owner.name, bug1.owner.name)
51 self.assertIsNone(bug.who_made_private)
52 if bug.id == bug1.id:
53 self.assertIsNone(bug.duplicateof)
54 else:
55 self.assertIsNotNone(bug.duplicateof)
56
57 self.assertThat(recorder, HasQueryCount(Equals(2)))
0\ No newline at end of file58\ No newline at end of file
diff --git a/lib/lp/soyuz/browser/queue.py b/lib/lp/soyuz/browser/queue.py
index a476e1d..a036f11 100644
--- a/lib/lp/soyuz/browser/queue.py
+++ b/lib/lp/soyuz/browser/queue.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2017 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Browser views for package queue."""4"""Browser views for package queue."""
@@ -10,6 +10,7 @@ __all__ = [
10 'QueueItemsView',10 'QueueItemsView',
11 ]11 ]
1212
13from collections import defaultdict
13from operator import attrgetter14from operator import attrgetter
1415
15from lazr.delegates import delegate_to16from lazr.delegates import delegate_to
@@ -68,6 +69,7 @@ from lp.soyuz.model.files import (
68from lp.soyuz.model.packagecopyjob import PackageCopyJob69from lp.soyuz.model.packagecopyjob import PackageCopyJob
69from lp.soyuz.model.queue import (70from lp.soyuz.model.queue import (
70 PackageUploadBuild,71 PackageUploadBuild,
72 PackageUploadLog,
71 PackageUploadSource,73 PackageUploadSource,
72 )74 )
73from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease75from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
@@ -207,7 +209,23 @@ class QueueItemsView(LaunchpadView):
207 jobs = load_related(Job, package_copy_jobs, ['job_id'])209 jobs = load_related(Job, package_copy_jobs, ['job_id'])
208 person_ids.extend(map(attrgetter('requester_id'), jobs))210 person_ids.extend(map(attrgetter('requester_id'), jobs))
209 list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(211 list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
210 person_ids, need_validity=True, need_icon=True))212 person_ids, need_validity=True))
213
214 def getPreloadedLogsDict(self, uploads):
215 """Returns a dict of preloaded PackageUploadLog objects by
216 package_upload_id, to be used on preloading CompletePackageUpload
217 objects"""
218 logs = load_referencing(
219 PackageUploadLog, uploads, ['package_upload_id'])
220 logs.sort(key=attrgetter("date_created"), reverse=True)
221 list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
222 [log.reviewer_id for log in logs],
223 need_validity=True
224 ))
225 logs_dict = defaultdict(list)
226 for log in logs:
227 logs_dict[log.package_upload_id].append(log)
228 return logs_dict
211229
212 def decoratedQueueBatch(self):230 def decoratedQueueBatch(self):
213 """Return the current batch, converted to decorated objects.231 """Return the current batch, converted to decorated objects.
@@ -227,6 +245,9 @@ class QueueItemsView(LaunchpadView):
227 pubs = load_referencing(245 pubs = load_referencing(
228 PackageUploadBuild, uploads, ['packageuploadID'])246 PackageUploadBuild, uploads, ['packageuploadID'])
229247
248 # preload logs info
249 logs_dict = self.getPreloadedLogsDict(uploads)
250
230 source_sprs = load_related(251 source_sprs = load_related(
231 SourcePackageRelease, puses, ['sourcepackagereleaseID'])252 SourcePackageRelease, puses, ['sourcepackagereleaseID'])
232 bpbs = load_related(BinaryPackageBuild, pubs, ['buildID'])253 bpbs = load_related(BinaryPackageBuild, pubs, ['buildID'])
@@ -264,7 +285,8 @@ class QueueItemsView(LaunchpadView):
264285
265 return [286 return [
266 CompletePackageUpload(287 CompletePackageUpload(
267 item, build_upload_files, source_upload_files, package_sets)288 item, build_upload_files, source_upload_files, package_sets,
289 logs_dict[item.id])
268 for item in uploads]290 for item in uploads]
269291
270 def is_new(self, binarypackagerelease):292 def is_new(self, binarypackagerelease):
@@ -493,13 +515,14 @@ class CompletePackageUpload:
493 date_created = None515 date_created = None
494 sources = None516 sources = None
495 builds = None517 builds = None
518 logs = None
496 customfiles = None519 customfiles = None
497 contains_source = None520 contains_source = None
498 contains_build = None521 contains_build = None
499 sourcepackagerelease = None522 sourcepackagerelease = None
500523
501 def __init__(self, packageupload, build_upload_files,524 def __init__(self, packageupload, build_upload_files,
502 source_upload_files, package_sets):525 source_upload_files, package_sets, logs=None):
503 self.pocket = packageupload.pocket526 self.pocket = packageupload.pocket
504 self.date_created = packageupload.date_created527 self.date_created = packageupload.date_created
505 self.context = packageupload528 self.context = packageupload
@@ -507,6 +530,7 @@ class CompletePackageUpload:
507 self.contains_source = len(self.sources) > 0530 self.contains_source = len(self.sources) > 0
508 self.builds = list(packageupload.builds)531 self.builds = list(packageupload.builds)
509 self.contains_build = len(self.builds) > 0532 self.contains_build = len(self.builds) > 0
533 self.logs = list(logs) if logs is not None else None
510 self.customfiles = list(packageupload.customfiles)534 self.customfiles = list(packageupload.customfiles)
511535
512 # Create a dictionary of binary files keyed by536 # Create a dictionary of binary files keyed by
diff --git a/lib/lp/soyuz/browser/tests/test_queue.py b/lib/lp/soyuz/browser/tests/test_queue.py
index c2d15f2..0990845 100644
--- a/lib/lp/soyuz/browser/tests/test_queue.py
+++ b/lib/lp/soyuz/browser/tests/test_queue.py
@@ -1,4 +1,4 @@
1# Copyright 2010-2018 Canonical Ltd. This software is licensed under the1# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Unit tests for QueueItemsView."""4"""Unit tests for QueueItemsView."""
@@ -33,6 +33,7 @@ from lp.testing import (
33 login_person,33 login_person,
34 logout,34 logout,
35 person_logged_in,35 person_logged_in,
36 record_two_runs,
36 StormStatementRecorder,37 StormStatementRecorder,
37 TestCaseWithFactory,38 TestCaseWithFactory,
38 )39 )
@@ -342,10 +343,10 @@ class TestQueueItemsView(TestCaseWithFactory):
342343
343 layer = LaunchpadFunctionalLayer344 layer = LaunchpadFunctionalLayer
344345
345 def makeView(self, distroseries, user):346 def makeView(self, distroseries, user, form=None):
346 """Create a queue view."""347 """Create a queue view."""
347 return create_initialized_view(348 return create_initialized_view(
348 distroseries, name='+queue', principal=user)349 distroseries, name='+queue', principal=user, form=form)
349350
350 def test_view_renders_source_upload(self):351 def test_view_renders_source_upload(self):
351 login(ADMIN_EMAIL)352 login(ADMIN_EMAIL)
@@ -437,7 +438,41 @@ class TestQueueItemsView(TestCaseWithFactory):
437 with StormStatementRecorder() as recorder:438 with StormStatementRecorder() as recorder:
438 view = self.makeView(distroseries, queue_admin)439 view = self.makeView(distroseries, queue_admin)
439 view()440 view()
440 self.assertThat(recorder, HasQueryCount(Equals(56)))441 self.assertThat(recorder, HasQueryCount(Equals(57)))
442
443 def test_package_upload_with_logs_query_count(self):
444 login(ADMIN_EMAIL)
445 uploads = []
446 distroseries = self.factory.makeDistroSeries()
447
448 for i in range(50):
449 uploads.append(self.factory.makeSourcePackageUpload(distroseries))
450 queue_admin = self.factory.makeArchiveAdmin(distroseries.main_archive)
451
452 def reject_some_package():
453 for upload in uploads:
454 if len(upload.logs) == 0:
455 person = self.factory.makePerson()
456 upload.rejectFromQueue(person)
457 break
458
459 def run_view():
460 Store.of(uploads[0]).invalidate()
461 with person_logged_in(queue_admin):
462 form = {
463 "queue_state": PackageUploadStatus.REJECTED.value}
464 view = self.makeView(distroseries, queue_admin, form=form)
465 view()
466
467 recorder1, recorder2 = record_two_runs(
468 run_view, reject_some_package, 1, 10)
469 # XXX: The query count should be the same, but there is one query
470 # fetching PersonLocation that only happens in the first run (no
471 # matter how many times we create anything), and it's not related at
472 # all to the logs.
473 # self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
474 self.assertThat(recorder1, HasQueryCount(Equals(37)))
475 self.assertThat(recorder2, HasQueryCount(Equals(36)))
441476
442477
443class TestCompletePackageUpload(TestCaseWithFactory):478class TestCompletePackageUpload(TestCaseWithFactory):
diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
index 0e35ccb..f159d27 100644
--- a/lib/lp/soyuz/configure.zcml
+++ b/lib/lp/soyuz/configure.zcml
@@ -1,4 +1,4 @@
1<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -151,6 +151,7 @@
151 displayarchs151 displayarchs
152 displayversion152 displayversion
153 isPPA153 isPPA
154 logs
154 package_copy_job155 package_copy_job
155 package_copy_job_id156 package_copy_job_id
156 package_name157 package_name
@@ -183,6 +184,11 @@
183 set_attributes="status distroseries pocket changesfile archive"/>184 set_attributes="status distroseries pocket changesfile archive"/>
184 </class>185 </class>
185 <class186 <class
187 class="lp.soyuz.model.queue.PackageUploadLog">
188 <allow
189 interface="lp.soyuz.interfaces.queue.IPackageUploadLog"/>
190 </class>
191 <class
186 class="lp.soyuz.model.queue.PackageUploadSource">192 class="lp.soyuz.model.queue.PackageUploadSource">
187 <allow193 <allow
188 interface="lp.soyuz.interfaces.queue.IPackageUploadSource"/>194 interface="lp.soyuz.interfaces.queue.IPackageUploadSource"/>
diff --git a/lib/lp/soyuz/interfaces/queue.py b/lib/lp/soyuz/interfaces/queue.py
index c57da73..6f440b3 100644
--- a/lib/lp/soyuz/interfaces/queue.py
+++ b/lib/lp/soyuz/interfaces/queue.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Queue interfaces."""4"""Queue interfaces."""
@@ -11,6 +11,7 @@ __all__ = [
11 'IHasQueueItems',11 'IHasQueueItems',
12 'IPackageUploadQueue',12 'IPackageUploadQueue',
13 'IPackageUpload',13 'IPackageUpload',
14 'IPackageUploadLog',
14 'IPackageUploadBuild',15 'IPackageUploadBuild',
15 'IPackageUploadSource',16 'IPackageUploadSource',
16 'IPackageUploadCustom',17 'IPackageUploadCustom',
@@ -37,6 +38,7 @@ from lazr.restful.declarations import (
37 REQUEST_USER,38 REQUEST_USER,
38 )39 )
39from lazr.restful.fields import Reference40from lazr.restful.fields import Reference
41from twisted.words.im.interfaces import IPerson
40from zope.interface import (42from zope.interface import (
41 Attribute,43 Attribute,
42 Interface,44 Interface,
@@ -149,6 +151,8 @@ class IPackageUpload(Interface):
149 title=_('Date created'),151 title=_('Date created'),
150 description=_("The date this package upload was done.")))152 description=_("The date this package upload was done.")))
151153
154 logs = Attribute(_("The change log of this PackageUpload."))
155
152 changesfile = Attribute("The librarian alias for the changes file "156 changesfile = Attribute("The librarian alias for the changes file "
153 "associated with this upload")157 "associated with this upload")
154 changes_file_url = exported(158 changes_file_url = exported(
@@ -711,6 +715,33 @@ class IPackageUploadCustom(Interface):
711 """715 """
712716
713717
718class IPackageUploadLog(Interface):
719 id = Int(title=_('ID'), required=True, readonly=True)
720
721 package_upload = Reference(
722 IPackageUpload,
723 title=_("Original package upload."), required=True, readonly=True)
724
725 date_created = Datetime(
726 title=_("When this action happened."), required=True, readonly=True)
727
728 reviewer = Reference(
729 IPerson, title=_("Who did this action."),
730 required=True, readonly=True)
731
732 old_status = Choice(
733 vocabulary=PackageUploadStatus, description=_("Old status."),
734 required=True, readonly=True)
735
736 new_status = Choice(
737 vocabulary=PackageUploadStatus, description=_("New status."),
738 required=True, readonly=True)
739
740 comment = TextLine(
741 title=_("User's comment about this change."),
742 required=False, readonly=True)
743
744
714class IPackageUploadSet(Interface):745class IPackageUploadSet(Interface):
715 """Represents a set of IPackageUploads"""746 """Represents a set of IPackageUploads"""
716747
diff --git a/lib/lp/soyuz/model/queue.py b/lib/lp/soyuz/model/queue.py
index 0fd1799..d394e9e 100644
--- a/lib/lp/soyuz/model/queue.py
+++ b/lib/lp/soyuz/model/queue.py
@@ -1,18 +1,21 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
5__all__ = [5__all__ = [
6 'PackageUploadQueue',
7 'PackageUpload',6 'PackageUpload',
8 'PackageUploadBuild',7 'PackageUploadBuild',
9 'PackageUploadSource',
10 'PackageUploadCustom',8 'PackageUploadCustom',
9 'PackageUploadLog',
10 'PackageUploadLog',
11 'PackageUploadQueue',
11 'PackageUploadSet',12 'PackageUploadSet',
13 'PackageUploadSource',
12 ]14 ]
1315
14from itertools import chain16from itertools import chain
1517
18import pytz
16from sqlobject import (19from sqlobject import (
17 ForeignKey,20 ForeignKey,
18 SQLMultipleJoin,21 SQLMultipleJoin,
@@ -29,6 +32,7 @@ from storm.locals import (
29 SQL,32 SQL,
30 Unicode,33 Unicode,
31 )34 )
35from storm.properties import DateTime
32from storm.store import (36from storm.store import (
33 EmptyResultSet,37 EmptyResultSet,
34 Store,38 Store,
@@ -49,7 +53,10 @@ from lp.services.database.bulk import (
49from lp.services.database.constants import UTC_NOW53from lp.services.database.constants import UTC_NOW
50from lp.services.database.datetimecol import UtcDateTimeCol54from lp.services.database.datetimecol import UtcDateTimeCol
51from lp.services.database.decoratedresultset import DecoratedResultSet55from lp.services.database.decoratedresultset import DecoratedResultSet
52from lp.services.database.enumcol import EnumCol56from lp.services.database.enumcol import (
57 DBEnum,
58 EnumCol,
59 )
53from lp.services.database.interfaces import (60from lp.services.database.interfaces import (
54 IMasterStore,61 IMasterStore,
55 IStore,62 IStore,
@@ -58,6 +65,7 @@ from lp.services.database.sqlbase import (
58 SQLBase,65 SQLBase,
59 sqlvalues,66 sqlvalues,
60 )67 )
68from lp.services.database.stormbase import StormBase
61from lp.services.database.stormexpr import (69from lp.services.database.stormexpr import (
62 Array,70 Array,
63 ArrayContains,71 ArrayContains,
@@ -97,6 +105,7 @@ from lp.soyuz.interfaces.queue import (
97 IPackageUpload,105 IPackageUpload,
98 IPackageUploadBuild,106 IPackageUploadBuild,
99 IPackageUploadCustom,107 IPackageUploadCustom,
108 IPackageUploadLog,
100 IPackageUploadQueue,109 IPackageUploadQueue,
101 IPackageUploadSet,110 IPackageUploadSet,
102 IPackageUploadSource,111 IPackageUploadSource,
@@ -106,7 +115,7 @@ from lp.soyuz.interfaces.queue import (
106 QueueInconsistentStateError,115 QueueInconsistentStateError,
107 QueueSourceAcceptError,116 QueueSourceAcceptError,
108 QueueStateWriteProtectedError,117 QueueStateWriteProtectedError,
109 )118 IPackageUploadLog)
110from lp.soyuz.interfaces.section import ISectionSet119from lp.soyuz.interfaces.section import ISectionSet
111from lp.soyuz.mail.packageupload import PackageUploadMailer120from lp.soyuz.mail.packageupload import PackageUploadMailer
112from lp.soyuz.model.binarypackagename import BinaryPackageName121from lp.soyuz.model.binarypackagename import BinaryPackageName
@@ -203,6 +212,26 @@ class PackageUpload(SQLBase):
203 if self.package_copy_job:212 if self.package_copy_job:
204 self.addSearchableNames([self.package_copy_job.package_name])213 self.addSearchableNames([self.package_copy_job.package_name])
205 self.addSearchableVersions([self.package_copy_job.package_version])214 self.addSearchableVersions([self.package_copy_job.package_version])
215 self._logs = None
216
217 @property
218 def logs(self):
219 if self._logs is None:
220 logs = Store.of(self).find(
221 PackageUploadLog,
222 PackageUploadLog.package_upload == self)
223 self._logs = list(logs.order_by(Desc('date_created')))
224 return self._logs
225
226 def _addLog(self, reviewer, new_status, comment=None):
227 self._logs = None # clear local cache
228 return Store.of(self).add(PackageUploadLog(
229 package_upload=self,
230 reviewer=reviewer,
231 old_status=self.status,
232 new_status=new_status,
233 comment=comment
234 ))
206235
207 @cachedproperty236 @cachedproperty
208 def sources(self):237 def sources(self):
@@ -571,6 +600,10 @@ class PackageUpload(SQLBase):
571600
572 def acceptFromQueue(self, user=None):601 def acceptFromQueue(self, user=None):
573 """See `IPackageUpload`."""602 """See `IPackageUpload`."""
603 # XXX: Only tests are not passing user here. We should adjust the
604 # tests and always create the log entries after
605 if user is not None:
606 self._addLog(user, PackageUploadStatus.ACCEPTED, None)
574 if self.package_copy_job is None:607 if self.package_copy_job is None:
575 self._acceptNonSyncFromQueue()608 self._acceptNonSyncFromQueue()
576 else:609 else:
@@ -581,6 +614,7 @@ class PackageUpload(SQLBase):
581614
582 def rejectFromQueue(self, user, comment=None):615 def rejectFromQueue(self, user, comment=None):
583 """See `IPackageUpload`."""616 """See `IPackageUpload`."""
617 self._addLog(user, PackageUploadStatus.REJECTED, comment)
584 self.setRejected()618 self.setRejected()
585 if self.package_copy_job is not None:619 if self.package_copy_job is not None:
586 # Circular imports :(620 # Circular imports :(
@@ -1153,6 +1187,46 @@ def get_properties_for_binary(bpr):
1153 }1187 }
11541188
11551189
1190@implementer(IPackageUploadLog)
1191class PackageUploadLog(StormBase):
1192 """Tracking of status changes for a given package upload"""
1193
1194 __storm_table__ = "PackageUploadLog"
1195
1196 id = Int(primary=True)
1197
1198 package_upload_id = Int(name='package_upload')
1199 package_upload = Reference(package_upload_id, PackageUpload.id)
1200
1201 date_created = DateTime(tzinfo=pytz.UTC, allow_none=False,
1202 default=UTC_NOW)
1203
1204 reviewer_id = Int(name='reviewer', allow_none=False)
1205 reviewer = Reference(reviewer_id, 'Person.id')
1206
1207 old_status = DBEnum(enum=PackageUploadStatus, allow_none=False)
1208
1209 new_status = DBEnum(enum=PackageUploadStatus, allow_none=False)
1210
1211 comment = Unicode(allow_none=True)
1212
1213 def __init__(self, package_upload, reviewer, old_status, new_status,
1214 comment=None, date_created=None):
1215 self.package_upload = package_upload
1216 self.reviewer = reviewer
1217 self.old_status = old_status
1218 self.new_status = new_status
1219 if comment is not None:
1220 self.comment = comment
1221 if date_created is not None:
1222 self.date_created = date_created
1223
1224 def __repr__(self):
1225 return (
1226 "<{self.__class__.__name__} ~{self.reviewer.name} "
1227 "changed {self.package_upload} to {self.new_status} "
1228 "{self.date_created}>").format(self=self)
1229
1156@implementer(IPackageUploadBuild)1230@implementer(IPackageUploadBuild)
1157class PackageUploadBuild(SQLBase):1231class PackageUploadBuild(SQLBase):
1158 """A Queue item's related builds."""1232 """A Queue item's related builds."""
diff --git a/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt b/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt
index 7417a7b..8dcd71a 100644
--- a/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt
+++ b/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt
@@ -510,7 +510,7 @@ values:
510 >>> filelist = find_tags_by_class(anon_browser.contents, 'queue-2')510 >>> filelist = find_tags_by_class(anon_browser.contents, 'queue-2')
511 >>> for row in filelist:511 >>> for row in filelist:
512 ... print(extract_text(row))512 ... print(extract_text(row))
513 pmount_1.0-1_all.deb (18 bytes) NEW 0.1-1 restricted admin extra513 pmount_1.0-1_all.deb (18 bytes) NEW 0.1-1 restricted admin extra Accepted a moment ago by Sample Person
514514
515'netapplet' has gone straight to the 'done' queue because it's a single515'netapplet' has gone straight to the 'done' queue because it's a single
516source upload, and we can see its overridden values there:516source upload, and we can see its overridden values there:
@@ -546,6 +546,15 @@ Rejecting 'alsa-utils' source:
546 netapplet...ddtp... - Release 2006-...546 netapplet...ddtp... - Release 2006-...
547 netapplet...dist... - Release 2006-...547 netapplet...dist... - Release 2006-...
548548
549 >>> upload_manager_browser.getControl(
550 ... name="queue_state", index=0).displayValue=['Rejected']
551 >>> upload_manager_browser.getControl("Update").click()
552 >>> logs = find_tags_by_class(
553 ... upload_manager_browser.contents, "log-content")
554 >>> for log in logs:
555 ... print(extract_text(log))
556 Rejected...a moment ago...by Sample Person...Foo
557
549One rejection email is generated:558One rejection email is generated:
550559
551 >>> run_package_upload_notification_jobs()560 >>> run_package_upload_notification_jobs()
@@ -599,6 +608,21 @@ button will be disabled.
599 >>> upload_manager_browser.getControl(name="Reject").disabled608 >>> upload_manager_browser.getControl(name="Reject").disabled
600 True609 True
601610
611Accepting alsa again, and check that the package upload log has more rows
612
613 >>> upload_manager_browser.getControl(name="QUEUE_ID").value = ['4']
614 >>> upload_manager_browser.getControl(name="Accept").click()
615 >>> upload_manager_browser.getControl(
616 ... name="queue_state", index=0).displayValue=['Accepted']
617 >>> upload_manager_browser.getControl("Update").click()
618 >>> pkg_content = first_tag_by_class(upload_manager_browser.contents,
619 ... "queue-4")
620 >>> logs = find_tags_by_class(str(pkg_content), "log-content")
621 >>> for log in logs:
622 ... print(extract_text(log))
623 Accepted...a moment ago...by Sample Person...
624 Rejected...a moment ago...by Sample Person...Foo
625
602626
603Clean up627Clean up
604========628========
diff --git a/lib/lp/soyuz/templates/distroseries-queue.pt b/lib/lp/soyuz/templates/distroseries-queue.pt
index 59b77d5..39629ce 100644
--- a/lib/lp/soyuz/templates/distroseries-queue.pt
+++ b/lib/lp/soyuz/templates/distroseries-queue.pt
@@ -149,6 +149,16 @@
149 </tbody>149 </tbody>
150 <tbody tal:attributes="class string:${filelist_class}">150 <tbody tal:attributes="class string:${filelist_class}">
151 <metal:filelist use-macro="template/macros/package-filelist"/>151 <metal:filelist use-macro="template/macros/package-filelist"/>
152 <tr class="log-content" tal:repeat="log packageupload/logs">
153 <td colspan="2" style="border: 0"></td>
154 <td colspan="8" style="border: 0">
155 <span tal:content="log/new_status"></span>
156 <span tal:attributes="title log/date_created/fmt:datetime"
157 tal:content="log/date_created/fmt:displaydate" />
158 by <span tal:content="structure log/reviewer/fmt:link" />
159 <p tal:condition="log/comment" tal:content="log/comment" />
160 </td>
161 </tr>
152 </tbody>162 </tbody>
153 </tal:block>163 </tal:block>
154 </tal:batch>164 </tal:batch>
diff --git a/lib/lp/soyuz/tests/test_packageupload.py b/lib/lp/soyuz/tests/test_packageupload.py
index cb4d038..a2eb80f 100644
--- a/lib/lp/soyuz/tests/test_packageupload.py
+++ b/lib/lp/soyuz/tests/test_packageupload.py
@@ -16,7 +16,10 @@ from lazr.restfulclient.errors import (
16 BadRequest,16 BadRequest,
17 Unauthorized,17 Unauthorized,
18 )18 )
19from testtools.matchers import Equals19from testtools.matchers import (
20 Equals,
21 MatchesStructure,
22 )
20import transaction23import transaction
21from zope.component import (24from zope.component import (
22 getUtility,25 getUtility,
@@ -91,6 +94,27 @@ class PackageUploadTestCase(TestCaseWithFactory):
91 if os.path.exists(config.personalpackagearchive.root):94 if os.path.exists(config.personalpackagearchive.root):
92 shutil.rmtree(config.personalpackagearchive.root)95 shutil.rmtree(config.personalpackagearchive.root)
9396
97 def test_add_log_entry(self):
98 upload = self.factory.makePackageUpload(
99 status=PackageUploadStatus.UNAPPROVED)
100 upload = removeSecurityProxy(upload)
101 self.assertEqual(0, len(upload.logs))
102
103 person = self.factory.makePerson(name='lpusername')
104
105 upload._addLog(person, PackageUploadStatus.REJECTED, 'just because')
106
107 log = upload.logs[0]
108 self.assertThat(log, MatchesStructure.byEquality(
109 reviewer=person, old_status=PackageUploadStatus.UNAPPROVED,
110 new_status=PackageUploadStatus.REJECTED, comment='just because'))
111
112 expected_repr = (
113 "<PackageUploadLog ~lpusername "
114 "changed {self.package_upload} to Rejected "
115 "{self.date_created}>").format(self=log)
116 self.assertEqual(str(expected_repr), repr(log))
117
94 def test_realiseUpload_for_overridden_component_archive(self):118 def test_realiseUpload_for_overridden_component_archive(self):
95 # If the component of an upload is overridden to 'Partner' for119 # If the component of an upload is overridden to 'Partner' for
96 # example, then the new publishing record should be for the120 # example, then the new publishing record should be for the
@@ -358,8 +382,14 @@ class PackageUploadTestCase(TestCaseWithFactory):
358382
359 # Accepting one of them works. (Since it's a single source upload,383 # Accepting one of them works. (Since it's a single source upload,
360 # it goes straight to DONE.)384 # it goes straight to DONE.)
361 upload_one.acceptFromQueue()385 person = self.factory.makePerson()
386 upload_one.acceptFromQueue(person)
362 self.assertEqual("DONE", upload_one.status.name)387 self.assertEqual("DONE", upload_one.status.name)
388
389 log = upload_one.logs[0]
390 self.assertThat(log, MatchesStructure.byEquality(
391 reviewer=person, old_status=PackageUploadStatus.UNAPPROVED,
392 new_status=PackageUploadStatus.ACCEPTED, comment=None))
363 transaction.commit()393 transaction.commit()
364394
365 # Trying to accept the second fails.395 # Trying to accept the second fails.
@@ -368,9 +398,15 @@ class PackageUploadTestCase(TestCaseWithFactory):
368 self.assertEqual("UNAPPROVED", upload_two.status.name)398 self.assertEqual("UNAPPROVED", upload_two.status.name)
369399
370 # Rejecting the second upload works.400 # Rejecting the second upload works.
371 upload_two.rejectFromQueue(self.factory.makePerson())401 upload_two.rejectFromQueue(person, 'Because yes')
372 self.assertEqual("REJECTED", upload_two.status.name)402 self.assertEqual("REJECTED", upload_two.status.name)
373403
404 self.assertEqual(1, len(upload_two.logs))
405 log = upload_two.logs[0]
406 self.assertThat(log, MatchesStructure.byEquality(
407 reviewer=person, old_status=PackageUploadStatus.UNAPPROVED,
408 new_status=PackageUploadStatus.REJECTED, comment='Because yes'))
409
374 def test_rejectFromQueue_source_sends_email(self):410 def test_rejectFromQueue_source_sends_email(self):
375 # Rejecting a source package sends an email to the uploader.411 # Rejecting a source package sends an email to the uploader.
376 self.test_publisher.prepareBreezyAutotest()412 self.test_publisher.prepareBreezyAutotest()