← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:questions-about-storm into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:questions-about-storm into launchpad:master.

Commit message:
Port Question to Storm

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/394763
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:questions-about-storm into launchpad:master.
diff --git a/lib/lp/answers/doc/workflow.txt b/lib/lp/answers/doc/workflow.txt
index 3cabebb..4d2116e 100644
--- a/lib/lp/answers/doc/workflow.txt
+++ b/lib/lp/answers/doc/workflow.txt
@@ -592,7 +592,7 @@ Users without launchpad.Moderator privileges cannot set the assignee.
     >>> question.assignee = sample_person
     Traceback (most recent call last):
       ...
-    Unauthorized: (<Question ...>, 'assignee', 'launchpad.Append')
+    Unauthorized: (<lp.answers.model.question.Question ...>, 'assignee', 'launchpad.Append')
 
 
 Events
diff --git a/lib/lp/answers/model/question.py b/lib/lp/answers/model/question.py
index 9c51e88..fd85013 100644
--- a/lib/lp/answers/model/question.py
+++ b/lib/lp/answers/model/question.py
@@ -13,7 +13,7 @@ __all__ = [
     'QuestionTargetMixin',
     ]
 
-from datetime import datetime
+from datetime import datetime, timedelta
 from email.utils import make_msgid
 import operator
 
@@ -26,18 +26,18 @@ from lazr.lifecycle.event import (
     ObjectModifiedEvent,
     )
 from lazr.lifecycle.snapshot import Snapshot
+from lp.services.database.stormbase import StormBase
 import pytz
 import six
-from sqlobject import (
-    ForeignKey,
-    SQLMultipleJoin,
-    SQLObjectNotFound,
-    StringCol,
+from storm.expr import (
+    Alias,
+    LeftJoin,
     )
-from storm.expr import LeftJoin
 from storm.locals import (
     And,
     ClassAlias,
+    Count,
+    DateTime,
     Desc,
     Int,
     Join,
@@ -46,6 +46,7 @@ from storm.locals import (
     Reference,
     Select,
     Store,
+    Unicode,
     )
 from storm.references import ReferenceSet
 from zope.component import getUtility
@@ -89,6 +90,7 @@ from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.bugs.interfaces.buglink import IBugLinkTarget
 from lp.bugs.interfaces.bugtask import BugTaskStatus
 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
+from lp.bugs.model.bugtask import BugTask
 from lp.registry.interfaces.distribution import (
     IDistribution,
     IDistributionSet,
@@ -111,16 +113,10 @@ from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
     )
-from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.decoratedresultset import DecoratedResultSet
-from lp.services.database.enumcol import EnumCol
+from lp.services.database.enumcol import DBEnum
 from lp.services.database.interfaces import IStore
 from lp.services.database.nl_search import nl_phrase_search
-from lp.services.database.sqlbase import (
-    cursor,
-    SQLBase,
-    sqlvalues,
-    )
 from lp.services.database.stormexpr import (
     fti_search,
     rank_by_fti,
@@ -137,6 +133,7 @@ from lp.services.worlddata.helpers import is_english_variant
 from lp.services.worlddata.interfaces.language import ILanguage
 from lp.services.worlddata.model.language import Language
 from lp.services.xref.interfaces import IXRefSet
+from lp.services.xref.model import XRef
 
 
 class notify_question_modified:
@@ -177,50 +174,50 @@ class notify_question_modified:
 
 
 @implementer(IQuestion, IBugLinkTarget)
-class Question(SQLBase, BugLinkTargetMixin):
+class Question(StormBase, BugLinkTargetMixin):
     """See `IQuestion`."""
 
-    _table = 'Question'
+    __storm_table__ = 'Question'
     _defaultOrder = ['-priority', 'datecreated']
 
     # db field names
-    owner = ForeignKey(
-        dbName='owner', foreignKey='Person',
-        storm_validator=validate_public_person, notNull=True)
-    title = StringCol(notNull=True)
-    description = StringCol(notNull=True)
-    language = ForeignKey(
-        dbName='language', notNull=True, foreignKey='Language')
-    status = EnumCol(
-        schema=QuestionStatus, notNull=True, default=QuestionStatus.OPEN)
-    priority = EnumCol(
-        schema=QuestionPriority, notNull=True,
-        default=QuestionPriority.NORMAL)
-    assignee = ForeignKey(
-        dbName='assignee', notNull=False, foreignKey='Person',
-        storm_validator=validate_public_person, default=None)
-    answerer = ForeignKey(
-        dbName='answerer', notNull=False, foreignKey='Person',
-        storm_validator=validate_public_person, default=None)
+    id = Int(primary=True)
+    owner_id = Int(name='owner', allow_none=False,
+                   validator=validate_public_person)
+    owner = Reference(owner_id, 'Person.id')
+    title = Unicode(allow_none=False)
+    description = Unicode(allow_none=False)
+    language_id = Int(name="language", allow_none=False)
+    language = Reference(language_id, 'Language.id')
+    status = DBEnum(name="status", enum=QuestionStatus, allow_none=False,
+                    default=QuestionStatus.OPEN)
+    priority = DBEnum(name="priority", enum=QuestionPriority,
+                      allow_none=False, default=QuestionPriority.NORMAL)
+    assignee_id = Int(name="assignee", allow_none=True,
+                      validator=validate_public_person, default=None)
+    assignee = Reference(assignee_id, 'Person.id')
+    answerer_id = Int(name="answerer", allow_none=True,
+                      validator=validate_public_person, default=None)
+    answerer = Reference(answerer_id, 'Person.id')
     answer_id = Int(name='answer', allow_none=True, default=None)
     answer = Reference(answer_id, 'QuestionMessage.id')
-    datecreated = UtcDateTimeCol(notNull=True, default=DEFAULT)
-    datedue = UtcDateTimeCol(notNull=False, default=None)
-    datelastquery = UtcDateTimeCol(notNull=True, default=DEFAULT)
-    datelastresponse = UtcDateTimeCol(notNull=False, default=None)
-    date_solved = UtcDateTimeCol(notNull=False, default=None)
-    product = ForeignKey(
-        dbName='product', foreignKey='Product', notNull=False, default=None)
-    distribution = ForeignKey(
-        dbName='distribution', foreignKey='Distribution', notNull=False,
-        default=None)
-    sourcepackagename = ForeignKey(
-        dbName='sourcepackagename', foreignKey='SourcePackageName',
-        notNull=False, default=None)
-    whiteboard = StringCol(notNull=False, default=None)
-
-    faq = ForeignKey(
-        dbName='faq', foreignKey='FAQ', notNull=False, default=None)
+    datecreated = DateTime(allow_none=False, default=DEFAULT, tzinfo=pytz.UTC)
+    datedue = DateTime(allow_none=True, default=None, tzinfo=pytz.UTC)
+    datelastquery = DateTime(
+        allow_none=False, default=DEFAULT, tzinfo=pytz.UTC)
+    datelastresponse = DateTime(allow_none=True, default=None, tzinfo=pytz.UTC)
+    date_solved = DateTime(allow_none=True, default=None, tzinfo=pytz.UTC)
+    product_id = Int(name='product', allow_none=True, default=None)
+    product = Reference(product_id, 'Product.id')
+    distribution_id = Int(name='distribution', allow_none=True, default=None)
+    distribution = Reference(distribution_id, 'Distribution.id')
+    sourcepackagename_id = Int(
+        name='sourcepackagename', allow_none=True, default=None)
+    sourcepackagename = Reference(sourcepackagename_id, 'SourcePackageName.id')
+    whiteboard = Unicode(allow_none=True, default=None)
+
+    faq_id = Int(name='faq', allow_none=True, default=None)
+    faq = Reference(faq_id, 'FAQ.id')
 
     # useful joins
     subscriptions = ReferenceSet(
@@ -238,6 +235,18 @@ class Question(SQLBase, BugLinkTargetMixin):
         'id', 'QuestionReopening.question_id',
         order_by='QuestionReopening.datecreated')
 
+    def __init__(self, title, description, owner, product, distribution,
+                 language, sourcepackagename, datecreated, datelastquery):
+        self.title = title
+        self.description = description
+        self.owner = owner
+        self.product = product
+        self.distribution = distribution
+        self.sourcepackagename = sourcepackagename
+        self.datecreated = datecreated
+        self.language = language
+        self.datelastquery = datelastquery
+
     @property
     def messages(self):
         return list(self._messages)
@@ -735,29 +744,28 @@ class QuestionSet:
         # This query joins to bugtasks that are not BugTaskStatus.INVALID
         # because there are many bugtasks to one question. A question is
         # included when BugTask.status IS NULL.
-        return Question.select("""
-            id in (SELECT Question.id
-                FROM Question
-                LEFT OUTER JOIN XRef ON (
-                    XRef.from_type = 'question'
-                    AND XRef.from_id_int = Question.id
-                    AND XRef.to_type = 'bug')
-                LEFT OUTER JOIN BugTask ON (
-                    BugTask.bug = XRef.to_id_int
-                    AND BugTask.status != %s)
-                WHERE
-                    Question.status IN (%s, %s)
-                    AND (Question.datelastresponse IS NULL
-                         OR Question.datelastresponse < (CURRENT_TIMESTAMP
-                            AT TIME ZONE 'UTC' - interval '%s days'))
-                    AND Question.datelastquery < (CURRENT_TIMESTAMP
-                            AT TIME ZONE 'UTC' - interval '%s days')
-                    AND Question.assignee IS NULL
-                    AND BugTask.status IS NULL)
-            """ % sqlvalues(
-                BugTaskStatus.INVALID,
-                QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
-                days_before_expiration, days_before_expiration))
+        origin = [
+            Question,
+            LeftJoin(XRef, And(
+                XRef.from_type == u'question',
+                XRef.from_id_int == Question.id,
+                XRef.to_type == u'bug')),
+            LeftJoin(BugTask, And(
+                BugTask.bug == XRef.to_id_int,
+                BugTask.status != BugTaskStatus.INVALID)),
+            ]
+        expiry = datetime.now(
+            pytz.timezone('UTC')) - timedelta(days=days_before_expiration)
+        return IStore(Question).using(*origin).find(
+            Question,
+            Question.status.is_in(
+                (QuestionStatus.OPEN, QuestionStatus.NEEDSINFO)),
+            Or(
+                Question.datelastresponse == None,
+                Question.datelastresponse < expiry),
+            Question.datelastquery < expiry,
+            Question.assignee == None,
+            BugTask._status == None).config(distinct=True)
 
     def searchQuestions(self, search_text=None, language=None,
                       status=QUESTION_STATUS_DEFAULT_SEARCH, sort=None):
@@ -773,32 +781,32 @@ class QuestionSet:
 
     def getMostActiveProjects(self, limit=5):
         """See `IQuestionSet`."""
-        cur = cursor()
-        cur.execute("""
-            SELECT product, distribution, count(*) AS "question_count"
-            FROM (
-                SELECT product, distribution
-                FROM Question
-                    LEFT OUTER JOIN Product ON (Question.product = Product.id)
-                    LEFT OUTER JOIN Distribution ON (
-                        Question.distribution = Distribution.id)
-                WHERE
-                    ((Product.answers_usage = %s AND Product.active)
-                    OR Distribution.answers_usage = %s)
-                    AND Question.datecreated > (
-                        current_timestamp -interval '60 days')
-                LIMIT 5000
-            ) AS "RecentQuestions"
-            GROUP BY product, distribution
-            ORDER BY question_count DESC
-            LIMIT %s
-            """ % sqlvalues(
-                    ServiceUsage.LAUNCHPAD, ServiceUsage.LAUNCHPAD, limit))
+
+        from lp.registry.model.product import Product
+        from lp.registry.model.distribution import Distribution
+
+        time_cutoff = datetime.now(pytz.timezone('UTC')) - timedelta(days=60)
+        question_count = Alias(Count())
+        results = IStore(Question).using(
+                Question,
+                LeftJoin(Product, Question.product_id == Product.id),
+                LeftJoin(Distribution,
+                         Question.distribution_id == Distribution.id)
+                ).find(
+            (Question.product_id, Question.distribution_id, question_count),
+            Or(
+                And(Product._answers_usage == ServiceUsage.LAUNCHPAD,
+                    Product.active),
+                Distribution._answers_usage == ServiceUsage.LAUNCHPAD),
+            Question.datecreated > time_cutoff
+            ).group_by(
+                Question.product_id, Question.distribution_id
+            ).order_by(Desc(question_count))[:limit]
 
         projects = []
         product_set = getUtility(IProductSet)
         distribution_set = getUtility(IDistributionSet)
-        for product_id, distribution_id, ignored in cur.fetchall():
+        for product_id, distribution_id, _ in results:
             if product_id:
                 projects.append(product_set.get(product_id))
             elif distribution_id:
@@ -830,10 +838,9 @@ class QuestionSet:
 
     def get(self, question_id, default=None):
         """See `IQuestionSet`."""
-        try:
-            return Question.get(question_id)
-        except SQLObjectNotFound:
-            return default
+        store = IStore(Question)
+        question = store.get(Question, question_id)
+        return question or default
 
     def getOpenQuestionCountByPackages(self, packages):
         """See `IQuestionSet`."""
@@ -861,27 +868,17 @@ class QuestionSet:
             package.sourcepackagename.id for package in packages]
         open_statuses = [QuestionStatus.OPEN, QuestionStatus.NEEDSINFO]
 
-        query = """
-            SELECT Question.distribution,
-                   Question.sourcepackagename,
-                   COUNT(*) AS open_questions
-            FROM Question
-            WHERE Question.status IN %(open_statuses)s
-                AND Question.sourcepackagename IN %(package_names)s
-                AND Question.distribution = %(distribution)s
-            GROUP BY Question.distribution, Question.sourcepackagename
-            """ % sqlvalues(
-                open_statuses=open_statuses,
-                package_names=package_name_ids,
-                distribution=distribution,
-                )
-        cur = cursor()
-        cur.execute(query)
+        results = IStore(Question).find(
+            (Question.distribution_id, Question.sourcepackagename_id, Count()),
+            Question.status.is_in(open_statuses),
+            Question.sourcepackagename_id.is_in(package_name_ids),
+            Question.distribution == distribution,
+        ).group_by(Question.distribution_id, Question.sourcepackagename_id)
         sourcepackagename_set = getUtility(ISourcePackageNameSet)
         # Only packages with open questions are included in the query
         # result, so initialize each package to 0.
         counts = dict((package, 0) for package in packages)
-        for distro_id, spn_id, open_questions in cur.fetchall():
+        for distro_id, spn_id, open_questions in results:
             # The SourcePackageNames here should already be pre-fetched,
             # so that .get(spn_id) won't issue a DB query.
             sourcepackagename = sourcepackagename_set.get(spn_id)
@@ -1024,7 +1021,7 @@ class QuestionSearch:
                     QuestionMessage.owner == self.needs_attention_from)))
 
         if self.language:
-            constraints.append(Question.languageID.is_in(
+            constraints.append(Question.language_id.is_in(
                 [language.id for language in self.language]))
 
         return constraints
@@ -1161,7 +1158,7 @@ class QuestionTargetSearch(QuestionSearch):
             supported_languages = (
                 self.unsupported_target.getSupportedLanguages())
             constraints.append(
-                Not(Question.languageID.is_in(
+                Not(Question.language_id.is_in(
                     [language.id for language in supported_languages])))
 
         return constraints
@@ -1327,9 +1324,9 @@ class QuestionTargetMixin:
 
     def getQuestion(self, question_id):
         """See `IQuestionTarget`."""
-        try:
-            question = Question.get(question_id)
-        except SQLObjectNotFound:
+        store = Store.of(self)
+        question = store.get(Question, question_id)
+        if not question:
             return None
         # Verify that the question is actually for this target.
         if not self.questionIsForTarget(question):
@@ -1349,18 +1346,14 @@ class QuestionTargetMixin:
 
     def getQuestionLanguages(self):
         """See `IQuestionTarget`."""
-        constraints = ['Language.id = Question.language']
-        targets = self.getTargetTypes()
-        for column, target in targets.items():
+        query = [Language.id == Question.language_id]
+        for column, target in self.getTargetTypes().items():
             if target is None:
-                constraint = "Question." + column + " IS NULL"
+                query.append(getattr(Question, column) is None)
             else:
-                constraint = "Question." + column + " = %s" % sqlvalues(
-                    target)
-            constraints.append(constraint)
-        return set(Language.select(
-            ' AND '.join(constraints),
-            clauseTables=['Question'], distinct=True))
+                query.append(getattr(Question, column) == target)
+        results = IStore(Language).find(Question, query).config(distinct=True)
+        return results
 
     @property
     def _store(self):
@@ -1461,44 +1454,34 @@ class QuestionTargetMixin:
         Store.of(answer_contact).flush()
         return True
 
-    def _selectPersonFromAnswerContacts(self, constraints, clause_tables):
-        """Return the Persons or Teams who are AnswerContacts."""
-        constraints.append("""Person.id = AnswerContact.person""")
-        clause_tables.append('AnswerContact')
-        # Avoid a circular import of Person, which imports the mixin.
-        from lp.registry.model.person import Person
-        return Person.select(
-            " AND ".join(constraints), clauseTables=clause_tables,
-            orderBy=['display_name'], distinct=True)
-
     def getAnswerContactsForLanguage(self, language):
         """See `IQuestionTarget`."""
+        from lp.registry.model.person import PersonLanguage, Person
         assert language is not None, (
             "The language cannot be None when selecting answer contacts.")
-        constraints = []
+        query = []
         targets = self.getTargetTypes()
         for column, target in targets.items():
             if target is None:
-                constraint = "AnswerContact." + column + " IS NULL"
+                query.append(getattr(AnswerContact, column) is None)
             else:
-                constraint = "AnswerContact." + column + " = %s" % sqlvalues(
-                    target)
-            constraints.append(constraint)
-
-        constraints.append("""
-            AnswerContact.person = PersonLanguage.person AND
-            PersonLanguage.Language = Language.id""")
+                query.append(getattr(AnswerContact, column) == target)
+        query.append(
+            And(
+                AnswerContact.person == PersonLanguage.person_id,
+                PersonLanguage.language_id == Language.id))
         # XXX sinzui 2007-07-12 bug=125545:
         # Using a LIKE constraint is suboptimal. We would not need this
         # if-else clause if variant languages knew their parent language.
         if language.code == 'en':
-            constraints.append("""
-                Language.code LIKE %s""" % sqlvalues('%s%%' % language.code))
+            query.append(Language.code.like(language.code))
         else:
-            constraints.append("""
-                Language.id = %s""" % sqlvalues(language))
-        return list((self._selectPersonFromAnswerContacts(
-            constraints, ['PersonLanguage', 'Language'])))
+            query.append(Language.id == language.id)
+
+        query.append(AnswerContact.person == Person.id)
+        results = IStore(Person).find(
+            Person, query).config(distinct=True).order_by("Person.displayname")
+        return results
 
     def getAnswerContactRecipients(self, language):
         """See `IQuestionTarget`."""
diff --git a/lib/lp/answers/model/tests/test_question.py b/lib/lp/answers/model/tests/test_question.py
index b13665c..d388755 100644
--- a/lib/lp/answers/model/tests/test_question.py
+++ b/lib/lp/answers/model/tests/test_question.py
@@ -2,11 +2,16 @@
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import absolute_import, print_function, unicode_literals
+from datetime import datetime, timedelta
+from lp.services import database
+import pytz
 
 __metaclass__ = type
 
 from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
 
+from lp.answers.interfaces.questioncollection import IQuestionSet
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.testing import (
     person_logged_in,
@@ -101,3 +106,23 @@ class TestQuestionInDirectSubscribers(TestCaseWithFactory):
 
         # Check the results.
         self.assertEqual([spanish_person], question.getIndirectSubscribers())
+
+
+class TestQuestionSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_expiredQuestions(self):
+        question = self.factory.makeQuestion()
+        removeSecurityProxy(question).datelastquery = datetime.now(
+            pytz.UTC) - timedelta(days=5)
+
+        question_set = getUtility(IQuestionSet)
+        expired = question_set.findExpiredQuestions(3)
+        self.assertIn(question, expired)
+
+    def test_expiredQuestionsDoesNotExpire(self):
+        question = self.factory.makeQuestion()
+        question_set = getUtility(IQuestionSet)
+        expired = question_set.findExpiredQuestions(3)
+        self.assertNotIn(question, expired)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 14e917d..f49a04e 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -2387,11 +2387,11 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         if target is None:
             target = self.makeProduct()
         if title is None:
-            title = self.getUniqueString('title')
+            title = self.getUniqueUnicode('title')
         if owner is None:
             owner = target.owner
         if description is None:
-            description = self.getUniqueString('description')
+            description = self.getUniqueUnicode('description')
         with person_logged_in(owner):
             question = target.newQuestion(
                 owner=owner, title=title, description=description, **kwargs)