Mercurial > personas_backend
view PersonasBackend/personas/models.py @ 88:7870c445d9f3
Fixed some logic re: publish date, made it so Personas don't have to have an owner in the admin interface.
author | Atul Varma <varmaa@toolness.com> |
---|---|
date | Tue, 18 Mar 2008 12:11:43 -0500 |
parents | 663f0410ff39 |
children | f9c59266abaf |
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 ensure_header_and_footer_are_valid( form_data ): """ Given a dictionry that corresponds to the form data for a Persona, raises a ValueError if there's any problems with the way the header/footer information have been filled out. Examples: >>> form_data = { ... 'header_img_url' : u'', ... 'header_img_file' : u'', ... 'footer_img_url' : u'', ... 'footer_img_file' : u'' ... } >>> ensure_header_and_footer_are_valid( form_data ) Traceback (most recent call last): ... ValueError: Please enter a URL for the header or upload a file. >>> form_data['header_img_url'] = u'http://mystuff.com/img.jpg' >>> ensure_header_and_footer_are_valid( form_data ) Traceback (most recent call last): ... ValueError: Please enter a URL for the footer or upload a file. >>> form_data['footer_img_url'] = u'http://mystuff.com/img2.jpg' >>> ensure_header_and_footer_are_valid( form_data ) >>> form_data['footer_img_file'] = u'img3.jpg' >>> ensure_header_and_footer_are_valid( form_data ) Traceback (most recent call last): ... ValueError: You can't submit both a URL and a file for the footer. """ for name in ["header", "footer"]: num_filled = get_num_filled( form_data["%s_img_url" % name], form_data["%s_img_file" % name], ) if num_filled == 0: raise ValueError( "Please enter a URL for the %s or " "upload a file." % name ) elif num_filled == 2: raise ValueError( "You can't submit both a URL and a " "file for the %s." % name ) def header_and_footer_validator( field_data, all_data ): """ Old-style forms Django validator wrapper for ensure_header_and_footer_are_valid(). This is needed because Django's admin interface currently uses old-style forms. """ from django.core import validators try: ensure_header_and_footer_are_valid( all_data ) except ValueError, e: raise validators.ValidationError( e.message ) def get_num_filled( arg1, arg2 ): """ Returns how many of the given arguments evaluate to a boolean value of True. >>> get_num_filled( 5, None ) 1 >>> get_num_filled( 'hello', 'there' ) 2 >>> get_num_filled( False, False ) 0 """ if not (arg1 or arg2): return 0 elif arg1 and arg2: return 2 else: return 1 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' """ assert get_num_filled( external, media ) == 1 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 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"), ) MAX_NAME_LENGTH = 50 name = models.CharField( max_length=MAX_NAME_LENGTH, blank=False, validator_list = [header_and_footer_validator], ) 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, ) 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="hosted-content/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="hosted-content/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_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 = 10 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", "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, 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.', ... header_img_file='test.png', ... footer_img_file='test2.png', ... category=Category.objects.get(name='Other')) >>> 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' """ ensure_header_and_footer_are_valid( self.__dict__ ) 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() 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 ) def _get_json_id( self ): return "persona-%d" % 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, ) 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.', ... header_img_file='test0.png', ... footer_img_file='test.png', ... 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.header_img_url = "http://www.test.com" >>> p.header_img_file = "" >>> 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', ... header_img_file='test.png', ... footer_img_file='test2.png', ... 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")