models.py 44 KB

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