Thumbnailing.
authorEevee <git@veekun.com>
Thu, 3 Dec 2009 06:12:46 +0000 (22:12 -0800)
committerEevee <git@veekun.com>
Thu, 3 Dec 2009 06:12:46 +0000 (22:12 -0800)
Most notably, uploading a file will now generate a thumbnail (and a
"medium" size image).

Also tried to clean up use of storage.  I'll revisit this again later,
but the intent was to make it easier to switch from file storage to
something like S3 or mogile or whatever in the future.  The biggest
change was to keep storage access out of the db model; Art no longer
knows anything about storage whatsoever.

development.ini
floof/controllers/art.py
floof/lib/file_storage.py
floof/lib/helpers.py
floof/model/art.py
floof/templates/art/show.mako
floof/templates/macros.mako
floof/websetup.py
setup.py

index f184f66..7e0e3ed 100644 (file)
@@ -39,6 +39,9 @@ sqlalchemy.url = sqlite:///%(here)s/development.db
 # execute malicious code after an exception is raised.
 # set debug = false
 
+# Floof-specific configuration
+thumbnail_size = 120
+medium_size = 960
 
 # Logging configuration
 [loggers]
index 41298c8..5b347b3 100644 (file)
@@ -1,22 +1,26 @@
 import logging
 
-from pylons import request, response, session, tmpl_context as c, h
+from pylons import config, request, response, session, tmpl_context as c, h
 from pylons.controllers.util import abort, redirect
 from pylons import url
 from floof.lib.base import BaseController, render
 
 log = logging.getLogger(__name__)
 
-import elixir
+from floof.lib import file_storage as storage
 from floof.model.users import User
 from floof.model import Art, Rating, UserRelation
 from floof.model.comments import Discussion
 from floof.model.users import User, UserRelationship
 
+import elixir
+#import magic
+import os.path
+import PIL
+import PIL.Image
 from sqlalchemy import func
 from sqlalchemy.exceptions import IntegrityError
 from sqlalchemy.orm.exc import NoResultFound
-
 from wtforms.validators import ValidationError
 from wtforms import *
 
@@ -79,32 +83,66 @@ class ArtController(BaseController):
     # TODO: login required
     def create(self):
         c.form = ArtUploadForm(request.params)
-        if c.form.validate():
+        if not c.form.validate():
+            ## TODO: JavaScript should be added to the upload form so that it is
+            ## impossible to submit the form when it contains any invalid users, 
+            ## so this never happens.  Only autocompled usernames should be allowed.
+            return render("/art/new.mako")
 
-            c.art = Art(uploader=c.user, **request.params)
-            c.art.discussion = Discussion(count=0)
+        # Save the file
+        upload = request.params['file']
+        hash = storage.save_file('art/original', upload.file)
 
-            for artist in c.form.by.data:
-                UserRelation(user=artist, kind="by", creator=c.user, art=c.art)
+        # Create a thumbnail and a medium view
+        img = PIL.Image.open(
+            config['app_conf']['static_root']
+            + storage.get_path('art/original', hash)[1:]
+        )
+        for subdir, config_size in [('medium',    config['medium_size']),
+                                    ('thumbnail', config['thumbnail_size'])]:
+            path = config['app_conf']['static_root'] + storage.get_path("art/{0}".format(subdir), hash)
 
-            file = request.params['file']
+            dir, _ = os.path.split(path)
+            if not os.path.exists(dir):
+                os.makedirs(dir)
 
-            try:
-                elixir.session.commit()
-                redirect(url('show_art', id=c.art.id))
-            except IntegrityError:
-                # hurr, there must be a better way to do this but I am lazy right now
-                hash = c.art.hash
-                elixir.session.rollback()
-                duplicate_art = Art.get_by(hash=hash)
-                h.flash("We already have that one.")
-                redirect(url('show_art', id=duplicate_art.id))
+            size = int(config_size)
+            shrunken_img = img.copy()
+            shrunken_img.thumbnail((size, size), PIL.Image.ANTIALIAS)
+            shrunken_img.save(path, img.format)
+
+        #magicker = magic.Magic(mime=True)
+        buffer = upload.file.read(64)
+        upload.file.seek(0)
+        mimetype = 'image/unknown' #magickery.from_buffer(buffer)
+
+        # XXX Ensure we can actually handle the mimetype
+
+        print mimetype
+        c.art = Art(
+            uploader=c.user,
+            original_filename=upload.filename,
+            hash=hash,
+            mimetype=mimetype,
+        )
+        c.art.discussion = Discussion(count=0)
+
+        for artist in c.form.by.data:
+            UserRelation(user=artist, kind="by", creator=c.user, art=c.art)
+
+
+        try:
+            elixir.session.commit()
+            redirect(url('show_art', id=c.art.id))
+        except IntegrityError:
+            # XXX Check this as early as possible, and clean up the filesystem!
+            # Right now this replaces the original file!
+            hash = c.art.hash
+            elixir.session.rollback()
+            duplicate_art = Art.get_by(hash=hash)
+            h.flash("We already have that one.")
+            redirect(url('show_art', id=duplicate_art.id))
 
-        else:
-            ## TODO: JavaScript should be added to the upload form so that it is
-            ## impossible to submit the form when it contains any invalid users, 
-            ## so this never happens.  Only autocompled usernames should be allowed.
-            return render("/art/new.mako")
 
 
     def show(self, id):
index 90561d0..b30a662 100644 (file)
@@ -7,12 +7,6 @@ from pylons import config
 
 """
 Notes:
-# Here's one way to move stuff...
-shutil.copyfileobj(temp.file, dest_file)
-
-# we should probably store the basename of the original filename in the database somewhere
-temp_base = os.path.basename(temp.filename)
-
 # You can get a mime type like so:
 from mimetypes import guess_type
 guess_type(temp.filename)[0]
@@ -22,45 +16,49 @@ def get_path(space, hash):
     return "/" + os.path.join( space, hash[:2], hash[2:] )
 
 
-def save_file(space, temp):
+def save_file(space, fileobj, hash=None):
+    """Saves the contents of fileobj to the given storage space.
 
+    If a hash is not provided, the SHA-1 sum of the file will be computed and
+    that will be used.  The ideal scenario here is to let the hash of the
+    original file be computed automatically, then create thumbnails et al. with
+    the same hash in a different space.
+
+    Returns the hashsum.
+    """
     dest_root = os.path.join( config['app_conf']['static_root'], space )
 
-    # we don't know where we're going to save this stuff yet,
-    # so I guess we'll write it to another tempfile.  One we know the path of.
-    # I'm assuming the tempfile we get from pylons is set to delete itself
-    # when it closes, and has no visible path.  Maybe I'm wrong?
-    intermediate_file_descriptor, intermediate_path = tempfile.mkstemp()
+    # The incoming fileobj could be a tempfile that's already been unlinked --
+    # and probably is, as it's coming from a Pylons upload object.  Thus we
+    # have to copy the data rather than the file, and we may have to read the
+    # file anyway to hash it, so do both at the same time.
 
-    # that function gives me an integer file descriptor for some reason.
-    intermediate_file = os.fdopen(intermediate_file_descriptor, "wb")
+    # Need a named temporary file to write to; it gets renamed once the hash is
+    # computed
+    temp_fd, temp_path = tempfile.mkstemp()
+    temp_file = os.fdopen(temp_fd, "wb")
 
     sha1 = hashlib.sha1()
-    while 1:
-        data = temp.file.read(chunk_size)
+    while True:
+        data = fileobj.read(chunk_size)
         if not data:
             break
-        sha1.update(data)
-        intermediate_file.write(data)
-
-    temp.file.close()
-    intermediate_file.close()
-    hash = sha1.hexdigest()
 
-    # git convention: first two characters are the directory
-    dest_dir = os.path.join( dest_root, hash[:2] )
-    dest_path =  os.path.join( dest_dir, hash[2:] )
+        if not hash:
+            sha1.update(data)
+        temp_file.write(data)
 
-    makedirs(dest_dir)
-    shutil.move(intermediate_path, dest_path)
-
-    return hash
+    temp_file.close()
+    if not hash:
+        hash = sha1.hexdigest()
 
+    # Git convention: first two characters are the directory
+    dest_dir  = os.path.join(dest_root, hash[:2])
+    dest_path = os.path.join(dest_dir,  hash[2:])
 
+    if not os.path.exists(dest_dir):
+        os.makedirs(dest_dir)
+    print dest_path
+    shutil.move(temp_path, dest_path)
 
-def makedirs(dir):
-    try:
-         os.makedirs(dir)
-    except OSError:
-         pass
-
+    return hash
index b4cac1f..14f92d0 100644 (file)
@@ -8,7 +8,7 @@ available to Controllers. This module is available to both as 'h'.
 # from webhelpers.html.tags import checkbox, password
 from webhelpers import *
 from routes import url_for, redirect_to
-from pylons import url
+from pylons import config, url
 
 # Scaffolding helper imports
 from webhelpers.html.tags import *
@@ -18,6 +18,8 @@ import sqlalchemy.types as types
 flash = Flash()
 # End of.
 
+from floof.lib import file_storage as storage
+
 def get_object_or_404(model, **kw):
     from pylons.controllers.util import abort
     """
@@ -34,10 +36,14 @@ def get_object_or_404(model, **kw):
 def get_comment_owner_url(**route):
     """Given a view route, returns the owner_url route parameter for generating
     comment URLs.
-    """ 
+    """
     if 'owner_url' in route:
         # We're already in a comments page.  Reuse the existing owner URL
         return route['owner_url']
 
     # url() returns URLs beginning with a slash.  We just need to strip it.
     return url(**route)[1:]
+
+def storage_url(prefix, identifier):
+    """Returns a URL for the given object-in-storage."""
+    return storage.get_path(prefix, identifier)
index bb68c9f..8989e8f 100644 (file)
@@ -21,7 +21,8 @@ import floof.model.comments
 class Art(Entity):
     title = Field(Unicode(120))
     original_filename = Field(Unicode(120))
-    hash = Field(String, unique=True, required=True)
+    hash = Field(Unicode(40), unique=True, required=True)
+    mimetype = Field(Unicode(32), required=True)
 
     uploader = ManyToOne('User', required=True)
     tags = OneToMany('Tag')
@@ -29,16 +30,6 @@ class Art(Entity):
 
     user_relations = OneToMany('UserRelation')
 
-
-    def set_file(self, file):
-        self.hash = save_file("art", file)
-        self.original_filename = file.filename
-
-    file = property(get_path, set_file)
-
-    def get_path(self):
-        if self.hash:
-            return get_path("art", self.hash)
-
-    def __unicode__(self):
-        return self.get_path()
+    @property
+    def file_path(self):
+        return get_path("art", self.hash)
index 05d1e42..a38806d 100644 (file)
@@ -45,7 +45,6 @@ By: ${h.text('username')}
 ${h.submit('add','Add')}
 ${h.end_form()}
 
-
-<img class="full" src="${c.art.get_path()}">
+<img class="full" src="${h.storage_url('art/medium', c.art.hash)}">
 
 ${comments.comment_block(c.art.discussion.comments)}
index 061b7b9..ca3933e 100644 (file)
@@ -3,9 +3,9 @@
         % for item in art:
             <li>
                 <a href="${h.url("show_art", id=item.id)}">
-                    <img width="180" src="${item.get_path()}">
+                    <img src="${h.storage_url('art/thumbnail', item.hash)}">
                 </a>
             </li>
         % endfor
     </ul>
-</%def>
\ No newline at end of file
+</%def>
index b1611c9..815832d 100644 (file)
@@ -10,23 +10,40 @@ from pylons import config
 from elixir import *
 from floof import model as model
 
+import os
+
 def setup_app(command, conf, vars):
     """Place any commands to setup floof here"""
     load_environment(conf.global_conf, conf.local_conf)
-    model.metadata.create_all()
 
-    # Initialisation here ... this sort of stuff:
+    ### Database schema
+    model.metadata.create_all()
 
+    ### Sample data
     # Users
     from floof.model.users import IdentityURL, User
     identity_url = IdentityURL(url=u'http://eevee.livejournal.com/')
     user = User(name=u'Eevee')
     user.identity_urls.append(identity_url)
 
-
-    # some_entity = model.Session.query(model.<modelfile>.<Some_Entity>).get(1)
-    # e.g. foo = model.Session.query(model.identity.User).get(1)
-    # from datetime import datetime
-    # some_entity.poked_on = datetime.now()
-    # model.Session.add(some_entity)
     model.Session.commit()
+
+
+    ### Filesystem stuff
+    # Only necessary if we're using the filesystem for storage.
+    # And we are!
+    art_path = os.path.join(config['app_conf']['static_root'], 'art')
+    # Test that we can write to the art directory
+    if os.access(art_path, os.W_OK | os.X_OK):
+        # Create directories for various image sizes
+        for subdir in ['original', 'medium', 'thumbnail']:
+            path = os.path.join(art_path, subdir)
+            # If it exists, it should be writeable.  If not, create it.
+            if os.path.exists(path):
+                if not os.access(path, os.W_OK | os.X_OK):
+                    print "*** WARNING: Can't write to the art/{0} " \
+                          "directory!".format(subdir)
+                continue
+            os.makedirs(path)
+    else:
+        print "*** WARNING: Can't write to the art directory!"
index 56e5af4..c71e6b2 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -17,6 +17,7 @@ setup(
         "Pylons>=0.9.7",
         "SQLAlchemy>=0.5",
         "elixir>=0.6",
+        'pil',
         'python-openid',
         'shabti',
         'wtforms',