1
# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
4
# pylint: disable-msg=E0611,W0212
15
from datetime import (
21
from bzrlib.revision import NULL_REVISION
23
from sqlobject import (
31
from storm.expr import (
40
from storm.locals import (
47
from storm.store import Store
48
from zope.component import getUtility
49
from zope.interface import implements
50
from zope.security.proxy import removeSecurityProxy
52
from canonical.database.constants import (
56
from canonical.database.datetimecol import UtcDateTimeCol
57
from canonical.database.sqlbase import (
62
from canonical.launchpad.helpers import shortlist
63
from lp.services.database.lpstorm import (
67
from canonical.launchpad.webapp.interfaces import (
72
from lp.code.interfaces.branch import DEFAULT_BRANCH_STATUS_IN_LISTING
73
from lp.code.interfaces.revision import (
80
from lp.registry.interfaces.person import validate_public_person
81
from lp.registry.interfaces.product import IProduct
82
from lp.registry.interfaces.projectgroup import IProjectGroup
83
from lp.registry.model.person import ValidPersonCache
84
from lp.services.identity.interfaces.emailaddress import (
90
class Revision(SQLBase):
95
date_created = UtcDateTimeCol(notNull=True, default=DEFAULT)
96
log_body = StringCol(notNull=True)
97
gpgkey = ForeignKey(dbName='gpgkey', foreignKey='GPGKey', default=None)
99
revision_author_id = Int(name='revision_author', allow_none=False)
100
revision_author = Reference(revision_author_id, 'RevisionAuthor.id')
102
revision_id = StringCol(notNull=True, alternateID=True,
103
alternateMethodName='byRevisionID')
104
revision_date = UtcDateTimeCol(notNull=False)
106
karma_allocated = BoolCol(default=False, notNull=True)
108
properties = SQLMultipleJoin('RevisionProperty', joinColumn='revision')
112
"""See IRevision.parents"""
113
return shortlist(RevisionParent.selectBy(
114
revision=self, orderBy='sequence'))
117
def parent_ids(self):
118
"""Sequence of globally unique ids for the parents of this revision.
120
The corresponding Revision objects can be retrieved, if they are
121
present in the database, using the RevisionSet Zope utility.
123
return [parent.parent_id for parent in self.parents]
125
def getLefthandParent(self):
126
if len(self.parent_ids) == 0:
127
parent_id = NULL_REVISION
129
parent_id = self.parent_ids[0]
130
return RevisionSet().getByRevisionId(parent_id)
132
def getProperties(self):
133
"""See `IRevision`."""
134
return dict((prop.name, prop.value) for prop in self.properties)
136
def allocateKarma(self, branch):
137
"""See `IRevision`."""
138
# If we know who the revision author is, give them karma.
139
author = self.revision_author.person
140
if author is not None:
141
# Backdate the karma to the time the revision was created. If the
142
# revision_date on the revision is in future (for whatever weird
143
# reason) we will use the date_created from the revision (which
144
# will be now) as the karma date created. Having future karma
145
# events is both wrong, as the revision has been created (and it
146
# is lying), and a problem with the way the Launchpad code
147
# currently does its karma degradation over time.
148
karma_date = min(self.revision_date, self.date_created)
149
karma = branch.target.assignKarma(
150
author, 'revisionadded', karma_date)
151
if karma is not None:
152
self.karma_allocated = True
157
def getBranch(self, allow_private=False, allow_junk=True):
158
"""See `IRevision`."""
159
from lp.code.model.branch import Branch
160
from lp.code.model.branchrevision import BranchRevision
162
store = Store.of(self)
165
self.id == BranchRevision.revision_id,
166
BranchRevision.branch_id == Branch.id)
167
if not allow_private:
168
query = And(query, Not(Branch.transitively_private))
172
# Not-junk branches are either associated with a product
173
# or with a source package.
175
(Branch.product != None),
177
Branch.sourcepackagename != None,
178
Branch.distroseries != None)))
179
result_set = store.find(Branch, query)
180
if self.revision_author.person is None:
181
result_set.order_by(Asc(BranchRevision.sequence))
184
Branch.ownerID != self.revision_author.personID,
185
Asc(BranchRevision.sequence))
187
return result_set.first()
190
class RevisionAuthor(SQLBase):
191
implements(IRevisionAuthor)
193
_table = 'RevisionAuthor'
195
name = StringCol(notNull=True, alternateID=True)
198
def name_without_email(self):
199
"""Return the name of the revision author without the email address.
201
If there is no name information (i.e. when the revision author only
202
supplied their email address), return None.
204
if '@' not in self.name:
206
return email.Utils.parseaddr(self.name)[0]
208
email = StringCol(notNull=False, default=None)
209
person = ForeignKey(dbName='person', foreignKey='Person', notNull=False,
210
storm_validator=validate_public_person, default=None)
212
def linkToLaunchpadPerson(self):
213
"""See `IRevisionAuthor`."""
214
if self.person is not None or self.email is None:
216
lp_email = getUtility(IEmailAddressSet).getByEmail(self.email)
217
# If not found, we didn't link this person.
220
# Only accept an email address that is validated.
221
if lp_email.status != EmailAddressStatus.NEW:
222
self.personID = lp_email.personID
228
class RevisionParent(SQLBase):
229
"""The association between a revision and its parent."""
231
implements(IRevisionParent)
233
_table = 'RevisionParent'
235
revision = ForeignKey(
236
dbName='revision', foreignKey='Revision', notNull=True)
238
sequence = IntCol(notNull=True)
239
parent_id = StringCol(notNull=True)
242
class RevisionProperty(SQLBase):
243
"""A property on a revision. See IRevisionProperty."""
245
implements(IRevisionProperty)
247
_table = 'RevisionProperty'
249
revision = ForeignKey(
250
dbName='revision', foreignKey='Revision', notNull=True)
251
name = StringCol(notNull=True)
252
value = StringCol(notNull=True)
257
implements(IRevisionSet)
259
def getByRevisionId(self, revision_id):
260
return Revision.selectOneBy(revision_id=revision_id)
262
def _createRevisionAuthor(self, revision_author):
263
"""Extract out the email and check to see if it matches a Person."""
264
email_address = email.Utils.parseaddr(revision_author)[1]
265
# If there is no @, then it isn't a real email address.
266
if '@' not in email_address:
269
author = RevisionAuthor(name=revision_author, email=email_address)
270
author.linkToLaunchpadPerson()
273
def new(self, revision_id, log_body, revision_date, revision_author,
274
parent_ids, properties, _date_created=None):
275
"""See IRevisionSet.new()"""
276
if properties is None:
278
if _date_created is None:
279
_date_created = UTC_NOW
280
author = self.acquireRevisionAuthor(revision_author)
283
revision_id=revision_id,
285
revision_date=revision_date,
286
revision_author=author,
287
date_created=_date_created)
288
# Don't create future revisions.
289
if revision.revision_date > revision.date_created:
290
revision.revision_date = revision.date_created
293
for sequence, parent_id in enumerate(parent_ids):
294
if parent_id in seen_parents:
296
seen_parents.add(parent_id)
297
RevisionParent(revision=revision, sequence=sequence,
300
# Create revision properties.
301
for name, value in properties.iteritems():
302
RevisionProperty(revision=revision, name=name, value=value)
306
def acquireRevisionAuthor(self, name):
307
"""Find or create the RevisionAuthor with the specified name.
309
Name may be any arbitrary string, but if it is an email-id, and
310
its email address is a verified email address, it will be
311
automatically linked to the corresponding Person.
313
Email-ids come in two major forms:
314
"Foo Bar" <foo@bar.com>
315
foo@bar.com (Foo Bar)
317
# create a RevisionAuthor if necessary:
319
return RevisionAuthor.byName(name)
320
except SQLObjectNotFound:
321
return self._createRevisionAuthor(name)
323
def _timestampToDatetime(self, timestamp):
324
"""Convert the given timestamp to a datetime object.
326
This works around a bug in Python that causes datetime.fromtimestamp
327
to raise an exception if it is given a negative, fractional timestamp.
329
:param timestamp: A timestamp from a bzrlib.revision.Revision
330
:type timestamp: float
332
:return: A datetime corresponding to the given timestamp.
334
# Work around Python bug #1646728.
335
# See https://launchpad.net/bugs/81544.
336
UTC = pytz.timezone('UTC')
337
int_timestamp = int(timestamp)
338
revision_date = datetime.fromtimestamp(int_timestamp, tz=UTC)
339
revision_date += timedelta(seconds=timestamp - int_timestamp)
342
def newFromBazaarRevision(self, bzr_revision):
343
"""See `IRevisionSet`."""
344
revision_id = bzr_revision.revision_id
345
revision_date = self._timestampToDatetime(bzr_revision.timestamp)
346
authors = bzr_revision.get_apparent_authors()
347
# XXX: JonathanLange 2009-05-01 bug=362686: We can only have one
348
# author per revision, so we use the first on the assumption that
349
# this is the primary author.
355
revision_id=revision_id,
356
log_body=bzr_revision.message,
357
revision_date=revision_date,
358
revision_author=author,
359
parent_ids=bzr_revision.parent_ids,
360
properties=bzr_revision.properties)
363
def onlyPresent(revids):
364
"""See `IRevisionSet`."""
367
store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
370
CREATE TEMPORARY TABLE Revids
375
data.append('(%s)' % sqlvalues(revid))
376
data = ', '.join(data)
378
"INSERT INTO Revids (revision_id) VALUES %s" % data)
379
result = store.execute(
381
SELECT Revids.revision_id
382
FROM Revids, Revision
383
WHERE Revids.revision_id = Revision.revision_id
386
for row in result.get_all():
388
store.execute("DROP TABLE Revids")
391
def checkNewVerifiedEmail(self, email):
392
"""See `IRevisionSet`."""
393
# Bypass zope's security because IEmailAddress.email is not public.
394
naked_email = removeSecurityProxy(email)
395
for author in RevisionAuthor.selectBy(email=naked_email.email):
396
author.personID = email.personID
398
def getTipRevisionsForBranches(self, branches):
399
"""See `IRevisionSet`."""
400
# If there are no branch_ids, then return None.
401
branch_ids = [branch.id for branch in branches]
404
return Revision.select("""
406
Revision.revision_id = Branch.last_scanned_id
407
""" % quote(branch_ids),
408
clauseTables=['Branch'], prejoins=['revision_author'])
411
def getRecentRevisionsForProduct(product, days):
412
"""See `IRevisionSet`."""
413
# Here to stop circular imports.
414
from lp.code.model.branch import Branch
415
from lp.code.model.branchrevision import BranchRevision
417
revision_subselect = Select(
418
Min(Revision.id), revision_time_limit(days))
419
# Only look in active branches.
420
result_set = Store.of(product).find(
421
(Revision, RevisionAuthor),
422
Revision.revision_author == RevisionAuthor.id,
423
revision_time_limit(days),
424
BranchRevision.revision == Revision.id,
425
BranchRevision.branch == Branch.id,
426
Branch.product == product,
427
Branch.lifecycle_status.is_in(DEFAULT_BRANCH_STATUS_IN_LISTING),
428
BranchRevision.revision_id >= revision_subselect)
429
result_set.config(distinct=True)
430
return result_set.order_by(Desc(Revision.revision_date))
433
def getRevisionsNeedingKarmaAllocated(limit=None):
434
"""See `IRevisionSet`."""
435
# Here to stop circular imports.
436
from lp.code.model.branch import Branch
437
from lp.code.model.branchrevision import BranchRevision
439
store = IStore(Revision)
440
results_with_dupes = store.find(
442
Revision.revision_author == RevisionAuthor.id,
443
RevisionAuthor.person == ValidPersonCache.id,
444
Not(Revision.karma_allocated),
445
BranchRevision.revision == Revision.id,
446
BranchRevision.branch == Branch.id,
447
Or(Branch.product != None, Branch.distroseries != None))[:limit]
448
# Eliminate duplicate rows, returning <= limit rows
450
Revision, Revision.id.is_in(
451
results_with_dupes.get_select_expr(Revision.id)))
454
def getPublicRevisionsForPerson(person, day_limit=30):
455
"""See `IRevisionSet`."""
456
# Here to stop circular imports.
457
from lp.code.model.branch import Branch
458
from lp.code.model.branchrevision import BranchRevision
459
from lp.registry.model.teammembership import (
462
store = Store.of(person)
466
Join(BranchRevision, BranchRevision.revision == Revision.id),
467
Join(Branch, BranchRevision.branch == Branch.id),
469
Revision.revision_author == RevisionAuthor.id),
474
Join(TeamParticipation,
475
RevisionAuthor.personID == TeamParticipation.personID))
476
person_condition = TeamParticipation.team == person
478
person_condition = RevisionAuthor.person == person
480
result_set = store.using(*origin).find(
482
And(revision_time_limit(day_limit),
484
Not(Branch.transitively_private)))
485
result_set.config(distinct=True)
486
return result_set.order_by(Desc(Revision.revision_date))
489
def _getPublicRevisionsHelper(obj, day_limit):
490
"""Helper method for Products and ProjectGroups."""
491
# Here to stop circular imports.
492
from lp.code.model.branch import Branch
493
from lp.registry.model.product import Product
494
from lp.code.model.branchrevision import BranchRevision
498
Join(BranchRevision, BranchRevision.revision == Revision.id),
499
Join(Branch, BranchRevision.branch == Branch.id),
502
conditions = And(revision_time_limit(day_limit),
503
Not(Branch.transitively_private))
505
if IProduct.providedBy(obj):
506
conditions = And(conditions, Branch.product == obj)
507
elif IProjectGroup.providedBy(obj):
508
origin.append(Join(Product, Branch.product == Product.id))
509
conditions = And(conditions, Product.project == obj)
511
raise AssertionError(
512
"Not an IProduct or IProjectGroup: %r" % obj)
514
result_set = Store.of(obj).using(*origin).find(
515
Revision, conditions)
516
result_set.config(distinct=True)
517
return result_set.order_by(Desc(Revision.revision_date))
520
def getPublicRevisionsForProduct(cls, product, day_limit=30):
521
"""See `IRevisionSet`."""
522
return cls._getPublicRevisionsHelper(product, day_limit)
525
def getPublicRevisionsForProjectGroup(cls, project, day_limit=30):
526
"""See `IRevisionSet`."""
527
return cls._getPublicRevisionsHelper(project, day_limit)
530
def updateRevisionCacheForBranch(branch):
531
"""See `IRevisionSet`."""
532
# Hand crafting the sql insert statement as storm doesn't handle the
533
# INSERT INTO ... SELECT ... syntax. Also there is no public api yet
534
# for storm to get the select statement.
536
# Remove the security proxy to get access to the ID columns.
537
naked_branch = removeSecurityProxy(branch)
539
insert_columns = ['Revision.id', 'revision_author', 'revision_date']
540
subselect_clauses = []
541
if branch.product is None:
542
insert_columns.append('NULL')
543
subselect_clauses.append('product IS NULL')
545
insert_columns.append(str(naked_branch.productID))
546
subselect_clauses.append('product = %s' % naked_branch.productID)
548
if branch.distroseries is None:
549
insert_columns.extend(['NULL', 'NULL'])
550
subselect_clauses.extend(
551
['distroseries IS NULL', 'sourcepackagename IS NULL'])
553
insert_columns.extend(
554
[str(naked_branch.distroseriesID),
555
str(naked_branch.sourcepackagenameID)])
556
subselect_clauses.extend(
557
['distroseries = %s' % naked_branch.distroseriesID,
558
'sourcepackagename = %s' % naked_branch.sourcepackagenameID])
560
insert_columns.append(str(branch.private))
562
subselect_clauses.append('private IS TRUE')
564
subselect_clauses.append('private IS FALSE')
566
insert_statement = """
567
INSERT INTO RevisionCache
568
(revision, revision_author, revision_date,
569
product, distroseries, sourcepackagename, private)
570
SELECT %(columns)s FROM Revision
571
JOIN BranchRevision ON BranchRevision.revision = Revision.id
572
WHERE Revision.revision_date > (
573
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval '30 days')
574
AND BranchRevision.branch = %(branch_id)s
575
AND Revision.id NOT IN (
576
SELECT revision FROM RevisionCache
577
WHERE %(subselect_where)s)
579
'columns': ', '.join(insert_columns),
580
'branch_id': branch.id,
581
'subselect_where': ' AND '.join(subselect_clauses),
583
Store.of(branch).execute(insert_statement)
586
def pruneRevisionCache(limit):
587
"""See `IRevisionSet`."""
588
# Storm doesn't handle remove a limited result set:
589
# FeatureError: Can't remove a sliced result set
590
store = IMasterStore(RevisionCache)
591
epoch = datetime.now(tz=pytz.UTC) - timedelta(days=30)
594
RevisionCache.revision_date < epoch,
596
store.find(RevisionCache, RevisionCache.id.is_in(subquery)).remove()
599
def revision_time_limit(day_limit):
600
"""The storm fragment to limit the revision_date field of the Revision."""
601
now = datetime.now(pytz.UTC)
602
earliest = now - timedelta(days=day_limit)
605
Revision.revision_date <= now,
606
Revision.revision_date > earliest)
609
class RevisionCache(Storm):
610
"""A cached version of a recent revision."""
612
__storm_table__ = 'RevisionCache'
614
id = Int(primary=True)
616
revision_id = Int(name='revision', allow_none=False)
617
revision = Reference(revision_id, 'Revision.id')
619
revision_author_id = Int(name='revision_author', allow_none=False)
620
revision_author = Reference(revision_author_id, 'RevisionAuthor.id')
622
revision_date = UtcDateTimeCol(notNull=True)
624
product_id = Int(name='product', allow_none=True)
625
product = Reference(product_id, 'Product.id')
627
distroseries_id = Int(name='distroseries', allow_none=True)
628
distroseries = Reference(distroseries_id, 'DistroSeries.id')
630
sourcepackagename_id = Int(name='sourcepackagename', allow_none=True)
631
sourcepackagename = Reference(
632
sourcepackagename_id, 'SourcePackageName.id')
634
private = Bool(allow_none=False, default=False)
636
def __init__(self, revision):
637
# Make the revision_author assignment first as traversing to the
638
# revision_author of the revision does a query which causes a store
639
# flush. If an assignment has been done already, the RevisionCache
640
# object would have been implicitly added to the store, and failes
641
# with an integrity check.
642
self.revision_author = revision.revision_author
643
self.revision = revision
644
self.revision_date = revision.revision_date