Mercurial > personas_backend
view personasbackend/personas/models.py @ 169:e24d244c0c84
Made the JSON id for personas be the same as their primary key id.
author | Atul Varma <varmaa@toolness.com> |
---|---|
date | Fri, 11 Apr 2008 11:05:27 -0700 |
parents | 994ae3fd828a |
children | 3fe6eb21636d |
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: 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 = "HTML is allowed.", 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")