view personasbackend/personas/models.py @ 178:ba0b8a29e034

Changed default ordering of persona lists to be by date published.
author Atul Varma <varmaa@toolness.com>
date Wed, 16 Apr 2008 19:22:12 -0700
parents 3fe6eb21636d
children 7a6e0966068a
line wrap: on
line source

# ***** BEGIN LICENSE BLOCK *****
# Version: MPL 1.1/GPL 2.0/LGPL 2.1
# 
# The contents of this file are subject to the Mozilla Public License Version
# 1.1 (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
# http://www.mozilla.org/MPL/
# 
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
# for the specific language governing rights and limitations under the
# License.
# 
# The Original Code is personasbackend.
# 
# The Initial Developer of the Original Code is Mozilla.
# Portions created by the Initial Developer are Copyright (C) 2008
# the Initial Developer. All Rights Reserved.
# 
# Contributor(s):
#   Atul Varma <atul@mozilla.org>
# 
# Alternatively, the contents of this file may be used under the terms of
# either the GNU General Public License Version 2 or later (the "GPL"), or
# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
# in which case the provisions of the GPL or the LGPL are applicable instead
# of those above. If you wish to allow use of your version of this file only
# under the terms of either the GPL or the LGPL, and not to allow others to
# use your version of this file under the terms of the MPL, indicate your
# decision by deleting the provisions above and replace them with the notice
# and other provisions required by the GPL or the LGPL. If you do not delete
# the provisions above, a recipient may use your version of this file under
# the terms of any one of the MPL, the GPL or the LGPL.
# 
# ***** END LICENSE BLOCK *****

"""
models.py

This module contains the models for the Personas application.

Note that changes to Personas are automatically revision-controlled.
Among other things, this allows for:

  (A) A humane user interface that supports undo operations.

  (B) A workflow that allows for Personas to be changed by a submitter
      and reviewed while the original, unchanged Persona remains
      published and accessible by clients.
"""

import os
import datetime

from django.db import models
from django.conf import settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from django.core.validators import ValidationError

from personasbackend.utils import make_absolute_url

MAX_COLOR_LENGTH = 7

LEGACY_PREFIX = "http://sm-labs01.mozilla.org/legacy-personas/"
LEGACY_FOOTER_URL = LEGACY_PREFIX + "%(name)s/stbar-%(name)s.jpg"
LEGACY_HEADER_URL = LEGACY_PREFIX + "%(name)s/tbox-%(name)s.jpg"

THUMBNAIL_DIR = "thumbnails"

def generate_thumbnail( header_file, footer_file ):
    """
    Given a header and footer file--which can be either file-like
    objects or absolute paths to files--returns a PIL image
    representing the Persona's thumbnail.
    """

    import Image

    header = Image.open( header_file )
    footer = Image.open( footer_file )

    # Downsample the header and footer to 50% of original size.

    header = header.resize( (header.size[0] / 2,
                             header.size[1] / 2),
                            Image.ANTIALIAS )
    footer = footer.resize( (footer.size[0] / 2,
                             footer.size[1] / 2),
                            Image.ANTIALIAS )

    # Crop the rightmost 1/3 of the header.

    one_third_header = header.size[0] / 3
    header = header.crop( (header.size[0] - one_third_header,
                           0,
                           header.size[0],
                           header.size[1]) )

    # Crop the leftmost 1/3 of the footer.

    one_third_footer = footer.size[0] / 3
    footer = footer.crop( (0,
                           0,
                           one_third_footer,
                           footer.size[1]) )

    # Return an image that consists of the header and footer pasted
    # together.

    thumbnail = Image.new(
        "RGB",
        ( header.size[0], header.size[1] + footer.size[1] )
        )
    thumbnail.paste( header, (0, 0) )
    thumbnail.paste( footer, (0, header.size[1]) )

    return thumbnail

def parse_personas_view_url( url ):
    """
    TODO: Document this.

    Examples:

      >>> parse_personas_view_url( "http://personas-view/blargy?thing=blarg" )
      {'kwargs': {'thing': 'blarg'}, 'view': 'blargy'}

      >>> parse_personas_view_url( "http://porsonas-view/blargy?thing=blarg" )
    """

    if not url.startswith( "http://personas-view/" ):
        return None

    import urlparse
    import cgi

    _, _, path, _, query, _ = urlparse.urlparse( url )
    view = path[1:]
    kwargs = cgi.parse_qs( query )

    # It is completely ridiculous that we have to do this, but
    # apparently we do, because django's reverse() doesn't like being
    # passed dictionaries with unicode keys.
    string_kwargs = {}
    for key in kwargs:
        string_kwargs[str(key)] = str(kwargs[key][0])

    return {'view':view, 'kwargs':string_kwargs}

def ensure_color_is_valid( color,
                           error_class = ValidationError ):
    """
    Given a color string, raises a ValidationError if the color isn't
    formatted properly.

    Examples:

      >>> ensure_color_is_valid('')

      >>> ensure_color_is_valid('blah')
      Traceback (most recent call last):
      ...
      ValidationError: [u"The color must start with a '#'."]

      >>> ensure_color_is_valid('#aaaaaaa')
      Traceback (most recent call last):
      ...
      ValidationError: [u'The color is too long.']

      >>> ensure_color_is_valid('#aaaaa')
      Traceback (most recent call last):
      ...
      ValidationError: [u'The color is too short.']

      >>> ensure_color_is_valid('#aaazaa')
      Traceback (most recent call last):
      ...
      ValidationError: [u"The character 'z' isn't valid."]

      >>> ensure_color_is_valid('#aaaaaa')
      >>> ensure_color_is_valid('#FFFF11')
    """

    HEX_CHARS = "0123456789abcdef"

    if not color:
        return

    if not color.startswith("#"):
        raise error_class( "The color must start with a '#'." )
    if len(color) > MAX_COLOR_LENGTH:
        raise error_class( "The color is too long." )
    if len(color) < MAX_COLOR_LENGTH:
        raise error_class( "The color is too short." )
    for char in color.lower()[1:MAX_COLOR_LENGTH]:
        if char not in HEX_CHARS:
            raise error_class( "The character '%s' isn't valid." % char )

class Category(models.Model):
    """
    Represents a Personas category; every Persona can be assigned one,
    and only one, category.
    """

    class Admin:
        pass

    class Meta:
        verbose_name_plural = "categories"

    name = models.CharField(
        "Category name",
        max_length=50,
        unique=True,
        )

    def __str__(self):
        return self.name

    def _get_json_id( self ):
        return "personas-category-%d" % self.id

    json_id = property( _get_json_id )

class Persona(models.Model):
    """
    Encapsulates the most recent revision of a Persona.
    """

    class Admin:
        list_display = ["name", "date_updated", "status"]

    class Meta:
        ordering = ["-date_published"]

        permissions = (
            ("can_publish", "Can publish"),
            )

    @models.permalink
    def get_absolute_url( self ):
        return ('edit-persona', [str(self.id)])

    MAX_NAME_LENGTH = 50

    name = models.CharField(
        max_length=MAX_NAME_LENGTH,
        blank=False,
        )

    owner = models.ForeignKey(
        User,
        help_text=("The user who owns this persona can make "
                   "changes to it."),
        related_name="owned_personas",
        # TODO: Consider making this null=False.  Keeping it true
        # makes it easier to write tests, though.
        null=True,
        blank=True,
        )

    category = models.ForeignKey(
        Category,
        related_name="personas",
        null=False,
        )

    description = models.TextField(
        help_text = "A short description of the Persona.",
        blank=False,
        )

    def __get_resolved_url(self):
        if not self.url:
            return make_absolute_url( reverse(
                    'hosted-static-persona',
                    kwargs = {'persona_id' : str(self.id)}
                    ) )

        viewinfo = parse_personas_view_url( self.url )
        if not viewinfo:
            return self.url
        else:
            return make_absolute_url( reverse(
                    viewinfo['view'],
                    kwargs = viewinfo['kwargs']
                    ) )

    resolved_url = property(__get_resolved_url)

    def __make_thumbnail( self, thumbnail_path ):
        import urllib2
        from cStringIO import StringIO

        success = True
        if self.header_img and self.footer_img:
            header_file = self.get_header_img_filename()
            footer_file = self.get_footer_img_filename()
        else:
            viewinfo = parse_personas_view_url( self.url )
            if not viewinfo:
                success = False
            elif viewinfo['view'] == 'legacy-cbeard-persona':
                header_url = LEGACY_HEADER_URL % viewinfo['kwargs']
                footer_url = LEGACY_FOOTER_URL % viewinfo['kwargs']
                header_contents = urllib2.urlopen( header_url ).read()
                header_file = StringIO( header_contents )
                footer_contents = urllib2.urlopen( footer_url ).read()
                footer_file = StringIO( footer_contents )
            else:
                success = False

        if success:
            img = generate_thumbnail( header_file, footer_file )
            img.save( thumbnail_path, quality=95 )

        return success

    def create_thumbnail( self ):
        from distutils.dir_util import mkpath

        abspath = self.thumbnail_filename
        thumbnail_dir = os.path.dirname( abspath )

        if not os.path.exists( thumbnail_dir ):
            mkpath( thumbnail_dir )
        if ( (not os.path.exists( abspath ))
             and (not self.__make_thumbnail( abspath )) ):
            return False
        else:
            return True

    def __clear_thumbnail( self ):
        if os.path.exists( self.thumbnail_filename ):
            os.remove( self.thumbnail_filename )

    def __get_thumbnail_filename( self ):
        return os.path.join( settings.MEDIA_ROOT,
                             THUMBNAIL_DIR,
                             "%d.jpg" % self.id )

    thumbnail_filename = property( __get_thumbnail_filename )

    def __get_thumbnail_url( self ):
        return reverse(
            'persona-thumbnail',
            kwargs = {'persona_id' : str(self.id)}
            )

    thumbnail_url = property( __get_thumbnail_url )

    MAX_FILE_NAME_LENGTH = 255

    header_img = models.ImageField(
        "Header image",
        help_text=("File for the image that will be placed behind "
                   "the browser's top chrome. It's also used to "
                   "generate the preview of the Persona."),
        upload_to="hosted-content/headers",
        blank=True,
        max_length=MAX_FILE_NAME_LENGTH,
        )

    def get_absolute_header_img_url( self ):
        return make_absolute_url(
            self.get_header_img_url()
            )

    footer_img = models.ImageField(
        "Footer image",
        help_text=("File for the image that will be placed behind "
                   "the browser's bottom chrome. It's also used "
                   "to generate the preview of the Persona."),
        upload_to="hosted-content/footers",
        blank=True,
        max_length=MAX_FILE_NAME_LENGTH,
        )

    def get_absolute_footer_img_url( self ):
        return make_absolute_url(
            self.get_footer_img_url()
            )

    url = models.URLField(
        "Dynamic Persona URL",
        help_text=(
            "<b>Advanced:</b> Specifies a URL for the content that will "
            "provide "
            "dynamic content to be placed behind the browser chrome. "
            "If you don't know what this means, you can leave the field "
            "blank."),
        verify_exists=False,
        blank=True,
        )

    text_color = models.CharField(
        help_text=("Optional. The RGB color, as a #RRGGBB color hash, "
                   "of the color of text that will be displayed "
                   "on the persona."),
        max_length=MAX_COLOR_LENGTH,
        validator_list=[ensure_color_is_valid],
        blank=True
        )

    accent_color = models.CharField(
        help_text=("Optional. The RGB color, as a #RRGGBB color hash, "
                   "of the accent colors that will be displayed "
                   "on the persona."),
        max_length=MAX_COLOR_LENGTH,
        validator_list=[ensure_color_is_valid],
        blank=True,
        )

    date_published = models.DateTimeField(
        help_text=("The date that the Persona was last published."),
        null=True,
        editable=True,
        )

    date_updated = models.DateTimeField(
        # This ensures that this field is updated with the current
        # timestamp whenever the record is changed.
        auto_now=True,
        null=False,
        editable=False,
        )

    updater = models.ForeignKey(
        User,
        help_text="The user who made this revision.",
        related_name="changed_personas",
        null=True,
        editable=False
        )

    revision = models.PositiveIntegerField(
        "Revision number",
        help_text=("This number is incremented whenever the Persona "
                   "is changed."),
        null=False,
        editable=False,
        )

    popularity = models.PositiveIntegerField(
        "Popularity",
        help_text=("A number indicating how popular the Persona is; the "
                   "higher this value, the better."),
        default=0,
        null=False,
        )

    MAX_STATUS_LENGTH = 20

    status = models.CharField(
        max_length=MAX_STATUS_LENGTH,
        choices=(("published", "Published"),
                 ("unpublished", "Unpublished"),
                 ("deleted", "Deleted")),
        blank=False,
        help_text=("Status of the Persona; can be deleted, published, "
                   "or unpublished (i.e., pending for review).")
        )

    def __str__(self):
        return self.name

    # These are the properties of this record that are "versioned",
    # i.e. tracked by built-in revision control when changed.
    VERSIONED_PROPERTIES = (
        "name",
        "owner",
        "category",
        "description",
        "url",
        "header_img",
        "footer_img",
        "text_color",
        "accent_color",
        "status",
        )

    def __make_new_revision(self):
        """
        Detect if any of our versioned properties have changed, and if
        so, make a new revision.
        """

        original = Persona.objects.get(id=self.id)

        assert original.revision == self.revision

        delta = {}
        for attr in self.VERSIONED_PROPERTIES:
            origValue = getattr(original, attr)
            newValue = getattr(self, attr)
            if origValue != newValue:
                delta[attr] = origValue
        if delta:
            rev = Revision(
                revision_of = original,
                date_updated = original.date_updated,
                updater = original.updater,
                revision = original.revision
                )

            for attr in delta:
                setattr(rev, attr, delta[attr])
                setattr(rev, "has_%s" % attr, True)
            rev.save()

            self.revision += 1

    def save( self, updater=None ):
        """
        Saves the object.  If a versioned property is changed, a new
        revision is generated automatically.

        Example:

          >>> p = Persona(name='Test Persona',
          ...             description='Thos is a test.',
          ...             url='http://blarg.com',
          ...             category=Category.objects.get(name='Other'))
          >>> p.save()
          >>> p.revision
          0
          >>> p.revisions.all()
          []

          >>> p.description = 'This is a test.'
          >>> p.url = 'http://blarg2.com'
          >>> p.save()
          >>> p.revision
          1
          >>> p.revisions.all()
          [<Revision: Test Persona - r0>]
          >>> p.revisions.get(revision=0).description
          u'Thos is a test.'
          >>> p.revisions.get(revision=0).url
          u'http://blarg.com'
        """

        if updater:
            self.updater = updater
        else:
            # We were probably saved through the admin interface,
            # which saves its own separate history of changes.
            self.updater = None

        if self.id == None:
            # We're a brand-new Persona.
            self.revision = 0
            if not self.owner:
                self.owner = self.updater
            assert self.owner == self.updater
            if not self.status:
                if ( self.updater and
                     self.updater.has_perm( "personas.can_publish" ) ):
                    # If the person creating the Persona can publish
                    # Personas, mark this new Persona as published, by
                    # default.
                    self.status = "published"
                else:
                    # Otherwise, mark this new Persona as unpublished by
                    # default.
                    self.status = "unpublished"
            if self.status == "published":
                self.date_published = datetime.datetime.now()
        else:
            # We're an existing Persona that's being modified.

            if self.updater:
                if not self.can_user_edit( self.updater ):
                    # The user can't actually edit this Persona, reject
                    # the change.  View logic should've prevented this
                    # from ever being the case, so we're going to be
                    # ungraceful here.
                    raise AssertionError( "User can't edit this persona." )

                if ( self.status == "published" and
                     not self.updater.has_perm( "personas.can_publish" ) ):
                    # The Persona is currently published, but a user
                    # without publishing permissions has just changed it,
                    # so mark it as unpublished so an editor can review it
                    # before re-publishing it.

                    self.status = "unpublished"

            original = Persona.objects.get(id=self.id)

            if ( (original.status != self.status) and 
                 (self.status == "published") ):
                self.date_published = datetime.datetime.now()

            if ( (original.header_img != self.header_img) or
                 (original.footer_img != self.footer_img) ):
                self.__clear_thumbnail()

            self.__make_new_revision()

        super(Persona, self).save()

    def can_user_edit( self, user ):
        """
        Returns whether the given User can edit this Persona.
        """

        return ( user.has_perm( "personas.can_publish" ) or
                 self.owner == user )

    # TODO: We should be able to get rid of this eventually, since the
    # JSON id is the same as the normal id.
    def _get_json_id( self ):
        return str( self.id )

    json_id = property( _get_json_id )

class Revision(models.Model):
    """
    Represents an old revision of a Persona.

    This record only stores a "reverse delta" relative to the revision
    that follows it; for instance, if a user changes a Persona at
    revision 0 by modifying its description, then the Revision record
    for revision 0 will contain only the description for the Persona
    at that revision, since all other properties can be obtained by
    looking at revision 1.  In this way, the complete "picture" of
    revision 0 can be reconstructed by starting at the record for
    revision 0 and traveling to newer revisions until every field of
    the Persona has been populated.
    """

    class Meta:
        unique_together = (("revision_of", "revision"),)
        ordering = ["revision"]

    # Auto-generated fields

    revision_of = models.ForeignKey(
        Persona,
        verbose_name="The Persona that this record is an old revision of",
        related_name="revisions",
        null=False,
        )

    date_updated = models.DateTimeField(
        null=False,
        )

    updater = models.ForeignKey(
        User,
        related_name="previously_changed_revisions",
        null=True,
        )

    revision = models.PositiveIntegerField(
        null=False,
        )

    # Versioned fields

    for attr in Persona.VERSIONED_PROPERTIES:
        locals()["has_%s" % attr] = models.BooleanField(default=False)
    del attr

    name = models.CharField(
        max_length=Persona.MAX_NAME_LENGTH,
        null=True,
        )

    owner = models.ForeignKey(
        User,
        related_name="previously_owned_personas",
        null=True,
        )

    category = models.ForeignKey(
        Category,
        related_name="revisions",
        null=True,
        )

    description = models.TextField(
        null=True,
        )

    url = models.URLField(
        null=True,
        )

    header_img = models.CharField(
        max_length=Persona.MAX_FILE_NAME_LENGTH,
        null=True
        )

    footer_img = models.CharField(
        max_length=Persona.MAX_FILE_NAME_LENGTH,
        null=True
        )

    text_color = models.CharField(
        max_length=MAX_COLOR_LENGTH,
        null=True,
        )

    accent_color = models.CharField(
        max_length=MAX_COLOR_LENGTH,
        null=True,
        )

    status = models.CharField(
        max_length=Persona.MAX_STATUS_LENGTH,
        null=True,
        )

    def __str__(self):
        return "%s - r%s" % (self.revision_of.name, self.revision)

    def resolve(self):
        """
        Fills-in (resolves) all the fields of the revision by
        computing reverse deltas to future/present revisions.

          >>> p = Persona(name='Test Persona',
          ...             description='Thos is a test.',
          ...             url='http://www.blarg.com',
          ...             category=Category.objects.get(name='Other'))
          >>> p.save()
          >>> p.description = 'This is a test.'
          >>> p.save()
          >>> p.name = 'Blarg'
          >>> p.save()
          >>> p.description = "This is a great test."
          >>> p.save()
          >>> p.url = "http://www.test.com"
          >>> p.save()
          >>> r = p.revisions.get(revision=0)
          >>> r.resolve()
          >>> r.name
          u'Test Persona'
          >>> r.description
          u'Thos is a test.'
          >>> r.url
          u'http://www.blarg.com'
        """

        persona = self.revision_of
        futureRevs = persona.revisions.filter(revision__gt=self.revision)
        attrsLeft = [ attr for attr in Persona.VERSIONED_PROPERTIES
                      if getattr(self, attr) == None ]
        for rev in futureRevs:
            attrsFound = []
            for attr in attrsLeft:
                if getattr(rev, "has_%s" % attr):
                    setattr(self, attr, getattr(rev, attr))
                    attrsFound.append(attr)
            if attrsFound:
                attrsLeft = [ attr for attr in attrsLeft
                              if attr not in attrsFound ]
                if not attrsLeft:
                    break
        for attr in attrsLeft:
            setattr(self, attr, getattr(persona, attr))

    def save(self):
        """
        The save method for a Revision can only be called once,
        because Revision objects are meant to be immutable:

          >>> p = Persona(name='Test Persona',
          ...             url='http://www.blah.com',
          ...             category=Category.objects.get(name='Other'))
          >>> p.save()
          >>> p.description = 'This is a test.'
          >>> p.save()
          >>> r = p.revisions.get(revision=0)
          >>> r.status = "deleted"
          >>> r.save()
          Traceback (most recent call last):
          ...
          AssertionError: Revisions are immutable
        """

        if self.id == None:
            super(Revision, self).save()
        else:
            raise AssertionError("Revisions are immutable")