Merge lp:~cjwatson/launchpad/git-activity-model into lp:launchpad
- git-activity-model
- Merge into devel
Proposed by
Colin Watson
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 18794 | ||||
Proposed branch: | lp:~cjwatson/launchpad/git-activity-model | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~cjwatson/launchpad/git-permissions-model | ||||
Diff against target: |
960 lines (+621/-22) 13 files modified
lib/lp/code/configure.zcml (+13/-0) lib/lp/code/enums.py (+23/-1) lib/lp/code/interfaces/gitactivity.py (+135/-0) lib/lp/code/interfaces/gitrepository.py (+9/-2) lib/lp/code/interfaces/gitrule.py (+11/-4) lib/lp/code/model/gitactivity.py (+145/-0) lib/lp/code/model/gitrepository.py (+16/-2) lib/lp/code/model/gitrule.py (+17/-3) lib/lp/code/model/tests/test_gitactivity.py (+48/-0) lib/lp/code/model/tests/test_gitrepository.py (+15/-4) lib/lp/code/model/tests/test_gitrule.py (+177/-4) lib/lp/security.py (+10/-0) lib/lp/testing/factory.py (+2/-2) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-activity-model | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+354399@code.launchpad.net |
Commit message
Add GitActivity model, currently logging additions, changes, and removals of rules and grants.
Description of the change
See https:/
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Revision history for this message
Colin Watson (cjwatson) : | # |
Revision history for this message
Colin Watson (cjwatson) : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/code/configure.zcml' |
2 | --- lib/lp/code/configure.zcml 2018-09-12 16:26:15 +0000 |
3 | +++ lib/lp/code/configure.zcml 2018-10-12 16:44:41 +0000 |
4 | @@ -948,6 +948,19 @@ |
5 | for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent" |
6 | handler="lp.code.model.gitrule.git_rule_grant_modified"/> |
7 | |
8 | + <!-- GitActivity --> |
9 | + |
10 | + <class class="lp.code.model.gitactivity.GitActivity"> |
11 | + <require |
12 | + permission="launchpad.View" |
13 | + interface="lp.code.interfaces.gitactivity.IGitActivity" /> |
14 | + </class> |
15 | + <securedutility |
16 | + class="lp.code.model.gitactivity.GitActivitySet" |
17 | + provides="lp.code.interfaces.gitactivity.IGitActivitySet"> |
18 | + <allow interface="lp.code.interfaces.gitactivity.IGitActivitySet" /> |
19 | + </securedutility> |
20 | + |
21 | <!-- GitCollection --> |
22 | |
23 | <class class="lp.code.model.gitcollection.GenericGitCollection"> |
24 | |
25 | === modified file 'lib/lp/code/enums.py' |
26 | --- lib/lp/code/enums.py 2018-09-03 16:07:56 +0000 |
27 | +++ lib/lp/code/enums.py 2018-10-12 16:44:41 +0000 |
28 | @@ -1,4 +1,4 @@ |
29 | -# Copyright 2009-2017 Canonical Ltd. This software is licensed under the |
30 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
31 | # GNU Affero General Public License version 3 (see the file LICENSE). |
32 | |
33 | """Enumerations used in the lp/code modules.""" |
34 | @@ -21,6 +21,7 @@ |
35 | 'CodeImportReviewStatus', |
36 | 'CodeReviewNotificationLevel', |
37 | 'CodeReviewVote', |
38 | + 'GitActivityType', |
39 | 'GitGranteeType', |
40 | 'GitObjectType', |
41 | 'GitRepositoryType', |
42 | @@ -196,6 +197,27 @@ |
43 | """) |
44 | |
45 | |
46 | +class GitActivityType(DBEnumeratedType): |
47 | + """Git Activity Type |
48 | + |
49 | + Various kinds of change can be recorded for a Git repository. |
50 | + """ |
51 | + |
52 | + RULE_ADDED = DBItem(1, "Added access rule") |
53 | + |
54 | + RULE_CHANGED = DBItem(2, "Changed access rule") |
55 | + |
56 | + RULE_REMOVED = DBItem(3, "Removed access rule") |
57 | + |
58 | + RULE_MOVED = DBItem(4, "Moved access rule") |
59 | + |
60 | + GRANT_ADDED = DBItem(11, "Added access grant") |
61 | + |
62 | + GRANT_CHANGED = DBItem(12, "Changed access grant") |
63 | + |
64 | + GRANT_REMOVED = DBItem(13, "Removed access grant") |
65 | + |
66 | + |
67 | class BranchLifecycleStatusFilter(EnumeratedType): |
68 | """Branch Lifecycle Status Filter |
69 | |
70 | |
71 | === added file 'lib/lp/code/interfaces/gitactivity.py' |
72 | --- lib/lp/code/interfaces/gitactivity.py 1970-01-01 00:00:00 +0000 |
73 | +++ lib/lp/code/interfaces/gitactivity.py 2018-10-12 16:44:41 +0000 |
74 | @@ -0,0 +1,135 @@ |
75 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
76 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
77 | + |
78 | +"""Git repository activity logs.""" |
79 | + |
80 | +from __future__ import absolute_import, print_function, unicode_literals |
81 | + |
82 | +__metaclass__ = type |
83 | +__all__ = [ |
84 | + 'IGitActivity', |
85 | + 'IGitActivitySet', |
86 | + ] |
87 | + |
88 | +from lazr.restful.fields import Reference |
89 | +from zope.interface import Interface |
90 | +from zope.schema import ( |
91 | + Choice, |
92 | + Datetime, |
93 | + Dict, |
94 | + Int, |
95 | + Text, |
96 | + TextLine, |
97 | + ) |
98 | + |
99 | +from lp import _ |
100 | +from lp.code.enums import GitActivityType |
101 | +from lp.code.interfaces.gitrepository import IGitRepository |
102 | +from lp.services.fields import ( |
103 | + PersonChoice, |
104 | + PublicPersonChoice, |
105 | + ) |
106 | + |
107 | + |
108 | +class IGitActivity(Interface): |
109 | + """An activity log entry for a Git repository.""" |
110 | + |
111 | + id = Int(title=_("ID"), readonly=True, required=True) |
112 | + |
113 | + repository = Reference( |
114 | + title=_("Repository"), required=True, readonly=True, |
115 | + schema=IGitRepository, |
116 | + description=_("The repository that this log entry is for.")) |
117 | + |
118 | + date_changed = Datetime( |
119 | + title=_("Date changed"), required=True, readonly=True, |
120 | + description=_("The time when this change happened.")) |
121 | + |
122 | + changer = PublicPersonChoice( |
123 | + title=_("Changer"), required=True, readonly=True, |
124 | + vocabulary="ValidPerson", |
125 | + description=_("The user who made this change.")) |
126 | + |
127 | + changee = PersonChoice( |
128 | + title=_("Changee"), required=False, readonly=True, |
129 | + vocabulary="ValidPersonOrTeam", |
130 | + description=_("The person or team that this change was applied to.")) |
131 | + |
132 | + what_changed = Choice( |
133 | + title=_("What changed"), required=True, readonly=True, |
134 | + vocabulary=GitActivityType, |
135 | + description=_("The property of the repository that changed.")) |
136 | + |
137 | + old_value = Dict( |
138 | + title=_("Old value"), required=False, readonly=True, |
139 | + description=_("Representation of the value before the change."), |
140 | + key_type=TextLine(), value_type=Text()) |
141 | + |
142 | + new_value = Dict( |
143 | + title=_("New value"), required=False, readonly=True, |
144 | + description=_("Representation of the value after the change."), |
145 | + key_type=TextLine(), value_type=Text()) |
146 | + |
147 | + |
148 | +class IGitActivitySet(Interface): |
149 | + """Utilities for managing Git repository activity log entries.""" |
150 | + |
151 | + def logRuleAdded(rule, user): |
152 | + """Log that an access rule was added. |
153 | + |
154 | + :param rule: The `IGitRule` that was added. |
155 | + :param user: The `IPerson` who added it. |
156 | + :return: The new `IGitActivity`. |
157 | + """ |
158 | + |
159 | + def logRuleChanged(old_rule, new_rule, user): |
160 | + """Log that an access rule was changed. |
161 | + |
162 | + :param old_rule: The `IGitRule` before the change. |
163 | + :param new_rule: The `IGitRule` after the change. |
164 | + :param user: The `IPerson` who made the change. |
165 | + :return: The new `IGitActivity`. |
166 | + """ |
167 | + |
168 | + def logRuleRemoved(rule, user): |
169 | + """Log that an access rule was removed. |
170 | + |
171 | + :param rule: The `IGitRule` that was removed. |
172 | + :param user: The `IPerson` who removed it. |
173 | + :return: The new `IGitActivity`. |
174 | + """ |
175 | + |
176 | + def logRuleMoved(rule, old_position, new_position, user): |
177 | + """Log that an access rule was moved to a different position. |
178 | + |
179 | + :param rule: The `IGitRule` that was moved. |
180 | + :param old_position: The old position in its repository's order. |
181 | + :param new_position: The new position in its repository's order. |
182 | + :param user: The `IPerson` who moved it. |
183 | + :return: The new `IGitActivity`. |
184 | + """ |
185 | + |
186 | + def logGrantAdded(grant, user): |
187 | + """Log that an access grant was added. |
188 | + |
189 | + :param grant: The `IGitRuleGrant` that was added. |
190 | + :param user: The `IPerson` who added it. |
191 | + :return: The new `IGitActivity`. |
192 | + """ |
193 | + |
194 | + def logGrantChanged(old_grant, new_grant, user): |
195 | + """Log that an access grant was changed. |
196 | + |
197 | + :param old_grant: The `IGitRuleGrant` before the change. |
198 | + :param new_grant: The `IGitRuleGrant` after the change. |
199 | + :param user: The `IPerson` who made the change. |
200 | + :return: The new `IGitActivity`. |
201 | + """ |
202 | + |
203 | + def logGrantRemoved(grant, user): |
204 | + """Log that an access grant was removed. |
205 | + |
206 | + :param grant: The `IGitRuleGrant` that was removed. |
207 | + :param user: The `IPerson` who removed it. |
208 | + :return: The new `IGitActivity`. |
209 | + """ |
210 | |
211 | === modified file 'lib/lp/code/interfaces/gitrepository.py' |
212 | --- lib/lp/code/interfaces/gitrepository.py 2018-10-03 13:19:08 +0000 |
213 | +++ lib/lp/code/interfaces/gitrepository.py 2018-10-12 16:44:41 +0000 |
214 | @@ -1,4 +1,4 @@ |
215 | -# Copyright 2015-2017 Canonical Ltd. This software is licensed under the |
216 | +# Copyright 2015-2018 Canonical Ltd. This software is licensed under the |
217 | # GNU Affero General Public License version 3 (see the file LICENSE). |
218 | |
219 | """Git repository interfaces.""" |
220 | @@ -622,6 +622,12 @@ |
221 | :return: The diff as a binary string. |
222 | """ |
223 | |
224 | + def getActivity(): |
225 | + """Get activity log entries for this repository. |
226 | + |
227 | + :return: A `ResultSet` of `IGitActivity`. |
228 | + """ |
229 | + |
230 | |
231 | class IGitRepositoryModerateAttributes(Interface): |
232 | """IGitRepository attributes that can be edited by more than one community. |
233 | @@ -729,7 +735,7 @@ |
234 | None to append it. |
235 | """ |
236 | |
237 | - def moveRule(rule, position): |
238 | + def moveRule(rule, position, user): |
239 | """Move a rule to a new position in its repository's rule order. |
240 | |
241 | :param rule: The `IGitRule` to move. |
242 | @@ -737,6 +743,7 @@ |
243 | the start, while `len(repository.rules)` puts the rule at the |
244 | end. If the new position is before the end of the list, then |
245 | other rules are shifted to later positions to make room. |
246 | + :param user: The `IPerson` who is moving the rule. |
247 | """ |
248 | |
249 | @export_read_operation() |
250 | |
251 | === modified file 'lib/lp/code/interfaces/gitrule.py' |
252 | --- lib/lp/code/interfaces/gitrule.py 2018-10-03 13:18:27 +0000 |
253 | +++ lib/lp/code/interfaces/gitrule.py 2018-10-12 16:44:41 +0000 |
254 | @@ -100,8 +100,11 @@ |
255 | matching this rule. |
256 | """ |
257 | |
258 | - def destroySelf(): |
259 | - """Delete this rule.""" |
260 | + def destroySelf(user): |
261 | + """Delete this rule. |
262 | + |
263 | + :param user: The `IPerson` doing the deletion. |
264 | + """ |
265 | |
266 | |
267 | class IGitRule(IGitRuleView, IGitRuleEditableAttributes, IGitRuleEdit): |
268 | @@ -169,8 +172,12 @@ |
269 | class IGitRuleGrantEdit(Interface): |
270 | """`IGitRuleGrant` attributes that require launchpad.Edit.""" |
271 | |
272 | - def destroySelf(): |
273 | - """Delete this access grant.""" |
274 | + def destroySelf(user=None): |
275 | + """Delete this access grant. |
276 | + |
277 | + :param user: The `IPerson` doing the deletion, or None if the |
278 | + deletion should not be logged. |
279 | + """ |
280 | |
281 | |
282 | class IGitRuleGrant(IGitRuleGrantView, IGitRuleGrantEditableAttributes, |
283 | |
284 | === added file 'lib/lp/code/model/gitactivity.py' |
285 | --- lib/lp/code/model/gitactivity.py 1970-01-01 00:00:00 +0000 |
286 | +++ lib/lp/code/model/gitactivity.py 2018-10-12 16:44:41 +0000 |
287 | @@ -0,0 +1,145 @@ |
288 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
289 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
290 | + |
291 | +"""Git repository activity logs.""" |
292 | + |
293 | +from __future__ import absolute_import, print_function, unicode_literals |
294 | + |
295 | +__metaclass__ = type |
296 | +__all__ = [ |
297 | + 'GitActivity', |
298 | + ] |
299 | + |
300 | +import pytz |
301 | +from storm.locals import ( |
302 | + DateTime, |
303 | + Int, |
304 | + JSON, |
305 | + Reference, |
306 | + ) |
307 | +from zope.interface import implementer |
308 | + |
309 | +from lp.code.enums import GitActivityType |
310 | +from lp.code.interfaces.gitactivity import ( |
311 | + IGitActivity, |
312 | + IGitActivitySet, |
313 | + ) |
314 | +from lp.registry.interfaces.person import ( |
315 | + validate_person, |
316 | + validate_public_person, |
317 | + ) |
318 | +from lp.services.database.constants import DEFAULT |
319 | +from lp.services.database.enumcol import EnumCol |
320 | +from lp.services.database.stormbase import StormBase |
321 | + |
322 | + |
323 | +@implementer(IGitActivity) |
324 | +class GitActivity(StormBase): |
325 | + """See IGitActivity`.""" |
326 | + |
327 | + __storm_table__ = 'GitActivity' |
328 | + |
329 | + id = Int(primary=True) |
330 | + |
331 | + repository_id = Int(name='repository', allow_none=False) |
332 | + repository = Reference(repository_id, 'GitRepository.id') |
333 | + |
334 | + date_changed = DateTime( |
335 | + name='date_changed', tzinfo=pytz.UTC, allow_none=False) |
336 | + |
337 | + changer_id = Int( |
338 | + name='changer', allow_none=False, validator=validate_public_person) |
339 | + changer = Reference(changer_id, 'Person.id') |
340 | + |
341 | + changee_id = Int( |
342 | + name='changee', allow_none=True, validator=validate_person) |
343 | + changee = Reference(changee_id, 'Person.id') |
344 | + |
345 | + what_changed = EnumCol( |
346 | + dbName='what_changed', enum=GitActivityType, notNull=True) |
347 | + |
348 | + old_value = JSON(name='old_value', allow_none=True) |
349 | + new_value = JSON(name='new_value', allow_none=True) |
350 | + |
351 | + def __init__(self, repository, changer, what_changed, changee=None, |
352 | + old_value=None, new_value=None, date_changed=DEFAULT): |
353 | + super(GitActivity, self).__init__() |
354 | + self.repository = repository |
355 | + self.date_changed = date_changed |
356 | + self.changer = changer |
357 | + self.changee = changee |
358 | + self.what_changed = what_changed |
359 | + self.old_value = old_value |
360 | + self.new_value = new_value |
361 | + |
362 | + |
363 | +def _make_rule_value(rule, position=None): |
364 | + return { |
365 | + "ref_pattern": rule.ref_pattern, |
366 | + "position": position if position is not None else rule.position, |
367 | + } |
368 | + |
369 | + |
370 | +def _make_grant_value(grant): |
371 | + return { |
372 | + # If the grantee is a person, then we store that in |
373 | + # GitActivity.changee; this makes it visible to person merges and so |
374 | + # on. |
375 | + "changee_type": grant.grantee_type.title, |
376 | + "ref_pattern": grant.rule.ref_pattern, |
377 | + "can_create": grant.can_create, |
378 | + "can_push": grant.can_push, |
379 | + "can_force_push": grant.can_force_push, |
380 | + } |
381 | + |
382 | + |
383 | +@implementer(IGitActivitySet) |
384 | +class GitActivitySet: |
385 | + |
386 | + def logRuleAdded(self, rule, user): |
387 | + """See `IGitActivitySet`.""" |
388 | + return GitActivity( |
389 | + rule.repository, user, GitActivityType.RULE_ADDED, |
390 | + new_value=_make_rule_value(rule)) |
391 | + |
392 | + def logRuleChanged(self, old_rule, new_rule, user): |
393 | + """See `IGitActivitySet`.""" |
394 | + return GitActivity( |
395 | + new_rule.repository, user, GitActivityType.RULE_CHANGED, |
396 | + old_value=_make_rule_value(old_rule), |
397 | + new_value=_make_rule_value(new_rule)) |
398 | + |
399 | + def logRuleRemoved(self, rule, user): |
400 | + """See `IGitActivitySet`.""" |
401 | + return GitActivity( |
402 | + rule.repository, user, GitActivityType.RULE_REMOVED, |
403 | + old_value=_make_rule_value(rule)) |
404 | + |
405 | + def logRuleMoved(self, rule, old_position, new_position, user): |
406 | + """See `IGitActivitySet`.""" |
407 | + return GitActivity( |
408 | + rule.repository, user, GitActivityType.RULE_MOVED, |
409 | + old_value=_make_rule_value(rule, position=old_position), |
410 | + new_value=_make_rule_value(rule, position=new_position)) |
411 | + |
412 | + def logGrantAdded(self, grant, user): |
413 | + """See `IGitActivitySet`.""" |
414 | + return GitActivity( |
415 | + grant.repository, user, GitActivityType.GRANT_ADDED, |
416 | + changee=grant.grantee, |
417 | + new_value=_make_grant_value(grant)) |
418 | + |
419 | + def logGrantChanged(self, old_grant, new_grant, user): |
420 | + """See `IGitActivitySet`.""" |
421 | + return GitActivity( |
422 | + new_grant.repository, user, GitActivityType.GRANT_CHANGED, |
423 | + changee=new_grant.grantee, |
424 | + old_value=_make_grant_value(old_grant), |
425 | + new_value=_make_grant_value(new_grant)) |
426 | + |
427 | + def logGrantRemoved(self, grant, user): |
428 | + """See `IGitActivitySet`.""" |
429 | + return GitActivity( |
430 | + grant.repository, user, GitActivityType.GRANT_REMOVED, |
431 | + changee=grant.grantee, |
432 | + old_value=_make_grant_value(grant)) |
433 | |
434 | === modified file 'lib/lp/code/model/gitrepository.py' |
435 | --- lib/lp/code/model/gitrepository.py 2018-10-05 15:35:34 +0000 |
436 | +++ lib/lp/code/model/gitrepository.py 2018-10-12 16:44:41 +0000 |
437 | @@ -88,6 +88,7 @@ |
438 | notify_modified, |
439 | ) |
440 | from lp.code.interfaces.codeimport import ICodeImportSet |
441 | +from lp.code.interfaces.gitactivity import IGitActivitySet |
442 | from lp.code.interfaces.gitcollection import ( |
443 | IAllGitRepositories, |
444 | IGitCollection, |
445 | @@ -108,6 +109,7 @@ |
446 | from lp.code.interfaces.revision import IRevisionSet |
447 | from lp.code.mail.branch import send_git_repository_modified_notifications |
448 | from lp.code.model.branchmergeproposal import BranchMergeProposal |
449 | +from lp.code.model.gitactivity import GitActivity |
450 | from lp.code.model.gitref import ( |
451 | GitRef, |
452 | GitRefDefault, |
453 | @@ -1166,19 +1168,24 @@ |
454 | else: |
455 | rules.insert(position, rule) |
456 | self._syncRulePositions(rules) |
457 | + getUtility(IGitActivitySet).logRuleAdded(rule, creator) |
458 | return rule |
459 | |
460 | - def moveRule(self, rule, position): |
461 | + def moveRule(self, rule, position, user): |
462 | """See `IGitRepository`.""" |
463 | if rule.repository != self: |
464 | raise ValueError("%r does not belong to %r" % (rule, self)) |
465 | if position < 0: |
466 | raise ValueError("Negative positions are not supported") |
467 | - if position != rule.position: |
468 | + current_position = rule.position |
469 | + if position != current_position: |
470 | rules = list(self.rules) |
471 | rules.remove(rule) |
472 | rules.insert(position, rule) |
473 | self._syncRulePositions(rules) |
474 | + if rule.position != current_position: |
475 | + getUtility(IGitActivitySet).logRuleMoved( |
476 | + rule, current_position, rule.position, user) |
477 | |
478 | @property |
479 | def grants(self): |
480 | @@ -1186,6 +1193,12 @@ |
481 | return Store.of(self).find( |
482 | GitRuleGrant, GitRuleGrant.repository_id == self.id) |
483 | |
484 | + def getActivity(self): |
485 | + """See `IGitRepository`.""" |
486 | + clauses = [GitActivity.repository_id == self.id] |
487 | + return Store.of(self).find(GitActivity, *clauses).order_by( |
488 | + Desc(GitActivity.date_changed), Desc(GitActivity.id)) |
489 | + |
490 | def canBeDeleted(self): |
491 | """See `IGitRepository`.""" |
492 | # Can't delete if the repository is associated with anything. |
493 | @@ -1317,6 +1330,7 @@ |
494 | self._deleteRepositorySubscriptions() |
495 | self._deleteJobs() |
496 | getUtility(IWebhookSet).delete(self.webhooks) |
497 | + self.getActivity().remove() |
498 | # We intentionally skip the usual destructors; the only other useful |
499 | # thing they do is to log the removal activity, and we remove the |
500 | # activity logs for removed repositories anyway. |
501 | |
502 | === modified file 'lib/lp/code/model/gitrule.py' |
503 | --- lib/lp/code/model/gitrule.py 2018-10-05 14:48:42 +0000 |
504 | +++ lib/lp/code/model/gitrule.py 2018-10-12 16:44:41 +0000 |
505 | @@ -21,15 +21,18 @@ |
506 | Store, |
507 | Unicode, |
508 | ) |
509 | +from zope.component import getUtility |
510 | from zope.interface import implementer |
511 | from zope.security.proxy import removeSecurityProxy |
512 | |
513 | from lp.code.enums import GitGranteeType |
514 | +from lp.code.interfaces.gitactivity import IGitActivitySet |
515 | from lp.code.interfaces.gitrule import ( |
516 | IGitRule, |
517 | IGitRuleGrant, |
518 | ) |
519 | from lp.registry.interfaces.person import ( |
520 | + IPerson, |
521 | validate_person, |
522 | validate_public_person, |
523 | ) |
524 | @@ -48,6 +51,9 @@ |
525 | events on Git repository rules. |
526 | """ |
527 | if event.edited_fields: |
528 | + user = IPerson(event.user) |
529 | + getUtility(IGitActivitySet).logRuleChanged( |
530 | + event.object_before_modification, rule, user) |
531 | removeSecurityProxy(rule).date_last_modified = UTC_NOW |
532 | |
533 | |
534 | @@ -105,13 +111,16 @@ |
535 | def addGrant(self, grantee, grantor, can_create=False, can_push=False, |
536 | can_force_push=False): |
537 | """See `IGitRule`.""" |
538 | - return GitRuleGrant( |
539 | + grant = GitRuleGrant( |
540 | rule=self, grantee=grantee, can_create=can_create, |
541 | can_push=can_push, can_force_push=can_force_push, grantor=grantor, |
542 | date_created=DEFAULT) |
543 | + getUtility(IGitActivitySet).logGrantAdded(grant, grantor) |
544 | + return grant |
545 | |
546 | - def destroySelf(self): |
547 | + def destroySelf(self, user): |
548 | """See `IGitRule`.""" |
549 | + getUtility(IGitActivitySet).logRuleRemoved(self, user) |
550 | for grant in self.grants: |
551 | grant.destroySelf() |
552 | rules = list(self.repository.rules) |
553 | @@ -127,6 +136,9 @@ |
554 | events on Git repository grants. |
555 | """ |
556 | if event.edited_fields: |
557 | + user = IPerson(event.user) |
558 | + getUtility(IGitActivitySet).logGrantChanged( |
559 | + event.object_before_modification, grant, user) |
560 | removeSecurityProxy(grant).date_last_modified = UTC_NOW |
561 | |
562 | |
563 | @@ -203,6 +215,8 @@ |
564 | ", ".join(permissions), grantee_name, self.repository.unique_name, |
565 | self.rule.ref_pattern) |
566 | |
567 | - def destroySelf(self): |
568 | + def destroySelf(self, user=None): |
569 | """See `IGitRuleGrant`.""" |
570 | + if user is not None: |
571 | + getUtility(IGitActivitySet).logGrantRemoved(self, user) |
572 | Store.of(self).remove(self) |
573 | |
574 | === added file 'lib/lp/code/model/tests/test_gitactivity.py' |
575 | --- lib/lp/code/model/tests/test_gitactivity.py 1970-01-01 00:00:00 +0000 |
576 | +++ lib/lp/code/model/tests/test_gitactivity.py 2018-10-12 16:44:41 +0000 |
577 | @@ -0,0 +1,48 @@ |
578 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
579 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
580 | + |
581 | +"""Tests for Git repository activity logs.""" |
582 | + |
583 | +from __future__ import absolute_import, print_function, unicode_literals |
584 | + |
585 | +__metaclass__ = type |
586 | + |
587 | +from storm.store import Store |
588 | +from testtools.matchers import MatchesStructure |
589 | + |
590 | +from lp.code.enums import GitActivityType |
591 | +from lp.code.interfaces.gitactivity import IGitActivity |
592 | +from lp.code.model.gitactivity import GitActivity |
593 | +from lp.services.database.sqlbase import get_transaction_timestamp |
594 | +from lp.testing import ( |
595 | + TestCaseWithFactory, |
596 | + verifyObject, |
597 | + ) |
598 | +from lp.testing.layers import DatabaseFunctionalLayer |
599 | + |
600 | + |
601 | +class TestGitActivity(TestCaseWithFactory): |
602 | + |
603 | + layer = DatabaseFunctionalLayer |
604 | + |
605 | + def test_implements_IGitActivity(self): |
606 | + repository = self.factory.makeGitRepository() |
607 | + activity = GitActivity( |
608 | + repository, repository.owner, GitActivityType.RULE_ADDED) |
609 | + verifyObject(IGitActivity, activity) |
610 | + |
611 | + def test_properties(self): |
612 | + repository = self.factory.makeGitRepository() |
613 | + changee = self.factory.makePerson() |
614 | + activity = GitActivity( |
615 | + repository, repository.owner, GitActivityType.RULE_ADDED, |
616 | + changee=changee, old_value={"old": None}, new_value={"new": None}) |
617 | + now = get_transaction_timestamp(Store.of(activity)) |
618 | + self.assertThat(activity, MatchesStructure.byEquality( |
619 | + repository=repository, |
620 | + date_changed=now, |
621 | + changer=repository.owner, |
622 | + changee=changee, |
623 | + what_changed=GitActivityType.RULE_ADDED, |
624 | + old_value={"old": None}, |
625 | + new_value={"new": None})) |
626 | |
627 | === modified file 'lib/lp/code/model/tests/test_gitrepository.py' |
628 | --- lib/lp/code/model/tests/test_gitrepository.py 2018-10-05 15:35:34 +0000 |
629 | +++ lib/lp/code/model/tests/test_gitrepository.py 2018-10-12 16:44:41 +0000 |
630 | @@ -87,6 +87,7 @@ |
631 | UpdatePreviewDiffJob, |
632 | ) |
633 | from lp.code.model.codereviewcomment import CodeReviewComment |
634 | +from lp.code.model.gitactivity import GitActivity |
635 | from lp.code.model.gitjob import ( |
636 | GitJob, |
637 | GitJobType, |
638 | @@ -542,10 +543,18 @@ |
639 | def test_related_rules_and_grants_deleted(self): |
640 | rule = self.factory.makeGitRule(repository=self.repository) |
641 | grant = self.factory.makeGitRuleGrant(rule=rule) |
642 | + store = Store.of(self.repository) |
643 | + repository_id = self.repository.id |
644 | + activities = store.find( |
645 | + GitActivity, GitActivity.repository_id == repository_id) |
646 | + self.assertNotEqual([], list(activities)) |
647 | self.repository.destroySelf() |
648 | transaction.commit() |
649 | self.assertRaises(LostObjectError, getattr, grant, 'rule') |
650 | self.assertRaises(LostObjectError, getattr, rule, 'repository') |
651 | + activities = store.find( |
652 | + GitActivity, GitActivity.repository_id == repository_id) |
653 | + self.assertEqual([], list(activities)) |
654 | |
655 | |
656 | class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
657 | @@ -2273,11 +2282,11 @@ |
658 | for _ in range(5)] |
659 | with person_logged_in(repository.owner): |
660 | self.assertEqual(rules, list(repository.rules)) |
661 | - repository.moveRule(rules[0], 4) |
662 | + repository.moveRule(rules[0], 4, repository.owner) |
663 | self.assertEqual(rules[1:] + [rules[0]], list(repository.rules)) |
664 | - repository.moveRule(rules[0], 0) |
665 | + repository.moveRule(rules[0], 0, repository.owner) |
666 | self.assertEqual(rules, list(repository.rules)) |
667 | - repository.moveRule(rules[2], 1) |
668 | + repository.moveRule(rules[2], 1, repository.owner) |
669 | self.assertEqual( |
670 | [rules[0], rules[2], rules[1], rules[3], rules[4]], |
671 | list(repository.rules)) |
672 | @@ -2285,7 +2294,9 @@ |
673 | def test_moveRule_non_negative(self): |
674 | rule = self.factory.makeGitRule() |
675 | with person_logged_in(rule.repository.owner): |
676 | - self.assertRaises(ValueError, rule.repository.moveRule, rule, -1) |
677 | + self.assertRaises( |
678 | + ValueError, rule.repository.moveRule, |
679 | + rule, -1, rule.repository.owner) |
680 | |
681 | def test_grants(self): |
682 | repository = self.factory.makeGitRepository() |
683 | |
684 | === modified file 'lib/lp/code/model/tests/test_gitrule.py' |
685 | --- lib/lp/code/model/tests/test_gitrule.py 2018-10-03 17:06:11 +0000 |
686 | +++ lib/lp/code/model/tests/test_gitrule.py 2018-10-12 16:44:41 +0000 |
687 | @@ -7,15 +7,23 @@ |
688 | |
689 | __metaclass__ = type |
690 | |
691 | +from lazr.lifecycle.event import ObjectModifiedEvent |
692 | +from lazr.lifecycle.snapshot import Snapshot |
693 | from storm.store import Store |
694 | from testtools.matchers import ( |
695 | Equals, |
696 | Is, |
697 | + MatchesDict, |
698 | MatchesSetwise, |
699 | MatchesStructure, |
700 | ) |
701 | +from zope.event import notify |
702 | +from zope.interface import providedBy |
703 | |
704 | -from lp.code.enums import GitGranteeType |
705 | +from lp.code.enums import ( |
706 | + GitActivityType, |
707 | + GitGranteeType, |
708 | + ) |
709 | from lp.code.interfaces.gitrule import ( |
710 | IGitRule, |
711 | IGitRuleGrant, |
712 | @@ -114,6 +122,93 @@ |
713 | can_push=Is(False), |
714 | can_force_push=Is(True)))) |
715 | |
716 | + def test_activity_rule_added(self): |
717 | + owner = self.factory.makeTeam() |
718 | + member = self.factory.makePerson(member_of=[owner]) |
719 | + repository = self.factory.makeGitRepository(owner=owner) |
720 | + self.factory.makeGitRule(repository=repository, creator=member) |
721 | + self.factory.makeGitRule( |
722 | + repository=repository, ref_pattern="refs/heads/stable/*", |
723 | + creator=member) |
724 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
725 | + repository=Equals(repository), |
726 | + changer=Equals(member), |
727 | + changee=Is(None), |
728 | + what_changed=Equals(GitActivityType.RULE_ADDED), |
729 | + old_value=Is(None), |
730 | + new_value=MatchesDict({ |
731 | + "ref_pattern": Equals("refs/heads/stable/*"), |
732 | + "position": Equals(1), |
733 | + }))) |
734 | + |
735 | + def test_activity_rule_changed(self): |
736 | + owner = self.factory.makeTeam() |
737 | + member = self.factory.makePerson(member_of=[owner]) |
738 | + repository = self.factory.makeGitRepository(owner=owner) |
739 | + rule = self.factory.makeGitRule( |
740 | + repository=repository, ref_pattern="refs/heads/*") |
741 | + rule_before_modification = Snapshot(rule, providing=providedBy(rule)) |
742 | + with person_logged_in(member): |
743 | + rule.ref_pattern = "refs/heads/other/*" |
744 | + notify(ObjectModifiedEvent( |
745 | + rule, rule_before_modification, ["ref_pattern"])) |
746 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
747 | + repository=Equals(repository), |
748 | + changer=Equals(member), |
749 | + changee=Is(None), |
750 | + what_changed=Equals(GitActivityType.RULE_CHANGED), |
751 | + old_value=MatchesDict({ |
752 | + "ref_pattern": Equals("refs/heads/*"), |
753 | + "position": Equals(0), |
754 | + }), |
755 | + new_value=MatchesDict({ |
756 | + "ref_pattern": Equals("refs/heads/other/*"), |
757 | + "position": Equals(0), |
758 | + }))) |
759 | + |
760 | + def test_activity_rule_removed(self): |
761 | + owner = self.factory.makeTeam() |
762 | + member = self.factory.makePerson(member_of=[owner]) |
763 | + repository = self.factory.makeGitRepository(owner=owner) |
764 | + rule = self.factory.makeGitRule( |
765 | + repository=repository, ref_pattern="refs/heads/*") |
766 | + with person_logged_in(member): |
767 | + rule.destroySelf(member) |
768 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
769 | + repository=Equals(repository), |
770 | + changer=Equals(member), |
771 | + changee=Is(None), |
772 | + what_changed=Equals(GitActivityType.RULE_REMOVED), |
773 | + old_value=MatchesDict({ |
774 | + "ref_pattern": Equals("refs/heads/*"), |
775 | + "position": Equals(0), |
776 | + }), |
777 | + new_value=Is(None))) |
778 | + |
779 | + def test_activity_rule_moved(self): |
780 | + owner = self.factory.makeTeam() |
781 | + member = self.factory.makePerson(member_of=[owner]) |
782 | + repository = self.factory.makeGitRepository(owner=owner) |
783 | + self.factory.makeGitRule( |
784 | + repository=repository, ref_pattern="refs/heads/*") |
785 | + rule = self.factory.makeGitRule( |
786 | + repository=repository, ref_pattern="refs/heads/stable/*") |
787 | + with person_logged_in(member): |
788 | + repository.moveRule(rule, 0, member) |
789 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
790 | + repository=Equals(repository), |
791 | + changer=Equals(member), |
792 | + changee=Is(None), |
793 | + what_changed=Equals(GitActivityType.RULE_MOVED), |
794 | + old_value=MatchesDict({ |
795 | + "ref_pattern": Equals("refs/heads/stable/*"), |
796 | + "position": Equals(1), |
797 | + }), |
798 | + new_value=MatchesDict({ |
799 | + "ref_pattern": Equals("refs/heads/stable/*"), |
800 | + "position": Equals(0), |
801 | + }))) |
802 | + |
803 | def test_destroySelf(self): |
804 | repository = self.factory.makeGitRepository() |
805 | rules = [ |
806 | @@ -125,7 +220,7 @@ |
807 | self.assertEqual([0, 1, 2, 3], [rule.position for rule in rules]) |
808 | self.assertEqual(rules, list(repository.rules)) |
809 | with person_logged_in(repository.owner): |
810 | - rules[1].destroySelf() |
811 | + rules[1].destroySelf(repository.owner) |
812 | del rules[1] |
813 | self.assertEqual([0, 1, 2], [rule.position for rule in rules]) |
814 | self.assertEqual(rules, list(repository.rules)) |
815 | @@ -136,7 +231,7 @@ |
816 | grant = self.factory.makeGitRuleGrant(rule=rule) |
817 | self.assertEqual([grant], list(repository.grants)) |
818 | with person_logged_in(repository.owner): |
819 | - rule.destroySelf() |
820 | + rule.destroySelf(repository.owner) |
821 | self.assertEqual([], list(repository.grants)) |
822 | |
823 | |
824 | @@ -208,6 +303,84 @@ |
825 | grantee.name, rule.repository.unique_name, rule.ref_pattern), |
826 | repr(grant)) |
827 | |
828 | + def test_activity_grant_added(self): |
829 | + owner = self.factory.makeTeam() |
830 | + member = self.factory.makePerson(member_of=[owner]) |
831 | + repository = self.factory.makeGitRepository(owner=owner) |
832 | + grant = self.factory.makeGitRuleGrant( |
833 | + repository=repository, grantor=member, can_push=True) |
834 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
835 | + repository=Equals(repository), |
836 | + changer=Equals(member), |
837 | + changee=Equals(grant.grantee), |
838 | + what_changed=Equals(GitActivityType.GRANT_ADDED), |
839 | + old_value=Is(None), |
840 | + new_value=MatchesDict({ |
841 | + "changee_type": Equals("Person"), |
842 | + "ref_pattern": Equals("refs/heads/*"), |
843 | + "can_create": Is(False), |
844 | + "can_push": Is(True), |
845 | + "can_force_push": Is(False), |
846 | + }))) |
847 | + |
848 | + def test_activity_grant_changed(self): |
849 | + owner = self.factory.makeTeam() |
850 | + member = self.factory.makePerson(member_of=[owner]) |
851 | + repository = self.factory.makeGitRepository(owner=owner) |
852 | + grant = self.factory.makeGitRuleGrant( |
853 | + repository=repository, grantee=GitGranteeType.REPOSITORY_OWNER, |
854 | + can_create=True) |
855 | + grant_before_modification = Snapshot( |
856 | + grant, providing=providedBy(grant)) |
857 | + with person_logged_in(member): |
858 | + grant.can_create = False |
859 | + grant.can_force_push = True |
860 | + notify(ObjectModifiedEvent( |
861 | + grant, grant_before_modification, |
862 | + ["can_create", "can_force_push"])) |
863 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
864 | + repository=Equals(repository), |
865 | + changer=Equals(member), |
866 | + changee=Is(None), |
867 | + what_changed=Equals(GitActivityType.GRANT_CHANGED), |
868 | + old_value=MatchesDict({ |
869 | + "changee_type": Equals("Repository owner"), |
870 | + "ref_pattern": Equals("refs/heads/*"), |
871 | + "can_create": Is(True), |
872 | + "can_push": Is(False), |
873 | + "can_force_push": Is(False), |
874 | + }), |
875 | + new_value=MatchesDict({ |
876 | + "changee_type": Equals("Repository owner"), |
877 | + "ref_pattern": Equals("refs/heads/*"), |
878 | + "can_create": Is(False), |
879 | + "can_push": Is(False), |
880 | + "can_force_push": Is(True), |
881 | + }))) |
882 | + |
883 | + def test_activity_grant_removed(self): |
884 | + owner = self.factory.makeTeam() |
885 | + member = self.factory.makePerson(member_of=[owner]) |
886 | + repository = self.factory.makeGitRepository(owner=owner) |
887 | + grant = self.factory.makeGitRuleGrant( |
888 | + repository=repository, can_create=True, can_push=True) |
889 | + grantee = grant.grantee |
890 | + with person_logged_in(member): |
891 | + grant.destroySelf(member) |
892 | + self.assertThat(repository.getActivity().first(), MatchesStructure( |
893 | + repository=Equals(repository), |
894 | + changer=Equals(member), |
895 | + changee=Equals(grantee), |
896 | + what_changed=Equals(GitActivityType.GRANT_REMOVED), |
897 | + old_value=MatchesDict({ |
898 | + "changee_type": Equals("Person"), |
899 | + "ref_pattern": Equals("refs/heads/*"), |
900 | + "can_create": Is(True), |
901 | + "can_push": Is(True), |
902 | + "can_force_push": Is(False), |
903 | + }), |
904 | + new_value=Is(None))) |
905 | + |
906 | def test_destroySelf(self): |
907 | rule = self.factory.makeGitRule() |
908 | grants = [ |
909 | @@ -217,5 +390,5 @@ |
910 | self.factory.makeGitRuleGrant(rule=rule, can_push=True), |
911 | ] |
912 | with person_logged_in(rule.repository.owner): |
913 | - grants[1].destroySelf() |
914 | + grants[1].destroySelf(rule.repository.owner) |
915 | self.assertThat(rule.grants, MatchesSetwise(Equals(grants[0]))) |
916 | |
917 | === modified file 'lib/lp/security.py' |
918 | --- lib/lp/security.py 2018-10-05 15:35:34 +0000 |
919 | +++ lib/lp/security.py 2018-10-12 16:44:41 +0000 |
920 | @@ -81,6 +81,7 @@ |
921 | ) |
922 | from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference |
923 | from lp.code.interfaces.diff import IPreviewDiff |
924 | +from lp.code.interfaces.gitactivity import IGitActivity |
925 | from lp.code.interfaces.gitcollection import IGitCollection |
926 | from lp.code.interfaces.gitref import IGitRef |
927 | from lp.code.interfaces.gitrepository import ( |
928 | @@ -2383,6 +2384,15 @@ |
929 | super(EditGitRuleGrant, self).__init__(obj, obj.repository) |
930 | |
931 | |
932 | +class ViewGitActivity(DelegatedAuthorization): |
933 | + """Anyone who can see a Git repository can see its activity logs.""" |
934 | + permission = 'launchpad.View' |
935 | + usedfor = IGitActivity |
936 | + |
937 | + def __init__(self, obj): |
938 | + super(ViewGitActivity, self).__init__(obj, obj.repository) |
939 | + |
940 | + |
941 | class AdminDistroSeriesTranslations(AuthorizationBase): |
942 | permission = 'launchpad.TranslationsAdmin' |
943 | usedfor = IDistroSeries |
944 | |
945 | === modified file 'lib/lp/testing/factory.py' |
946 | --- lib/lp/testing/factory.py 2018-10-05 15:35:34 +0000 |
947 | +++ lib/lp/testing/factory.py 2018-10-12 16:44:41 +0000 |
948 | @@ -1840,10 +1840,10 @@ |
949 | |
950 | def makeGitRuleGrant(self, rule=None, grantee=None, grantor=None, |
951 | can_create=False, can_push=False, |
952 | - can_force_push=False): |
953 | + can_force_push=False, **rule_kwargs): |
954 | """Create a Git repository access grant.""" |
955 | if rule is None: |
956 | - rule = self.makeGitRule() |
957 | + rule = self.makeGitRule(**rule_kwargs) |
958 | if grantee is None: |
959 | grantee = self.makePerson() |
960 | if grantor is None: |