bgneal@1
|
1 import os
|
bgneal@1
|
2 import random
|
bgneal@1
|
3 import zipfile
|
bgneal@1
|
4
|
bgneal@1
|
5 from datetime import datetime
|
bgneal@1
|
6 from inspect import isclass
|
bgneal@1
|
7
|
bgneal@1
|
8 from django.db import models
|
bgneal@1
|
9 from django.db.models.signals import post_init
|
bgneal@1
|
10 from django.conf import settings
|
bgneal@1
|
11 from django.core.files.base import ContentFile
|
bgneal@1
|
12 from django.core.urlresolvers import reverse
|
bgneal@94
|
13 from django.utils.text import slugify
|
bgneal@1
|
14 from django.utils.functional import curry
|
bgneal@1
|
15 from django.utils.translation import ugettext_lazy as _
|
bgneal@151
|
16 from django.utils.http import urlquote
|
bgneal@1
|
17
|
bgneal@1
|
18 # Required PIL classes may or may not be available from the root namespace
|
bgneal@1
|
19 # depending on the installation method used.
|
bgneal@1
|
20 try:
|
bgneal@1
|
21 import Image
|
bgneal@1
|
22 import ImageFile
|
bgneal@1
|
23 import ImageFilter
|
bgneal@1
|
24 import ImageEnhance
|
bgneal@1
|
25 except ImportError:
|
bgneal@1
|
26 try:
|
bgneal@1
|
27 from PIL import Image
|
bgneal@1
|
28 from PIL import ImageFile
|
bgneal@1
|
29 from PIL import ImageFilter
|
bgneal@1
|
30 from PIL import ImageEnhance
|
bgneal@1
|
31 except ImportError:
|
bgneal@1
|
32 raise ImportError(_('Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.'))
|
bgneal@1
|
33
|
bgneal@1
|
34 # attempt to load the django-tagging TagField from default location,
|
bgneal@1
|
35 # otherwise we substitude a dummy TagField.
|
bgneal@1
|
36 try:
|
bgneal@1
|
37 from tagging.fields import TagField
|
bgneal@1
|
38 tagfield_help_text = _('Separate tags with spaces, put quotes around multiple-word tags.')
|
bgneal@1
|
39 except ImportError:
|
bgneal@1
|
40 class TagField(models.CharField):
|
bgneal@1
|
41 def __init__(self, **kwargs):
|
bgneal@1
|
42 default_kwargs = {'max_length': 255, 'blank': True}
|
bgneal@1
|
43 default_kwargs.update(kwargs)
|
bgneal@1
|
44 super(TagField, self).__init__(**default_kwargs)
|
bgneal@1
|
45 def get_internal_type(self):
|
bgneal@1
|
46 return 'CharField'
|
bgneal@1
|
47 tagfield_help_text = _('Django-tagging was not found, tags will be treated as plain text.')
|
bgneal@1
|
48
|
bgneal@1
|
49 from utils import EXIF
|
bgneal@1
|
50 from utils.reflection import add_reflection
|
bgneal@1
|
51 from utils.watermark import apply_watermark
|
bgneal@1
|
52
|
bgneal@1
|
53 # Path to sample image
|
bgneal@1
|
54 SAMPLE_IMAGE_PATH = getattr(settings, 'SAMPLE_IMAGE_PATH', os.path.join(os.path.dirname(__file__), 'res', 'sample.jpg')) # os.path.join(settings.PROJECT_PATH, 'photologue', 'res', 'sample.jpg'
|
bgneal@1
|
55
|
bgneal@1
|
56 # Modify image file buffer size.
|
bgneal@1
|
57 ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10)
|
bgneal@1
|
58
|
bgneal@1
|
59 # Photologue image path relative to media root
|
bgneal@1
|
60 PHOTOLOGUE_DIR = getattr(settings, 'PHOTOLOGUE_DIR', 'photologue')
|
bgneal@1
|
61
|
bgneal@1
|
62 # Look for user function to define file paths
|
bgneal@1
|
63 PHOTOLOGUE_PATH = getattr(settings, 'PHOTOLOGUE_PATH', None)
|
bgneal@1
|
64 if PHOTOLOGUE_PATH is not None:
|
bgneal@1
|
65 if callable(PHOTOLOGUE_PATH):
|
bgneal@1
|
66 get_storage_path = PHOTOLOGUE_PATH
|
bgneal@1
|
67 else:
|
bgneal@1
|
68 parts = PHOTOLOGUE_PATH.split('.')
|
bgneal@1
|
69 module_name = '.'.join(parts[:-1])
|
bgneal@1
|
70 module = __import__(module_name)
|
bgneal@1
|
71 get_storage_path = getattr(module, parts[-1])
|
bgneal@1
|
72 else:
|
bgneal@1
|
73 def get_storage_path(instance, filename):
|
bgneal@1
|
74 return os.path.join(PHOTOLOGUE_DIR, 'photos', filename)
|
bgneal@1
|
75
|
bgneal@1
|
76 # Quality options for JPEG images
|
bgneal@1
|
77 JPEG_QUALITY_CHOICES = (
|
bgneal@1
|
78 (30, _('Very Low')),
|
bgneal@1
|
79 (40, _('Low')),
|
bgneal@1
|
80 (50, _('Medium-Low')),
|
bgneal@1
|
81 (60, _('Medium')),
|
bgneal@1
|
82 (70, _('Medium-High')),
|
bgneal@1
|
83 (80, _('High')),
|
bgneal@1
|
84 (90, _('Very High')),
|
bgneal@1
|
85 )
|
bgneal@1
|
86
|
bgneal@1
|
87 # choices for new crop_anchor field in Photo
|
bgneal@1
|
88 CROP_ANCHOR_CHOICES = (
|
bgneal@1
|
89 ('top', _('Top')),
|
bgneal@1
|
90 ('right', _('Right')),
|
bgneal@1
|
91 ('bottom', _('Bottom')),
|
bgneal@1
|
92 ('left', _('Left')),
|
bgneal@1
|
93 ('center', _('Center (Default)')),
|
bgneal@1
|
94 )
|
bgneal@1
|
95
|
bgneal@1
|
96 IMAGE_TRANSPOSE_CHOICES = (
|
bgneal@1
|
97 ('FLIP_LEFT_RIGHT', _('Flip left to right')),
|
bgneal@1
|
98 ('FLIP_TOP_BOTTOM', _('Flip top to bottom')),
|
bgneal@1
|
99 ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')),
|
bgneal@1
|
100 ('ROTATE_270', _('Rotate 90 degrees clockwise')),
|
bgneal@1
|
101 ('ROTATE_180', _('Rotate 180 degrees')),
|
bgneal@1
|
102 )
|
bgneal@1
|
103
|
bgneal@1
|
104 WATERMARK_STYLE_CHOICES = (
|
bgneal@1
|
105 ('tile', _('Tile')),
|
bgneal@1
|
106 ('scale', _('Scale')),
|
bgneal@1
|
107 )
|
bgneal@1
|
108
|
bgneal@1
|
109 # Prepare a list of image filters
|
bgneal@1
|
110 filter_names = []
|
bgneal@1
|
111 for n in dir(ImageFilter):
|
bgneal@1
|
112 klass = getattr(ImageFilter, n)
|
bgneal@1
|
113 if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \
|
bgneal@1
|
114 hasattr(klass, 'name'):
|
bgneal@1
|
115 filter_names.append(klass.__name__)
|
bgneal@1
|
116 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)))
|
bgneal@1
|
117
|
bgneal@1
|
118
|
bgneal@1
|
119 class Gallery(models.Model):
|
bgneal@1
|
120 date_added = models.DateTimeField(_('date published'), default=datetime.now)
|
bgneal@1
|
121 title = models.CharField(_('title'), max_length=100, unique=True)
|
bgneal@1
|
122 title_slug = models.SlugField(_('title slug'), unique=True,
|
bgneal@1
|
123 help_text=_('A "slug" is a unique URL-friendly title for an object.'))
|
bgneal@1
|
124 description = models.TextField(_('description'), blank=True)
|
bgneal@1
|
125 is_public = models.BooleanField(_('is public'), default=True,
|
bgneal@1
|
126 help_text=_('Public galleries will be displayed in the default views.'))
|
bgneal@1
|
127 photos = models.ManyToManyField('Photo', related_name='galleries', verbose_name=_('photos'),
|
bgneal@186
|
128 blank=True)
|
bgneal@1
|
129 tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags'))
|
bgneal@1
|
130
|
bgneal@1
|
131 class Meta:
|
bgneal@1
|
132 ordering = ['-date_added']
|
bgneal@1
|
133 get_latest_by = 'date_added'
|
bgneal@1
|
134 verbose_name = _('gallery')
|
bgneal@1
|
135 verbose_name_plural = _('galleries')
|
bgneal@1
|
136
|
bgneal@1
|
137 def __unicode__(self):
|
bgneal@1
|
138 return self.title
|
bgneal@1
|
139
|
bgneal@1
|
140 def __str__(self):
|
bgneal@1
|
141 return self.__unicode__()
|
bgneal@1
|
142
|
bgneal@1
|
143 def get_absolute_url(self):
|
bgneal@1
|
144 return reverse('pl-gallery', args=[self.title_slug])
|
bgneal@1
|
145
|
bgneal@1
|
146 def latest(self, limit=0, public=True):
|
bgneal@1
|
147 if limit == 0:
|
bgneal@1
|
148 limit = self.photo_count()
|
bgneal@1
|
149 if public:
|
bgneal@1
|
150 return self.public()[:limit]
|
bgneal@1
|
151 else:
|
bgneal@1
|
152 return self.photos.all()[:limit]
|
bgneal@1
|
153
|
bgneal@1
|
154 def sample(self, count=0, public=True):
|
bgneal@1
|
155 if count == 0 or count > self.photo_count():
|
bgneal@1
|
156 count = self.photo_count()
|
bgneal@1
|
157 if public:
|
bgneal@1
|
158 photo_set = self.public()
|
bgneal@1
|
159 else:
|
bgneal@1
|
160 photo_set = self.photos.all()
|
bgneal@1
|
161 return random.sample(photo_set, count)
|
bgneal@1
|
162
|
bgneal@1
|
163 def photo_count(self, public=True):
|
bgneal@1
|
164 if public:
|
bgneal@1
|
165 return self.public().count()
|
bgneal@1
|
166 else:
|
bgneal@1
|
167 return self.photos.all().count()
|
bgneal@1
|
168 photo_count.short_description = _('count')
|
bgneal@1
|
169
|
bgneal@1
|
170 def public(self):
|
bgneal@1
|
171 return self.photos.filter(is_public=True)
|
bgneal@1
|
172
|
bgneal@1
|
173
|
bgneal@1
|
174 class GalleryUpload(models.Model):
|
bgneal@1
|
175 zip_file = models.FileField(_('images file (.zip)'), upload_to=PHOTOLOGUE_DIR+"/temp",
|
bgneal@1
|
176 help_text=_('Select a .zip file of images to upload into a new Gallery.'))
|
bgneal@1
|
177 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.'))
|
bgneal@1
|
178 title = models.CharField(_('title'), max_length=75, help_text=_('All photos in the gallery will be given a title made up of the gallery title + a sequential number.'))
|
bgneal@1
|
179 caption = models.TextField(_('caption'), blank=True, help_text=_('Caption will be added to all photos.'))
|
bgneal@1
|
180 description = models.TextField(_('description'), blank=True, help_text=_('A description of this Gallery.'))
|
bgneal@1
|
181 is_public = models.BooleanField(_('is public'), default=True, help_text=_('Uncheck this to make the uploaded gallery and included photographs private.'))
|
bgneal@1
|
182 tags = models.CharField(max_length=255, blank=True, help_text=tagfield_help_text, verbose_name=_('tags'))
|
bgneal@1
|
183
|
bgneal@1
|
184 class Meta:
|
bgneal@1
|
185 verbose_name = _('gallery upload')
|
bgneal@1
|
186 verbose_name_plural = _('gallery uploads')
|
bgneal@1
|
187
|
bgneal@1
|
188 def save(self):
|
bgneal@1
|
189 super(GalleryUpload, self).save()
|
bgneal@1
|
190 self.process_zipfile()
|
bgneal@1
|
191 super(GalleryUpload, self).delete()
|
bgneal@1
|
192
|
bgneal@1
|
193 def process_zipfile(self):
|
bgneal@1
|
194 if os.path.isfile(self.zip_file.path):
|
bgneal@1
|
195 # TODO: implement try-except here
|
bgneal@1
|
196 zip = zipfile.ZipFile(self.zip_file.path)
|
bgneal@1
|
197 bad_file = zip.testzip()
|
bgneal@1
|
198 if bad_file:
|
bgneal@1
|
199 raise Exception('"%s" in the .zip archive is corrupt.' % bad_file)
|
bgneal@1
|
200 count = 1
|
bgneal@1
|
201 if self.gallery:
|
bgneal@1
|
202 gallery = self.gallery
|
bgneal@1
|
203 else:
|
bgneal@1
|
204 gallery = Gallery.objects.create(title=self.title,
|
bgneal@1
|
205 title_slug=slugify(self.title),
|
bgneal@1
|
206 description=self.description,
|
bgneal@1
|
207 is_public=self.is_public,
|
bgneal@1
|
208 tags=self.tags)
|
bgneal@1
|
209 from cStringIO import StringIO
|
bgneal@1
|
210 for filename in zip.namelist():
|
bgneal@1
|
211 if filename.startswith('__'): # do not process meta files
|
bgneal@1
|
212 continue
|
bgneal@1
|
213 data = zip.read(filename)
|
bgneal@1
|
214 if len(data):
|
bgneal@1
|
215 try:
|
bgneal@1
|
216 # the following is taken from django.newforms.fields.ImageField:
|
bgneal@1
|
217 # load() is the only method that can spot a truncated JPEG,
|
bgneal@1
|
218 # but it cannot be called sanely after verify()
|
bgneal@1
|
219 trial_image = Image.open(StringIO(data))
|
bgneal@1
|
220 trial_image.load()
|
bgneal@1
|
221 # verify() is the only method that can spot a corrupt PNG,
|
bgneal@1
|
222 # but it must be called immediately after the constructor
|
bgneal@1
|
223 trial_image = Image.open(StringIO(data))
|
bgneal@1
|
224 trial_image.verify()
|
bgneal@1
|
225 except Exception:
|
bgneal@1
|
226 # if a "bad" file is found we just skip it.
|
bgneal@1
|
227 continue
|
bgneal@1
|
228 while 1:
|
bgneal@1
|
229 title = ' '.join([self.title, str(count)])
|
bgneal@1
|
230 slug = slugify(title)
|
bgneal@1
|
231 try:
|
bgneal@1
|
232 p = Photo.objects.get(title_slug=slug)
|
bgneal@94
|
233 except Photo.DoesNotExist:
|
bgneal@1
|
234 photo = Photo(title=title, title_slug=slug,
|
bgneal@1
|
235 caption=self.caption,
|
bgneal@1
|
236 is_public=self.is_public,
|
bgneal@1
|
237 tags=self.tags)
|
bgneal@1
|
238 photo.image.save(filename, ContentFile(data))
|
bgneal@1
|
239 gallery.photos.add(photo)
|
bgneal@1
|
240 count = count + 1
|
bgneal@1
|
241 break
|
bgneal@1
|
242 count = count + 1
|
bgneal@1
|
243 zip.close()
|
bgneal@1
|
244
|
bgneal@1
|
245
|
bgneal@1
|
246 class ImageModel(models.Model):
|
bgneal@1
|
247 image = models.ImageField(_('image'), upload_to=get_storage_path)
|
bgneal@1
|
248 date_taken = models.DateTimeField(_('date taken'), null=True, blank=True, editable=False)
|
bgneal@1
|
249 view_count = models.PositiveIntegerField(default=0, editable=False)
|
bgneal@1
|
250 crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES)
|
bgneal@1
|
251 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect'))
|
bgneal@1
|
252
|
bgneal@1
|
253 class Meta:
|
bgneal@1
|
254 abstract = True
|
bgneal@1
|
255
|
bgneal@1
|
256 @property
|
bgneal@1
|
257 def EXIF(self):
|
bgneal@1
|
258 try:
|
bgneal@1
|
259 return EXIF.process_file(open(self.image.path, 'rb'))
|
bgneal@1
|
260 except:
|
bgneal@1
|
261 try:
|
bgneal@1
|
262 return EXIF.process_file(open(self.image.path, 'rb'), details=False)
|
bgneal@1
|
263 except:
|
bgneal@1
|
264 return {}
|
bgneal@1
|
265
|
bgneal@1
|
266 def admin_thumbnail(self):
|
bgneal@1
|
267 func = getattr(self, 'get_admin_thumbnail_url', None)
|
bgneal@1
|
268 if func is None:
|
bgneal@1
|
269 return _('An "admin_thumbnail" photo size has not been defined.')
|
bgneal@1
|
270 else:
|
bgneal@1
|
271 if hasattr(self, 'get_absolute_url'):
|
bgneal@1
|
272 return u'<a href="%s"><img src="%s"></a>' % \
|
bgneal@1
|
273 (self.get_absolute_url(), func())
|
bgneal@1
|
274 else:
|
bgneal@1
|
275 return u'<a href="%s"><img src="%s"></a>' % \
|
bgneal@1
|
276 (self.image.url, func())
|
bgneal@1
|
277 admin_thumbnail.short_description = _('Thumbnail')
|
bgneal@1
|
278 admin_thumbnail.allow_tags = True
|
bgneal@1
|
279
|
bgneal@1
|
280 def cache_path(self):
|
bgneal@1
|
281 return os.path.join(os.path.dirname(self.image.path), "cache")
|
bgneal@1
|
282
|
bgneal@1
|
283 def cache_url(self):
|
bgneal@1
|
284 return '/'.join([os.path.dirname(self.image.url), "cache"])
|
bgneal@1
|
285
|
bgneal@1
|
286 def image_filename(self):
|
bgneal@1
|
287 return os.path.basename(self.image.path)
|
bgneal@1
|
288
|
bgneal@1
|
289 def _get_filename_for_size(self, size):
|
bgneal@1
|
290 size = getattr(size, 'name', size)
|
bgneal@1
|
291 base, ext = os.path.splitext(self.image_filename())
|
bgneal@1
|
292 return ''.join([base, '_', size, ext])
|
bgneal@1
|
293
|
bgneal@1
|
294 def _get_SIZE_photosize(self, size):
|
bgneal@1
|
295 return PhotoSizeCache().sizes.get(size)
|
bgneal@1
|
296
|
bgneal@1
|
297 def _get_SIZE_size(self, size):
|
bgneal@1
|
298 photosize = PhotoSizeCache().sizes.get(size)
|
bgneal@1
|
299 if not self.size_exists(photosize):
|
bgneal@1
|
300 self.create_size(photosize)
|
bgneal@1
|
301 return Image.open(self._get_SIZE_filename(size)).size
|
bgneal@1
|
302
|
bgneal@1
|
303 def _get_SIZE_url(self, size):
|
bgneal@1
|
304 photosize = PhotoSizeCache().sizes.get(size)
|
bgneal@1
|
305 if not self.size_exists(photosize):
|
bgneal@1
|
306 self.create_size(photosize)
|
bgneal@1
|
307 if photosize.increment_count:
|
bgneal@1
|
308 self.view_count += 1
|
bgneal@1
|
309 self.save(update=True)
|
bgneal@151
|
310 return '/'.join([self.cache_url(),
|
bgneal@151
|
311 urlquote(self._get_filename_for_size(photosize.name))])
|
bgneal@1
|
312
|
bgneal@1
|
313 def _get_SIZE_filename(self, size):
|
bgneal@1
|
314 photosize = PhotoSizeCache().sizes.get(size)
|
bgneal@1
|
315 return os.path.join(self.cache_path(),
|
bgneal@1
|
316 self._get_filename_for_size(photosize.name))
|
bgneal@1
|
317
|
bgneal@1
|
318 def add_accessor_methods(self, *args, **kwargs):
|
bgneal@1
|
319 for size in PhotoSizeCache().sizes.keys():
|
bgneal@1
|
320 setattr(self, 'get_%s_size' % size,
|
bgneal@1
|
321 curry(self._get_SIZE_size, size=size))
|
bgneal@1
|
322 setattr(self, 'get_%s_photosize' % size,
|
bgneal@1
|
323 curry(self._get_SIZE_photosize, size=size))
|
bgneal@1
|
324 setattr(self, 'get_%s_url' % size,
|
bgneal@1
|
325 curry(self._get_SIZE_url, size=size))
|
bgneal@1
|
326 setattr(self, 'get_%s_filename' % size,
|
bgneal@1
|
327 curry(self._get_SIZE_filename, size=size))
|
bgneal@1
|
328
|
bgneal@1
|
329 def size_exists(self, photosize):
|
bgneal@1
|
330 func = getattr(self, "get_%s_filename" % photosize.name, None)
|
bgneal@1
|
331 if func is not None:
|
bgneal@1
|
332 if os.path.isfile(func()):
|
bgneal@1
|
333 return True
|
bgneal@1
|
334 return False
|
bgneal@1
|
335
|
bgneal@1
|
336 def resize_image(self, im, photosize):
|
bgneal@1
|
337 cur_width, cur_height = im.size
|
bgneal@1
|
338 new_width, new_height = photosize.size
|
bgneal@1
|
339 if photosize.crop:
|
bgneal@1
|
340 ratio = max(float(new_width)/cur_width,float(new_height)/cur_height)
|
bgneal@1
|
341 x = (cur_width * ratio)
|
bgneal@1
|
342 y = (cur_height * ratio)
|
bgneal@1
|
343 xd = abs(new_width - x)
|
bgneal@1
|
344 yd = abs(new_height - y)
|
bgneal@1
|
345 x_diff = int(xd / 2)
|
bgneal@1
|
346 y_diff = int(yd / 2)
|
bgneal@1
|
347 if self.crop_from == 'top':
|
bgneal@1
|
348 box = (int(x_diff), 0, int(x_diff+new_width), new_height)
|
bgneal@1
|
349 elif self.crop_from == 'left':
|
bgneal@1
|
350 box = (0, int(y_diff), new_width, int(y_diff+new_height))
|
bgneal@1
|
351 elif self.crop_from == 'bottom':
|
bgneal@1
|
352 box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height
|
bgneal@1
|
353 elif self.crop_from == 'right':
|
bgneal@1
|
354 box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width
|
bgneal@1
|
355 else:
|
bgneal@1
|
356 box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height))
|
bgneal@1
|
357 im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box)
|
bgneal@1
|
358 else:
|
bgneal@1
|
359 if not new_width == 0 and not new_height == 0:
|
bgneal@1
|
360 ratio = min(float(new_width)/cur_width,
|
bgneal@1
|
361 float(new_height)/cur_height)
|
bgneal@1
|
362 else:
|
bgneal@1
|
363 if new_width == 0:
|
bgneal@1
|
364 ratio = float(new_height)/cur_height
|
bgneal@1
|
365 else:
|
bgneal@1
|
366 ratio = float(new_width)/cur_width
|
bgneal@1
|
367 new_dimensions = (int(round(cur_width*ratio)),
|
bgneal@1
|
368 int(round(cur_height*ratio)))
|
bgneal@1
|
369 if new_dimensions[0] > cur_width or \
|
bgneal@1
|
370 new_dimensions[1] > cur_height:
|
bgneal@1
|
371 if not photosize.upscale:
|
bgneal@1
|
372 return im
|
bgneal@1
|
373 im = im.resize(new_dimensions, Image.ANTIALIAS)
|
bgneal@1
|
374 return im
|
bgneal@1
|
375
|
bgneal@1
|
376 def create_size(self, photosize):
|
bgneal@1
|
377 if self.size_exists(photosize):
|
bgneal@1
|
378 return
|
bgneal@1
|
379 if not os.path.isdir(self.cache_path()):
|
bgneal@1
|
380 os.makedirs(self.cache_path())
|
bgneal@1
|
381 try:
|
bgneal@1
|
382 im = Image.open(self.image.path)
|
bgneal@1
|
383 except IOError:
|
bgneal@1
|
384 return
|
bgneal@1
|
385 # Apply effect if found
|
bgneal@1
|
386 if self.effect is not None:
|
bgneal@1
|
387 im = self.effect.pre_process(im)
|
bgneal@1
|
388 elif photosize.effect is not None:
|
bgneal@1
|
389 im = photosize.effect.pre_process(im)
|
bgneal@1
|
390 # Resize/crop image
|
bgneal@1
|
391 if im.size != photosize.size:
|
bgneal@1
|
392 im = self.resize_image(im, photosize)
|
bgneal@1
|
393 # Apply watermark if found
|
bgneal@1
|
394 if photosize.watermark is not None:
|
bgneal@1
|
395 im = photosize.watermark.post_process(im)
|
bgneal@1
|
396 # Apply effect if found
|
bgneal@1
|
397 if self.effect is not None:
|
bgneal@1
|
398 im = self.effect.post_process(im)
|
bgneal@1
|
399 elif photosize.effect is not None:
|
bgneal@1
|
400 im = photosize.effect.post_process(im)
|
bgneal@1
|
401 # Save file
|
bgneal@1
|
402 im_filename = getattr(self, "get_%s_filename" % photosize.name)()
|
bgneal@1
|
403 try:
|
bgneal@1
|
404 if im.format == 'JPEG':
|
bgneal@1
|
405 im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True)
|
bgneal@1
|
406 else:
|
bgneal@1
|
407 im.save(im_filename)
|
bgneal@1
|
408 except IOError, e:
|
bgneal@1
|
409 if os.path.isfile(im_filename):
|
bgneal@1
|
410 os.unlink(im_filename)
|
bgneal@1
|
411 raise e
|
bgneal@1
|
412
|
bgneal@1
|
413 def remove_size(self, photosize, remove_dirs=True):
|
bgneal@1
|
414 if not self.size_exists(photosize):
|
bgneal@1
|
415 return
|
bgneal@1
|
416 filename = getattr(self, "get_%s_filename" % photosize.name)()
|
bgneal@1
|
417 if os.path.isfile(filename):
|
bgneal@1
|
418 os.remove(filename)
|
bgneal@1
|
419 if remove_dirs:
|
bgneal@1
|
420 self.remove_cache_dirs()
|
bgneal@1
|
421
|
bgneal@1
|
422 def clear_cache(self):
|
bgneal@1
|
423 cache = PhotoSizeCache()
|
bgneal@1
|
424 for photosize in cache.sizes.values():
|
bgneal@1
|
425 self.remove_size(photosize, False)
|
bgneal@1
|
426 self.remove_cache_dirs()
|
bgneal@1
|
427
|
bgneal@1
|
428 def pre_cache(self):
|
bgneal@1
|
429 cache = PhotoSizeCache()
|
bgneal@1
|
430 for photosize in cache.sizes.values():
|
bgneal@1
|
431 if photosize.pre_cache:
|
bgneal@1
|
432 self.create_size(photosize)
|
bgneal@1
|
433
|
bgneal@1
|
434 def remove_cache_dirs(self):
|
bgneal@1
|
435 try:
|
bgneal@1
|
436 os.removedirs(self.cache_path())
|
bgneal@1
|
437 except:
|
bgneal@1
|
438 pass
|
bgneal@1
|
439
|
bgneal@1
|
440 def save(self, update=False):
|
bgneal@1
|
441 if update:
|
bgneal@1
|
442 models.Model.save(self)
|
bgneal@1
|
443 return
|
bgneal@1
|
444 if self.date_taken is None:
|
bgneal@1
|
445 try:
|
bgneal@1
|
446 exif_date = self.EXIF.get('EXIF DateTimeOriginal', None)
|
bgneal@1
|
447 if exif_date is not None:
|
bgneal@1
|
448 d, t = str.split(exif_date.values)
|
bgneal@1
|
449 year, month, day = d.split(':')
|
bgneal@1
|
450 hour, minute, second = t.split(':')
|
bgneal@1
|
451 self.date_taken = datetime(int(year), int(month), int(day),
|
bgneal@1
|
452 int(hour), int(minute), int(second))
|
bgneal@1
|
453 except:
|
bgneal@1
|
454 pass
|
bgneal@1
|
455 if self.date_taken is None:
|
bgneal@1
|
456 self.date_taken = datetime.now()
|
bgneal@1
|
457 if self._get_pk_val():
|
bgneal@1
|
458 self.clear_cache()
|
bgneal@1
|
459 super(ImageModel, self).save()
|
bgneal@1
|
460 self.pre_cache()
|
bgneal@1
|
461
|
bgneal@1
|
462 def delete(self):
|
bgneal@1
|
463 self.clear_cache()
|
bgneal@1
|
464 super(ImageModel, self).delete()
|
bgneal@1
|
465
|
bgneal@1
|
466
|
bgneal@1
|
467 class Photo(ImageModel):
|
bgneal@1
|
468 title = models.CharField(_('title'), max_length=100, unique=True)
|
bgneal@1
|
469 title_slug = models.SlugField(_('slug'), unique=True,
|
bgneal@1
|
470 help_text=('A "slug" is a unique URL-friendly title for an object.'))
|
bgneal@1
|
471 caption = models.TextField(_('caption'), blank=True)
|
bgneal@1
|
472 date_added = models.DateTimeField(_('date added'), default=datetime.now, editable=False)
|
bgneal@1
|
473 is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.'))
|
bgneal@1
|
474 tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags'))
|
bgneal@1
|
475
|
bgneal@1
|
476 class Meta:
|
bgneal@1
|
477 ordering = ['-date_added']
|
bgneal@1
|
478 get_latest_by = 'date_added'
|
bgneal@1
|
479 verbose_name = _("photo")
|
bgneal@1
|
480 verbose_name_plural = _("photos")
|
bgneal@1
|
481
|
bgneal@1
|
482 def __unicode__(self):
|
bgneal@1
|
483 return self.title
|
bgneal@1
|
484
|
bgneal@1
|
485 def __str__(self):
|
bgneal@1
|
486 return self.__unicode__()
|
bgneal@1
|
487
|
bgneal@1
|
488 def save(self, update=False):
|
bgneal@1
|
489 if self.title_slug is None:
|
bgneal@1
|
490 self.title_slug = slugify(self.title)
|
bgneal@1
|
491 super(Photo, self).save(update)
|
bgneal@1
|
492
|
bgneal@1
|
493 def get_absolute_url(self):
|
bgneal@1
|
494 return reverse('pl-photo', args=[self.title_slug])
|
bgneal@1
|
495
|
bgneal@1
|
496 def public_galleries(self):
|
bgneal@1
|
497 """Return the public galleries to which this photo belongs."""
|
bgneal@1
|
498 return self.galleries.filter(is_public=True)
|
bgneal@1
|
499
|
bgneal@1
|
500
|
bgneal@1
|
501 class BaseEffect(models.Model):
|
bgneal@1
|
502 name = models.CharField(_('name'), max_length=30, unique=True)
|
bgneal@1
|
503 description = models.TextField(_('description'), blank=True)
|
bgneal@1
|
504
|
bgneal@1
|
505 class Meta:
|
bgneal@1
|
506 abstract = True
|
bgneal@1
|
507
|
bgneal@1
|
508 def sample_dir(self):
|
bgneal@1
|
509 return os.path.join(settings.MEDIA_ROOT, PHOTOLOGUE_DIR, 'samples')
|
bgneal@1
|
510
|
bgneal@1
|
511 def sample_url(self):
|
bgneal@1
|
512 return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')])
|
bgneal@1
|
513
|
bgneal@1
|
514 def sample_filename(self):
|
bgneal@1
|
515 return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample'))
|
bgneal@1
|
516
|
bgneal@1
|
517 def create_sample(self):
|
bgneal@1
|
518 if not os.path.isdir(self.sample_dir()):
|
bgneal@1
|
519 os.makedirs(self.sample_dir())
|
bgneal@1
|
520 try:
|
bgneal@1
|
521 im = Image.open(SAMPLE_IMAGE_PATH)
|
bgneal@1
|
522 except IOError:
|
bgneal@1
|
523 raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH)
|
bgneal@1
|
524 im = self.process(im)
|
bgneal@1
|
525 im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True)
|
bgneal@1
|
526
|
bgneal@1
|
527 def admin_sample(self):
|
bgneal@1
|
528 return u'<img src="%s">' % self.sample_url()
|
bgneal@1
|
529 admin_sample.short_description = 'Sample'
|
bgneal@1
|
530 admin_sample.allow_tags = True
|
bgneal@1
|
531
|
bgneal@1
|
532 def pre_process(self, im):
|
bgneal@1
|
533 return im
|
bgneal@1
|
534
|
bgneal@1
|
535 def post_process(self, im):
|
bgneal@1
|
536 return im
|
bgneal@1
|
537
|
bgneal@1
|
538 def process(self, im):
|
bgneal@1
|
539 im = self.pre_process(im)
|
bgneal@1
|
540 im = self.post_process(im)
|
bgneal@1
|
541 return im
|
bgneal@1
|
542
|
bgneal@1
|
543 def __unicode__(self):
|
bgneal@1
|
544 return self.name
|
bgneal@1
|
545
|
bgneal@1
|
546 def __str__(self):
|
bgneal@1
|
547 return self.__unicode__()
|
bgneal@1
|
548
|
bgneal@1
|
549 def save(self):
|
bgneal@1
|
550 try:
|
bgneal@1
|
551 os.remove(self.sample_filename())
|
bgneal@1
|
552 except:
|
bgneal@1
|
553 pass
|
bgneal@1
|
554 models.Model.save(self)
|
bgneal@1
|
555 self.create_sample()
|
bgneal@1
|
556 for size in self.photo_sizes.all():
|
bgneal@1
|
557 size.clear_cache()
|
bgneal@1
|
558 # try to clear all related subclasses of ImageModel
|
bgneal@1
|
559 for prop in [prop for prop in dir(self) if prop[-8:] == '_related']:
|
bgneal@1
|
560 for obj in getattr(self, prop).all():
|
bgneal@1
|
561 obj.clear_cache()
|
bgneal@1
|
562 obj.pre_cache()
|
bgneal@1
|
563
|
bgneal@1
|
564 def delete(self):
|
bgneal@1
|
565 try:
|
bgneal@1
|
566 os.remove(self.sample_filename())
|
bgneal@1
|
567 except:
|
bgneal@1
|
568 pass
|
bgneal@1
|
569 super(PhotoEffect, self).delete()
|
bgneal@1
|
570
|
bgneal@1
|
571
|
bgneal@1
|
572 class PhotoEffect(BaseEffect):
|
bgneal@1
|
573 """ A pre-defined effect to apply to photos """
|
bgneal@1
|
574 transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES)
|
bgneal@1
|
575 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."))
|
bgneal@1
|
576 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."))
|
bgneal@1
|
577 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."))
|
bgneal@1
|
578 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."))
|
bgneal@1
|
579 filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT))
|
bgneal@1
|
580 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."))
|
bgneal@1
|
581 reflection_strength = models.FloatField(_('strength'), default=0.6, help_text="The initial opacity of the reflection gradient.")
|
bgneal@1
|
582 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.")
|
bgneal@1
|
583
|
bgneal@1
|
584 class Meta:
|
bgneal@1
|
585 verbose_name = _("photo effect")
|
bgneal@1
|
586 verbose_name_plural = _("photo effects")
|
bgneal@1
|
587
|
bgneal@1
|
588 def pre_process(self, im):
|
bgneal@1
|
589 if self.transpose_method != '':
|
bgneal@1
|
590 method = getattr(Image, self.transpose_method)
|
bgneal@1
|
591 im = im.transpose(method)
|
bgneal@1
|
592 if im.mode != 'RGB' and im.mode != 'RGBA':
|
bgneal@1
|
593 return im
|
bgneal@1
|
594 for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
|
bgneal@1
|
595 factor = getattr(self, name.lower())
|
bgneal@1
|
596 if factor != 1.0:
|
bgneal@1
|
597 im = getattr(ImageEnhance, name)(im).enhance(factor)
|
bgneal@1
|
598 for name in self.filters.split('->'):
|
bgneal@1
|
599 image_filter = getattr(ImageFilter, name.upper(), None)
|
bgneal@1
|
600 if image_filter is not None:
|
bgneal@1
|
601 try:
|
bgneal@1
|
602 im = im.filter(image_filter)
|
bgneal@1
|
603 except ValueError:
|
bgneal@1
|
604 pass
|
bgneal@1
|
605 return im
|
bgneal@1
|
606
|
bgneal@1
|
607 def post_process(self, im):
|
bgneal@1
|
608 if self.reflection_size != 0.0:
|
bgneal@1
|
609 im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength)
|
bgneal@1
|
610 return im
|
bgneal@1
|
611
|
bgneal@1
|
612
|
bgneal@1
|
613 class Watermark(BaseEffect):
|
bgneal@1
|
614 image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR+"/watermarks")
|
bgneal@1
|
615 style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale')
|
bgneal@1
|
616 opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay."))
|
bgneal@1
|
617
|
bgneal@1
|
618 class Meta:
|
bgneal@1
|
619 verbose_name = _('watermark')
|
bgneal@1
|
620 verbose_name_plural = _('watermarks')
|
bgneal@1
|
621
|
bgneal@1
|
622 def post_process(self, im):
|
bgneal@1
|
623 mark = Image.open(self.image.path)
|
bgneal@1
|
624 return apply_watermark(im, mark, self.style, self.opacity)
|
bgneal@1
|
625
|
bgneal@1
|
626
|
bgneal@1
|
627 class PhotoSize(models.Model):
|
bgneal@1
|
628 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".'))
|
bgneal@1
|
629 width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.'))
|
bgneal@1
|
630 height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width'))
|
bgneal@1
|
631 quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.'))
|
bgneal@1
|
632 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.'))
|
bgneal@1
|
633 crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.'))
|
bgneal@1
|
634 pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.'))
|
bgneal@1
|
635 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.'))
|
bgneal@1
|
636 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect'))
|
bgneal@1
|
637 watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image'))
|
bgneal@1
|
638
|
bgneal@1
|
639 class Meta:
|
bgneal@1
|
640 ordering = ['width', 'height']
|
bgneal@1
|
641 verbose_name = _('photo size')
|
bgneal@1
|
642 verbose_name_plural = _('photo sizes')
|
bgneal@1
|
643
|
bgneal@1
|
644 def __unicode__(self):
|
bgneal@1
|
645 return self.name
|
bgneal@1
|
646
|
bgneal@1
|
647 def __str__(self):
|
bgneal@1
|
648 return self.__unicode__()
|
bgneal@1
|
649
|
bgneal@1
|
650 def clear_cache(self):
|
bgneal@1
|
651 for cls in ImageModel.__subclasses__():
|
bgneal@1
|
652 for obj in cls.objects.all():
|
bgneal@1
|
653 obj.remove_size(self)
|
bgneal@1
|
654 if self.pre_cache:
|
bgneal@1
|
655 obj.create_size(self)
|
bgneal@1
|
656 PhotoSizeCache().reset()
|
bgneal@1
|
657
|
bgneal@1
|
658 def save(self):
|
bgneal@1
|
659 if self.width + self.height <= 0:
|
bgneal@1
|
660 raise ValueError(_('A PhotoSize must have a positive height or width.'))
|
bgneal@1
|
661 super(PhotoSize, self).save()
|
bgneal@1
|
662 PhotoSizeCache().reset()
|
bgneal@1
|
663 self.clear_cache()
|
bgneal@1
|
664
|
bgneal@1
|
665 def delete(self):
|
bgneal@1
|
666 self.clear_cache()
|
bgneal@1
|
667 super(PhotoSize, self).delete()
|
bgneal@1
|
668
|
bgneal@1
|
669 def _get_size(self):
|
bgneal@1
|
670 return (self.width, self.height)
|
bgneal@1
|
671 def _set_size(self, value):
|
bgneal@1
|
672 self.width, self.height = value
|
bgneal@1
|
673 size = property(_get_size, _set_size)
|
bgneal@1
|
674
|
bgneal@1
|
675
|
bgneal@1
|
676 class PhotoSizeCache(object):
|
bgneal@1
|
677 __state = {"sizes": {}}
|
bgneal@1
|
678
|
bgneal@1
|
679 def __init__(self):
|
bgneal@1
|
680 self.__dict__ = self.__state
|
bgneal@1
|
681 if not len(self.sizes):
|
bgneal@1
|
682 sizes = PhotoSize.objects.all()
|
bgneal@1
|
683 for size in sizes:
|
bgneal@1
|
684 self.sizes[size.name] = size
|
bgneal@1
|
685
|
bgneal@1
|
686 def reset(self):
|
bgneal@1
|
687 self.sizes = {}
|
bgneal@1
|
688
|
bgneal@1
|
689
|
bgneal@1
|
690 # Set up the accessor methods
|
bgneal@1
|
691 def add_methods(sender, instance, signal, *args, **kwargs):
|
bgneal@1
|
692 """ Adds methods to access sized images (urls, paths)
|
bgneal@1
|
693
|
bgneal@1
|
694 after the Photo model's __init__ function completes,
|
bgneal@1
|
695 this method calls "add_accessor_methods" on each instance.
|
bgneal@1
|
696 """
|
bgneal@1
|
697 if hasattr(instance, 'add_accessor_methods'):
|
bgneal@1
|
698 instance.add_accessor_methods()
|
bgneal@1
|
699
|
bgneal@1
|
700 # connect the add_accessor_methods function to the post_init signal
|
bgneal@1
|
701 post_init.connect(add_methods)
|