models.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049
  1. import os
  2. import random
  3. import shutil
  4. import zipfile
  5. from datetime import datetime
  6. from inspect import isclass
  7. from django.db import models
  8. from django.db.models.signals import post_init
  9. from django.conf import settings
  10. from django.core.files.base import ContentFile
  11. from django.core.urlresolvers import reverse
  12. from django.template.defaultfilters import slugify
  13. from django.utils.functional import curry
  14. from django.utils.translation import ugettext_lazy as _
  15. from django.contrib.auth.models import User
  16. from django.contrib.sites.managers import CurrentSiteManager
  17. from django.contrib.sites.models import Site
  18. from django_extensions.db.models import TimeStampedModel
  19. from django_extensions.db.fields import AutoSlugField
  20. from darkroom.managers import *
  21. from directory.models import Town, Place
  22. from taggit.managers import TaggableManager
  23. from taggit.models import Tag
  24. # Required PIL classes may or may not be available from the root namespace
  25. # depending on the installation method used.
  26. try:
  27. import Image
  28. import ImageFile
  29. import ImageFilter
  30. import ImageEnhance
  31. except ImportError:
  32. try:
  33. from PIL import Image
  34. from PIL import ImageFile
  35. from PIL import ImageFilter
  36. from PIL import ImageEnhance
  37. except ImportError:
  38. raise ImportError(_('Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.'))
  39. from utils import EXIF
  40. from utils.image_entropy import image_entropy
  41. from utils.reflection import add_reflection
  42. from utils.watermark import apply_watermark
  43. # Path to sample image
  44. SAMPLE_IMAGE_PATH = getattr(settings, 'SAMPLE_IMAGE_PATH', os.path.join(os.path.dirname(__file__), 'res', 'sample.jpg')) # os.path.join(settings.PROJECT_PATH, 'darkroom', 'res', 'sample.jpg'
  45. # Modify image file buffer size.
  46. ImageFile.MAXBLOCK = getattr(settings, 'DARKROOM_MAXBLOCK', 256 * 2 ** 10)
  47. # Photologue image path relative to media root
  48. DARKROOM_DIR = getattr(settings, 'DARKROOM_DIR', 'darkroom/')
  49. # Look for user function to define file paths
  50. DARKROOM_PATH = getattr(settings, 'DARKROOM_PATH', None)
  51. if DARKROOM_PATH is not None:
  52. if callable(DARKROOM_PATH):
  53. get_storage_path = DARKROOM_PATH
  54. else:
  55. parts = DARKROOM_PATH.split('.')
  56. module_name = '.'.join(parts[:-1])
  57. module = __import__(module_name)
  58. get_storage_path = getattr(module, parts[-1])
  59. else:
  60. def get_storage_path(instance, filename):
  61. return os.path.join(DARKROOM_DIR, 'photos', filename)
  62. # Quality options for JPEG images
  63. JPEG_QUALITY_CHOICES = (
  64. (30, _('Very Low')),
  65. (40, _('Low')),
  66. (50, _('Medium-Low')),
  67. (60, _('Medium')),
  68. (70, _('Medium-High')),
  69. (80, _('High')),
  70. (90, _('Very High')),
  71. (100, _('Unchanged')),
  72. )
  73. # choices for new crop_anchor field in Photo
  74. CROP_ANCHOR_CHOICES = (
  75. ('top', _('Top')),
  76. ('right', _('Right')),
  77. ('bottom', _('Bottom')),
  78. ('left', _('Left')),
  79. ('center', _('Center')),
  80. ('smart', _('Smart (Default)')),
  81. )
  82. IMAGE_TRANSPOSE_CHOICES = (
  83. ('FLIP_LEFT_RIGHT', _('Flip left to right')),
  84. ('FLIP_TOP_BOTTOM', _('Flip top to bottom')),
  85. ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')),
  86. ('ROTATE_270', _('Rotate 90 degrees clockwise')),
  87. ('ROTATE_180', _('Rotate 180 degrees')),
  88. )
  89. WATERMARK_STYLE_CHOICES = (
  90. ('tile', _('Tile')),
  91. ('scale', _('Scale')),
  92. )
  93. IMAGE_ORIENTATION_CHOICES = (
  94. (0,_('no rotation')),
  95. (2,_('Rotate 90 degrees counter-clockwise')),
  96. (3,_('Rotate 180 degrees')),
  97. (4,_('Rotate 90 degrees clockwise')),
  98. )
  99. # see http://www.impulseadventure.com/photo/exif-orientation.html
  100. IMAGE_EXIF_ORIENTATION_MAP = {
  101. 1: 0,
  102. 8: 2,
  103. 3: 3,
  104. 6: 4,
  105. }
  106. # Slideshow size choices, as dictated by the Soundslides application
  107. # default ouputs.
  108. SLIDESHOW_SIZE_CHOICES = (
  109. ('425x356', _('Small')),
  110. ('620x533', _('Standard')),
  111. ('800x596', _('Large')),
  112. )
  113. def _compare_entropy(start_slice, end_slice, slice, difference):
  114. """
  115. Calculate the entropy of two slices (from the start and end of an axis),
  116. returning a tuple containing the amount that should be added to the start
  117. and removed from the end of the axis.
  118. """
  119. start_entropy = image_entropy(start_slice)
  120. end_entropy = image_entropy(end_slice)
  121. if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01:
  122. # Less than 1% difference, remove from both sides.
  123. if difference >= slice * 2:
  124. return slice, slice
  125. half_slice = slice // 2
  126. return half_slice, slice - half_slice
  127. if start_entropy > end_entropy:
  128. return 0, slice
  129. else:
  130. return slice, 0
  131. # Prepare a list of image filters
  132. filter_names = []
  133. for n in dir(ImageFilter):
  134. klass = getattr(ImageFilter, n)
  135. if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \
  136. hasattr(klass, 'name'):
  137. filter_names.append(klass.__name__)
  138. IMAGE_FILTERS_HELP_TEXT = _('Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE". Image filters will be applied in order. The following filters are available: %s.' % (', '.join(filter_names)))
  139. # Next five functions streamline expanding zip files to folders
  140. def path_name(name):
  141. return os.path.split(name)[0]
  142. def file_name(name):
  143. return os.path.split(name)[1]
  144. def path_names(names):
  145. return (name for name in names if path_name(name) and not file_name(name))
  146. def file_names(names):
  147. return (name for name in names if file_name(name))
  148. def extract(zipfile):
  149. SLIDE_PATH=settings.MEDIA_ROOT+DARKROOM_DIR+"slideshows/"
  150. names = zipfile.namelist()
  151. for name in path_names(names):
  152. if not os.path.exists(SLIDE_PATH+name):
  153. if not name.find('__MACOSX'): # do not process meta or hidden files
  154. continue
  155. else:
  156. os.mkdir(SLIDE_PATH+name)
  157. for name in file_names(names):
  158. if name.startswith('__MACOSX'): # do not process meta or hidden files
  159. continue
  160. else:
  161. outfile = file(SLIDE_PATH+name, 'wb')
  162. outfile.write(zipfile.read(name))
  163. outfile.close()
  164. return names[0].split('/')[0] # Return the root folder for the model's sake
  165. # End zip file to folder functions
  166. class MediaMetadata(TimeStampedModel):
  167. title = models.CharField(_('title'), max_length=100)
  168. slug = AutoSlugField(_('slug'), populate_from='title', editable=True)
  169. description=models.TextField(_('description'), blank=True, null=True)
  170. published=models.BooleanField(_('published'), default=False, help_text=_('Published items will appear on all parts of the site.'))
  171. published_on = models.DateTimeField(_('date published'), blank=True)
  172. weight = models.IntegerField(_('Weight'), blank=True, null=True, max_length=2)
  173. towns = models.ManyToManyField(Town, blank=True, null=True)
  174. tags = TaggableManager(blank=True)
  175. auto_tag=models.BooleanField(_('auto-tag'), default=True)
  176. class Meta:
  177. unique_together=(("title", "slug"),)
  178. abstract=True
  179. def do_auto_tag(self):
  180. """
  181. Performs the auto-tagging work if necessary.
  182. Returns True if an additional save is required, False otherwise.
  183. """
  184. found = False
  185. if self.auto_tag:
  186. import re
  187. # don't clobber any existing tags!
  188. try:
  189. existing_ids = [t.id for t in self.tags.all()]
  190. unused = Tag.objects.all()
  191. unused = unused.exclude(id__in=existing_ids)
  192. except:
  193. unused = Tag.objects.all()
  194. for tag in unused:
  195. regex = re.compile(r'\b%s\b' % tag.name, re.I)
  196. if regex.search(self.description) or regex.search(self.title):
  197. self.tags.add(tag)
  198. found = True
  199. return found
  200. def save(self, *args, **kwargs):
  201. super(TimeStampedModel, self).save(*args, **kwargs)
  202. requires_save = self.do_auto_tag()
  203. if requires_save:
  204. super(TimeStampedModel, self).save()
  205. class Photographer(TimeStampedModel):
  206. name = models.CharField(_('name'), max_length=100)
  207. title = models.CharField(_('Title'), blank=True, null=True, max_length=100)
  208. slug = AutoSlugField(_('slug'), populate_from='name', editable=True)
  209. user = models.ForeignKey(User, blank=True, null=True)
  210. date_added = models.DateTimeField(_('date added'), default=datetime.now(), editable=False)
  211. class Meta:
  212. ordering = ('name',)
  213. get_latest_by = 'date_added'
  214. def __unicode__(self):
  215. return self.name
  216. def __str__(self):
  217. return self.__unicode__()
  218. def get_absolute_url(self):
  219. return reverse('dr-photographer', args=[self.slug])
  220. class Slideshow(MediaMetadata):
  221. preview=models.ImageField(_('preview'), upload_to=get_storage_path, help_text="Slideshow previews are a standard 410 pixels wide by 276 pixels high")
  222. folder=models.CharField(_('folder'), max_length=200, null=True, blank=True)
  223. size = models.CharField(_('size'), max_length=9, default='620x533', choices=SLIDESHOW_SIZE_CHOICES)
  224. class Meta:
  225. ordering =['created']
  226. get_latest_by='created'
  227. verbose_name=_('slideshow')
  228. verbose_name_plural=_('slideshows')
  229. def __unicode__(self):
  230. return self.title
  231. def __str__(self):
  232. return self.__unicode__()
  233. def get_absolute_url(self):
  234. return reverse('dr-slideshow-detail', args=[self.slug])
  235. def path(self):
  236. return
  237. def delete(self):
  238. try:
  239. os.remove(self.folder)
  240. except:
  241. pass
  242. super(Slideshow, self).delete()
  243. class SlideshowUpload(models.Model):
  244. zip_file = models.FileField(_('slideshow file (.zip)'),
  245. upload_to=DARKROOM_DIR+"/slideshows",
  246. help_text=_('Select a .zip file with files to create a new Slideshow.'))
  247. slideshow=models.ForeignKey(Slideshow, null=True, blank=True)
  248. class Meta:
  249. verbose_name = _('slideshow upload')
  250. verbose_name_plural = _('slideshow uploads')
  251. def save(self, *args, **kwargs):
  252. super(SlideshowUpload, self).save(*args, **kwargs)
  253. self.process_zipfile()
  254. super(SlideshowUpload, self).delete()
  255. def process_zipfile(self):
  256. if os.path.isfile(self.zip_file.path):
  257. # TODO: implement try-except here
  258. zip = zipfile.ZipFile(self.zip_file.path)
  259. bad_file = zip.testzip()
  260. if bad_file:
  261. raise Exception('"%s" in the .zip archive is corrupt.' % bad_file)
  262. folder=extract(zip)
  263. slideshow = self.slideshow
  264. slideshow.folder = extract(zip) # Extract the zip contents,
  265. # save the folder name to the model
  266. slideshow.save()
  267. zip.close()
  268. class Webcam(models.Model):
  269. title = models.CharField(_('title'), max_length=80)
  270. slug = AutoSlugField(_('slug'), populate_from='title', editable=True)
  271. town = models.ForeignKey(Town, blank=True, null=True)
  272. image_file = models.CharField(_('webcam image location'), max_length=255)
  273. thumb_file = models.CharField(_('webcam thumbnail location'), max_length=255, blank=True, null=True)
  274. image_width = models.IntegerField(_('image width'), max_length=5, blank=True, null=True)
  275. image_height = models.IntegerField(_('image height'), max_length=5, blank=True, null=True)
  276. alt_text=models.CharField(_('alt text'), max_length=255, blank=True, null=True)
  277. description = models.TextField(_('description'), blank=True, null=True)
  278. ordering =models.IntegerField(_('order'), blank=True, null=True, max_length=3)
  279. class Meta:
  280. verbose_name=_('webcam')
  281. verbose_name_plural=_('webcams')
  282. ordering=['ordering']
  283. def __unicode__(self):
  284. return u'%s webcam' % self.title
  285. def get_absolute_url(self):
  286. return reverse('dr-webcam-detail', args=[self.slug])
  287. class Gallery(MediaMetadata):
  288. preview = models.ImageField(_('graphic'), upload_to=DARKROOM_DIR+"/gallery-graphics", help_text="Gallery previews are a displayed at the top of the page if they exist.", null=True, blank=True)
  289. photos = models.ManyToManyField('Photo', related_name='gallery_photos', verbose_name=_('photos'), null=True, blank=True)
  290. slideshow=models.ForeignKey(Slideshow, null=True, blank=True)
  291. photographer=models.ForeignKey(Photographer, null=True, blank=True)
  292. sites=models.ManyToManyField(Site)
  293. objects = MediaManager()
  294. on_site = CurrentSiteManager('sites')
  295. class Meta:
  296. ordering = ['-created']
  297. get_latest_by = 'created'
  298. verbose_name = _('gallery')
  299. verbose_name_plural = _('galleries')
  300. def __unicode__(self):
  301. return self.title
  302. @models.permalink
  303. def get_absolute_url(self, site=''):
  304. return ('dr-gallery-detail', (), {
  305. 'year': self.published_on.year,
  306. 'month': self.published_on.strftime('%b').lower(),
  307. 'day': self.published_on.day,
  308. 'slug': self.slug})
  309. def last_photo(self):
  310. return self.photos.latest()
  311. def latest(self, limit=0, published=True):
  312. if limit == 0:
  313. limit = self.photo_count()
  314. if published:
  315. return self.published()[:limit]
  316. else:
  317. return self.photos.all()[:limit]
  318. def sample(self, count=0, published=True):
  319. if count == 0 or count > self.photo_count():
  320. count = self.photo_count()
  321. if published:
  322. photo_set = self.published()
  323. else:
  324. photo_set = self.photos.all()
  325. return random.sample(photo_set, count)
  326. def photo_count(self, published=True):
  327. if published:
  328. return self.published_photos().count()
  329. else:
  330. return self.photos.all().count()
  331. photo_count.short_description = _('count')
  332. def published_photos(self):
  333. return self.photos.filter(published=True).order_by('created')
  334. def save(self, *args, **kwargs):
  335. super(Gallery, self).save(*args, **kwargs)
  336. if self.photos:
  337. if self.published:
  338. for p in self.photos.all():
  339. p.published=True
  340. p.save()
  341. else:
  342. for p in self.photos.all():
  343. p.published=False
  344. p.save()
  345. class GalleryUpload(models.Model):
  346. zip_file = models.FileField(_('images file (.zip)'), upload_to=DARKROOM_DIR+"/temp",
  347. help_text=_('Select a .zip file of images to upload into a new Gallery.'))
  348. gallery = models.ForeignKey(Gallery, null=True, blank=True, help_text=_('Select a gallery to add these images to. leave this empty to create a new gallery from the supplied title.'))
  349. title = models.CharField(_('title'), blank=True, null=True, help_text=_('Used to create a gallery if none is specified.'), max_length=140)
  350. description = models.TextField(_('description'), blank=True, help_text=_('Descrpition will be added to all photos.'))
  351. photographer=models.ForeignKey(Photographer, null=True, blank=True)
  352. class Meta:
  353. verbose_name = _('gallery upload')
  354. verbose_name_plural = _('gallery uploads')
  355. def save(self):
  356. super(GalleryUpload, self).save(*args, **kwargs)
  357. self.process_zipfile()
  358. super(GalleryUpload, self).delete()
  359. def process_zipfile(self):
  360. if os.path.isfile(self.zip_file.path):
  361. # TODO: implement try-except here
  362. zip = zipfile.ZipFile(self.zip_file.path)
  363. bad_file = zip.testzip()
  364. if bad_file:
  365. raise Exception('"%s" in the .zip archive is corrupt.' % bad_file)
  366. count = 1
  367. if self.gallery:
  368. gallery = self.gallery
  369. else:
  370. gallery = Gallery.objects.create(title=self.title,
  371. slug=slugify(self.title),
  372. description=self.description,
  373. photographer=self.photographer)
  374. from cStringIO import StringIO
  375. for filename in zip.namelist():
  376. if filename.startswith('__'): # do not process meta files
  377. continue
  378. data = zip.read(filename)
  379. if len(data):
  380. try:
  381. # the following is taken from django.newforms.fields.ImageField:
  382. # load() is the only method that can spot a truncated JPEG,
  383. # but it cannot be called sanely after verify()
  384. trial_image = Image.open(StringIO(data))
  385. trial_image.load()
  386. # verify() is the only method that can spot a corrupt PNG,
  387. # but it must be called immediately after the constructor
  388. trial_image = Image.open(StringIO(data))
  389. trial_image.verify()
  390. except Exception:
  391. # if a "bad" file is found we just skip it.
  392. continue
  393. while 1:
  394. title = ' '.join([gallery.title, str(count)])
  395. slug = slugify(title)
  396. try:
  397. p = Photo.objects.get(slug=slug)
  398. except Photo.DoesNotExist:
  399. photo = Photo(title=title, slug=slug,
  400. published_on=gallery.published_on,
  401. description=self.description,
  402. published=gallery.published,
  403. photographer=self.photographer)
  404. photo.image.save(filename, ContentFile(data))
  405. # If EXIF data exists for comment and headline, set appropriate image info
  406. #if photo.EXIF['Image ImageDescription']:
  407. # photo.description=unicode(photo.EXIF['Image ImageDescription'])
  408. #else:
  409. # pass
  410. #if photo.EXIF['Image DocumentName']:
  411. # photo.title=photo.EXIFunicode(['Image DocumentName'])
  412. # photo.slug=slugify(photo.EXIF['Image DocumentName'])
  413. #else:
  414. # pass
  415. photo.save()
  416. gallery.photos.add(photo)
  417. count = count + 1
  418. break
  419. count = count + 1
  420. zip.close()
  421. class ImageModel(MediaMetadata):
  422. view_count = models.PositiveIntegerField(default=0, editable=False)
  423. crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES)
  424. effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect'))
  425. orientation = models.IntegerField(_('Orientation'), choices=IMAGE_ORIENTATION_CHOICES, null=True, blank=True)
  426. class Meta:
  427. abstract = True
  428. @property
  429. def EXIF(self):
  430. try:
  431. return EXIF.process_file(open(self.image.path, 'rb'))
  432. except:
  433. try:
  434. return EXIF.process_file(open(self.image.path, 'rb'), details=False)
  435. except:
  436. return {}
  437. def admin_thumbnail(self):
  438. func = getattr(self, 'get_admin_thumbnail_url', None)
  439. if func is None:
  440. return _('An "admin_thumbnail" photo size has not been defined.')
  441. else:
  442. if hasattr(self, 'get_absolute_url'):
  443. return u'<a href="%s"><img src="%s"></a>' % \
  444. (self.get_absolute_url(), func())
  445. else:
  446. return u'<a href="%s"><img src="%s"></a>' % \
  447. (self.image.url, func())
  448. admin_thumbnail.short_description = _('Thumbnail')
  449. admin_thumbnail.allow_tags = True
  450. def cache_path(self):
  451. return os.path.join(os.path.dirname(self.image.path), "cache")
  452. def cache_url(self):
  453. return '/'.join([os.path.dirname(self.image.url), "cache"])
  454. def image_filename(self):
  455. return os.path.basename(self.image.path)
  456. def _get_filename_for_size(self, size):
  457. size = getattr(size, 'name', size)
  458. base, ext = os.path.splitext(self.image_filename())
  459. return ''.join([base, '_', size, ext])
  460. def _get_SIZE_photosize(self, size):
  461. return PhotoSizeCache().sizes.get(size)
  462. def _get_SIZE_size(self, size):
  463. photosize = PhotoSizeCache().sizes.get(size)
  464. if not self.size_exists(photosize):
  465. self.create_size(photosize)
  466. return Image.open(self._get_SIZE_filename(size)).size
  467. def _get_SIZE_url(self, size):
  468. photosize = PhotoSizeCache().sizes.get(size)
  469. if not self.size_exists(photosize):
  470. self.create_size(photosize)
  471. if photosize.increment_count:
  472. self.view_count += 1
  473. self.save(update=True)
  474. return '/'.join([self.cache_url(), self._get_filename_for_size(photosize.name)])
  475. def _get_SIZE_filename(self, size):
  476. photosize = PhotoSizeCache().sizes.get(size)
  477. return os.path.join(self.cache_path(),
  478. self._get_filename_for_size(photosize.name))
  479. def add_accessor_methods(self, *args, **kwargs):
  480. for size in PhotoSizeCache().sizes.keys():
  481. setattr(self, 'get_%s_size' % size,
  482. curry(self._get_SIZE_size, size=size))
  483. setattr(self, 'get_%s_photosize' % size,
  484. curry(self._get_SIZE_photosize, size=size))
  485. setattr(self, 'get_%s_url' % size,
  486. curry(self._get_SIZE_url, size=size))
  487. setattr(self, 'get_%s_filename' % size,
  488. curry(self._get_SIZE_filename, size=size))
  489. def size_exists(self, photosize):
  490. func = getattr(self, "get_%s_filename" % photosize.name, None)
  491. if func is not None:
  492. if os.path.isfile(func()):
  493. return True
  494. return False
  495. def resize_image(self, im, photosize):
  496. cur_width, cur_height = im.size
  497. new_width, new_height = photosize.size
  498. if photosize.crop:
  499. ratio = max(float(new_width)/cur_width,float(new_height)/cur_height)
  500. x = (cur_width * ratio)
  501. y = (cur_height * ratio)
  502. xd = abs(new_width - x)
  503. yd = abs(new_height - y)
  504. x_diff = int(xd / 2)
  505. y_diff = int(yd / 2)
  506. if self.crop_from == 'top':
  507. box = (int(x_diff), 0, int(x_diff+new_width), new_height)
  508. elif self.crop_from == 'left':
  509. box = (0, int(y_diff), new_width, int(y_diff+new_height))
  510. elif self.crop_from == 'bottom':
  511. box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height
  512. elif self.crop_from == 'right':
  513. box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width
  514. elif self.crop_from == 'smart':
  515. left = top = 0
  516. right, bottom = x, y
  517. while xd:
  518. slice = min(xd, max(xd // 5, 10))
  519. start = im.crop((left, 0, left + slice, y))
  520. end = im.crop((right - slice, 0, right, y))
  521. add, remove = _compare_entropy(start, end, slice, xd)
  522. left += add
  523. right -= remove
  524. xd = xd - add - remove
  525. while yd:
  526. slice = min(yd, max(yd // 5, 10))
  527. start = im.crop((0, top, x, top + slice))
  528. end = im.crop((0, bottom - slice, x, bottom))
  529. add, remove = _compare_entropy(start, end, slice, yd)
  530. top += add
  531. bottom -= remove
  532. yd = yd - add - remove
  533. box = (left, top, right, bottom)
  534. else:
  535. box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height))
  536. im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box)
  537. else:
  538. if not new_width == 0 and not new_height == 0:
  539. ratio = min(float(new_width)/cur_width,
  540. float(new_height)/cur_height)
  541. else:
  542. if new_width == 0:
  543. ratio = float(new_height)/cur_height
  544. else:
  545. ratio = float(new_width)/cur_width
  546. new_dimensions = (int(round(cur_width*ratio)),
  547. int(round(cur_height*ratio)))
  548. if new_dimensions[0] > cur_width or \
  549. new_dimensions[1] > cur_height:
  550. if not photosize.upscale:
  551. return im
  552. im = im.resize(new_dimensions, Image.ANTIALIAS)
  553. return im
  554. def create_size(self, photosize):
  555. if self.size_exists(photosize):
  556. return
  557. if not os.path.isdir(self.cache_path()):
  558. os.makedirs(self.cache_path())
  559. try:
  560. im = Image.open(self.image.path)
  561. except IOError:
  562. return
  563. # Apply effect if found
  564. if self.effect is not None:
  565. im = self.effect.pre_process(im)
  566. elif photosize.effect is not None:
  567. im = photosize.effect.pre_process(im)
  568. # Resize/crop image
  569. if im.size != photosize.size:
  570. im = self.resize_image(im, photosize)
  571. # Apply watermark if found
  572. if photosize.watermark is not None:
  573. im = photosize.watermark.post_process(im)
  574. # Apply effect if found
  575. if self.effect is not None:
  576. im = self.effect.post_process(im)
  577. elif photosize.effect is not None:
  578. im = photosize.effect.post_process(im)
  579. if self.orientation is not None and self.orientation > 0:
  580. im = im.transpose(self.orientation)
  581. # Save file
  582. im_filename = getattr(self, "get_%s_filename" % photosize.name)()
  583. try:
  584. if im.format == 'JPEG':
  585. im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True)
  586. else:
  587. im.save(im_filename)
  588. except IOError, e:
  589. if os.path.isfile(im_filename):
  590. os.unlink(im_filename)
  591. raise e
  592. def remove_size(self, photosize, remove_dirs=True):
  593. if not self.size_exists(photosize):
  594. return
  595. filename = getattr(self, "get_%s_filename" % photosize.name)()
  596. if os.path.isfile(filename):
  597. os.remove(filename)
  598. if remove_dirs:
  599. self.remove_cache_dirs()
  600. def clear_cache(self):
  601. cache = PhotoSizeCache()
  602. for photosize in cache.sizes.values():
  603. self.remove_size(photosize, False)
  604. self.remove_cache_dirs()
  605. def pre_cache(self):
  606. cache = PhotoSizeCache()
  607. for photosize in cache.sizes.values():
  608. if photosize.pre_cache:
  609. self.create_size(photosize)
  610. def remove_cache_dirs(self):
  611. try:
  612. os.removedirs(self.cache_path())
  613. except:
  614. pass
  615. def save(self, update=False, *args, **kwargs):
  616. if update:
  617. models.Model.save(self)
  618. return
  619. exif_title=self.EXIF.get('Image DocumentName', None)
  620. exif_description=self.EXIF.get('Image UserComment', None)
  621. if exif_title is not None:
  622. self.title=exif_title
  623. self.slug=slugify(exif_title)
  624. if exif_description is not None:
  625. self.description=exif_description
  626. if self.date_taken:
  627. if self.date_taken is None:
  628. try:
  629. exif_date = self.EXIF.get('EXIF DateTimeOriginal', None)
  630. if exif_date is not None:
  631. d, t = str.split(exif_date.values)
  632. year, month, day = d.split(':')
  633. hour, minute, second = t.split(':')
  634. self.date_taken = datetime(int(year), int(month), int(day),
  635. int(hour), int(minute), int(second))
  636. except:
  637. self.date_taken = datetime.now()
  638. if self.orientation is None:
  639. try:
  640. exif_orientation = self.EXIF.get('Image Orientation', 1).values[0]
  641. self.orientation = IMAGE_EXIF_ORIENTATION_MAP[exif_orientation]
  642. except:
  643. self.orientation = 0
  644. if self._get_pk_val():
  645. self.clear_cache()
  646. super(ImageModel, self).save(*args, **kwargs)
  647. self.pre_cache()
  648. def delete(self):
  649. self.clear_cache()
  650. super(ImageModel, self).delete()
  651. class Movie(MediaMetadata):
  652. flv_video = models.FileField(_('video file (.flv)'), upload_to=DARKROOM_DIR+"/movies")
  653. original_video = models.FileField(_('original file'), upload_to=DARKROOM_DIR+"/movies", blank=True, null=True, help_text=_('The original video preferably in a standard format like H.264.'))
  654. flash_skin = models.CharField(_('flash player skin'), blank=False, null=True, max_length=100, help_text=_("Do not include the .swf extension with the skin name."))
  655. photographer = models.ForeignKey(Photographer, blank=True, null=True)
  656. preview=models.ImageField(_('preview'), upload_to=get_storage_path, help_text="Movie previews are a standard 70 pixels wide by 50 pixels high")
  657. objects = MediaManager()
  658. class Meta:
  659. ordering=['-created']
  660. get_latest_by='created'
  661. verbose_name=_('movie')
  662. verbose_name_plural=_('movies')
  663. def __unicode__(self):
  664. return self.title
  665. def __str__(self):
  666. return self.__unicode__()
  667. def get_absolute_url(self):
  668. return reverse('dr-movie-detail', args=[self.slug])
  669. class Photo(ImageModel):
  670. image = models.ImageField(_('Image'), upload_to=get_storage_path)
  671. cropped_image = models.ImageField(_('Cropped image'), blank=True, null=True, upload_to='darkroom/cropped/')
  672. date_taken = models.DateTimeField(_('Date taken'), null=True, blank=True, editable=False)
  673. photographer = models.ForeignKey(Photographer, blank=True, null=True)
  674. courtesy=models.CharField(_('Courtesy'), max_length=140, blank=True, null=True)
  675. file_photo=models.BooleanField(_('File photo?'), default=False)
  676. sites=models.ManyToManyField(Site)
  677. objects = MediaManager()
  678. on_site = CurrentSiteManager('sites')
  679. class Meta:
  680. ordering = ['-created']
  681. get_latest_by = 'published_on'
  682. verbose_name = _("photo")
  683. verbose_name_plural = _("photos")
  684. def __unicode__(self):
  685. return self.title
  686. def save(self, update=False):
  687. if self.slug is None:
  688. self.slug = slugify(self.title)
  689. super(Photo, self).save(update)
  690. @models.permalink
  691. def get_absolute_url(self, site=''):
  692. return ('dr-photo-detail', (), {
  693. 'year': self.published_on.year,
  694. 'month': self.published_on.strftime('%b').lower(),
  695. 'day': self.published_on.day,
  696. 'slug': self.slug})
  697. def get_lb_url(self):
  698. return reverse('dr-photo-lightbox', args=[self.slug])
  699. def published_galleries(self):
  700. """Return the published galleries to which this photo belongs."""
  701. return self.galleries.filter(published=True)
  702. def get_previous_in_gallery(self, gallery):
  703. try:
  704. return self.get_previous_by_published_on(galleries__exact=gallery)
  705. except Photo.DoesNotExist:
  706. return None
  707. def get_next_in_gallery(self, gallery):
  708. try:
  709. return self.get_next_by_published_on(galleries__exact=gallery)
  710. except Photo.DoesNotExist:
  711. return None
  712. class Graphic(ImageModel):
  713. image = models.ImageField(_('image'), upload_to=DARKROOM_DIR+"/graphics")
  714. date_taken = models.DateTimeField(_('date created'), null=True, blank=True, editable=False)
  715. data_source=models.CharField(_('data source'), blank=True, null=True, max_length=150)
  716. source=models.ForeignKey(Place, blank=True, null=True)
  717. class Meta:
  718. ordering=['-created']
  719. get_latest_by='created'
  720. verbose_name=_("graphic")
  721. verbose_name_plural=_("graphics")
  722. def __unicode__(self):
  723. return self.title
  724. def __str__(self):
  725. return self.__unicode__()
  726. @models.permalink
  727. def get_absolute_url(self, site=''):
  728. return ('dr-graphic-detail', (), {
  729. 'year': self.published_on.year,
  730. 'month': self.published_on.strftime('%b').lower(),
  731. 'day': self.published_on.day,
  732. 'slug': self.slug})
  733. class BaseEffect(models.Model):
  734. name = models.CharField(_('name'), max_length=30, unique=True)
  735. description = models.TextField(_('description'), blank=True)
  736. class Meta:
  737. abstract = True
  738. def sample_dir(self):
  739. return os.path.join(settings.MEDIA_ROOT, DARKROOM_DIR, 'samples')
  740. def sample_url(self):
  741. return settings.MEDIA_URL + '/'.join([DARKROOM_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')])
  742. def sample_filename(self):
  743. return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample'))
  744. def create_sample(self):
  745. if not os.path.isdir(self.sample_dir()):
  746. os.makedirs(self.sample_dir())
  747. try:
  748. im = Image.open(SAMPLE_IMAGE_PATH)
  749. except IOError:
  750. raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH)
  751. im = self.process(im)
  752. im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True)
  753. def admin_sample(self):
  754. return u'<img src="%s">' % self.sample_url()
  755. admin_sample.short_description = 'Sample'
  756. admin_sample.allow_tags = True
  757. def pre_process(self, im):
  758. return im
  759. def post_process(self, im):
  760. return im
  761. def process(self, im):
  762. im = self.pre_process(im)
  763. im = self.post_process(im)
  764. return im
  765. def __unicode__(self):
  766. return self.name
  767. def __str__(self):
  768. return self.__unicode__()
  769. def save(self):
  770. try:
  771. os.remove(self.sample_filename())
  772. except:
  773. pass
  774. models.Model.save(self)
  775. self.create_sample()
  776. for size in self.photo_sizes.all():
  777. size.clear_cache()
  778. # try to clear all related subclasses of ImageModel
  779. for prop in [prop for prop in dir(self) if prop[-8:] == '_related']:
  780. for obj in getattr(self, prop).all():
  781. obj.clear_cache()
  782. obj.pre_cache()
  783. def delete(self):
  784. try:
  785. os.remove(self.sample_filename())
  786. except:
  787. pass
  788. super(PhotoEffect, self).delete()
  789. class PhotoEffect(BaseEffect):
  790. """ A pre-defined effect to apply to photos """
  791. transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES)
  792. color = models.FloatField(_('color'), default=1.0, help_text=_("A factor of 0.0 gives a black and white image, a factor of 1.0 gives the original image."))
  793. brightness = models.FloatField(_('brightness'), default=1.0, help_text=_("A factor of 0.0 gives a black image, a factor of 1.0 gives the original image."))
  794. contrast = models.FloatField(_('contrast'), default=1.0, help_text=_("A factor of 0.0 gives a solid grey image, a factor of 1.0 gives the original image."))
  795. sharpness = models.FloatField(_('sharpness'), default=1.0, help_text=_("A factor of 0.0 gives a blurred image, a factor of 1.0 gives the original image."))
  796. filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT))
  797. reflection_size = models.FloatField(_('size'), default=0, help_text=_("The height of the reflection as a percentage of the orignal image. A factor of 0.0 adds no reflection, a factor of 1.0 adds a reflection equal to the height of the orignal image."))
  798. reflection_strength = models.FloatField(_('strength'), default=0.6, help_text="The initial opacity of the reflection gradient.")
  799. background_color = models.CharField(_('color'), max_length=7, default="#FFFFFF", help_text="The background color of the reflection gradient. Set this to match the background color of your page.")
  800. class Meta:
  801. verbose_name = _("photo effect")
  802. verbose_name_plural = _("photo effects")
  803. def pre_process(self, im):
  804. if self.transpose_method != '':
  805. method = getattr(Image, self.transpose_method)
  806. im = im.transpose(method)
  807. if im.mode != 'RGB' and im.mode != 'RGBA':
  808. return im
  809. for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
  810. factor = getattr(self, name.lower())
  811. if factor != 1.0:
  812. im = getattr(ImageEnhance, name)(im).enhance(factor)
  813. for name in self.filters.split('->'):
  814. image_filter = getattr(ImageFilter, name.upper(), None)
  815. if image_filter is not None:
  816. try:
  817. im = im.filter(image_filter)
  818. except ValueError:
  819. pass
  820. return im
  821. def post_process(self, im):
  822. if self.reflection_size != 0.0:
  823. im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength)
  824. return im
  825. class Watermark(BaseEffect):
  826. image = models.ImageField(_('image'), upload_to=DARKROOM_DIR+"/watermarks")
  827. style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale')
  828. opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay."))
  829. class Meta:
  830. verbose_name = _('watermark')
  831. verbose_name_plural = _('watermarks')
  832. def post_process(self, im):
  833. mark = Image.open(self.image.path)
  834. return apply_watermark(im, mark, self.style, self.opacity)
  835. class PhotoSize(models.Model):
  836. name = models.CharField(_('name'), max_length=20, unique=True, help_text=_('Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".'))
  837. width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.'))
  838. height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width'))
  839. quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.'))
  840. upscale = models.BooleanField(_('upscale images?'), default=False, help_text=_('If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting.'))
  841. crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.'))
  842. pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.'))
  843. increment_count = models.BooleanField(_('increment view count?'), default=False, help_text=_('If selected the image\'s "view_count" will be incremented when this photo size is displayed.'))
  844. effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect'))
  845. watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image'))
  846. class Meta:
  847. ordering = ['width', 'height']
  848. verbose_name = _('photo size')
  849. verbose_name_plural = _('photo sizes')
  850. def __unicode__(self):
  851. return self.name
  852. def __str__(self):
  853. return self.__unicode__()
  854. def clear_cache(self):
  855. for cls in ImageModel.__subclasses__():
  856. for obj in cls.objects.all():
  857. obj.remove_size(self)
  858. if self.pre_cache:
  859. obj.create_size(self)
  860. PhotoSizeCache().reset()
  861. def save(self, *args, **kwargs):
  862. if self.width + self.height <= 0:
  863. raise ValueError(_('A PhotoSize must have a positive height or width.'))
  864. super(PhotoSize, self).save(*args, **kwargs)
  865. PhotoSizeCache().reset()
  866. self.clear_cache()
  867. def delete(self):
  868. self.clear_cache()
  869. super(PhotoSize, self).delete()
  870. def _get_size(self):
  871. return (self.width, self.height)
  872. def _set_size(self, value):
  873. self.width, self.height = value
  874. size = property(_get_size, _set_size)
  875. class PhotoSizeCache(object):
  876. __state = {"sizes": {}}
  877. def __init__(self):
  878. self.__dict__ = self.__state
  879. if not len(self.sizes):
  880. sizes = PhotoSize.objects.all()
  881. for size in sizes:
  882. self.sizes[size.name] = size
  883. def reset(self):
  884. self.sizes = {}
  885. # Set up the accessor methods
  886. def add_methods(sender, instance, signal, *args, **kwargs):
  887. """ Adds methods to access sized images (urls, paths)
  888. after the Photo model's __init__ function completes,
  889. this method calls "add_accessor_methods" on each instance.
  890. """
  891. if hasattr(instance, 'add_accessor_methods'):
  892. instance.add_accessor_methods()
  893. # connect the add_accessor_methods function to the post_init signal
  894. post_init.connect(add_methods)