From 03f015f99a5e020ed9e721be7509daa8d2eceeaa Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 1 Apr 2008 21:54:43 +0000 Subject: [PATCH] * Propsetting a bunch of svn:ignores. * Added a bunch of thumbnail-related methods to controller.Files. * Modified Files.download() method to redirect to image preview if requested. * Implemented image preview to popup full image in a separate window. * Added empty stubs for relevant unit tests. Still to-do. * Added new dependency on python-imaging package (PIL). * Updated file info popup to include clickable thumbnail. --- INSTALL | 4 +- controller/Files.py | 158 +++++++++++++++++++++++++++- controller/test/Test_files.py | 69 ++++++++++++ static/css/style.css | 7 ++ static/images/default_thumbnail.png | Bin 0 -> 872 bytes static/images/default_thumbnail.xcf | Bin 0 -> 2262 bytes static/images/luminotes_title.jpg | Bin 0 -> 4829 bytes static/js/Editor.js | 8 +- static/js/Wiki.js | 17 ++- view/File_preview_page.py | 23 ++++ 10 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 static/images/default_thumbnail.png create mode 100644 static/images/default_thumbnail.xcf create mode 100644 static/images/luminotes_title.jpg create mode 100644 view/File_preview_page.py diff --git a/INSTALL b/INSTALL index f2b9d61..957a217 100644 --- a/INSTALL +++ b/INSTALL @@ -10,12 +10,14 @@ First, install the prerequisites: * psycopg 2.0 * simplejson 1.3 * pytz 2006p + * Python Imaging Library 1.1 In Debian GNU/Linux, you can issue the following command to install these packages: apt-get install python2.4 python-cherrypy postgresql-8.1 \ - postgresql-contrib-8.1 python-psycopg2 python-simplejson python-tz + postgresql-contrib-8.1 python-psycopg2 python-simplejson \ + python-tz python-imaging database setup diff --git a/controller/Files.py b/controller/Files.py index d7c5ee4..94b099d 100644 --- a/controller/Files.py +++ b/controller/Files.py @@ -5,6 +5,8 @@ import time import urllib import tempfile import cherrypy +from PIL import Image +from cStringIO import StringIO from threading import Lock, Event from Expose import expose from Validate import validate, Valid_int, Valid_bool, Validation_error @@ -17,6 +19,7 @@ from view.Upload_page import Upload_page from view.Blank_page import Blank_page from view.Json import Json from view.Progress_bar import stream_progress, stream_quota_error, quota_error_script, general_error_script +from view.File_preview_page import File_preview_page class Access_error( Exception ): @@ -238,9 +241,10 @@ class Files( object ): @validate( file_id = Valid_id(), quote_filename = Valid_bool( none_okay = True ), + preview = Valid_bool( none_okay = True ), user_id = Valid_id( none_okay = True ), ) - def download( self, file_id, quote_filename = False, user_id = None ): + def download( self, file_id, quote_filename = False, preview = True, user_id = None ): """ Return the contents of file that a user has previously uploaded. @@ -250,14 +254,17 @@ class Files( object ): @param quote_filename: True to URL quote the filename of the downloaded file, False to leave it as UTF-8. IE expects quoting while Firefox doesn't (optional, defaults to False) + @type preview: bool + @param preview: True to redirect to a preview page if the file is a valid image, False to + unconditionally initiate a download @type user_id: unicode or NoneType @param user_id: id of current logged-in user (if any) - @rtype: unicode + @rtype: generator @return: file data @raise Access_error: the current user doesn't have access to the notebook that the file is in """ # release the session lock before beginning to stream the download. otherwise, if the - # upload is cancelled before it's done, the lock won't be released + # download is cancelled before it's done, the lock won't be released try: cherrypy.session.release_lock() except KeyError: @@ -268,7 +275,14 @@ class Files( object ): if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): raise Access_error() - db_file = self.__database.load( File, file_id ) + # if the file is openable as an image, then allow the user to view it instead of downloading it + if preview: + server_filename = Upload_file.make_server_filename( file_id ) + try: + Image.open( server_filename ) + return dict( redirect = u"/files/preview?file_id=%s"e_filename=%s" % ( file_id, quote_filename ) ) + except IOError: + pass cherrypy.response.headerMap[ u"Content-Type" ] = db_file.content_type @@ -290,6 +304,142 @@ class Files( object ): return stream() + @expose( view = File_preview_page ) + @end_transaction + @grab_user_id + @validate( + file_id = Valid_id(), + quote_filename = Valid_bool( none_okay = True ), + user_id = Valid_id( none_okay = True ), + ) + def preview( self, file_id, quote_filename = False, user_id = None ): + """ + Return the contents of file that a user has previously uploaded. + + @type file_id: unicode + @param file_id: id of the file to view + @type quote_filename: bool + @param quote_filename: quote_filename value to include in download URL + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype: unicode + @return: file data + @raise Access_error: the current user doesn't have access to the notebook that the file is in + """ + db_file = self.__database.load( File, file_id ) + + if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): + raise Access_error() + + filename = db_file.filename.replace( '"', r"\"" ).encode( "utf8" ) + + return dict( + file_id = file_id, + filename = filename, + quote_filename = quote_filename, + ) + + @expose() + @end_transaction + @grab_user_id + @validate( + file_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def thumbnail( self, file_id, user_id = None ): + """ + Return a thumbnail for a file that a user has previously uploaded. If a thumbnail cannot be + generated for the given file, return a default thumbnail image. + + @type file_id: unicode + @param file_id: id of the file to return a thumbnail for + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype: generator + @return: thumbnail image data + @raise Access_error: the current user doesn't have access to the notebook that the file is in + """ + try: + cherrypy.session.release_lock() + except KeyError: + pass + + db_file = self.__database.load( File, file_id ) + + if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): + raise Access_error() + + cherrypy.response.headerMap[ u"Content-Type" ] = u"image/png" + + # attempt to open the file as an image + server_filename = Upload_file.make_server_filename( file_id ) + try: + image = Image.open( server_filename ) + + # scale the image down into a thumbnail + THUMBNAIL_MAX_SIZE = ( 75, 75 ) # in pixels + image.thumbnail( THUMBNAIL_MAX_SIZE, Image.ANTIALIAS ) + except IOError: + image = Image.open( "static/images/default_thumbnail.png" ) + + # save the image into a memory buffer + image_buffer = StringIO() + image.save( image_buffer, "PNG" ) + image_buffer.seek( 0 ) + + def stream( image_buffer ): + CHUNK_SIZE = 8192 + + while True: + data = image_buffer.read( CHUNK_SIZE ) + if len( data ) == 0: break + yield data + + return stream( image_buffer ) + + @expose() + @end_transaction + @grab_user_id + @validate( + file_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def image( self, file_id, user_id = None ): + """ + Return the contents of an image file that a user has previously uploaded. This is distinct + from the download() method above in that it doesn't set HTTP headers for a file download. + + @type file_id: unicode + @param file_id: id of the file to return + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype: generator + @return: image data + @raise Access_error: the current user doesn't have access to the notebook that the file is in + """ + try: + cherrypy.session.release_lock() + except KeyError: + pass + + db_file = self.__database.load( File, file_id ) + + if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): + raise Access_error() + + cherrypy.response.headerMap[ u"Content-Type" ] = db_file.content_type + + def stream(): + CHUNK_SIZE = 8192 + local_file = Upload_file.open_file( file_id ) + + while True: + data = local_file.read( CHUNK_SIZE ) + if len( data ) == 0: break + yield data + + return stream() + @expose( view = Upload_page ) @strongly_expire @end_transaction diff --git a/controller/test/Test_files.py b/controller/test/Test_files.py index c3d294b..782f47b 100644 --- a/controller/test/Test_files.py +++ b/controller/test/Test_files.py @@ -183,6 +183,24 @@ class Test_files( Test_controller ): def test_download_with_unicode_unquoted_filename( self ): self.test_download( self.unicode_filename, quote_filename = False ) + def test_download_image_with_preview_none( self ): + raise NotImplementedError() + + def test_download_image_with_preview_true( self ): + raise NotImplementedError() + + def test_download_image_with_preview_false( self ): + raise NotImplementedError() + + def test_download_non_image_with_preview_none( self ): + raise NotImplementedError() + + def test_download_non_image_with_preview_true( self ): + raise NotImplementedError() + + def test_download_non_image_with_preview_false( self ): + raise NotImplementedError() + def test_download_without_login( self ): self.login() @@ -238,6 +256,57 @@ class Test_files( Test_controller ): assert u"access" in result[ u"body" ][ 0 ] + def test_preview( self ): + raise NotImplementedError() + + def test_preview_with_unicode_filename( self ): + raise NotImplementedError() + + def test_preview_with_quote_filename_true( self ): + raise NotImplementedError() + + def test_preview_with_quote_filename_false( self ): + raise NotImplementedError() + + def test_preview_without_login( self ): + raise NotImplementedError() + + def test_preview_without_access( self ): + raise NotImplementedError() + + def test_preview_with_unknown_file_id( self ): + raise NotImplementedError() + + def test_thumbnail( self ): + raise NotImplementedError() + + def test_thumbnail_with_non_image( self ): + raise NotImplementedError() + + def test_thumbnail_without_login( self ): + raise NotImplementedError() + + def test_thumbnail_without_access( self ): + raise NotImplementedError() + + def test_thumbnail_with_unknown_file_id( self ): + raise NotImplementedError() + + def test_image( self ): + raise NotImplementedError() + + def test_image_with_non_image( self ): + raise NotImplementedError() + + def test_image_without_login( self ): + raise NotImplementedError() + + def test_image_without_access( self ): + raise NotImplementedError() + + def test_image_with_unknown_file_id( self ): + raise NotImplementedError() + def test_upload_page( self ): self.login() diff --git a/static/css/style.css b/static/css/style.css index cc77771..3469b6a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -643,3 +643,10 @@ img { width: 40em; height: 4em; } + +.file_thumbnail { + margin-right: 0.5em; + vertical-align: top; + float: left; + cursor: pointer; +} diff --git a/static/images/default_thumbnail.png b/static/images/default_thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..26971a5cdc1de901c5e6f89398429ef2f02f72f7 GIT binary patch literal 872 zcmV-u1DE`XP)GuEt0`*Bm zK~zY`wU*tE+e8${f8)e_Xctg^?|5M%MX%of+GxeyRMAqrN#AjhCF< zkN;JnPd?t|)-C_p04Mij-hb}{I-L&9X7e8lrD+OS(UZxa>2%2RoH&lldp4Ufold#9xL~oEGf(CuNy38%KjHg6hlhuB---D4yOhU| z7f)9J&}=q|4BM7t1AIeD(SEAs0hcIXyjf6G2qVbyWqFeiZTUV8D~}bAEpK3kG=Y^*1r%-DNVFAfnp=h$_R;5S%skvdmQ#6(x!e*xTDh zB&X4AvQAg*?!C-!fBgD<095GrBM=;8_te5NXBR@?2W0h>&h6XWc;!`o`0->5i%^EA zBiHX0YZPQ-%|0zy)az~^vWU{}N0&k^vv9YR*@y*J-40+jo1rRC;k}hEJ;%(r!0s yx0|%v4Qe$HV@##M7(*EPgrU#Po2~!sx8*N1gjzrP94ImX0000+xVCGRKx9BMN~H>s zl~#M{sfQ}brH?~funicnc^J}4y%ba_FU-?6*j~SZH7^sh+y9@PL7>t@PaSDI|9tb! zKjZK7ziYeW#!a)!Vl&$uZB`E2OSEHr9y-s%$MeulE#IbI#}08l^m6EB&@a*$c`pF} zRT%5+khalrqt$M0v^yQ21H{2PZ))i5>S?o@9qnCKXQ$QD<>+Ydyj0y_vs*gaI-KSP zGBi5v*IUg^mzzjCC1|EgmtgDlQ?{~o^S=kU)zx{k)#7Nko158L0QQ^)QmnDN-O&Y? zw|rs6Te~`JwoYr;(FJk$KOqA-xfS)GjsJ$@sSS(9w@On=^9tT9ZI_ep-@n_Ha9o&o|HZ;eF|qDNP6M72Q&N( zKfO1V{c%&@{#6241z6qM0%k6ky(^VT6_8O^kr&*Vnw-qc2Kw&YM`ljak(SS8GwIa) zqu&QT6x|30r+_&zo>byX!8wLPJ|LzPC6S3nRYW0iev+RoOpK5F{bOUJBkKf)#Dtkl zDv4NZecd1zBsBxX0Ac!gUnxS#YC46?I5MN*Fhf&>IWpq&4G#^i5_CsW2{E?57U3iO zYH5X$6L`8o^?H|%P{_F!jf7WMSC*F+72}I2u~9-C9Q1lU?gfSd5t&F_SO^hRlav}o z!(G~K3EXjU&1jRX^`TAjF`Hy!lYC@Tp4cR#O*t8C0-t84gF(Y38Bi5aRo&VEm$KP= za+zF#M3t~W`t2M<8=l%TH!RSbo;E>v|$P&lBb2MAFljs-#~a#%q|3_$Ey}85FiXQjgI?ZST4ZdblB`611uwlTD>1_+U=}^@ zfe?XnvVzPA_=HA<3{;cRDBPxfC4)abhEbx{i$;mWsJ-I;J`|Mr5f+rloC0OtP*CD0 zu=hZu1+@b||I80qP$Ix8z^aNKfLeFOG7+p2(JCwwsF6QMdwcI85m3U)W2xv)Lair3B8p}qC6&}Syvk6+QKD!#Y@p9X6)J@HZ;w%# zSPG*=j`XA{RK-t-D81Lcz|eRM{EURdMEDRv_e9lD9)#a_5A^rXGcpPf%;m+!(ER+< z1m1W@Ohida&p^Xy*Xb}+!k`J9z-i|oI;>Id&%ZF&2$`9e9*y@(HI_VmWLNA<_nTjp!6Jys{K muxI`M^{&Cc6ttPXrvbYyylYIOCo$6>IR9FBPpADm9RCKl$mp*C literal 0 HcmV?d00001 diff --git a/static/images/luminotes_title.jpg b/static/images/luminotes_title.jpg new file mode 100644 index 0000000000000000000000000000000000000000..270c4a7cd541710a1d994fef9f986c77ddc33515 GIT binary patch literal 4829 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