示例#1
0
class Content(Model):
    """Ancestor class for curated content objects: Theme, Topic, Lesson, and
    Practice.
    """

    name = sndb.StringProperty(required=True, search_type=search.TextField)
    summary = sndb.TextProperty(default='', search_type=search.TextField)
    tags = sndb.StringProperty(repeated=True, search_type=search.AtomField)
    subjects = sndb.StringProperty(repeated=True, search_type=search.AtomField)
    min_grade = sndb.IntegerProperty(default=0, search_type=search.NumberField)
    max_grade = sndb.IntegerProperty(default=13,
                                     search_type=search.NumberField)
    promoted = sndb.BooleanProperty(default=False,
                                    search_type=search.AtomField)
示例#2
0
class Assessment(Model):
    """A MSK representation of a mindset meter assessment, created by PERTS.

    Mostly just a name to list avaiable assessments and to organize surveys
    created from them. The bulk of the definition of an assessment happens on
    [mindsetmeter](survey.perts.net) in the form of html templates for surveys
    and reports.

    Always available to everyone, so listed by default.
    """
    name = sndb.StringProperty(required=True, search_type=search.TextField)
    # Defined on the client, but typically: self.name.lower().replace(' ', '-')
    # So for 'Growth Mindset' this is 'growth-mindset', and it's used to make
    # URLs for mindset meter, so users would be directed to
    # 'survey.perts.net/take/growth-mindset'
    url_name = sndb.StringProperty(required=True, validator=url_name_validator)
    description = sndb.TextProperty(default='', search_type=search.TextField)
    num_phases = sndb.IntegerProperty(required=True)
    listed = sndb.BooleanProperty(default=True)
示例#3
0
class Email(Model):
    """An email in a queue (not necessarily one that has been sent).

    Emails have regular fields, to, from, subject, body. Also a send date and
    an sent boolean to support queuing.

    Uses jinja to attempt to attempt to interpolate values in the
    template_data dictionary into the body and subject.

    Body text comes in two flavors: raw text and html. The raw text is
    interpreted as markdown (after jinja processing) to auto-generate the html
    version (templates/email.html is also used for default styles). Which
    version users see depends on their email client.

    Consequently, all emails should be composed in markdown.
    """

    to_address = sndb.StringProperty(required=True)
    from_address = sndb.StringProperty(
        default=config.from_server_email_address)
    reply_to = sndb.StringProperty(default=config.from_server_email_address)
    subject = sndb.StringProperty(default="A message from the Mindset Kit")
    body = sndb.TextProperty()
    html = sndb.TextProperty()
    template = sndb.StringProperty()
    template_data_string = sndb.TextProperty(default='{}')
    scheduled_date = sndb.DateProperty(auto_now_add=True)
    was_sent = sndb.BooleanProperty(default=False)
    was_attempted = sndb.BooleanProperty(default=False)
    errors = sndb.TextProperty()

    @property
    def template_data(self):
        return json.loads(self.template_data_string)

    @template_data.setter
    def template_data(self, value):
        self.template_data_string = json.dumps(value,
                                               default=util.json_dumps_default)
        return value

    @classmethod
    def create(klass, **kwargs):
        """
        WARNING: Do NOT pass any objects in kwargs['template_data']
        And be sure any associated templates use plain values in jinja code
        """
        email = super(klass, klass).create(**kwargs)
        return email

    @classmethod
    def we_are_spamming(self, email):
        """Did we recently send email to this recipient?
        @todo discuss - I can think of cases where this can be harmful...
        IE 2 different users comment on 2 different uploads from same user
        I guess we should introduce Notifications in the app :)
        """

        to = email.to_address

        # We can spam admins, like
        # so we white list them in the config file
        if to in config.addresses_we_can_spam:
            return False

        # We can also spam admins living at a @mindsetkit.org
        if to.endswith('mindsetkit.org'):
            return False

        since = datetime.datetime.utcnow() - datetime.timedelta(
            minutes=config.suggested_delay_between_emails)

        query = Email.query(
            Email.was_sent == True,
            Email.scheduled_date >= since,
            Email.to_address == to,
        )

        return query.count(limit=1) > 0

    @classmethod
    def send(self, emails):
        to_addresses = []
        for email in emails:
            if self.we_are_spamming(email):
                # Do not send
                # This user has already recieved very recent emails

                # Debugging info
                logging.error("We are spamming {}:\n{}".format(
                    email.to_address, email.to_dict()))
            elif email.to_address in to_addresses:
                # Do not send
                # We don't send multiple emails to an address per 'send'

                # Debugging info
                logging.error("We are spamming {}:\n{}".format(
                    email.to_address, email.to_dict()))
            else:
                # Not spam! Let's send it
                to_addresses.append(email.to_address)

                # Debugging info
                logging.info(u"sending email: {}".format(email.to_dict()))
                logging.info(u"to: {}".format(email.to_address))
                logging.info(u"subject: {}".format(email.subject))

                if email.body:
                    logging.info(u"body:\n{}".format(email.body))
                    mandrill.send(
                        to_address=email.to_address,
                        subject=email.subject,
                        body=email.body,
                        template_data=email.template_data,
                    )

                elif email.template:
                    logging.info(u"template: {}".format(email.template))
                    mandrill.send(
                        to_address=email.to_address,
                        subject=email.subject,
                        template=email.template,
                        template_data=email.template_data,
                    )

                email.was_sent = True
                logging.info("""Sent successfully!""")

            # Note that we are attempting to send so that we don't keep attempting.
            email.was_attempted = True
            email.put()

    @classmethod
    def fetch_pending_emails(self):
        to_send = Email.query(
            Email.deleted == False,
            Email.scheduled_date <= datetime.datetime.utcnow(),
            Email.was_sent == False,
            Email.was_attempted == False,
        )

        return to_send.fetch()

    @classmethod
    def send_pending_email(self):
        """Send the next unsent emails in the queue.
        """

        emails = self.fetch_pending_emails()

        if emails:
            self.send(emails)
            return emails
        else:
            return None
示例#4
0
class Model(ndb.Model):
    """Superclass for all others; contains generic properties and methods."""

    # This id is an encoding of the entity's key. For root entities (those
    # with no ancestors), it's a class name (same as a GAE "kind") and
    # an identifier separated by an underscore.
    # Example: Theme_mW4iQ4cO
    # For entities in a group (those with ancestors), their full heirarchy is
    # encoded, ending with the root entity, separated by periods.
    # Example: Comment_p46aOHS6.User_80h41Q4c
    # An id is always sufficient to look up an entity directly.
    # N.B.: We can't just call it 'id' because that breaks ndb.
    uid = sndb.TextProperty(default=None)
    short_uid = sndb.TextProperty(default=None, search_type=search.AtomField)
    deleted = sndb.BooleanProperty(default=False)
    created = sndb.DateTimeProperty(auto_now_add=True,
                                    search_type=search.DateField)
    modified = sndb.DateTimeProperty(auto_now=True,
                                     search_type=search.DateField)
    listed = sndb.BooleanProperty(default=False)

    # Do NOT use sndb.JsonProperty here! That stores the data as a blob, which
    # is DROPPED upon export to BigQuery. Instead, this stores the JSON as a
    # string, which you can cleanly asscess as a python object through the
    # `json_properties` property, below.
    json_properties_string = sndb.TextProperty(default='{}')

    @property
    def json_properties(self):
        return json.loads(self.json_properties_string)

    @json_properties.setter
    def json_properties(self, value):
        self.json_properties_string = json.dumps(
            value, default=util.json_dumps_default)
        return value

    @classmethod
    def create(klass, **kwargs):
        # ndb expects parents to be set with a key, but we find it more
        # convenient to pass in entities. Do the translation here.
        if 'parent' in kwargs:
            parent = kwargs['parent']  # used in id generation
            kwargs['parent'] = kwargs['parent'].key
        else:
            parent = None

        if 'id' in kwargs:
            # User has supplied their own id. That's okay (it makes certain
            # URLs nice and readable), but it's not a real uid yet b/c it
            # doesn't adhere to our ClassName_identifierXYZ convention. We'll
            # pass it into generated_uid() later.
            identifier = kwargs['id']
            del kwargs['id']
        else:
            identifier = None

        # ndb won't recognize the simulated json_properties if passed directly
        # as a keyword argument. Translate that too.
        if 'json_properties' in kwargs:
            kwargs['json_properties_string'] = json.dumps(
                kwargs['json_properties'])
            del kwargs['json_properties']

        # ndb won't recognize the simulated template_data if passed directly
        # as a keyword argument. Translate that too.
        if 'template_data' in kwargs:
            kwargs['template_data_string'] = json.dumps(
                kwargs['template_data'])
            del kwargs['template_data']

        # Make sure id is unique, otherwise "creating" this entity will
        # overwrite an existing one, which could be a VERY hard bug to chase
        # down.
        for x in range(5):
            uid = klass.generate_uid(parent, identifier)
            existing_entity = Model.get_by_id(uid)
            if not existing_entity:
                break
        if existing_entity:
            if identifier:
                raise Exception("Entity {} already exists.".format(uid))
            else:
                raise Exception("After five tries, could not generate a "
                                "unique id. This should NEVER happen.")

        kwargs['uid'] = uid
        kwargs['short_uid'] = klass.convert_uid(uid)
        return klass(id=uid, **kwargs)

    @classmethod
    def generate_uid(klass, parent=None, identifier=None, existing=False):
        """Make a gobally unique id string, e.g. 'Program_mW4iQ4cO'.

        Using 8 random chars, if we made 10,000 entities of the same kind (and
        comments by a single user count as a kind), the probability of
        duplication is 2E-7. Combined with the five attempts at uniqueness
        in Model.create(), chances of duplication are essentially nil.
        http://en.wikipedia.org/wiki/Universally_unique_identifier#Random_UUID_probability_of_duplicates

        If a parent entity is specified, it becomes part of the uid, like this:
        Comment_p46aOHS6.User_80h41Q4c

        If an identifier is specified, it is used instead of random characters:
        Theme_growth-mindset-is-cool
        """
        if identifier:
            if not re.match(r'^[A-Za-z0-9\-]+$', identifier):
                raise Exception("Invalid identifier: {}. Letters, numbers, "
                                "and hyphens only.".format(identifier))
            suffix = identifier
        else:
            chars = (string.ascii_uppercase + string.ascii_lowercase +
                     string.digits)
            suffix = ''.join(random.choice(chars) for x in range(8))
        uid = klass.__name__ + '_' + suffix

        # Because comments exist as descendants of other entities, a simple
        # id-as-key-name is insufficient. We must store information about its
        # ancestry as well. Example:
        # Comment_p46aOHS6.User_80h41Q4c
        if parent:
            uid += '.' + parent.uid

        return uid

    @classmethod
    def convert_uid(klass, short_or_long_uid):
        """Changes long-form uid's to short ones, and vice versa.

        Long form example: Theme_growth-mindset-is-cool
        Short form exmaple: growth-mindset-is-cool
        """
        if '_' in short_or_long_uid:
            return short_or_long_uid.split('_')[1]
        else:
            return klass.generate_uid(identifier=short_or_long_uid)

    @classmethod
    def get_long_uid(klass, short_or_long_uid):
        """Changes short or long-form uid's to long ones.

        Long form example: Theme_growth-mindset-is-cool
        Short form exmaple: growth-mindset-is-cool
        """
        if '_' in short_or_long_uid:
            return short_or_long_uid
        else:
            # Check the characters to make sure this could be valid
            if re.match(r'^[A-Za-z0-9\-]+$', short_or_long_uid):
                return klass.generate_uid(identifier=short_or_long_uid)
            else:
                # Original UID is invalid anyway, return None
                None

    @classmethod
    def get_parent_uid(klass, uid):
        """Don't use the datastore; get parent ids based on convention."""
        if '.' not in uid:
            raise Exception("Can't get parent of id: {}".format(uid))
        return '.'.join(uid.split('.')[1:])

    @classmethod
    def get_kind(klass, obj):
        """Get the kind (class name string) of an entity, key, or id.

        Examples:
        * For a Theme entity, the kind is 'Theme'
        * For the id 'Comment_p46aOHS6.User_80h41Q4c',
          the kind is 'Comment'.
        """
        if isinstance(obj, basestring):
            return str(obj.split('_')[0])
        elif isinstance(obj, Model):
            return obj.__class__.__name__
        elif isinstance(obj, ndb.Key):
            return obj.kind()
        else:
            raise Exception('Model.get_kind() invalid input: {} ({}).'.format(
                obj, str(type(obj))))

    @classmethod
    def get_class(klass, kind):
        """Convert a class name string (same as a GAE kind) to a class.

        See http://stackoverflow.com/questions/1176136/convert-string-to-python-class-object
        """
        return getattr(sys.modules['model'], kind, None)

    @classmethod
    def id_to_key(klass, id):
        parts = id.split('.')
        pairs = [(Model.get_kind(p), '.'.join(parts[-x:]))
                 for x, p in enumerate(parts)]
        return ndb.Key(pairs=reversed(pairs))

    @classmethod
    def get_by_id(klass, id_or_list):
        """The main way to get entities with known ids.

        Args:
            id_or_list: A single perts id string, or a list of such strings,
                of any kind or mixed kinds.
        Returns an entity or list of entities, depending on input.
        """

        # Sanitize input to a list of strings.
        if type(id_or_list) in [str, unicode]:
            ids = [id_or_list]
        elif type(id_or_list) is list:
            ids = id_or_list
        else:
            # I don't think we should be blocking code here
            # Problem was occuring when you search a bad id or just None
            # Ex. "/topics/foobar."
            return None
            raise Exception("Invalid id / id: {}.".format(id_or_list))

        # Iterate through the list, generating a key for each id
        keys = []
        for id in ids:
            keys.append(Model.id_to_key(id))
        results = ndb.get_multi(keys)

        # Wrangle results into expected structure.
        if len(results) is 0:
            return None
        if type(id_or_list) in [str, unicode]:
            return results[0]
        if type(id_or_list) is list:
            return results

    def __str__(self):
        """A string represenation of the entity. Goal is to be readable.

        Returns, e.g. <id_model.User User_oha4tp8a>.
        Native implementation does nothing useful.
        """
        return '<{}>'.format(self.key.id())

    def __repr__(self):
        """A unique representation of the entity. Goal is to be unambiguous.

        But our ids are unambiguous, so we can just forward to __str__.

        Native implemention returns a useless memory address, e.g.
            <id_model.User 0xa5e418cdd>
        The big benefit here is to be able to print debuggable lists of
        entities, without need to manipulate them first, e.g.
            print [entity.id for entity in entity_list]
        Now you can just write
            print entity_list
        """
        return self.__str__()

    # special methods to allow comparing entities, even if they're different
    # instances according to python
    # https://groups.google.com/forum/?fromgroups=#!topic/google-appengine-python/uYneYzhIEzY
    def __eq__(self, value):
        """Allows entity == entity to be True if keys match.

        Is NOT called by `foo is bar`."""
        if self.__class__ == value.__class__:
            return self.key.id() == value.key.id()
        else:
            return False

    # Because we defined the 'equals' method, eq, we must also be sure to
    # define the 'not equals' method, ne, otherwise you might get A == B is
    # True, and A != B is also True!
    def __ne__(self, value):
        """Allows entity != entity to be False if keys match."""
        if self.__class__ == value.__class__:
            # if they're the same class, compare their keys
            return self.key.id() != value.key.id()
        else:
            # if they're not the same class, then it's definitely True that
            # they're not equal.
            return True

    def __hash__(self):
        """Allows entity in entity_list to be True."""
        return hash(str(self.key))

    def _post_put_hook(self, future):
        """Executes after an entity is put.

        1. Updates search index

        To allow for batch processing (i.e. doing the stuff this function does
        for many entities all at once, instead of doing it here one by one),
        this function can be disabled by adding an emphemeral attribute (one
        not saved to the datastore) to the entity. Example:

            # Don't do post-put processing for these entities, so we can handle
            # them in a batch later.
            for e in entities:
                e.forbid_post_put_hook = True
            ndb.put_multi(entities)
        """
        if getattr(self, 'forbid_post_put_hook', False):
            return

        if self.get_kind(self) in config.indexed_models:
            index = search.Index(config.content_index)
            if self.listed:
                logging.info("Indexing content for search.")
                index.put(self.to_search_document())
            else:
                # Unlisted entities should be actively removed from search.
                logging.info("Removing unlisted content from search.")
                index.delete(self.uid)

    @classmethod
    def _post_delete_hook(klass, key, future):
        """We rarely truely delete entities, but when we do, we prefer Dos
        Equis. I mean, we want to delete them from the search index."""
        if klass.get_kind(key) in config.indexed_models:
            logging.info(
                "Removing hard-deleted content from search: {}".format(
                    key.id()))
            search.Index(config.content_index).delete(key.id())

    def to_client_dict(self, override=None):
        """Convert an app engine entity to a dictionary.

        Ndb provides a to_dict() method, but we want to add creature-features:
        1. Put properties in a predictable order so they're easier to read.
        2. Remove or mask certain properties based on the preferences of our
           javascript.
        3. Handle our string-based json_properties correctly.
        4. Ndb (different from db) stores datetimes as true python datetimes,
           which JSON.dumps() doesn't know how to handle. We'll convert them
           to ISO strings (e.g. "2010-04-20T20:08:21.634121")

        Args:
            override: obj, if provided, method turns this object into
                a dictionary, rather than self.
        """
        output = self.to_dict()

        output['json_properties'] = self.json_properties
        del output['json_properties_string']

        for k, v in output.items():
            if isinstance(v, dict):
                for k2, v2 in output.items():
                    if hasattr(v2, 'isoformat'):
                        output[k][k2] = v2.isoformat()
            if hasattr(v, 'isoformat'):
                output[k] = v.isoformat()

        client_safe_output = {}
        for k, v in output.items():
            if k in config.client_private_properties:
                client_safe_output['_' + k] = v
            elif k not in config.client_hidden_properties:
                client_safe_output[k] = v

        # order them so they're easier to read
        ordered_dict = collections.OrderedDict(
            sorted(client_safe_output.items(), key=lambda t: t[0]))

        return ordered_dict

    def _get_search_fields(self):
        fields = []
        klass = self.__class__

        # Scan the properties in entity's class definition for information on
        # how the entity should be converted to a search document. Properties
        # can have search field types defined; we use those to construct the
        # document.
        for prop_name in dir(klass):
            # This is the abstract property object in the class definition.
            # E.g. sndb.StringProperty()
            prop = getattr(klass, prop_name)
            # This is the actual data defined on the entity
            value = getattr(self, prop_name)
            # This is (maybe) the type of search field the property should be
            # converted to.
            search_type = getattr(prop, 'search_type', None)

            # The dir() function iterates all object attributes; only deal with
            # those which 1) are datastore properties, 2) aren't private, and
            # 3) have a search type defined.
            is_field = (isinstance(prop, ndb.model.Property)
                        and not prop_name.startswith('_') and search_type)

            if is_field:
                # Search documents field names aren't unique; storing a list of
                # values means making many fields of the same name. For
                # brevity, make everything as a (possibly single-element) list.
                if not type(value) is list:
                    value = [value]
                for v in value:
                    # It will probably be common to put a boolean in a search
                    # document. The easiest way is to make it a string in an
                    # atom field.
                    if type(v) is bool and search_type is search.AtomField:
                        v = 'true' if v else 'false'
                    fields.append(search_type(name=prop_name, value=v))

        # kind is not a field, but it's useful as a filter.
        fields.append(search.AtomField(name='kind',
                                       value=Model.get_kind(self)))

        return fields

    def to_search_document(self, rank=None):
        fields = self._get_search_fields()
        # Our uid's are already globally unique, so it's fine (as well as
        # extremely convenient) to use them as search document ids as well.
        # N.B. english language hard coded here. If we ever internationalize,
        # will need to be dynamic.
        return search.Document(doc_id=self.uid,
                               fields=fields,
                               rank=rank,
                               language='en')
示例#5
0
class Comment(Model):
    """Always in a group under a User."""

    body = sndb.TextProperty(default='')
    practice_id = sndb.StringProperty(default=None)
    lesson_id = sndb.StringProperty(default=None)
    page_id = sndb.StringProperty(default=None)
    # Overrides Model's default for this property, which is False. We always
    # want to see comments.
    listed = sndb.BooleanProperty(default=True)

    @classmethod
    def create(klass, **kwargs):
        if ('practice_id' not in kwargs) and ('lesson_id' not in kwargs) and (
                'page_id' not in kwargs):
            raise Exception(
                'Must specify a practice, lesson, or page when '
                'creating a comment. Received kwargs: {}'.format(kwargs))
        comment = super(klass, klass).create(**kwargs)

        # For email notifications
        content_url = '/'
        content = None

        if comment.page_id:
            page = Page.get_by_id(comment.page_id)
            if page is not None:
                page.num_comments += 1
                page.put()

                # For email
                content = page
                content_url = '/pages/{}'.format(page.short_uid)

                # Send email to creator
                author_ids = page.authors
                commenter = comment.get_parent_user()
                authors = User.get_by_id(author_ids) or []

                for author in authors:

                    # logic to not email yourself...
                    if author != commenter.email:

                        short_name = author.first_name or ''
                        full_name = author.full_name
                        commenter_image_url = commenter.profile_image

                        # Uses Email model to queue email and prevent spam
                        email = Email.create(
                            to_address=author.email,
                            subject=
                            "Someone commented on your BELE Library upload",
                            template="comment_creator_notification.html",
                            template_data={
                                'short_name':
                                short_name,
                                'full_name':
                                full_name,
                                'commenter_name':
                                commenter.full_name,
                                'commenter_image_url':
                                commenter_image_url,
                                'content_name':
                                content.title,
                                'comment_body':
                                comment.body,
                                'content_url':
                                content_url,
                                'domain':
                                'https://{}'.format(
                                    os.environ['HOSTING_DOMAIN']),
                                'year':
                                datetime.date.today().year,
                            },
                        )

                        email.put()

                    # Send email to any users @replied to
                    usernames = re.search('\@(\w+)', comment.body)

                    if usernames is not None:
                        username = usernames.group(0).split('@')[1]

                        # Fetch user from username and send email message
                        replied_to = User.query(
                            User.username == username).fetch(1)
                        if replied_to:
                            replied_to = replied_to[0]

                            short_name = replied_to.first_name or ''
                            full_name = replied_to.full_name
                            commenter_image_url = commenter.profile_image

                            # Uses Email model to queue email and prevent spam
                            email = Email.create(
                                to_address=replied_to.email,
                                subject=
                                "Someone replied to you on BELE Library",
                                template="comment_reply_notification.html",
                                template_data={
                                    'short_name':
                                    short_name,
                                    'full_name':
                                    full_name,
                                    'commenter_name':
                                    commenter.full_name,
                                    'commenter_image_url':
                                    commenter_image_url,
                                    'content_name':
                                    content.title,
                                    'comment_body':
                                    comment.body,
                                    'content_url':
                                    content_url,
                                    'domain':
                                    'https://{}'.format(
                                        os.environ['HOSTING_DOMAIN']),
                                    'year':
                                    datetime.date.today().year,
                                },
                            )

                            email.put()

        # Email interested team members that a comment has been created
        mandrill.send(
            to_address=config.comment_recipients,
            subject="New Comment on BELE Library!",
            template="comment_notification.html",
            template_data={
                'comment': comment,
                'user': comment.get_parent_user(),
                'content_name': content.title,
                'content_url': content_url,
                'domain': 'https://{}'.format(os.environ['HOSTING_DOMAIN'])
            },
        )

        logging.info('model.Comment queueing an email to: {}'.format(
            config.comment_recipients))

        return comment

    @classmethod
    def convert_uid(klass, short_or_long_uid):
        """Changes long-form uid's to short ones, and vice versa.

        Overrides method provided in Model.

        Long form example: Practice_Pb4g9gus.User_oha4tp8a
        Short form exmaple: Pb4g9gusoha4tp8a
        """
        if '.' in short_or_long_uid:
            parts = short_or_long_uid.split('.')
            return ''.join([x.split('_')[1] for x in parts])
        else:
            return 'Comment_{}.User_{}'.format(short_or_long_uid[:8],
                                               short_or_long_uid[8:])

    def parent_user_id(self):
        return self.key.parent().id()

    def get_parent_user(self):
        return self.key.parent().get()
示例#6
0
class Practice(Content):
    """Always in a group under a User."""

    mindset_tags = sndb.StringProperty(repeated=True)
    practice_tags = sndb.StringProperty(repeated=True)
    time_of_year = sndb.StringProperty(default='')
    class_period = sndb.StringProperty(default='')
    # Stores the UID of 'associated' content (Theme or Topic)
    # Used to fetch related practices from various pages
    associated_content = sndb.StringProperty(default='')
    type = sndb.StringProperty(default='text', search_type=search.AtomField)
    body = sndb.TextProperty(default='', search_type=search.TextField)
    youtube_id = sndb.StringProperty(default='')
    iframe_src = sndb.StringProperty(default='')
    has_files = sndb.BooleanProperty(default=False,
                                     search_type=search.AtomField)
    pending = sndb.BooleanProperty(default=True)
    votes_for = sndb.IntegerProperty(default=0, search_type=search.NumberField)
    num_comments = sndb.IntegerProperty(default=0,
                                        search_type=search.NumberField)

    @classmethod
    def create(klass, **kwargs):
        """Sends email to interested parties.
        """
        practice = super(klass, klass).create(**kwargs)

        # Email interested parties that a practice has been uploaded.
        mandrill.send(
            to_address=config.practice_upload_recipients,
            subject="Practice Uploaded to Mindset Kit!",
            template="practice_upload_notification.html",
            template_data={
                'user': practice.get_parent_user(),
                'practice': practice,
                'domain': os.environ['HOSTING_DOMAIN']
            },
        )

        logging.info('model.Practice queueing an email to: {}'.format(
            config.practice_upload_recipients))

        return practice

    @classmethod
    def convert_uid(klass, short_or_long_uid):
        """Changes long-form uid's to short ones, and vice versa.

        Overrides method provided in Model.

        Long form example: Practice_Pb4g9gus.User_oha4tp8a
        Short form exmaple: Pb4g9gusoha4tp8a
        """
        if '.' in short_or_long_uid:
            parts = short_or_long_uid.split('.')
            return ''.join([x.split('_')[1] for x in parts])
        else:
            return 'Practice_{}.User_{}'.format(short_or_long_uid[:8],
                                                short_or_long_uid[8:])

    @classmethod
    def get_long_uid(klass, short_or_long_uid):
        """Changes short of long-form uid's to long ones.

        Overrides method provided in Model.

        Long form example: Practice_Pb4g9gus.User_oha4tp8a
        Short form exmaple: Pb4g9gusoha4tp8a
        """
        if '.' in short_or_long_uid:  # is long
            return short_or_long_uid
        else:  # is short
            return 'Practice_{}.User_{}'.format(short_or_long_uid[:8],
                                                short_or_long_uid[8:])

    @classmethod
    def get_related_practices(klass, content, count):
        """Fetches practices related to a content object

        Will default to random pratices if none found.
        """
        related_practices = []
        query = Practice.query(
            Practice.deleted == False,
            Practice.listed == True,
        )
        if content.uid:
            if Model.get_kind(content.uid) == 'Practice':
                if content.associated_content:
                    query = query.filter(Practice.associated_content ==
                                         content.associated_content)
                query = query.filter(Practice.uid != content.uid)
            else:
                query = query.filter(
                    Practice.associated_content == content.uid)
        query.order(-Practice.created)
        # Pull a 'bucket' of practices to sample from
        related_practices_bucket = query.fetch(15)
        # Only return a random selection of the practices
        if len(related_practices_bucket) > count:
            related_practices = random.sample(related_practices_bucket, count)
        elif len(related_practices_bucket) <= 0:
            related_practices = []
        else:
            related_practices = related_practices_bucket
        return related_practices

    @classmethod
    def get_popular_practices(klass):
        """Fetches popular practices to display on landing page

        @todo: figure out a way to generate this list
        - Possibly adding a field to practices and flagging x practices / week
        - Needs more discussion
        """
        practices = []
        query = Practice.query(
            Practice.deleted == False,
            Practice.listed == True,
            Practice.promoted == True,
        )
        query.order(-Practice.created)
        practices = query.fetch(20)
        if len(practices) > 6:
            practices = random.sample(practices, 6)
        return practices

    def add_file_data(self, file_dicts):
        """Save dictionaries of uploaded file meta data."""
        jp = self.json_properties

        if 'files' not in jp:
            jp['files'] = []
        jp['files'].extend(file_dicts)

        self.json_properties = jp

        self.has_files = len(jp['files']) > 0

    def remove_file_data(self, file_key):
        """Remove file dictionaries from existing json_properties"""
        jp = self.json_properties
        # Find and remove file from 'files' in json_properties
        if 'files' in jp:
            for index, file_dict in enumerate(jp['files']):
                if file_key == file_dict[u'gs_object_name']:
                    jp['files'].pop(index)

        self.json_properties = jp
        self.has_files = len(jp['files']) > 0

    def get_parent_user(self):
        return self.key.parent().get()

    def check_status_update(self, **kwargs):
        """Checks the status of an updated practice to determine if the creator
        should be notified of approval or rejection

        Only triggered if pending set from True to False (prevents duplicates)
        """
        if (self.pending is True and kwargs.get('pending') is False):

            creator = self.get_parent_user()
            short_name = creator.first_name if creator.first_name else ''
            full_name = creator.full_name
            if (self.listed is False and kwargs.get('listed') is True):
                # Send acceptance message
                # @todo: add name to subject line
                mandrill.send(
                    to_address=creator.email,
                    subject="Your practice upload is approved!",
                    template="accepted_notification.html",
                    template_data={
                        'short_name': short_name,
                        'full_name': full_name,
                        'practice_name': self.name,
                        'practice_url': '/practices/' + self.short_uid,
                        'domain': os.environ['HOSTING_DOMAIN']
                    },
                )

            else:
                # Send rejection message
                mandrill.send(
                    to_address=creator.email,
                    subject="We couldn't approve your practice...",
                    template="rejected_notification.html",
                    template_data={
                        'short_name': short_name,
                        'full_name': full_name,
                        'practice_name': self.name,
                        'practice_url': '/practices/' + self.short_uid,
                        'edit_url': '/practices/edit/' + self.short_uid,
                        'domain': os.environ['HOSTING_DOMAIN']
                    },
                )

    def to_search_document(self, rank=None):
        """Extends inherited method in Model."""
        fields = super(Practice, self)._get_search_fields()

        # Add information about the parent user to the search document.
        user = self.get_parent_user()
        # Allow for empty first/last names, and default to an empty string.
        if user is not None:
            user_name = ''.join([(user.first_name or ''), (user.last_name
                                                           or '')])
            fields.append(search.TextField(name='author', value=user_name))

        # Simplify checking for video and file attachments
        if self.has_files:
            fields.append(search.AtomField(name='content_type', value='files'))
        if self.youtube_id != '':
            fields.append(search.AtomField(name='content_type', value='video'))

        return search.Document(doc_id=self.uid,
                               fields=fields,
                               rank=rank,
                               language='en')
示例#7
0
class Page(Content):
    ####
    chapters = sndb.StringProperty(repeated=True)  # unordered parents
    authors = sndb.StringProperty(
        repeated=True, search_type=search.TextField)  # unordered children

    icon = sndb.StringProperty(default='', search_type=search.TextField)
    iconPath = sndb.ComputedProperty(
        lambda self: util.extract_value_from_json(self.icon, 'link'))

    # "Challenges & Preconditions"
    preconditions_for_success = sndb.TextProperty(
        default=''
    )  # Up to 10,000 characters, HTML text with ability to include links and images.
    # "Connection to Equity"
    advances_equity = sndb.TextProperty(
        default='')  # up to 5,000 characters, HTML
    # "Time Required"
    time_required = sndb.StringProperty(default='')  # minutes
    # "Required Materials"
    required_materials = sndb.TextProperty(
        default='')  # up to 10,000 characters
    # "Associated Measures"
    associated_measures = sndb.TextProperty(
        default='')  # Up to 5000 characters, HTML
    # "Evidence of Effectiveness"
    evidence_of_effectiveness = sndb.TextProperty(
        default=''
    )  # Up to 10,000 characters, HTML text with ability to include links and images.
    # "Related BELE Library Pages"
    related_pages = sndb.StringProperty(
        repeated=True)  # up to 10 related pages.
    # "Optional Contact Information"
    acknowledgements = sndb.TextProperty(
        default='')  # Up to 5000 characters, HTML
    # "Preferred Citation"
    preferred_citation = sndb.StringProperty(
        default='')  # Up to 1000 characters, text
    use_license = sndb.StringProperty(default='')

    status = sndb.StringProperty(
        default='pending',
        choices=['draft', 'pending', 'approved', 'rejected',
                 'deleted'])  # enum: draft, pending, approved, or rejected
    ####

    mindset_tags = sndb.StringProperty(repeated=True)
    page_tags = sndb.StringProperty(repeated=True)
    time_of_year = sndb.StringProperty(default='')
    class_period = sndb.StringProperty(default='')
    type = sndb.StringProperty(default='text', search_type=search.AtomField)
    body = sndb.TextProperty(default='', search_type=search.TextField)
    youtube_id = sndb.StringProperty(default='')
    iframe_src = sndb.StringProperty(default='')
    has_files = sndb.BooleanProperty(default=False,
                                     search_type=search.AtomField)
    pending = sndb.BooleanProperty(default=True)
    display_order = sndb.IntegerProperty(default=1,
                                         search_type=search.NumberField)
    votes_for = sndb.IntegerProperty(default=0, search_type=search.NumberField)
    num_comments = sndb.IntegerProperty(default=0,
                                        search_type=search.NumberField)

    # class properties that contain files as json strings
    file_props = ['icon']

    @property
    def ui_props(self):
        return {
            'time_required': {
                'default_value': Page.time_required._default,
                'data_prop': 'time_required',
                'scope_prop': 'pageTime',
                'heading_title': 'Time Required',
                'value_suffix': None,
            },
            'required_materials': {
                'default_value': Page.required_materials._default,
                'data_prop': 'required_materials',
                'scope_prop': 'pageMaterials',
                'heading_title': 'Required Materials',
                'value_suffix': None,
            },
            'preconditions_for_success': {
                'default_value': Page.preconditions_for_success._default,
                'data_prop': 'preconditions_for_success',
                'scope_prop': 'pagePreconditions',
                'heading_title': 'Preconditions for Success',
                'value_suffix': None,
            },
            'advances_equity': {
                'default_value': Page.advances_equity._default,
                'data_prop': 'advances_equity',
                'scope_prop': 'advancesEquity',
                'heading_title': 'Connection to Equity',
                'value_suffix': None,
            },
            'evidence_of_effectiveness': {
                'default_value': Page.evidence_of_effectiveness._default,
                'data_prop': 'evidence_of_effectiveness',
                'scope_prop': 'pageEvidence',
                'heading_title': 'Evidence of Effectiveness',
                'value_suffix': None,
            },
            'associated_measures': {
                'default_value': Page.associated_measures._default,
                'data_prop': 'associated_measures',
                'scope_prop': 'pageMeasures',
                'heading_title': 'Associated Measures',
                'value_suffix': None,
            },
            'acknowledgements': {
                'default_value': Page.acknowledgements._default,
                'data_prop': 'acknowledgements',
                'scope_prop': 'pageAcknowledgements',
                'heading_title': 'Acknowledgements',
                'value_suffix': None,
            },
            'preferred_citation': {
                'default_value': Page.preferred_citation._default,
                'data_prop': 'preferred_citation',
                'scope_prop': 'pageCitations',
                'heading_title': 'Preferred Citation',
                'value_suffix': None,
            },
        }

    @classmethod
    def create(klass, **kwargs):
        """Sends email to interested parties.
        """
        page = super(klass, klass).create(**kwargs)

        # Email interested parties that a page has been uploaded.
        # mandrill.send(
        #     to_address=config.page_upload_recipients,
        #     subject="Page Uploaded to Mindset Kit!",
        #     template="page_upload_notification.html",
        #     template_data={'user': page.get_parent_user(),
        #                    'page': page,
        #                    'domain': 'https://{}'.format(os.environ['HOSTING_DOMAIN'])},
        # )

        # logging.info('model.Page queueing an email to: {}'
        #              .format(config.page_upload_recipients))
        logging.info('creating page')
        return page

    @classmethod
    def convert_uid(klass, short_or_long_uid):
        """Changes long-form uid's to short ones, and vice versa.

        Overrides method provided in Model.

        Long form example: Page_Pb4g9gus
        Short form exmaple: Pb4g9gus
        """
        if '_' in short_or_long_uid:
            return ''.join([short_or_long_uid.split('_')[1]])
        else:
            return 'Page_{}'.format(short_or_long_uid[:4])

    @classmethod
    def get_long_uid(klass, short_or_long_uid):
        """Changes short of long-form uid's to long ones.

        Overrides method provided in Model.

        Long form example: Page_Pb4g9gus.User_oha4tp8a
        Short form exmaple: Pb4g9gusoha4tp8a
        """
        if '_' in short_or_long_uid:  # is long
            return short_or_long_uid
        else:  # is short
            return 'Page_{}'.format(short_or_long_uid[-8:])

    @classmethod
    def get_popular_pages(klass):
        """Fetches popular pages to display on landing page

        @todo: figure out a way to generate this list
        - Possibly adding a field to pages and flagging x pages / week
        - Needs more discussion
        """
        pages = []
        query = Page.query(
            Page.deleted == False,
            Page.listed == True,
            Page.promoted == True,
        )
        query.order(-Page.created)
        pages = query.fetch(20)
        if len(pages) > 6:
            pages = random.sample(pages, 6)
        return pages

    def add_file_data(self, file_dicts, entity_field=None):
        """Save dictionaries of uploaded file meta data."""
        # Process specific field json files, specified by entity_field parameter
        entity_fields_whitelist = self.file_props
        if entity_field in entity_fields_whitelist:
            for k, v in file_dicts[0].items():
                if hasattr(v, 'isoformat'):
                    file_dicts[0][k] = v.isoformat()

            setattr(self, entity_field,
                    json.dumps(file_dicts[0], default=util.json_dumps_default))

        jp = self.json_properties

        # Process generic json files if not an entity field
        if entity_field is None:
            if 'files' not in jp:
                jp['files'] = []
            jp['files'].extend(file_dicts)

            self.json_properties = jp

        self.has_files = 'files' in jp and len(jp['files']) > 0

    def remove_file_data(self, file_key):
        """Remove file dictionaries from existing json_properties"""
        jp = self.json_properties
        # Find and remove file from 'files' in json_properties
        if 'files' in jp:
            for index, file_dict in enumerate(jp['files']):
                if file_key == file_dict[u'gs_object_name']:
                    jp['files'].pop(index)

        self.json_properties = jp
        self.has_files = len(jp['files']) > 0

    def get_parent_user(self):
        return {}

    def check_status_update(self, **kwargs):
        """Checks the status of an updated page to determine if the creator
        should be notified of approval or rejection.
        """
        new_status = kwargs.get('status', None)
        now_reviewed = new_status in ('approved', 'rejected')
        changing = self.status != new_status
        if not now_reviewed or not changing:
            return

        authors = User.get_by_id(self.authors) or []
        for author in authors:
            short_name = author.first_name or ''
            full_name = author.full_name
            if new_status == 'approved':
                # Send acceptance message
                # @todo: add name to subject line
                mandrill.send(
                    to_address=author.email,
                    subject="Your page upload is approved!",
                    template="accepted_notification.html",
                    template_data={
                        'short_name': short_name,
                        'full_name': full_name,
                        'entity_name': self.title,
                        'entity_url': '/pages/' + self.short_uid,
                        'domain':
                        'https://{}'.format(os.environ['HOSTING_DOMAIN']),
                        'year': datetime.date.today().year,
                    },
                )

            else:
                # Send rejection message
                mandrill.send(
                    to_address=author.email,
                    subject="We couldn't approve your page...",
                    template="rejected_notification.html",
                    template_data={
                        'short_name': short_name,
                        'full_name': full_name,
                        'entity_name': self.title,
                        'entity_url': '/pages/' + self.short_uid,
                        'edit_url': '/pages/edit/' + self.short_uid,
                        'domain':
                        'https://{}'.format(os.environ['HOSTING_DOMAIN']),
                        'year': datetime.date.today().year,
                    },
                )

    def to_search_document(self, rank=None):
        """Extends inherited method in Model."""
        fields = super(Page, self)._get_search_fields()

        # Add information about the parent user to the search document.
        for author_id in self.authors:
            author = self.get_by_id(author_id)
            # Allow for empty first/last names, and default to an empty string.
            if author is not None:
                user_name = ''.join([(author.first_name or ''),
                                     (author.last_name or '')])
                fields.append(search.TextField(name='author', value=user_name))

        # Simplify checking for video and file attachments
        if self.has_files:
            fields.append(search.AtomField(name='content_type', value='files'))
        if self.youtube_id != '':
            fields.append(search.AtomField(name='content_type', value='video'))

        return search.Document(doc_id=self.uid,
                               fields=fields,
                               rank=rank,
                               language='en')

    def to_client_dict(self):
        d = super(Page, self).to_client_dict()
        official_tags = []
        additional_tags = []
        for tag in d['tags']:
            if tag in OFFICIAL_TAGS:
                official_tags.append(tag)
            else:
                additional_tags.append(tag)
        d['tags'] = official_tags
        d['additional_tags'] = ', '.join(additional_tags)

        return d
示例#8
0
class Email(Model):
    """An email in a queue (not necessarily one that has been sent).

    Emails have regular fields, to, from, subject, body. Also a send date and
    an sent boolean to support queuing.

    Uses jinja to attempt to attempt to interpolate values in the
    template_data dictionary into the body and subject.

    Body text comes in two flavors: raw text and html. The raw text is
    interpreted as markdown (after jinja processing) to auto-generate the html
    version (templates/email.html is also used for default styles). Which
    version users see depends on their email client.

    Consequently, all emails should be composed in markdown.
    """

    to_address = sndb.StringProperty(required=True)
    from_address = sndb.StringProperty(required=True)
    reply_to = sndb.StringProperty(default=config.from_server_email_address)
    subject = sndb.StringProperty(default="A message from the Mindset Kit")
    body = sndb.TextProperty()
    html = sndb.TextProperty()
    scheduled_date = sndb.DateProperty()
    was_sent = sndb.BooleanProperty(default=False)
    was_attempted = sndb.BooleanProperty(default=False)
    errors = sndb.TextProperty()

    @classmethod
    def create(klass, template_data={}, **kwargs):
        def render(s):
            return jinja2.Environment().from_string(s).render(**template_data)

        kwargs['subject'] = render(kwargs['subject'])
        kwargs['body'] = render(kwargs['body'])

        return super(klass, klass).create(**kwargs)

    @classmethod
    def we_are_spamming(self, email):
        """Did we recently send email to this recipient?"""

        to = email.to_address

        # We can spam admins, like 
        # so we white list them in the config file
        if to in config.addresses_we_can_spam:
            return False

        # We can also spam admins living at a @mindsetkit.org
        if to.endswith('mindsetkit.org'):
            return False

        # Temporary spamming override...
        return False

        since = datetime.datetime.utcnow() - datetime.timedelta(
            days=config.suggested_delay_between_emails)

        query = Email.query(Email.was_sent == True,
                            Email.scheduled_date >= since)

        return query.count(limit=1) > 0

    @classmethod
    def send(self, email):
        if self.we_are_spamming(email):
            logging.error("We are spamming {}:\n{}"
                          .format(email.to_address, email.to_dict()))

        # Note that we are attempting to send so that we don't keep attempting.
        email.was_attempted = True
        email.put()

        # Debugging info
        logging.info(u"sending email: {}".format(email.to_dict()))
        logging.info(u"to: {}".format(email.to_address))
        logging.info(u"subject: {}".format(email.subject))
        logging.info(u"body:\n{}".format(email.body))

        # Make html version if it has not been explicitly passed in.
        if not email.html:
            email.html = (
                jinja2.Environment(loader=jinja2.FileSystemLoader('templates'))
                .get_template('email.html')
                .render({'email_body': markdown.markdown(email.body)})
            )

        # Try to email through App Engine's API.
        mail.send_mail(email.from_address,
                       email.to_address,
                       email.subject,
                       email.body,
                       reply_to=email.reply_to,
                       html=email.html)

        email.was_sent = True
        logging.info("""Sent successfully!""")
        email.put()

    @classmethod
    def fetch_next_pending_email(self):
        to_send = Email.query(
            Email.deleted == False,
            Email.scheduled_date <= datetime.datetime.utcnow(),
            Email.was_sent == False,
            Email.was_attempted == False,
        )

        return to_send.get()

    @classmethod
    def send_pending_email(self):
        """Send the next unsent email in the queue.

        We only send one email at a time; this allows us to raise errors for
        each email and avoid sending some crazy huge mass mail.
        """

        email = self.fetch_next_pending_email()

        if email:
            self.send(email)
            return email
        else:
            return None
示例#9
0
class Book(Content):

    chapters = sndb.StringProperty(repeated=True)  # ordered children
    authors = sndb.StringProperty(repeated=True, search_type=search.TextField)  # unordered children

    book_image = sndb.StringProperty(default='', search_type=search.TextField)
    icon = sndb.StringProperty(default='', search_type=search.TextField)
    status = sndb.StringProperty(default='pending', choices=['draft', 'pending', 'approved', 'rejected', 'deleted']) # enum: draft, pending, approved, or rejected

    acknowledgements = sndb.TextProperty(default='') # up to 1000 chars, HTML
    preferred_citation = sndb.StringProperty(default='') # up to 1000 chars, text

    display_order = sndb.IntegerProperty(default=1,
                                    search_type=search.NumberField)
    votes_for = sndb.IntegerProperty(default=0, search_type=search.NumberField)
    num_comments = sndb.IntegerProperty(default=0, search_type=search.NumberField)
    locale = sndb.StringProperty(default='en')

    # class properties that contain files as json strings
    file_props = ['book_image', 'icon'];

    def add_file_data(self, file_dicts, entity_field='files'):
        """Save dictionaries of uploaded file meta data."""
        entity_fields_whitelist = self.file_props
        if entity_field in entity_fields_whitelist:
            setattr(self, entity_field, json.dumps(file_dicts[0], default=util.json_dumps_default))

    def remove_file_data(self, file_key):
        """Remove file dictionaries from existing json_properties"""
        for prop in self.file_props:
            value = getattr(self, prop)
            value = util.try_parse_json(value)
            # delete file if matches old file, or if new value is empty string
            if value == '' or file_key == value['gs_object_name']:
                setattr(self, prop, '')

    def check_status_update(self, **kwargs):
        """Checks the status of an updated page to determine if the creator
        should be notified of approval or rejection.
        """
        new_status = kwargs.get('status', None)
        now_reviewed = new_status in ('approved', 'rejected')
        changing = self.status != new_status
        if not now_reviewed or not changing:
            return

        authors = User.get_by_id(self.authors) or []
        for author in authors:
            short_name = author.first_name or ''
            full_name = author.full_name
            if (new_status == 'approved'):
                # Send acceptance message
                # @todo: add name to subject line
                mandrill.send(
                    to_address=author.email,
                    subject="Your book upload is approved!",
                    template="accepted_notification.html",
                    template_data={
                        'short_name': short_name,
                        'full_name': full_name,
                        'entity_name': self.title,
                        'entity_url': '/books/' + self.short_uid,
                        'domain': 'https://{}'.format(os.environ['HOSTING_DOMAIN']),
                        'year': datetime.date.today().year,
                    },
                )

            elif (new_status == 'rejected'):
                # Send rejection message
                mandrill.send(
                    to_address=author.email,
                    subject="We couldn't approve your book...",
                    template="rejected_notification.html",
                    template_data={
                        'short_name': short_name,
                        'full_name': full_name,
                        'entity_name': self.title,
                        'entity_url': '/books/' + self.short_uid,
                        'edit_url': '/books/manage/' + self.short_uid,
                        'domain': 'https://{}'.format(os.environ['HOSTING_DOMAIN']),
                        'year': datetime.date.today().year,
                    },
                )

    def get_chapters_with_pages(self, map_function=None):
        """Return ordered array of page data, organized by chapter"""
        pages_dict = {};
        chapters_dict = {};
        chapters = Chapter.get_by_id(self.chapters)
        if chapters is None:
            return {}
        chapter_pages = []
        page_ids = []
        for chapter in chapters:
            if chapter:
                chapters_dict[chapter.uid] = chapter.to_client_dict()
                page_ids.extend(chapter.pages)
                page_ids = list(set(page_ids))
                pages = Page.get_by_id(page_ids)
                pages = pages if pages is not None else []
                for page in pages:
                    page.icon = util.extract_value_from_json(page.icon, 'link')
                    page.icon = page.icon + '?size=360' if page.icon else page.icon
                    pages_dict[page.uid] = page.to_client_dict()
            # Returns a dict with the page uid and a link to the page detail
            def get_page_dict(page_id):
                page = pages_dict[page_id]
                # page['icon'] = util.extract_value_from_json(page['icon'], 'link')
                return page;

        for c in chapters:
            if c:
                chapter_dict = chapters_dict[c.uid]
                chapter_dict['pages'] = map(lambda page_id: get_page_dict(page_id), c.pages)
                chapter_pages.append(chapter_dict)
        return chapter_pages