view PersonasBackend/personas/models.py @ 44:9e11c9374822

The JSON feeds now properly provide absolute URLs to personas, regardless of whether the persona images are self-hosted or not.
author Atul Varma <varmaa@toolness.com>
date Tue, 04 Mar 2008 11:15:10 -0600
parents 564818c67b57
children cf6b5f26e902
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 datetime

from django.db import models
from django.contrib.auth.models import User
from django.contrib.sites.models import Site

def _external_url_or_hosted_media_url( external, media ):
    """
    Given an absolute URL representing an externally-hosted resource
    OR an absolute or relative URL representing a hosted resource,
    returns an absolute URL to the resource.

      >>> _external_url_or_hosted_media_url(
      ...   'http://blarg.com/', None
      ... )
      'http://blarg.com/'

      >>> _external_url_or_hosted_media_url(
      ...   None, '/personas/headers/blah.jpg'
      ... )
      u'http://example.com/personas/headers/blah.jpg'

      >>> _external_url_or_hosted_media_url(
      ...   None, 'http://blah.com/personas/headers/blah.jpg'
      ... )
      'http://blah.com/personas/headers/blah.jpg'
    """

    # TODO: Consider uncommenting the following assertions; they may
    # make some tests break, though.
    # 
    #assert external or media, \
    #    "one parameter must be non-null"
    #assert (not external and media), \
    #    "both paramaters cannot be be non-null"

    if external:
        return external
    else:
        if not media.startswith( "/" ):
            return media
        else:
            return "http://%s%s" % (
                Site.objects.get_current().domain,
                media
                )

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

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

    class Admin:
        pass

    MAX_NAME_LENGTH = 50

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

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

    description = models.TextField(
        help_text = "HTML is allowed.",
        blank=False,
        )

    header_img_url = models.URLField(
        "Header image URL",
        help_text=("URL for the image that will be placed behind "
                   "the browser's top chrome. Only needed if "
                   "a header image file is not specified."),
        verify_exists=True,
        blank=True,
        )

    MAX_FILE_NAME_LENGTH = 255

    header_img_file = models.ImageField(
        "Header image file",
        help_text=("File for the image that will be placed behind "
                   "the browser's top chrome.  Only needed if "
                   "a header image URL is not specified."),
        upload_to="headers",
        blank=True,
        max_length=MAX_FILE_NAME_LENGTH,
        )

    footer_img_url = models.URLField(
        "Footer image URL",
        help_text=("URL for the image that will be placed behind "
                   "the browser's bottom chrome.  Only needed if "
                   "a footer image file is not specified."),
        verify_exists=True,
        blank=True,
        max_length=MAX_FILE_NAME_LENGTH,
        )

    footer_img_file = models.ImageField(
        "Footer image file",
        help_text=("File for the image that will be placed behind "
                   "the browser's bottom chrome.  Only needed if "
                   "a footer image URL is not specified."),
        upload_to="footers",
        blank=True,
        )

    MAX_COLOR_SCHEME_LENGTH = 10

    color_scheme = models.CharField(
        help_text=("If 'light', any text displayed over the "
                   "Persona will be dark.  If 'dark', any text "
                   "displayed over the Persona will be light."),
        max_length=MAX_COLOR_SCHEME_LENGTH,
        choices=(("light", "Light"),
                 ("dark", "Dark")),
        blank=False,
        default="light",
        )

    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="personas",
        null=True,
        )

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

    MAX_STATUS_LENGTH = 10

    status = models.CharField(
        max_length=MAX_STATUS_LENGTH,
        choices=(("published", "Published"),
                 ("needs_review", "Needs Review"),
                 ("deleted", "Deleted")),
        blank=False,
        default="published",
        # TODO: Add help text for this, and figure out what all
        # possible statuses are.
        )

    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",
        "category",
        "description",
        "header_img_url",
        "header_img_file",
        "footer_img_url",
        "footer_img_file",
        "color_scheme",
        "status",
        )

    def get_header_url(self):
        """
        Regardless of how a header image was submitted or where it's
        located, return an absolute URL to its location.
        """

        return _external_url_or_hosted_media_url(
            self.header_img_url,
            self.get_header_img_file_url()
            )

    def get_footer_url(self):
        """
        Regardless of how a footer image was submitted or where it's
        located, return an absolute URL to its location.
        """

        return _external_url_or_hosted_media_url(
            self.footer_img_url,
            self.get_footer_img_file_url()
            )

    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):
        """
        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.',
          ...             header_img_file='test.png',
          ...             category=Category.objects.get(name='General'))
          >>> p.save()
          >>> p.revision
          0
          >>> p.revisions.all()
          []

          >>> p.description = 'This is a test.'
          >>> p.header_img_file = 'blarg.png'
          >>> 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).header_img_file
          u'test.png'
        """

        if self.id == None:
            self.revision = 0
        else:
            # TODO: See who made the update; depending on how much we
            # trust them, this may mean:
            #
            #   * rejecting the change, 
            #   * accepting the changes but marking the persona's
            #     status as "needs review",
            #   * accepting the changes.

            self.__make_new_revision()

        super(Persona, self).save()

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="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,
        )

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

    description = models.TextField(
        null=True,
        )

    # TODO: Add validation that ensures that either header_img_url or
    # header_img_file is non-null; same goes for footer.

    header_img_url = models.URLField(
        null=True,
        )

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

    footer_img_url = models.URLField(
        null=True,
        )

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

    color_scheme = models.CharField(
        max_length=Persona.MAX_COLOR_SCHEME_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.',
          ...             footer_img_file='test.png',
          ...             category=Category.objects.get(name='General'))
          >>> 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.header_img_url = "http://www.test.com"
          >>> p.footer_img_file = "test2.png"
          >>> p.save()
          >>> r = p.revisions.get(revision=0)
          >>> r.resolve()
          >>> r.name
          u'Test Persona'
          >>> r.description
          u'Thos is a test.'
          >>> r.header_img_url
          u''
          >>> r.footer_img_file
          u'test.png'
        """

        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',
          ...             category=Category.objects.get(name='General'))
          >>> 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")