--- a/.hgignore Wed Jan 20 12:41:13 2010 +0100
+++ b/.hgignore Thu Jan 21 18:41:10 2010 +0100
@@ -5,3 +5,4 @@
^web/.htaccess$
^web/blinkster/.htaccess$
^web/blinkster/config.py$
+^web/static/photologue
--- a/web/blinkster/__init__.py Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/__init__.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,3 @@
+VERSION = (0,1)
+
+VERSION_STR = unicode(".".join(map(lambda i:"%02d" % (i,), VERSION)))
\ No newline at end of file
--- a/web/blinkster/config.py.tmpl Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/config.py.tmpl Thu Jan 21 18:41:10 2010 +0100
@@ -8,12 +8,12 @@
# Absolute path to the directory that holds media.
# Example: "/home/media/media.lawrence.com/"
-MEDIA_ROOT = ''
+# MEDIA_ROOT = os.path.abspath(BASE_DIR + "../static/")
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
# trailing slash if there is a path component (optional in other cases).
# Examples: "http://media.lawrence.com", "http://example.com/media/"
-MEDIA_URL = ''
+MEDIA_URL = MEDIA_BASE_URL
CONTENT_ROOT = MEDIA_ROOT + "media/content/"
--- a/web/blinkster/ldt/views.py Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/ldt/views.py Thu Jan 21 18:41:10 2010 +0100
@@ -3,7 +3,7 @@
from django.shortcuts import render_to_response
from django.template import RequestContext
from blinkster import settings
-from blinkster import version
+import blinkster
from blinkster.ldt.forms import LdtImportForm
from blinkster.ldt.forms import LdtProjectImportForm
from blinkster.ldt.forms import SearchForm, LdtForm, reindexForm
@@ -285,7 +285,7 @@
writer = MarkupWriter(resp, indent = u"yes")
writer.startDocument()
- writer.startElement(u"iri", attributes={u"version": unicode(version.VERSION)})
+ writer.startElement(u"iri", attributes={u"version": blinkster.VERSION_STR})
writer.startElement(u"projects")
for project in projects:
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/blinkster/models.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,52 @@
+import os
+
+from django.db import models
+from django.contrib import admin
+import uuid
+import photologue.models
+from django.conf import settings
+from modeltranslation.utils import build_localized_fieldname
+
+def get_sid():
+ return unicode(uuid.uuid1())
+
+class Roi(models.Model):
+
+ sid = models.CharField(max_length=36, unique=True, blank=False, null=False, db_index=True, editable=False, default=get_sid)
+ title = models.CharField(max_length=1024, unique=True, blank=False, null=False)
+ short_title = models.CharField(max_length=512, unique=True, blank=False, null=False)
+ description = models.TextField(blank=True, null=True)
+ loc = models.CharField(max_length=512, unique=False, blank=True, null=True)
+ loc_radius = models.FloatField(null=True)
+ photos = models.ForeignKey(photologue.models.Gallery, null=True)
+
+
+ def serialize_to_dict(self):
+ res = {
+ "sid": self.sid,
+ }
+
+ for fieldname in ["title","short_title","description"]:
+ lang_dict = {}
+ for l in settings.LANGUAGES:
+ lang = l[0]
+ lfieldname = build_localized_fieldname(fieldname, lang)
+ lang_dict[lang] = self.__dict__[lfieldname]
+ res[fieldname] = lang_dict
+
+ res["loc"] = {}
+ if self.loc:
+ loc_array = self.loc.split(",")
+ res["loc"] = { "type" : "Point", "coordinates": [float(loc_array[0]),float(loc_array[1])] }
+
+ res["loc_radius"] = self.loc_radius
+
+ res["photos"] = []
+
+ if self.photos:
+ for photo in self.photos.photos.all():
+ res["photos"].append({"title":photo.title,"url":settings.WEB_URL.strip("/")+photo.image.url})
+
+ return res
+
+admin.site.register(Roi)
\ No newline at end of file
--- a/web/blinkster/settings.py Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/settings.py Thu Jan 21 18:41:10 2010 +0100
@@ -38,7 +38,7 @@
# Absolute path to the directory that holds media.
# Example: "/home/media/media.lawrence.com/"
-MEDIA_ROOT = ''
+# MEDIA_ROOT = os.path.abspath(BASE_DIR + "../static/")
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
# trailing slash if there is a path component (optional in other cases).
@@ -73,7 +73,8 @@
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
# Always use forward slashes, even on Windows.
# Don't forget to use absolute paths, not relative paths.
- os.path.join(BASE_DIR, "templates")
+ os.path.join(BASE_DIR, "templates"),
+ os.path.join(BASE_DIR,"..","lib", "photologue", "templates"),
)
INSTALLED_APPS = (
@@ -83,7 +84,10 @@
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.admin',
+ 'modeltranslation',
+ 'photologue',
'blinkster.ldt',
+ 'blinkster',
)
TEMPLATE_CONTEXT_PROCESSORS = (
@@ -104,4 +108,14 @@
"__MACOSX",
)
+gettext = lambda s: s
+# IRIUSER_PAGE = True
+
+LANGUAGES = (
+ ('fr', gettext('fr')),
+)
+
+TRANSLATION_REGISTRY = "blinkster.translation"
+
+
from config import *
--- a/web/blinkster/templates/admin/base_site.html Wed Jan 20 12:41:13 2010 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-{% extends "admin/base.html" %}
-{% load i18n %}
-
-{% block title %}{{ title }} | {% trans 'Administration pocket film ' %}{% endblock %}
-
-{% block branding %}
-<h1 id="site-name">{% trans 'Administration experimentation Pocket Films' %}</h1>
-{% endblock %}
-
-{% block nav-global %}{% endblock %}
--- a/web/blinkster/templates/admin/index.html Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/templates/admin/index.html Thu Jan 21 18:41:10 2010 +0100
@@ -1,7 +1,11 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
-{% block stylesheet %}{% load adminmedia %}{% admin_media_prefix %}css/dashboard.css{% endblock %}
+{% block extrastyle %}
+{{ block.super }}
+<link rel="stylesheet" type="text/css" href="{% load adminmedia %}{% admin_media_prefix %}css/dashboard.css" />
+<link rel="stylesheet" type="text/css" href="{{BASE_URL}}static/css/admin_style.css" />
+{% endblock %}
{% block coltype %}colMS{% endblock %}
@@ -16,7 +20,7 @@
{% for app in app_list %}
<div class="module">
<table summary="{% blocktrans with app.name as name %}Models available in the {{ name }} application.{% endblocktrans %}">
- <caption>{% blocktrans with app.name as name %}{{ name }}{% endblocktrans %}</caption>
+ <caption><a href="{{ app.app_url }}" class="section">{% blocktrans with app.name as name %}{{ name }}{% endblocktrans %}</a></caption>
{% for model in app.models %}
<tr>
{% if model.perms.change %}
@@ -42,36 +46,36 @@
</div>
{% endfor %}
<div class="module">
- <table summary="Import">
- <caption>Import</caption>
- <tr>
- <th>
- <a href="import/form">Import an ldt/import content</a>
- </th>
- <td>
- </td>
- <tr>
- <th>
- <a href="export/form">Generate ldt</a>
- </th>
- <td>
+ <table summary="Import">
+ <caption>Import</caption>
+ <tr>
+ <th>
+ <a href="import/form">Import an ldt/import content</a>
+ </th>
+ <td>
</td>
- </tr>
- <tr>
- <th>
- <a href="import/projectForm">Import a project</a>
- </th>
- <td>
+ <tr>
+ <th>
+ <a href="export/form">Generate ldt</a>
+ </th>
+ <td>
</td>
- </tr>
- <tr>
+ </tr>
+ <tr>
+ <th>
+ <a href="import/projectForm">Import a project</a>
+ </th>
+ <td>
+ </td>
+ </tr>
+ <tr>
<th>
<a href="reindex/">Reindex</a>
</th>
<td>
</td>
- </tr>
- </table>
+ </tr>
+ </table>
</div>
<div id="footer">
</div>
@@ -93,7 +97,19 @@
{% else %}
<ul class="actionlist">
{% for entry in admin_log %}
- <li class="{% if entry.is_addition %}addlink{% endif %}{% if entry.is_change %}changelink{% endif %}{% if entry.is_deletion %}deletelink{% endif %}">{% if not entry.is_deletion %}<a href="{{ entry.get_admin_url }}">{% endif %}{{ entry.object_repr|escape }}{% if not entry.is_deletion %}</a>{% endif %}<br /><span class="mini quiet">{% filter capfirst %}{% trans entry.content_type.name %}{% endfilter %}</span></li>
+ <li class="{% if entry.is_addition %}addlink{% endif %}{% if entry.is_change %}changelink{% endif %}{% if entry.is_deletion %}deletelink{% endif %}">
+ {% if entry.is_deletion %}
+ {{ entry.object_repr }}
+ {% else %}
+ <a href="{{ entry.get_admin_url }}">{{ entry.object_repr }}</a>
+ {% endif %}
+ <br/>
+ {% if entry.content_type %}
+ <span class="mini quiet">{% filter capfirst %}{% trans entry.content_type.name %}{% endfilter %}</span>
+ {% else %}
+ <span class="mini quiet">{% trans 'Unknown content' %}</span>
+ {% endif %}
+ </li>
{% endfor %}
</ul>
{% endif %}
@@ -101,12 +117,11 @@
</div>
{% endblock %}
{% block footer %}
-<div>
+<div id="admin_footer">
<div class="footer_img">
<a href="http://www.iri.centrepompidou.fr"><img src="{{MEDIA_URL}}/img/logo_IRI_footer.png" alt="Logo IRI" /></a>
</div>
<div class="version" id="version"><a>{{VERSION}}</a></div>
-<div class="small">©2009 <a style="text-decoration: none; color: #4F5155;" href="http://www.iri.centrepompidou.fr">IRI / Centre Pompidou</a></div>
+<div class="small">©2010 <a style="text-decoration: none; color: #4F5155;" href="http://www.iri.centrepompidou.fr">IRI / Centre Pompidou</a></div>
</div>
{% endblock %}
-
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/blinkster/translation.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,7 @@
+from modeltranslation.translator import translator, TranslationOptions
+from blinkster.models import Roi
+
+class RoiTranslationOption(TranslationOptions):
+ fields = ('title', 'short_title', 'description',)
+
+translator.register(Roi, RoiTranslationOption)
--- a/web/blinkster/urls.py Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/urls.py Thu Jan 21 18:41:10 2010 +0100
@@ -38,6 +38,7 @@
(r'^ldt/project/(?P<id>.*)$', 'blinkster.ldt.views.ldtProject'),
(r'^ldt/projectslist/(?P<content_id>.*)$', 'blinkster.ldt.views.getProjectFromContentId'),
#(r'^.*(?P<content>flvplayer|mp3player|ClearExternalAllBlue)\.swf$','django.views.generic.simple.redirect_to', {'url':blinkster.settings.BASE_URL+'/static/swf/ldt/pkg/%(content)s.swf'}),
-
+ (r'^roi/list/$', 'blinkster.views.roi'),
+ (r'^photologue/', include('photologue.urls')),
)
--- a/web/blinkster/utils/context_processors.py Wed Jan 20 12:41:13 2010 +0100
+++ b/web/blinkster/utils/context_processors.py Thu Jan 21 18:41:10 2010 +0100
@@ -1,8 +1,8 @@
import blinkster.settings;
-import blinkster.version;
+import blinkster;
def version(request):
- return {'VERSION': blinkster.version.VERSION }
+ return {'VERSION': blinkster.VERSION_STR }
def base(request):
return {'BASE_URL': blinkster.settings.BASE_URL }
--- a/web/blinkster/version.py Wed Jan 20 12:41:13 2010 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-VERSION = "00.00"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/blinkster/views.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,21 @@
+# Create your views here.
+from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import render_to_response
+from django.core.urlresolvers import reverse
+from django.template import RequestContext, TemplateDoesNotExist
+from django.template.loader import get_template
+from django.core import serializers
+from django.core.serializers.json import DjangoJSONEncoder
+from django.utils import simplejson
+
+from blinkster.models import Roi
+import blinkster
+
+def roi(request):
+ response = HttpResponse(content_type="application/json; charset=utf-8")
+ objs = {
+ "version" : blinkster.VERSION,
+ "rois" : [roi.serialize_to_dict() for roi in Roi.objects.all()]
+ }
+ simplejson.dump(objs, response, cls=DjangoJSONEncoder,ensure_ascii=False, indent=4)
+ return response
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/admin.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+from copy import deepcopy
+
+from django.conf import settings
+from django.contrib import admin
+from django.contrib.contenttypes.models import ContentType
+from django.forms import widgets
+from django import forms, template
+from django.forms.fields import MultiValueField
+from django.shortcuts import get_object_or_404, render_to_response
+from django.utils.safestring import mark_safe
+
+from modeltranslation.translator import translator
+
+
+class TranslationAdmin(admin.ModelAdmin):
+
+ def patch_translation_field(self, db_field, field, **kwargs):
+ trans_opts = translator.get_options_for_model(self.model)
+
+ # Hide the original field by making it non-editable.
+ if db_field.name in trans_opts.fields:
+ db_field.editable = False
+
+ # For every localized field copy the widget from the original field
+ if db_field.name in trans_opts.localized_fieldnames_rev:
+ orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name]
+ orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs)
+
+ # In case the original form field was required, make the default
+ # translation field required instead.
+ if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required:
+ orig_formfield.required = False
+ field.required = True
+
+ field.widget = deepcopy(orig_formfield.widget)
+
+ def formfield_for_dbfield(self, db_field, **kwargs):
+ # Call the baseclass function to get the formfield
+ field = super(TranslationAdmin, self).formfield_for_dbfield(db_field, **kwargs)
+ self.patch_translation_field(db_field, field, **kwargs)
+ return field
+
+ #def save_model(self, request, obj, form, change):
+ #"""
+ #Given a model instance save it to the database.
+
+ #Because each translated field is wrapped with a descriptor to return
+ #the translated fields value (determined by the current language) we
+ #cannot set the field directly.
+ #To bypass the descriptor the assignment is done using the __dict__
+ #of the object.
+ #"""
+ #trans_opts = translator.get_options_for_model(self.model)
+ #for field_name in trans_opts.fields:
+ ## Bypass the descriptor applied to the original field by setting
+ ## it's value via the __dict__ (which doesn't call the descriptor).
+ #obj.__dict__[field_name] = form.cleaned_data[field_name]
+
+ ## Call the baseclass method
+ #super(TranslationAdmin, self).save_model(request, obj, form, change)
+
+
+class TranslationTabularInline(admin.TabularInline):
+
+ def patch_translation_field(self, db_field, field, **kwargs):
+ trans_opts = translator.get_options_for_model(self.model)
+
+ # Hide the original field by making it non-editable.
+ if db_field.name in trans_opts.fields:
+ db_field.editable = False
+
+ # For every localized field copy the widget from the original field
+ if db_field.name in trans_opts.localized_fieldnames_rev:
+ orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name]
+ orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs)
+
+ # In case the original form field was required, make the default
+ # translation field required instead.
+ if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required:
+ orig_formfield.required = False
+ field.required = True
+
+ field.widget = deepcopy(orig_formfield.widget)
+
+ def formfield_for_dbfield(self, db_field, **kwargs):
+ # Call the baseclass function to get the formfield
+ field = super(TranslationTabularInline, self).formfield_for_dbfield(db_field, **kwargs)
+ self.patch_translation_field(db_field, field, **kwargs)
+ return field
+
+
+class TranslationStackedInline(admin.StackedInline):
+
+ def patch_translation_field(self, db_field, field, **kwargs):
+ trans_opts = translator.get_options_for_model(self.model)
+
+ # Hide the original field by making it non-editable.
+ if db_field.name in trans_opts.fields:
+ db_field.editable = False
+
+ # For every localized field copy the widget from the original field
+ if db_field.name in trans_opts.localized_fieldnames_rev:
+ orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name]
+ orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs)
+
+ # In case the original form field was required, make the default
+ # translation field required instead.
+ if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required:
+ orig_formfield.required = False
+ field.required = True
+
+ field.widget = deepcopy(orig_formfield.widget)
+
+ def formfield_for_dbfield(self, db_field, **kwargs):
+ # Call the baseclass function to get the formfield
+ field = super(TranslationStackedInline, self).formfield_for_dbfield(db_field, **kwargs)
+ self.patch_translation_field(db_field, field, **kwargs)
+ return field
+
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/fields.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,98 @@
+
+from django.conf import settings
+from django.db.models.fields import Field, CharField
+from django.utils.translation import get_language
+
+from modeltranslation.utils import build_localized_fieldname
+
+class TranslationField(Field):
+ """
+ The translation field functions as a proxy to the original field which is
+ wrapped.
+
+ For every field defined in the model's ``TranslationOptions`` localized
+ versions of that field are added to the model depending on the languages
+ given in ``settings.LANGUAGES``.
+
+ If for example there is a model ``News`` with a field ``title`` which is
+ registered for translation and the ``settings.LANGUAGES`` contains the
+ ``de`` and ``en`` languages, the fields ``title_de`` and ``title_en`` will
+ be added to the model class. These fields are realized using this
+ descriptor.
+
+ The translation field needs to know which language it contains therefore
+ that needs to be specified when the field is created.
+ """
+ def __init__(self, translated_field, language, *args, **kwargs):
+ # Store the originally wrapped field for later
+ self.translated_field = translated_field
+ self.language = language
+
+ # Update the dict of this field with the content of the original one
+ # This might be a bit radical?! Seems to work though...
+ self.__dict__.update(translated_field.__dict__)
+
+ # Translation are always optional (for now - maybe add some parameters
+ # to the translation options for configuring this)
+ self.null = True
+ self.blank = True
+
+ # Adjust the name of this field to reflect the language
+ self.attname = build_localized_fieldname(translated_field.name, language)
+ self.name = self.attname
+
+ # Copy the verbose name and append a language suffix (will e.g. in the
+ # admin). This might be a proxy function so we have to check that here.
+ if hasattr(translated_field.verbose_name, '_proxy____unicode_cast'):
+ verbose_name = translated_field.verbose_name._proxy____unicode_cast()
+ else:
+ verbose_name = translated_field.verbose_name
+ self.verbose_name = '%s [%s]' % (verbose_name, language)
+
+ def pre_save(self, model_instance, add):
+ val = super(TranslationField, self).pre_save(model_instance, add)
+ if get_language() == self.language and not add:
+ # Rule is: 3. Assigning a value to a translation field of the default language
+ # also updates the original field
+ model_instance.__dict__[self.translated_field.name] = val
+ #setattr(model_instance, self.attname, orig_val)
+ # Also return the original value
+ #return orig_val
+ return val
+
+ #def get_attname(self):
+ #return self.attname
+
+ def get_internal_type(self):
+ return self.translated_field.get_internal_type()
+
+ def contribute_to_class(self, cls, name):
+
+ super(TranslationField, self).contribute_to_class(cls, name)
+
+ #setattr(cls, 'get_%s_display' % self.name, curry(cls._get_FIELD_display, field=self))
+
+#class CurrentLanguageField(CharField):
+ #def __init__(self, **kwargs):
+ #super(CurrentLanguageField, self).__init__(null=True, max_length=5, **kwargs)
+
+ #def contribute_to_class(self, cls, name):
+ #super(CurrentLanguageField, self).contribute_to_class(cls, name)
+ #registry = CurrentLanguageFieldRegistry()
+ #registry.add_field(cls, self)
+
+
+#class CurrentLanguageFieldRegistry(object):
+ #_registry = {}
+
+ #def add_field(self, model, field):
+ #reg = self.__class__._registry.setdefault(model, [])
+ #reg.append(field)
+
+ #def get_fields(self, model):
+ #return self.__class__._registry.get(model, [])
+
+ #def __contains__(self, model):
+ #return model in self.__class__._registry
+
+
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/management/commands/update_translation_fields.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,25 @@
+
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError, NoArgsCommand
+
+from modeltranslation.translator import translator
+from modeltranslation.utils import build_localized_fieldname
+
+class Command(NoArgsCommand):
+ help = 'Updates the default translation fields of all or the specified'\
+ 'translated application using the value of the original field.'
+ # args = '[app_name]'
+
+ def handle(self, **options):
+ default_lang = settings.LANGUAGES[0][0]
+ print "Using default language:", default_lang
+
+ for model, trans_opts in translator._registry.items():
+ print "Updating data of model '%s'" % model
+ for obj in model.objects.all():
+ for fieldname in trans_opts.fields:
+ def_lang_fieldname = build_localized_fieldname(fieldname, default_lang)
+ # print "setting %s from %s to %s." % (def_lang_fieldname, fieldname, obj.__dict__[fieldname])
+ if not getattr(obj, def_lang_fieldname):
+ setattr(obj, def_lang_fieldname, obj.__dict__[fieldname])
+ obj.save()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/middleware.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,24 @@
+# from django.db.models import signals
+# from django.utils.functional import curry
+
+#class TranslationMiddleware(object):
+ #def process_request(self, request):
+ #if hasattr(request, 'LANGUAGE_CODE'):
+ #print "TranslationMiddleware: preferred lang=", request.LANGUAGE_CODE
+ #update_lang = curry(self.update_lang, request.LANGUAGE_CODE)
+ #signals.pre_save.connect(update_lang, dispatch_uid=request, weak=False)
+ #else:
+ #print "TranslationMiddleware: no lang"
+ #pass
+
+
+ #def update_lang(self, lang, sender, instance, **kwargs):
+ #registry = registration.FieldRegistry()
+ #if sender in registry:
+ #for field in registry.get_fields(sender):
+ #setattr(instance, field.name, lang)
+
+ #def process_response(self, request, response):
+ #print "response:", dir(response)
+ #signals.pre_save.disconnect(dispatch_uid=request)
+ #return response
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/models.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,25 @@
+
+from django.db import models
+from django.conf import settings
+
+from modeltranslation.translator import translator
+
+# Every model registered with the modeltranslation.translator.translator
+# is patched to contain additional localized versions for every
+# field specified in the model's translation options.
+
+# Import the project's global "translation.py" which registers model
+# classes and their translation options with the translator object.
+# This requires an extra settings entry, because I see no other way
+# to determine the module name of the project
+try:
+ translation_mod = __import__(settings.TRANSLATION_REGISTRY, {}, {}, [''])
+except ImportError, exc:
+ print "No translation.py found in the project directory."
+ #raise ImportError("No translation.py found in the project directory.")
+
+# After importing all translation modules, all translation classes are
+# registered with the translator.
+translated_app_names = ', '.join(t.__name__ for t in translator._registry.keys())
+print "modeltranslation: registered %d applications for translation (%s)." % (len(translator._registry),
+ translated_app_names)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/tests.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,253 @@
+
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.test import TestCase
+from django.contrib.auth.models import User
+from django.utils.translation import get_language
+from django.utils.translation import trans_real
+from django.utils.thread_support import currentThread
+
+from modeltranslation import translator
+
+class TestModel(models.Model):
+ title = models.CharField(max_length=255)
+ text = models.TextField(null=True)
+
+class TestTranslationOptions(translator.TranslationOptions):
+ fields = ('title', 'text',)
+
+translator.translator.register(TestModel, TestTranslationOptions)
+
+class ModelTranslationTest(TestCase):
+ "Basic tests for the modeltranslation application."
+
+ urls = 'modeltranslation.testurls'
+
+ def setUp(self):
+ trans_real.activate("de")
+
+ def tearDown(self):
+ trans_real.deactivate()
+
+
+ def test_registration(self):
+ self.client.post('/set_language/', data={'language': 'de'})
+ #self.client.session['django_language'] = 'de-de'
+ #self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = 'de-de'
+
+ langs = tuple(l[0] for l in settings.LANGUAGES)
+ self.failUnlessEqual(2, len(langs))
+ self.failUnless('de' in langs)
+ self.failUnless('en' in langs)
+ self.failUnless(translator.translator)
+
+ # Check that only one model is registered for translation
+ self.failUnlessEqual(len(translator.translator._registry), 1)
+
+ # Try to unregister a model that is not registered
+ self.assertRaises(translator.NotRegistered, translator.translator.unregister, User)
+
+ # Try to get options for a model that is not registered
+ self.assertRaises(translator.NotRegistered, translator.translator.get_options_for_model, User)
+
+
+ def test_translated_models(self):
+ # First create an instance of the test model to play with
+ inst = TestModel.objects.create(title="Testtitle", text="Testtext")
+ field_names = dir(inst)
+ self.failUnless('id' in field_names)
+ self.failUnless('title' in field_names)
+ self.failUnless('text' in field_names)
+ self.failUnless('title_de' in field_names)
+ self.failUnless('title_en' in field_names)
+ self.failUnless('text_de' in field_names)
+ self.failUnless('text_en' in field_names)
+
+ inst.delete()
+
+ def test_set_translation(self):
+ self.failUnlessEqual(get_language(), "de")
+ # First create an instance of the test model to play with
+ title1_de = "title de"
+ title1_en = "title en"
+ title2_de = "title2 de"
+ inst1 = TestModel(title_en=title1_en, text="Testtext")
+ inst1.title = title1_de
+ inst2 = TestModel(title=title2_de, text="Testtext")
+ inst1.save()
+ inst2.save()
+
+ self.failUnlessEqual(inst1.title, title1_de)
+ self.failUnlessEqual(inst1.title_en, title1_en)
+
+ self.failUnlessEqual(inst2.title, title2_de)
+ self.failUnlessEqual(inst2.title_en, None)
+
+ del inst1
+ del inst2
+
+ # Check that the translation fields are correctly saved and provide the
+ # correct value when retrieving them again.
+ n = TestModel.objects.get(title=title1_de)
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.title_en, title1_en)
+
+ def test_titleonly(self):
+ title1_de = "title de"
+ n = TestModel.objects.create(title=title1_de)
+ self.failUnlessEqual(n.title, title1_de)
+ # Because the original field "title" was specified in the constructor
+ # it is directly passed into the instance's __dict__ and the descriptor
+ # which updates the associated default translation field is not called
+ # and the default translation will be None.
+ self.failUnlessEqual(n.title_de, None)
+ self.failUnlessEqual(n.title_en, None)
+
+ # Now assign the title, that triggers the descriptor and the default
+ # translation field is updated
+ n.title = title1_de
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.title_de, title1_de)
+ self.failUnlessEqual(n.title_en, None)
+
+ def test_rule1(self):
+ """
+ Rule 1: Reading the value from the original field returns the value in
+ translated to the current language.
+ """
+ title1_de = "title de"
+ title1_en = "title en"
+ text_de = "Dies ist ein deutscher Satz"
+ text_en = "This is an english sentence"
+
+ # Test 1.
+ n = TestModel.objects.create(title_de=title1_de, title_en=title1_en,
+ text_de=text_de, text_en=text_en)
+ n.save()
+
+ # language is set to "de" at this point
+ self.failUnlessEqual(get_language(), "de")
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.title_de, title1_de)
+ self.failUnlessEqual(n.title_en, title1_en)
+ self.failUnlessEqual(n.text, text_de)
+ self.failUnlessEqual(n.text_de, text_de)
+ self.failUnlessEqual(n.text_en, text_en)
+ # Now switch to "en"
+ trans_real.activate("en")
+ self.failUnlessEqual(get_language(), "en")
+ # Title should now be return the english one (just by switching the
+ # language)
+ self.failUnlessEqual(n.title, title1_en)
+ self.failUnlessEqual(n.text, text_en)
+
+ n = TestModel.objects.create(title_de=title1_de, title_en=title1_en,
+ text_de=text_de, text_en=text_en)
+ n.save()
+ # language is set to "en" at this point
+ self.failUnlessEqual(n.title, title1_en)
+ self.failUnlessEqual(n.title_de, title1_de)
+ self.failUnlessEqual(n.title_en, title1_en)
+ self.failUnlessEqual(n.text, text_en)
+ self.failUnlessEqual(n.text_de, text_de)
+ self.failUnlessEqual(n.text_en, text_en)
+ trans_real.activate("de")
+ self.failUnlessEqual(get_language(), "de")
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.text, text_de)
+ trans_real.deactivate()
+
+
+ def test_rule2(self):
+ """
+ Rule 2: Assigning a value to the original field also updates the value
+ in the associated translation field of the default language
+ """
+ self.failUnlessEqual(get_language(), "de")
+ title1_de = "title de"
+ title1_en = "title en"
+ n = TestModel.objects.create(title_de=title1_de, title_en=title1_en)
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.title_de, title1_de)
+ self.failUnlessEqual(n.title_en, title1_en)
+
+ title2 = "Neuer Titel"
+ n.title = title2
+ n.save()
+ self.failUnlessEqual(n.title, title2)
+ self.failUnlessEqual(n.title, n.title_de)
+
+ trans_real.activate("en")
+ self.failUnlessEqual(get_language(), "en")
+ title3 = "new title"
+
+ n.title = title3
+ n.title_de = title1_de
+ n.save()
+ self.failUnlessEqual(n.title, title3)
+ self.failUnlessEqual(n.title, n.title_en)
+ self.failUnlessEqual(title1_de, n.title_de)
+
+ trans_real.deactivate()
+
+
+ def test_rule3(self):
+ """
+ Rule 3: Assigning a value to a translation field of the default language
+ also updates the original field - note that the value of the original
+ field will not be updated until the model instance is saved.
+ """
+ title1_de = "title de"
+ title1_en = "title en"
+ n = TestModel.objects.create(title_de=title1_de, title_en=title1_en)
+ self.failUnlessEqual(get_language(), "de")
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.title_de, title1_de)
+ self.failUnlessEqual(n.title_en, title1_en)
+
+ n.title_de = "Neuer Titel"
+ n.save()
+ self.failUnlessEqual(n.title, n.title_de)
+
+ # Now switch to "en"
+ trans_real.activate("en")
+ self.failUnlessEqual(get_language(), "en")
+ n.title_en = "New title"
+ # the n.title field is not updated before the instance is saved
+ n.save()
+ self.failUnlessEqual(n.title, n.title_en)
+ trans_real.deactivate()
+
+
+ def test_rule4(self):
+ """
+ Rule 4: If both fields - the original and the translation field of the
+ default language - are updated at the same time, the translation field
+ wins.
+ """
+ self.failUnlessEqual(get_language(), "de")
+ title1_de = "title de"
+ title1_en = "title en"
+ n = TestModel.objects.create(title_de=title1_de, title_en=title1_en)
+ self.failUnlessEqual(n.title, title1_de)
+ self.failUnlessEqual(n.title_de, title1_de)
+ self.failUnlessEqual(n.title_en, title1_en)
+
+ title2_de = "neu de"
+ title2_en = "new en"
+ title_foo = "foo"
+ n.title = title_foo
+ n.title_de = title2_de
+ n.title_en = title2_en
+ n.save()
+ self.failUnlessEqual(n.title, title2_de)
+ self.failUnlessEqual(n.title_de, title2_de)
+ self.failUnlessEqual(n.title_en, title2_en)
+
+ n.title = title_foo
+ n.save()
+ self.failUnlessEqual(n.title, title_foo)
+ self.failUnlessEqual(n.title_de, title_foo)
+ self.failUnlessEqual(n.title_en, title2_en)
+
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/testurls.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,13 @@
+
+from django.conf.urls.defaults import *
+from django.contrib import admin
+from django.views.generic.simple import direct_to_template
+
+urlpatterns = patterns('',
+
+ url(r'^set_language/$',
+ 'django.views.i18n.set_language',
+ {},
+ name='set_language'),
+
+)
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/translator.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,177 @@
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.db.models import signals
+from django.db.models.base import ModelBase
+from django.utils.functional import curry
+
+from modeltranslation.fields import TranslationField
+from modeltranslation.utils import TranslationFieldDescriptor, build_localized_fieldname
+
+class AlreadyRegistered(Exception):
+ pass
+
+class NotRegistered(Exception):
+ pass
+
+class TranslationOptions(object):
+ """
+ The TranslationOptions object is used to specify the fields to translate.
+
+ The options are registered in combination with a model class at the
+ ``modeltranslation.translator.translator`` instance.
+
+ It caches the content type of the translated model for faster lookup later
+ on.
+ """
+ def __init__(self, *args, **kwargs):
+ # self.translation_model = None
+ self.model_ct = None
+ self.localized_fieldnames = list()
+
+#def get_localized_fieldnames(model):
+
+def add_localized_fields(model):
+ """
+ Monkey patchs the original model class to provide additional fields for
+ every language. Only do that for fields which are defined in the
+ translation options of the model.
+
+ Returns a dict mapping the original fieldname to a list containing the names
+ of the localized fields created for the original field.
+ """
+ localized_fields = dict()
+ translation_opts = translator.get_options_for_model(model)
+ for field_name in translation_opts.fields:
+ localized_fields[field_name] = list()
+ for l in settings.LANGUAGES:
+ # Construct the name for the localized field
+ localized_field_name = build_localized_fieldname(field_name, l[0])
+ # Check if the model already has a field by that name
+ if hasattr(model, localized_field_name):
+ raise ValueError("Error adding translation field. The model "\
+ "'%s' already contains a field named '%s'. "\
+ % (instance.__class__.__name__, localized_field_name))
+
+ # This approach implements the translation fields as full valid
+ # django model fields and therefore adds them via add_to_class
+ localized_field = model.add_to_class(localized_field_name,
+ TranslationField(model._meta.get_field(field_name),
+ l[0]))
+ localized_fields[field_name].append(localized_field_name)
+
+
+ return localized_fields
+ # model.add_to_class('current_language', CurrentLanguageField())
+
+#def translated_model_initialized(field_names, instance, **kwargs):
+ #print "translated_model_initialized instance:", instance, ", field:", field_names
+ #for field_name in field_names:
+ #initial_val = getattr(instance, field_name)
+ #print " field: %s, initialval: %s" % (field_name, initial_val)
+ #setattr(instance.__class__, field_name, TranslationFieldDescriptor(field_name,
+ #initial_val))
+#def translated_model_initializing(sender, args, kwargs, **signal_kwargs):
+ #print "translated_model_initializing", sender, args, kwargs
+ #trans_opts = translator.get_options_for_model(sender)
+ #for field_name in trans_opts.fields:
+ #setattr(sender, field_name, TranslationFieldDescriptor(field_name))
+
+
+class Translator(object):
+ """
+ A Translator object encapsulates an instance of a translator. Models are
+ registered with the Translator using the register() method.
+ """
+ def __init__(self):
+ self._registry = {} # model_class class -> translation_opts instance
+
+ def register(self, model_or_iterable, translation_opts, **options):
+ """
+ Registers the given model(s) with the given translation options.
+
+ The model(s) should be Model classes, not instances.
+
+ If a model is already registered for translation, this will raise
+ AlreadyRegistered.
+ """
+ # Don't import the humongous validation code unless required
+ if translation_opts and settings.DEBUG:
+ from django.contrib.admin.validation import validate
+ else:
+ validate = lambda model, adminclass: None
+
+ #if not translation_opts:
+ #translation_opts = TranslationOptions
+ if isinstance(model_or_iterable, ModelBase):
+ model_or_iterable = [model_or_iterable]
+
+ for model in model_or_iterable:
+ if model in self._registry:
+ raise AlreadyRegistered('The model %s is already registered for translation' % model.__name__)
+
+ # If we got **options then dynamically construct a subclass of
+ # translation_opts with those **options.
+ if options:
+ # For reasons I don't quite understand, without a __module__
+ # the created class appears to "live" in the wrong place,
+ # which causes issues later on.
+ options['__module__'] = __name__
+ translation_opts = type("%sAdmin" % model.__name__, (translation_opts,), options)
+
+ # Validate (which might be a no-op)
+ #validate(translation_opts, model)
+
+ # Store the translation class associated to the model
+ self._registry[model] = translation_opts
+
+ # Get the content type of the original model and store it on the
+ # translation options for faster lookup later on.
+ translation_opts.model_ct = ContentType.objects.get_for_model(model)
+
+ # Add the localized fields to the model and store the names of these
+ # fields in the model's translation options for faster lookup later
+ # on.
+ translation_opts.localized_fieldnames = add_localized_fields(model)
+
+ # Create a reverse dict mapping the localized_fieldnames to the
+ # original fieldname
+ rev_dict = dict()
+ for orig_name, loc_names in translation_opts.localized_fieldnames.items():
+ for ln in loc_names:
+ rev_dict[ln] = orig_name
+
+ translation_opts.localized_fieldnames_rev = rev_dict
+
+ # print "Applying descriptor field for model %s" % model
+ for field_name in translation_opts.fields:
+ setattr(model, field_name, TranslationFieldDescriptor(field_name))
+
+ #signals.pre_init.connect(translated_model_initializing, sender=model, weak=False)
+
+ def unregister(self, model_or_iterable):
+ """
+ Unregisters the given model(s).
+
+ If a model isn't already registered, this will raise NotRegistered.
+ """
+ if isinstance(model_or_iterable, ModelBase):
+ model_or_iterable = [model_or_iterable]
+ for model in model_or_iterable:
+ if model not in self._registry:
+ raise NotRegistered('The model "%s" is not registered for translation' % model.__name__)
+ del self._registry[model]
+
+ def get_options_for_model(self, model):
+ """
+ Returns the translation options for the given ``model``. If the
+ ``model`` is not registered a ``NotRegistered`` exception is raised.
+ """
+ try:
+ return self._registry[model]
+ except KeyError:
+ raise NotRegistered('The model "%s" is not registered for translation' % model.__name__)
+
+
+# This global object represents the singleton translator object
+translator = Translator()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/modeltranslation/utils.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,103 @@
+from django.db import models
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import get_language
+
+class TranslationFieldDescriptor(object):
+ """
+ A descriptor used for the original translated field.
+ """
+ def __init__(self, name, initial_val=""):
+ """
+ The ``name`` is the name of the field (which is not available in the
+ descriptor by default - this is Python behaviour).
+ """
+ self.name = name
+ self.val = initial_val
+
+ def __set__(self, instance, value):
+ # print "Descriptor.__set__%s %s %s.%s: %s" % (id(instance), id(self), type(instance), self.name, value)
+ lang = get_language()
+ loc_field_name = build_localized_fieldname(self.name, lang)
+
+ # also update the translation field of the current language
+ setattr(instance, loc_field_name, value)
+
+ # update the original field via the __dict__ to prevent calling the
+ # descriptor
+ instance.__dict__[self.name] = value
+
+
+ def __get__(self, instance, owner):
+ # print "Descriptor.__get__%s %s %s.%s: %s" % (id(instance), id(self), type(instance), self.name, self.val)
+ if not instance:
+ raise ValueError(u"Translation field '%s' can only be "\
+ "accessed via an instance not via "\
+ "a class." % self.name)
+
+ lang = get_language()
+ loc_field_name = build_localized_fieldname(self.name, lang)
+ if hasattr(instance, loc_field_name):
+ return getattr(instance, loc_field_name) or instance.__dict__[self.name]
+ return instance.__dict__[self.name]
+
+
+#def create_model(name, fields=None, app_label='', module='', options=None, admin_opts=None):
+ #"""
+ #Create specified model.
+ #This is taken from http://code.djangoproject.com/wiki/DynamicModels
+ #"""
+ #class Meta:
+ ## Using type('Meta', ...) gives a dictproxy error during model creation
+ #pass
+
+ #if app_label:
+ ## app_label must be set using the Meta inner class
+ #setattr(Meta, 'app_label', app_label)
+
+ ## Update Meta with any options that were provided
+ #if options is not None:
+ #for key, value in options.iteritems():
+ #setattr(Meta, key, value)
+
+ ## Set up a dictionary to simulate declarations within a class
+ #attrs = {'__module__': module, 'Meta': Meta}
+
+ ## Add in any fields that were provided
+ #if fields:
+ #attrs.update(fields)
+
+ ## Create the class, which automatically triggers ModelBase processing
+ #model = type(name, (models.Model,), attrs)
+
+ ## Create an Admin class if admin options were provided
+ #if admin_opts is not None:
+ #class Admin(admin.ModelAdmin):
+ #pass
+ #for key, value in admin_opts:
+ #setattr(Admin, key, value)
+ #admin.site.register(model, Admin)
+
+ #return model
+
+
+def copy_field(field):
+ """Instantiate a new field, with all of the values from the old one, except the
+ to and to_field in the case of related fields.
+
+ This taken from http://www.djangosnippets.org/snippets/442/
+ """
+ base_kw = dict([(n, getattr(field,n, '_null')) for n in models.fields.Field.__init__.im_func.func_code.co_varnames])
+ if isinstance(field, models.fields.related.RelatedField):
+ rel = base_kw.get('rel')
+ rel_kw = dict([(n, getattr(rel,n, '_null')) for n in rel.__init__.im_func.func_code.co_varnames])
+ if isinstance(field, models.fields.related.ForeignKey):
+ base_kw['to_field'] = rel_kw.pop('field_name')
+ base_kw.update(rel_kw)
+ base_kw.pop('self')
+ return field.__class__(**base_kw)
+
+
+def build_localized_fieldname(field_name, lang):
+ return '%s_%s' % (field_name, lang)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/__init__.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,1 @@
+VERSION = (2, 2)
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/admin.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,64 @@
+""" Newforms Admin configuration for Photologue
+
+"""
+from django.contrib import admin
+from models import *
+
+class GalleryAdmin(admin.ModelAdmin):
+ list_display = ('title', 'date_added', 'photo_count', 'is_public')
+ list_filter = ['date_added', 'is_public']
+ date_hierarchy = 'date_added'
+ prepopulated_fields = {'title_slug': ('title',)}
+ filter_horizontal = ('photos',)
+
+class PhotoAdmin(admin.ModelAdmin):
+ list_display = ('title', 'date_taken', 'date_added', 'is_public', 'tags', 'view_count', 'admin_thumbnail')
+ list_filter = ['date_added', 'is_public']
+ search_fields = ['title', 'caption']
+ list_per_page = 10
+ prepopulated_fields = {'title_slug': ('title',)}
+
+class PhotoEffectAdmin(admin.ModelAdmin):
+ list_display = ('name', 'description', 'color', 'brightness', 'contrast', 'sharpness', 'filters', 'admin_sample')
+ fieldsets = (
+ (None, {
+ 'fields': ('name', 'description')
+ }),
+ ('Adjustments', {
+ 'fields': ('color', 'brightness', 'contrast', 'sharpness')
+ }),
+ ('Filters', {
+ 'fields': ('filters',)
+ }),
+ ('Reflection', {
+ 'fields': ('reflection_size', 'reflection_strength', 'background_color')
+ }),
+ ('Transpose', {
+ 'fields': ('transpose_method',)
+ }),
+ )
+
+class PhotoSizeAdmin(admin.ModelAdmin):
+ list_display = ('name', 'width', 'height', 'crop', 'pre_cache', 'effect', 'increment_count')
+ fieldsets = (
+ (None, {
+ 'fields': ('name', 'width', 'height', 'quality')
+ }),
+ ('Options', {
+ 'fields': ('upscale', 'crop', 'pre_cache', 'increment_count')
+ }),
+ ('Enhancements', {
+ 'fields': ('effect', 'watermark',)
+ }),
+ )
+
+class WatermarkAdmin(admin.ModelAdmin):
+ list_display = ('name', 'opacity', 'style')
+
+
+admin.site.register(Gallery, GalleryAdmin)
+admin.site.register(GalleryUpload)
+admin.site.register(Photo, PhotoAdmin)
+admin.site.register(PhotoEffect, PhotoEffectAdmin)
+admin.site.register(PhotoSize, PhotoSizeAdmin)
+admin.site.register(Watermark, WatermarkAdmin)
\ No newline at end of file
Binary file web/lib/photologue/locale/pl/LC_MESSAGES/django.mo has changed
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/locale/pl/LC_MESSAGES/django.po Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,419 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Photologue Preview 2\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2008-07-22 23:05+0200\n"
+"PO-Revision-Date: 2008-07-22 23:08+0100\n"
+"Last-Translator: Jakub Wiśniowski <restless.being@gmail.com>\n"
+"Language-Team: Jakub Wiśniowski <restless.being@gmail.com>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\\n\n"
+"X-Poedit-Language: Polish\n"
+"X-Poedit-Country: POLAND\n"
+"X-Poedit-SourceCharset: utf-8\n"
+
+#: models.py:32
+msgid "Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path."
+msgstr "Photologue nie był w stanie zaimportować Python Imaging Library. Upewnij się, że pakiet ten jest zainstalowany i znajduje się w ścieżce dostępnej dla Pythona."
+
+#: models.py:38
+msgid "Separate tags with spaces, put quotes around multiple-word tags."
+msgstr "Rozdziel tagi spacjami, ujmij w cudzysłowy tagi złożone z wielu słów."
+
+#: models.py:47
+msgid "Django-tagging was not found, tags will be treated as plain text."
+msgstr "Django-tagging nie zostało znalezione. Tagi będą traktowane jako czysty tekst."
+
+#: models.py:64
+msgid "Very Low"
+msgstr "Bardzo niska"
+
+#: models.py:65
+msgid "Low"
+msgstr "Niska"
+
+#: models.py:66
+msgid "Medium-Low"
+msgstr "Niższa średnia"
+
+#: models.py:67
+msgid "Medium"
+msgstr "Åšrednia"
+
+#: models.py:68
+msgid "Medium-High"
+msgstr "Wyższa średnia"
+
+#: models.py:69
+msgid "High"
+msgstr "Wysoka"
+
+#: models.py:70
+msgid "Very High"
+msgstr "Bardzo wysoka"
+
+#: models.py:75
+msgid "Top"
+msgstr "Góra"
+
+#: models.py:76
+msgid "Right"
+msgstr "Prawo"
+
+#: models.py:77
+msgid "Bottom"
+msgstr "Dół"
+
+#: models.py:78
+msgid "Left"
+msgstr "Lewo"
+
+#: models.py:79
+msgid "Center (Default)"
+msgstr "Środek (Domyślnie)"
+
+#: models.py:83
+msgid "Flip left to right"
+msgstr "Odbij w poziomie"
+
+#: models.py:84
+msgid "Flip top to bottom"
+msgstr "Odbij w pionie"
+
+#: models.py:85
+msgid "Rotate 90 degrees counter-clockwise"
+msgstr "Odwróć 90 stopni w lewo"
+
+#: models.py:86
+msgid "Rotate 90 degrees clockwise"
+msgstr "Odwróć 90 stopni w prawo"
+
+#: models.py:87
+msgid "Rotate 180 degrees"
+msgstr "Obróć o 180 stopni"
+
+#: models.py:91
+msgid "Tile"
+msgstr "Kafelki"
+
+#: models.py:92
+msgid "Scale"
+msgstr "Skaluj"
+
+#: models.py:102
+#, python-format
+msgid "Chain multiple filters using the following pattern \"FILTER_ONE->FILTER_TWO->FILTER_THREE\". Image filters will be applied in order. The following filter are available: %s."
+msgstr "Połącz wiele filtrów używając następującego wzorca: \"FILTR_PIERWSZY->FILTR_DRUGI->FILTR_TRZECI\". Filtry obrazów będą zastosowane w kolejności. Dostępne są następujące filtry: %s."
+
+#: models.py:107
+msgid "date published"
+msgstr "data publikacji"
+
+#: models.py:108
+#: models.py:164
+#: models.py:448
+msgid "title"
+msgstr "tytuł"
+
+#: models.py:109
+msgid "title slug"
+msgstr "tytuł - slug "
+
+#: models.py:110
+msgid "A \"slug\" is a unique URL-friendly title for an object."
+msgstr "\"Slug\" jest unikalnym, zgodnym z formatem dla URL-i tytułem obiektu."
+
+#: models.py:111
+#: models.py:166
+#: models.py:483
+msgid "description"
+msgstr "opis"
+
+#: models.py:112
+#: models.py:167
+#: models.py:453
+msgid "is public"
+msgstr "jest publiczna"
+
+#: models.py:113
+msgid "Public galleries will be displayed in the default views."
+msgstr "Galerie publiczne będą wyświetlana w domyślnych widokach."
+
+#: models.py:114
+#: models.py:460
+msgid "photos"
+msgstr "zdjęcia"
+
+#: models.py:116
+#: models.py:168
+#: models.py:454
+msgid "tags"
+msgstr "tagi"
+
+#: models.py:121
+msgid "gallery"
+msgstr "galeria"
+
+#: models.py:122
+msgid "galleries"
+msgstr "galerie"
+
+#: models.py:155
+msgid "count"
+msgstr "ilość"
+
+#: models.py:162
+msgid "images file (.zip)"
+msgstr "plik z obrazami (.zip)"
+
+#: models.py:163
+msgid "Select a .zip file of images to upload into a new Gallery."
+msgstr "Wybierz plik .zip zawierający zdjęcia które chcesz załadować do nowej Galerii."
+
+#: models.py:164
+msgid "All photos in the gallery will be given a title made up of the gallery title + a sequential number."
+msgstr "Wszystkie "
+
+#: models.py:165
+#: models.py:451
+msgid "caption"
+msgstr "podpis"
+
+#: models.py:165
+msgid "Caption will be added to all photos."
+msgstr "Podpis będzie dodany do wszystkich zdjęć."
+
+#: models.py:166
+msgid "A description of this Gallery."
+msgstr "Opis tej Galerii."
+
+#: models.py:167
+msgid "Uncheck this to make the uploaded gallery and included photographs private."
+msgstr "Odznacz aby uczynić wrzucaną galerię oraz zawarte w niej zdjęcia prywatnymi."
+
+#: models.py:171
+msgid "gallery upload"
+msgstr "wrzucona galeria"
+
+#: models.py:172
+msgid "gallery uploads"
+msgstr "wrzucone galerie"
+
+#: models.py:228
+#: models.py:594
+msgid "image"
+msgstr "obraz"
+
+#: models.py:229
+msgid "date taken"
+msgstr "data wykonania"
+
+#: models.py:231
+msgid "crop from"
+msgstr "obetnij z"
+
+#: models.py:232
+msgid "effect"
+msgstr "efekt"
+
+#: models.py:250
+msgid "An \"admin_thumbnail\" photo size has not been defined."
+msgstr "Rozmiar zdjęcia \"admin_thumbnail\" nie został zdefiniowany."
+
+#: models.py:258
+msgid "Thumbnail"
+msgstr "Miniaturka"
+
+#: models.py:449
+msgid "slug"
+msgstr "slug"
+
+#: models.py:452
+msgid "date added"
+msgstr "data dodania"
+
+#: models.py:453
+msgid "Public photographs will be displayed in the default views."
+msgstr "Publiczne zdjęcia będą wyświetlane w domyślnych widokach."
+
+#: models.py:459
+msgid "photo"
+msgstr "zdjęcie"
+
+#: models.py:482
+#: models.py:608
+msgid "name"
+msgstr "nazwa"
+
+#: models.py:554
+msgid "rotate or flip"
+msgstr "obróć lub odbij"
+
+#: models.py:555
+#: models.py:562
+msgid "color"
+msgstr "kolor"
+
+#: models.py:555
+msgid "A factor of 0.0 gives a black and white image, a factor of 1.0 gives the original image."
+msgstr "Współczynnik 0.0 daje czarno-biały obraz, współczynnik 1.0 daje obraz oryginalny."
+
+#: models.py:556
+msgid "brightness"
+msgstr "jasność"
+
+#: models.py:556
+msgid "A factor of 0.0 gives a black image, a factor of 1.0 gives the original image."
+msgstr "Współczynnik 0.0 daje czarny obraz, współczynnik 1.0 daje obraz oryginalny."
+
+#: models.py:557
+msgid "contrast"
+msgstr "kontrast"
+
+#: models.py:557
+msgid "A factor of 0.0 gives a solid grey image, a factor of 1.0 gives the original image."
+msgstr "Współczynnik 0.0 daje jednolity szary obraz, współczynnik 1.0 daje obraz oryginalny."
+
+#: models.py:558
+msgid "sharpness"
+msgstr "ostrość"
+
+#: models.py:558
+msgid "A factor of 0.0 gives a blurred image, a factor of 1.0 gives the original image."
+msgstr "Współczynnik 0.0 daje rozmazany obraz, współczynnik 1.0 daje obraz oryginalny."
+
+#: models.py:559
+msgid "filters"
+msgstr "filtry"
+
+#: models.py:560
+msgid "size"
+msgstr "rozmiar"
+
+#: models.py:560
+msgid "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."
+msgstr "Wysokość odbicia jako procent oryginalnego obrazu. Współczynnik 0.0 nie dodaje odbicia, współczynnik 1.0 dodaje odbicie równe wysokości oryginalnego obrazu."
+
+#: models.py:561
+msgid "strength"
+msgstr "intensywność"
+
+#: models.py:565
+#: models.py:616
+msgid "photo effect"
+msgstr "efekt zdjęcia"
+
+#: models.py:566
+msgid "photo effects"
+msgstr "efekty zdjęć"
+
+#: models.py:595
+msgid "style"
+msgstr "styl"
+
+#: models.py:596
+msgid "opacity"
+msgstr "przeźroczystość"
+
+#: models.py:596
+msgid "The opacity of the overlay."
+msgstr "Poziom przezroczystości"
+
+#: models.py:599
+msgid "watermark"
+msgstr "znak wodny"
+
+#: models.py:600
+msgid "watermarks"
+msgstr "znaki wodne"
+
+#: models.py:608
+msgid "Photo size name should contain only letters, numbers and underscores. Examples: \"thumbnail\", \"display\", \"small\", \"main_page_widget\"."
+msgstr "Nazwa rozmiaru zdjęcia powinna zawierać tylko litery, cyfry i podkreślenia. Przykłady: \"miniatura\", \"wystawa\", \"male\", \"widget_strony_glownej\"."
+
+#: models.py:609
+msgid "width"
+msgstr "szerokość"
+
+#: models.py:609
+msgid "If width is set to \"0\" the image will be scaled to the supplied height."
+msgstr "Jeśli szerokość jest ustawiona na \"0\" to obraz będzie skalowany do podanej wysokości."
+
+#: models.py:610
+msgid "height"
+msgstr "wysokość"
+
+#: models.py:610
+msgid "If height is set to \"0\" the image will be scaled to the supplied width"
+msgstr "Jeśli wysokość jest ustawiona na \"0\" to obraz będzie skalowany do podanej szerokości."
+
+#: models.py:611
+msgid "quality"
+msgstr "jakość"
+
+#: models.py:611
+msgid "JPEG image quality."
+msgstr "Jakość obrazu JPEG"
+
+#: models.py:612
+msgid "upscale images?"
+msgstr "skalować obrazy w górę?"
+
+#: models.py:612
+msgid "If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting."
+msgstr "Jeśli zaznaczone to obraz będzie skalowany w górę tak aby pasował do podanych wymiarów. Obcinane rozmiary będą skalowane niezależnie od tego ustawienia."
+
+#: models.py:613
+msgid "crop to fit?"
+msgstr "przyciąć aby pasował?"
+
+#: models.py:613
+msgid "If selected the image will be scaled and cropped to fit the supplied dimensions."
+msgstr "Jeśli zaznaczone to obraz będzie skalowany i przycinany tak aby pasował do podanych wymiarów."
+
+#: models.py:614
+msgid "pre-cache?"
+msgstr "wstępnie cachować?"
+
+#: models.py:614
+msgid "If selected this photo size will be pre-cached as photos are added."
+msgstr "Jesli zaznaczone to ten rozmiar zdjęć będzie wstępnie cachowany przy dodawaniu zdjęć."
+
+#: models.py:615
+msgid "increment view count?"
+msgstr "zwiększyć licznik odsłon?"
+
+#: models.py:615
+msgid "If selected the image's \"view_count\" will be incremented when this photo size is displayed."
+msgstr "Jeśli zaznaczone to \"licznik_odslon\" będzie zwiększany gdy ten rozmiar zdjęcia będzie wyświetlany."
+
+#: models.py:617
+msgid "watermark image"
+msgstr "oznacz kluczem wodnym"
+
+#: models.py:621
+msgid "photo size"
+msgstr "rozmiar zdjęcia"
+
+#: models.py:622
+msgid "photo sizes"
+msgstr "rozmiary zdjęć"
+
+#: models.py:640
+msgid "A PhotoSize must have a positive height or width."
+msgstr "PhotoSize musi mieć dodatnią wysokość i szerokość."
+
+#~ msgid "Leave to size the image to the set height"
+#~ msgstr "Ustaw aby przeskalować obraz do wybranej wysokości"
+#~ msgid "Leave to size the image to the set width"
+#~ msgstr "Ustaw aby przeskalować obraz do wybranej szerokości"
+#~ msgid "original image"
+#~ msgstr "oryginalny obraz"
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/management/__init__.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/management/commands/__init__.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,37 @@
+from photologue.models import PhotoSize
+
+def get_response(msg, func=int, default=None):
+ while True:
+ resp = raw_input(msg)
+ if not resp and default is not None:
+ return default
+ try:
+ return func(resp)
+ except:
+ print 'Invalid input.'
+
+def create_photosize(name, width=0, height=0, crop=False, pre_cache=False, increment_count=False):
+ try:
+ size = PhotoSize.objects.get(name=name)
+ exists = True
+ except PhotoSize.DoesNotExist:
+ size = PhotoSize(name=name)
+ exists = False
+ if exists:
+ msg = 'A "%s" photo size already exists. Do you want to replace it? (yes, no):' % name
+ if not get_response(msg, lambda inp: inp == 'yes', False):
+ return
+ print '\nWe will now define the "%s" photo size:\n' % size
+ w = get_response('Width (in pixels):', lambda inp: int(inp), width)
+ h = get_response('Height (in pixels):', lambda inp: int(inp), height)
+ c = get_response('Crop to fit? (yes, no):', lambda inp: inp == 'yes', crop)
+ p = get_response('Pre-cache? (yes, no):', lambda inp: inp == 'yes', pre_cache)
+ i = get_response('Increment count? (yes, no):', lambda inp: inp == 'yes', increment_count)
+ size.width = w
+ size.height = h
+ size.crop = c
+ size.pre_cache = p
+ size.increment_count = i
+ size.save()
+ print '\nA "%s" photo size has been created.\n' % name
+ return size
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/management/commands/plcache.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,43 @@
+from django.core.management.base import BaseCommand, CommandError
+from optparse import make_option
+from photologue.models import PhotoSize, ImageModel
+
+class Command(BaseCommand):
+ option_list = BaseCommand.option_list + (
+ make_option('--reset', '-r', action='store_true', dest='reset', help='Reset photo cache before generating'),
+ )
+
+ help = ('Manages Photologue cache file for the given sizes.')
+ args = '[sizes]'
+
+ requires_model_validation = True
+ can_import_settings = True
+
+ def handle(self, *args, **options):
+ return create_cache(args, options)
+
+def create_cache(sizes, options):
+ """
+ Creates the cache for the given files
+ """
+ reset = options.get('reset', None)
+
+ size_list = [size.strip(' ,') for size in sizes]
+
+ if len(size_list) < 1:
+ sizes = PhotoSize.objects.filter(pre_cache=True)
+ else:
+ sizes = PhotoSize.objects.filter(name__in=size_list)
+
+ if not len(sizes):
+ raise CommandError('No photo sizes were found.')
+
+ print 'Caching photos, this may take a while...'
+
+ for cls in ImageModel.__subclasses__():
+ for photosize in sizes:
+ print 'Cacheing %s size images' % photosize.name
+ for obj in cls.objects.all():
+ if reset:
+ obj.remove_size(photosize)
+ obj.create_size(photosize)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/management/commands/plcreatesize.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,13 @@
+from django.core.management.base import BaseCommand, CommandError
+from photologue.management.commands import create_photosize
+
+class Command(BaseCommand):
+ help = ('Creates a new Photologue photo size interactively.')
+ requires_model_validation = True
+ can_import_settings = True
+
+ def handle(self, *args, **options):
+ create_size(args[0])
+
+def create_size(size):
+ create_photosize(size)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/management/commands/plflush.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,35 @@
+from django.core.management.base import BaseCommand, CommandError
+from optparse import make_option
+from photologue.models import PhotoSize, ImageModel
+
+class Command(BaseCommand):
+ help = ('Clears the Photologue cache for the given sizes.')
+ args = '[sizes]'
+
+ requires_model_validation = True
+ can_import_settings = True
+
+ def handle(self, *args, **options):
+ return create_cache(args, options)
+
+def create_cache(sizes, options):
+ """
+ Clears the cache for the given files
+ """
+ size_list = [size.strip(' ,') for size in sizes]
+
+ if len(size_list) < 1:
+ sizes = PhotoSize.objects.all()
+ else:
+ sizes = PhotoSize.objects.filter(name__in=size_list)
+
+ if not len(sizes):
+ raise CommandError('No photo sizes were found.')
+
+ print 'Flushing cache...'
+
+ for cls in ImageModel.__subclasses__():
+ for photosize in sizes:
+ print 'Flushing %s size images' % photosize.name
+ for obj in cls.objects.all():
+ obj.remove_size(photosize)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/management/commands/plinit.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,30 @@
+from django.core.management.base import BaseCommand, CommandError
+from photologue.management.commands import get_response, create_photosize
+from photologue.models import PhotoEffect
+
+class Command(BaseCommand):
+ help = ('Prompts the user to set up the default photo sizes required by Photologue.')
+ requires_model_validation = True
+ can_import_settings = True
+
+ def handle(self, *args, **kwargs):
+ return init(*args, **kwargs)
+
+def init(*args, **kwargs):
+ msg = '\nPhotologue requires a specific photo size to display thumbnail previews in the Django admin application.\nWould you like to generate this size now? (yes, no):'
+ if get_response(msg, lambda inp: inp == 'yes', False):
+ admin_thumbnail = create_photosize('admin_thumbnail', width=100, height=75, crop=True, pre_cache=True)
+ msg = 'Would you like to apply a sample enhancement effect to your admin thumbnails? (yes, no):'
+ if get_response(msg, lambda inp: inp == 'yes', False):
+ effect, created = PhotoEffect.objects.get_or_create(name='Enhance Thumbnail', description="Increases sharpness and contrast. Works well for smaller image sizes such as thumbnails.", contrast=1.2, sharpness=1.3)
+ admin_thumbnail.effect = effect
+ admin_thumbnail.save()
+ msg = '\nPhotologue comes with a set of templates for setting up a complete photo gallery. These templates require you to define both a "thumbnail" and "display" size.\nWould you like to define them now? (yes, no):'
+ if get_response(msg, lambda inp: inp == 'yes', False):
+ thumbnail = create_photosize('thumbnail', width=100, height=75)
+ display = create_photosize('display', width=400, increment_count=True)
+ msg = 'Would you like to apply a sample reflection effect to your display images? (yes, no):'
+ if get_response(msg, lambda inp: inp == 'yes', False):
+ effect, created = PhotoEffect.objects.get_or_create(name='Display Reflection', description="Generates a reflection with a white background", reflection_size=0.4)
+ display.effect = effect
+ display.save()
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/models.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,725 @@
+import os
+import random
+import shutil
+import zipfile
+
+from datetime import datetime
+from inspect import isclass
+
+from django.db import models
+from django.db.models.signals import post_init
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.core.urlresolvers import reverse
+from django.template.defaultfilters import slugify
+from django.utils.functional import curry
+from django.utils.translation import ugettext_lazy as _
+
+# Required PIL classes may or may not be available from the root namespace
+# depending on the installation method used.
+try:
+ import Image
+ import ImageFile
+ import ImageFilter
+ import ImageEnhance
+except ImportError:
+ try:
+ from PIL import Image
+ from PIL import ImageFile
+ from PIL import ImageFilter
+ from PIL import ImageEnhance
+ except ImportError:
+ raise ImportError('Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')
+
+# attempt to load the django-tagging TagField from default location,
+# otherwise we substitude a dummy TagField.
+try:
+ from tagging.fields import TagField
+ tagfield_help_text = _('Separate tags with spaces, put quotes around multiple-word tags.')
+except ImportError:
+ class TagField(models.CharField):
+ def __init__(self, **kwargs):
+ default_kwargs = {'max_length': 255, 'blank': True}
+ default_kwargs.update(kwargs)
+ super(TagField, self).__init__(**default_kwargs)
+ def get_internal_type(self):
+ return 'CharField'
+ tagfield_help_text = _('Django-tagging was not found, tags will be treated as plain text.')
+
+from utils import EXIF
+from utils.reflection import add_reflection
+from utils.watermark import apply_watermark
+
+# Path to sample image
+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'
+
+# Modify image file buffer size.
+ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10)
+
+# Photologue image path relative to media root
+PHOTOLOGUE_DIR = getattr(settings, 'PHOTOLOGUE_DIR', 'photologue')
+
+# Look for user function to define file paths
+PHOTOLOGUE_PATH = getattr(settings, 'PHOTOLOGUE_PATH', None)
+if PHOTOLOGUE_PATH is not None:
+ if callable(PHOTOLOGUE_PATH):
+ get_storage_path = PHOTOLOGUE_PATH
+ else:
+ parts = PHOTOLOGUE_PATH.split('.')
+ module_name = '.'.join(parts[:-1])
+ module = __import__(module_name)
+ get_storage_path = getattr(module, parts[-1])
+else:
+ def get_storage_path(instance, filename):
+ return os.path.join(PHOTOLOGUE_DIR, 'photos', filename)
+
+# Quality options for JPEG images
+JPEG_QUALITY_CHOICES = (
+ (30, _('Very Low')),
+ (40, _('Low')),
+ (50, _('Medium-Low')),
+ (60, _('Medium')),
+ (70, _('Medium-High')),
+ (80, _('High')),
+ (90, _('Very High')),
+)
+
+# choices for new crop_anchor field in Photo
+CROP_ANCHOR_CHOICES = (
+ ('top', _('Top')),
+ ('right', _('Right')),
+ ('bottom', _('Bottom')),
+ ('left', _('Left')),
+ ('center', _('Center (Default)')),
+)
+
+IMAGE_TRANSPOSE_CHOICES = (
+ ('FLIP_LEFT_RIGHT', _('Flip left to right')),
+ ('FLIP_TOP_BOTTOM', _('Flip top to bottom')),
+ ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')),
+ ('ROTATE_270', _('Rotate 90 degrees clockwise')),
+ ('ROTATE_180', _('Rotate 180 degrees')),
+)
+
+WATERMARK_STYLE_CHOICES = (
+ ('tile', _('Tile')),
+ ('scale', _('Scale')),
+)
+
+# Prepare a list of image filters
+filter_names = []
+for n in dir(ImageFilter):
+ klass = getattr(ImageFilter, n)
+ if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \
+ hasattr(klass, 'name'):
+ filter_names.append(klass.__name__)
+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)))
+
+
+class Gallery(models.Model):
+ date_added = models.DateTimeField(_('date published'), default=datetime.now)
+ title = models.CharField(_('title'), max_length=100, unique=True)
+ title_slug = models.SlugField(_('title slug'), unique=True,
+ help_text=_('A "slug" is a unique URL-friendly title for an object.'))
+ description = models.TextField(_('description'), blank=True)
+ is_public = models.BooleanField(_('is public'), default=True,
+ help_text=_('Public galleries will be displayed in the default views.'))
+ photos = models.ManyToManyField('Photo', related_name='galleries', verbose_name=_('photos'),
+ null=True, blank=True)
+ tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags'))
+
+ class Meta:
+ ordering = ['-date_added']
+ get_latest_by = 'date_added'
+ verbose_name = _('gallery')
+ verbose_name_plural = _('galleries')
+
+ def __unicode__(self):
+ return self.title
+
+ def __str__(self):
+ return self.__unicode__()
+
+ def get_absolute_url(self):
+ return reverse('pl-gallery', args=[self.title_slug])
+
+ def latest(self, limit=0, public=True):
+ if limit == 0:
+ limit = self.photo_count()
+ if public:
+ return self.public()[:limit]
+ else:
+ return self.photos.all()[:limit]
+
+ def sample(self, count=0, public=True):
+ if count == 0 or count > self.photo_count():
+ count = self.photo_count()
+ if public:
+ photo_set = self.public()
+ else:
+ photo_set = self.photos.all()
+ return random.sample(photo_set, count)
+
+ def photo_count(self, public=True):
+ if public:
+ return self.public().count()
+ else:
+ return self.photos.all().count()
+ photo_count.short_description = _('count')
+
+ def public(self):
+ return self.photos.filter(is_public=True)
+
+
+class GalleryUpload(models.Model):
+ zip_file = models.FileField(_('images file (.zip)'), upload_to=PHOTOLOGUE_DIR+"/temp",
+ help_text=_('Select a .zip file of images to upload into a new Gallery.'))
+ 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.'))
+ 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.'))
+ caption = models.TextField(_('caption'), blank=True, help_text=_('Caption will be added to all photos.'))
+ description = models.TextField(_('description'), blank=True, help_text=_('A description of this Gallery.'))
+ is_public = models.BooleanField(_('is public'), default=True, help_text=_('Uncheck this to make the uploaded gallery and included photographs private.'))
+ tags = models.CharField(max_length=255, blank=True, help_text=tagfield_help_text, verbose_name=_('tags'))
+
+ class Meta:
+ verbose_name = _('gallery upload')
+ verbose_name_plural = _('gallery uploads')
+
+ def save(self, *args, **kwargs):
+ super(GalleryUpload, self).save(*args, **kwargs)
+ gallery = self.process_zipfile()
+ super(GalleryUpload, self).delete()
+ return gallery
+
+ def process_zipfile(self):
+ if os.path.isfile(self.zip_file.path):
+ # TODO: implement try-except here
+ zip = zipfile.ZipFile(self.zip_file.path)
+ bad_file = zip.testzip()
+ if bad_file:
+ raise Exception('"%s" in the .zip archive is corrupt.' % bad_file)
+ count = 1
+ if self.gallery:
+ gallery = self.gallery
+ else:
+ gallery = Gallery.objects.create(title=self.title,
+ title_slug=slugify(self.title),
+ description=self.description,
+ is_public=self.is_public,
+ tags=self.tags)
+ from cStringIO import StringIO
+ for filename in zip.namelist():
+ if filename.startswith('__'): # do not process meta files
+ continue
+ data = zip.read(filename)
+ if len(data):
+ try:
+ # the following is taken from django.newforms.fields.ImageField:
+ # load() is the only method that can spot a truncated JPEG,
+ # but it cannot be called sanely after verify()
+ trial_image = Image.open(StringIO(data))
+ trial_image.load()
+ # verify() is the only method that can spot a corrupt PNG,
+ # but it must be called immediately after the constructor
+ trial_image = Image.open(StringIO(data))
+ trial_image.verify()
+ except Exception:
+ # if a "bad" file is found we just skip it.
+ continue
+ while 1:
+ title = ' '.join([self.title, str(count)])
+ slug = slugify(title)
+ try:
+ p = Photo.objects.get(title_slug=slug)
+ except Photo.DoesNotExist:
+ photo = Photo(title=title,
+ title_slug=slug,
+ caption=self.caption,
+ is_public=self.is_public,
+ tags=self.tags)
+ photo.image.save(filename, ContentFile(data))
+ gallery.photos.add(photo)
+ count = count + 1
+ break
+ count = count + 1
+ zip.close()
+ return gallery
+
+
+class ImageModel(models.Model):
+ image = models.ImageField(_('image'), upload_to=get_storage_path)
+ date_taken = models.DateTimeField(_('date taken'), null=True, blank=True, editable=False)
+ view_count = models.PositiveIntegerField(default=0, editable=False)
+ crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES)
+ effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect'))
+
+ class Meta:
+ abstract = True
+
+ @property
+ def EXIF(self):
+ try:
+ return EXIF.process_file(open(self.image.path, 'rb'))
+ except:
+ try:
+ return EXIF.process_file(open(self.image.path, 'rb'), details=False)
+ except:
+ return {}
+
+ def admin_thumbnail(self):
+ func = getattr(self, 'get_admin_thumbnail_url', None)
+ if func is None:
+ return _('An "admin_thumbnail" photo size has not been defined.')
+ else:
+ if hasattr(self, 'get_absolute_url'):
+ return u'<a href="%s"><img src="%s"></a>' % \
+ (self.get_absolute_url(), func())
+ else:
+ return u'<a href="%s"><img src="%s"></a>' % \
+ (self.image.url, func())
+ admin_thumbnail.short_description = _('Thumbnail')
+ admin_thumbnail.allow_tags = True
+
+ def cache_path(self):
+ return os.path.join(os.path.dirname(self.image.path), "cache")
+
+ def cache_url(self):
+ return '/'.join([os.path.dirname(self.image.url), "cache"])
+
+ def image_filename(self):
+ return os.path.basename(self.image.path)
+
+ def _get_filename_for_size(self, size):
+ size = getattr(size, 'name', size)
+ base, ext = os.path.splitext(self.image_filename())
+ return ''.join([base, '_', size, ext])
+
+ def _get_SIZE_photosize(self, size):
+ return PhotoSizeCache().sizes.get(size)
+
+ def _get_SIZE_size(self, size):
+ photosize = PhotoSizeCache().sizes.get(size)
+ if not self.size_exists(photosize):
+ self.create_size(photosize)
+ return Image.open(self._get_SIZE_filename(size)).size
+
+ def _get_SIZE_url(self, size):
+ photosize = PhotoSizeCache().sizes.get(size)
+ if not self.size_exists(photosize):
+ self.create_size(photosize)
+ if photosize.increment_count:
+ self.increment_count()
+ return '/'.join([self.cache_url(), self._get_filename_for_size(photosize.name)])
+
+ def _get_SIZE_filename(self, size):
+ photosize = PhotoSizeCache().sizes.get(size)
+ return os.path.join(self.cache_path(),
+ self._get_filename_for_size(photosize.name))
+
+ def increment_count(self):
+ self.view_count += 1
+ models.Model.save(self)
+
+ def add_accessor_methods(self, *args, **kwargs):
+ for size in PhotoSizeCache().sizes.keys():
+ setattr(self, 'get_%s_size' % size,
+ curry(self._get_SIZE_size, size=size))
+ setattr(self, 'get_%s_photosize' % size,
+ curry(self._get_SIZE_photosize, size=size))
+ setattr(self, 'get_%s_url' % size,
+ curry(self._get_SIZE_url, size=size))
+ setattr(self, 'get_%s_filename' % size,
+ curry(self._get_SIZE_filename, size=size))
+
+ def size_exists(self, photosize):
+ func = getattr(self, "get_%s_filename" % photosize.name, None)
+ if func is not None:
+ if os.path.isfile(func()):
+ return True
+ return False
+
+ def resize_image(self, im, photosize):
+ cur_width, cur_height = im.size
+ new_width, new_height = photosize.size
+ if photosize.crop:
+ ratio = max(float(new_width)/cur_width,float(new_height)/cur_height)
+ x = (cur_width * ratio)
+ y = (cur_height * ratio)
+ xd = abs(new_width - x)
+ yd = abs(new_height - y)
+ x_diff = int(xd / 2)
+ y_diff = int(yd / 2)
+ if self.crop_from == 'top':
+ box = (int(x_diff), 0, int(x_diff+new_width), new_height)
+ elif self.crop_from == 'left':
+ box = (0, int(y_diff), new_width, int(y_diff+new_height))
+ elif self.crop_from == 'bottom':
+ box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height
+ elif self.crop_from == 'right':
+ box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width
+ else:
+ box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height))
+ im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box)
+ else:
+ if not new_width == 0 and not new_height == 0:
+ ratio = min(float(new_width)/cur_width,
+ float(new_height)/cur_height)
+ else:
+ if new_width == 0:
+ ratio = float(new_height)/cur_height
+ else:
+ ratio = float(new_width)/cur_width
+ new_dimensions = (int(round(cur_width*ratio)),
+ int(round(cur_height*ratio)))
+ if new_dimensions[0] > cur_width or \
+ new_dimensions[1] > cur_height:
+ if not photosize.upscale:
+ return im
+ im = im.resize(new_dimensions, Image.ANTIALIAS)
+ return im
+
+ def create_size(self, photosize):
+ if self.size_exists(photosize):
+ return
+ if not os.path.isdir(self.cache_path()):
+ os.makedirs(self.cache_path())
+ try:
+ im = Image.open(self.image.path)
+ except IOError:
+ return
+ # Save the original format
+ im_format = im.format
+ # Apply effect if found
+ if self.effect is not None:
+ im = self.effect.pre_process(im)
+ elif photosize.effect is not None:
+ im = photosize.effect.pre_process(im)
+ # Resize/crop image
+ if im.size != photosize.size and photosize.size != (0, 0):
+ im = self.resize_image(im, photosize)
+ # Apply watermark if found
+ if photosize.watermark is not None:
+ im = photosize.watermark.post_process(im)
+ # Apply effect if found
+ if self.effect is not None:
+ im = self.effect.post_process(im)
+ elif photosize.effect is not None:
+ im = photosize.effect.post_process(im)
+ # Save file
+ im_filename = getattr(self, "get_%s_filename" % photosize.name)()
+ try:
+ if im_format != 'JPEG':
+ try:
+ im.save(im_filename)
+ return
+ except KeyError:
+ pass
+ im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True)
+ except IOError, e:
+ if os.path.isfile(im_filename):
+ os.unlink(im_filename)
+ raise e
+
+ def remove_size(self, photosize, remove_dirs=True):
+ if not self.size_exists(photosize):
+ return
+ filename = getattr(self, "get_%s_filename" % photosize.name)()
+ if os.path.isfile(filename):
+ os.remove(filename)
+ if remove_dirs:
+ self.remove_cache_dirs()
+
+ def clear_cache(self):
+ cache = PhotoSizeCache()
+ for photosize in cache.sizes.values():
+ self.remove_size(photosize, False)
+ self.remove_cache_dirs()
+
+ def pre_cache(self):
+ cache = PhotoSizeCache()
+ for photosize in cache.sizes.values():
+ if photosize.pre_cache:
+ self.create_size(photosize)
+
+ def remove_cache_dirs(self):
+ try:
+ os.removedirs(self.cache_path())
+ except:
+ pass
+
+ def save(self, *args, **kwargs):
+ if self.date_taken is None:
+ try:
+ exif_date = self.EXIF.get('EXIF DateTimeOriginal', None)
+ if exif_date is not None:
+ d, t = str.split(exif_date.values)
+ year, month, day = d.split(':')
+ hour, minute, second = t.split(':')
+ self.date_taken = datetime(int(year), int(month), int(day),
+ int(hour), int(minute), int(second))
+ except:
+ pass
+ if self.date_taken is None:
+ self.date_taken = datetime.now()
+ if self._get_pk_val():
+ self.clear_cache()
+ super(ImageModel, self).save(*args, **kwargs)
+ self.pre_cache()
+
+ def delete(self):
+ assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (self._meta.object_name, self._meta.pk.attname)
+ self.clear_cache()
+ super(ImageModel, self).delete()
+
+
+class Photo(ImageModel):
+ title = models.CharField(_('title'), max_length=100, unique=True)
+ title_slug = models.SlugField(_('slug'), unique=True,
+ help_text=('A "slug" is a unique URL-friendly title for an object.'))
+ caption = models.TextField(_('caption'), blank=True)
+ date_added = models.DateTimeField(_('date added'), default=datetime.now, editable=False)
+ is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.'))
+ tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags'))
+
+ class Meta:
+ ordering = ['-date_added']
+ get_latest_by = 'date_added'
+ verbose_name = _("photo")
+ verbose_name_plural = _("photos")
+
+ def __unicode__(self):
+ return self.title
+
+ def __str__(self):
+ return self.__unicode__()
+
+ def save(self, *args, **kwargs):
+ if self.title_slug is None:
+ self.title_slug = slugify(self.title)
+ super(Photo, self).save(*args, **kwargs)
+
+ def get_absolute_url(self):
+ return reverse('pl-photo', args=[self.title_slug])
+
+ def public_galleries(self):
+ """Return the public galleries to which this photo belongs."""
+ return self.galleries.filter(is_public=True)
+
+ def get_previous_in_gallery(self, gallery):
+ try:
+ return self.get_previous_by_date_added(galleries__exact=gallery,
+ is_public=True)
+ except Photo.DoesNotExist:
+ return None
+
+ def get_next_in_gallery(self, gallery):
+ try:
+ return self.get_next_by_date_added(galleries__exact=gallery,
+ is_public=True)
+ except Photo.DoesNotExist:
+ return None
+
+
+class BaseEffect(models.Model):
+ name = models.CharField(_('name'), max_length=30, unique=True)
+ description = models.TextField(_('description'), blank=True)
+
+ class Meta:
+ abstract = True
+
+ def sample_dir(self):
+ return os.path.join(settings.MEDIA_ROOT, PHOTOLOGUE_DIR, 'samples')
+
+ def sample_url(self):
+ return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')])
+
+ def sample_filename(self):
+ return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample'))
+
+ def create_sample(self):
+ if not os.path.isdir(self.sample_dir()):
+ os.makedirs(self.sample_dir())
+ try:
+ im = Image.open(SAMPLE_IMAGE_PATH)
+ except IOError:
+ raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH)
+ im = self.process(im)
+ im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True)
+
+ def admin_sample(self):
+ return u'<img src="%s">' % self.sample_url()
+ admin_sample.short_description = 'Sample'
+ admin_sample.allow_tags = True
+
+ def pre_process(self, im):
+ return im
+
+ def post_process(self, im):
+ return im
+
+ def process(self, im):
+ im = self.pre_process(im)
+ im = self.post_process(im)
+ return im
+
+ def __unicode__(self):
+ return self.name
+
+ def __str__(self):
+ return self.__unicode__()
+
+ def save(self, *args, **kwargs):
+ try:
+ os.remove(self.sample_filename())
+ except:
+ pass
+ models.Model.save(self, *args, **kwargs)
+ self.create_sample()
+ for size in self.photo_sizes.all():
+ size.clear_cache()
+ # try to clear all related subclasses of ImageModel
+ for prop in [prop for prop in dir(self) if prop[-8:] == '_related']:
+ for obj in getattr(self, prop).all():
+ obj.clear_cache()
+ obj.pre_cache()
+
+ def delete(self):
+ try:
+ os.remove(self.sample_filename())
+ except:
+ pass
+ models.Model.delete(self)
+
+
+class PhotoEffect(BaseEffect):
+ """ A pre-defined effect to apply to photos """
+ transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES)
+ 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."))
+ 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."))
+ 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."))
+ 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."))
+ filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT))
+ 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."))
+ reflection_strength = models.FloatField(_('strength'), default=0.6, help_text=_("The initial opacity of the reflection gradient."))
+ 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."))
+
+ class Meta:
+ verbose_name = _("photo effect")
+ verbose_name_plural = _("photo effects")
+
+ def pre_process(self, im):
+ if self.transpose_method != '':
+ method = getattr(Image, self.transpose_method)
+ im = im.transpose(method)
+ if im.mode != 'RGB' and im.mode != 'RGBA':
+ return im
+ for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
+ factor = getattr(self, name.lower())
+ if factor != 1.0:
+ im = getattr(ImageEnhance, name)(im).enhance(factor)
+ for name in self.filters.split('->'):
+ image_filter = getattr(ImageFilter, name.upper(), None)
+ if image_filter is not None:
+ try:
+ im = im.filter(image_filter)
+ except ValueError:
+ pass
+ return im
+
+ def post_process(self, im):
+ if self.reflection_size != 0.0:
+ im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength)
+ return im
+
+
+class Watermark(BaseEffect):
+ image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR+"/watermarks")
+ style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale')
+ opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay."))
+
+ class Meta:
+ verbose_name = _('watermark')
+ verbose_name_plural = _('watermarks')
+
+ def post_process(self, im):
+ mark = Image.open(self.image.path)
+ return apply_watermark(im, mark, self.style, self.opacity)
+
+
+class PhotoSize(models.Model):
+ 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".'))
+ width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.'))
+ height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width'))
+ quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.'))
+ 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.'))
+ crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.'))
+ pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.'))
+ 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.'))
+ effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect'))
+ watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image'))
+
+ class Meta:
+ ordering = ['width', 'height']
+ verbose_name = _('photo size')
+ verbose_name_plural = _('photo sizes')
+
+ def __unicode__(self):
+ return self.name
+
+ def __str__(self):
+ return self.__unicode__()
+
+ def clear_cache(self):
+ for cls in ImageModel.__subclasses__():
+ for obj in cls.objects.all():
+ obj.remove_size(self)
+ if self.pre_cache:
+ obj.create_size(self)
+ PhotoSizeCache().reset()
+
+ def save(self, *args, **kwargs):
+ if self.crop is True:
+ if self.width == 0 or self.height == 0:
+ raise ValueError("PhotoSize width and/or height can not be zero if crop=True.")
+ super(PhotoSize, self).save(*args, **kwargs)
+ PhotoSizeCache().reset()
+ self.clear_cache()
+
+ def delete(self):
+ assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (self._meta.object_name, self._meta.pk.attname)
+ self.clear_cache()
+ super(PhotoSize, self).delete()
+
+ def _get_size(self):
+ return (self.width, self.height)
+ def _set_size(self, value):
+ self.width, self.height = value
+ size = property(_get_size, _set_size)
+
+
+class PhotoSizeCache(object):
+ __state = {"sizes": {}}
+
+ def __init__(self):
+ self.__dict__ = self.__state
+ if not len(self.sizes):
+ sizes = PhotoSize.objects.all()
+ for size in sizes:
+ self.sizes[size.name] = size
+
+ def reset(self):
+ self.sizes = {}
+
+
+# Set up the accessor methods
+def add_methods(sender, instance, signal, *args, **kwargs):
+ """ Adds methods to access sized images (urls, paths)
+
+ after the Photo model's __init__ function completes,
+ this method calls "add_accessor_methods" on each instance.
+ """
+ if hasattr(instance, 'add_accessor_methods'):
+ instance.add_accessor_methods()
+
+# connect the add_accessor_methods function to the post_init signal
+post_init.connect(add_methods)
Binary file web/lib/photologue/res/sample.jpg has changed
Binary file web/lib/photologue/res/test_landscape.jpg has changed
Binary file web/lib/photologue/res/test_portrait.jpg has changed
Binary file web/lib/photologue/res/test_square.jpg has changed
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/gallery_archive.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,26 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Latest Photo Galleries{% endblock %}
+
+{% block content %}
+
+<h1>Latest Photo Galleries</h1>
+
+{% if latest %}
+ {% for gallery in latest %}
+ <div class="photo-gallery">
+ <h2><a href="{{ gallery.get_absolute_url }}">{{ gallery.title }}</a></h2>
+ {% for photo in gallery.sample|slice:sample_size %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+ </div>
+ {% endfor %}
+{% else %}
+ <p>No galleries were found.</p>
+{% endif %}
+
+<p><a href="{% url pl-gallery-list 1 %}">View all galleries.</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/gallery_archive_day.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,26 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Galleries for {{ day|date }}{% endblock %}
+
+{% block content %}
+
+<h1>Galleries for {{ day|date }}</h1>
+
+{% if object_list %}
+ {% for gallery in object_list %}
+ <div class="photo-gallery">
+ <h2>{{ gallery.title }}</h2>
+ {% for photo in gallery.sample|slice:sample_size %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+ </div>
+ {% endfor %}
+{% else %}
+ <p>No galleries were found.</p>
+{% endif %}
+
+<p><a href="{% url pl-gallery-list 1 %}">View all galleries.</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/gallery_archive_month.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,26 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Galleries for {{ month|date:"F Y" }}{% endblock %}
+
+{% block content %}
+
+<h1>Galleries for {{ month|date:"F Y" }}</h1>
+
+{% if object_list %}
+ {% for gallery in object_list %}
+ <div class="photo-gallery">
+ <h2>{{ gallery.title }}</h2>
+ {% for photo in gallery.sample|slice:sample_size %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+ </div>
+ {% endfor %}
+{% else %}
+ <p>No galleries were found.</p>
+{% endif %}
+
+<p><a href="{% url pl-gallery-list 1 %}">View all galleries.</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/gallery_archive_year.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,16 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Galleries for {{ year }}{% endblock %}
+
+{% block content %}
+
+<h1>Galleries for {{ year }}</h1>
+<ul>
+{% for date in date_list %}
+<li><a href="{{ date|date:"M"|lower }}/">{{ date|date:"F" }}</a></li>
+{% endfor %}
+</ul>
+
+<p><a href="{% url pl-gallery-list 1 %}">View all galleries.</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/gallery_detail.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,19 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}{{ object.title }}{% endblock %}
+
+{% block content %}
+
+<h1>{{ object.title }}</h1>
+<h2>Originally published {{ object.date_added|date:"l, F jS, Y" }}</h2>
+{% if object.description %}<p>{{ object.description }}</p>{% endif %}
+<div class="photo-gallery">
+ {% for photo in object.public %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+</div>
+<p><a href="{% url pl-gallery-list 1 %}">View all galleries</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/gallery_list.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,31 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}All Galleries{% endblock %}
+
+{% block content %}
+
+<h1>All galleries</h1>
+
+{% if object_list %}
+ {% for gallery in object_list %}
+ <div class="photo-gallery">
+ <h2><a href="{{ gallery.get_absolute_url }}">{{ gallery.title }}</a></h2>
+ {% for photo in gallery.sample|slice:sample_size %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+ </div>
+ {% endfor %}
+{% else %}
+ <p>No galleries were found.</p>
+{% endif %}
+
+{% if is_paginated %}
+<p>{{ hits }} galleries total.</p>
+<div id="page_controls">
+ <p>{% if has_previous %}<a href="{% url pl-gallery-list previous %}">Previous</a> | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| <a href="{% url pl-gallery-list next %}">Next</a>{% endif %}</p>
+</div>
+{% endif %}
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/photo_archive.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,20 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Latest Photos{% endblock %}
+
+{% block content %}
+
+<h1>Latest Photos</h1>
+
+{% if latest %}
+ {% for photo in latest %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+{% else %}
+<p>No photos were found.</p>
+{% endif %}
+<p><a href="{% url pl-photo-list 1 %}">View all photographs</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/photo_archive_day.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,20 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Photos for {{ day|date }}{% endblock %}
+
+{% block content %}
+
+<h1>Photos for {{ day|date }}</h1>
+
+{% if object_list %}
+ {% for photo in object_list %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+{% else %}
+<p>No photos were found.</p>
+{% endif %}
+<p><a href="{% url pl-photo-list 1 %}">View all photographs</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/photo_archive_month.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,20 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Photos for {{ month|date:"F Y" }}{% endblock %}
+
+{% block content %}
+
+<h1>Photos for {{ month|date:"F Y" }}</h1>
+
+{% if object_list %}
+ {% for photo in object_list %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+{% else %}
+<p>No photos were found.</p>
+{% endif %}
+<p><a href="{% url pl-photo-list 1 %}">View all photographs</a></p>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/photo_archive_year.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,14 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}Galleries for {{ year }}{% endblock %}
+
+{% block content %}
+
+<h1>Photos for {{ year }}</h1>
+<ul>
+{% for date in date_list %}
+<li><a href="{{ date|date:"M"|lower }}/">{{ date|date:"F" }}</a></li>
+{% endfor %}
+</ul>
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/photo_detail.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,23 @@
+{% extends "photologue/root.html" %}
+
+{% load photologue_tags %}
+
+{% block title %}{{ object.title }}{% endblock %}
+
+{% block content %}
+
+<h1>{{ object.title }}</h1>
+<div class="gallery-photo">
+ <a href="{{ object.image.url }}"><img src="{{ object.get_display_url }}" alt="{{ object.title }}"/></a>
+ {% if object.caption %}<p>{{ object.caption }}</p>{% endif %}
+</div>
+{% if object.public_galleries %}
+<h2>This photo is found in the following galleries:</h2>
+<ol>
+{% for gallery in object.public_galleries %}
+ <li>{%previous_in_gallery object gallery%} <a href="{{ gallery.get_absolute_url }}">{{ gallery.title }}</a> {%next_in_gallery object gallery%}</li>
+{% endfor %}
+</ol>
+{% endif %}
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/photo_list.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,26 @@
+{% extends "photologue/root.html" %}
+
+{% block title %}All Photos{% endblock %}
+
+{% block content %}
+
+<h1>All Photos</h1>
+
+{% if object_list %}
+ {% for photo in object_list %}
+ <div class="gallery-photo">
+ <a href="{{ photo.get_absolute_url }}"><img src="{{ photo.get_thumbnail_url }}" alt="{{ photo.title }}"/></a>
+ </div>
+ {% endfor %}
+{% else %}
+<p>No photos were found.</p>
+{% endif %}
+
+{% if is_paginated %}
+<p>{{ hits }} photos total.</p>
+<div id="page_controls">
+ <p>{% if has_previous %}<a href="{% url pl-photo-list previous %}">Previous</a> | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| <a href="{% url pl-photo-list next %}">Next</a>{% endif %}</p>
+</div>
+{% endif %}
+
+{% endblock %}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templates/photologue/root.html Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,1 @@
+{% extends "base.html" %}
\ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/templatetags/photologue_tags.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,17 @@
+from django import template
+
+register = template.Library()
+
+@register.simple_tag
+def next_in_gallery(photo, gallery):
+ next = photo.get_next_in_gallery(gallery)
+ if next:
+ return '<a title="%s" href="%s"><img src="%s"/></a>' % (next.title, next.get_absolute_url(), next.get_thumbnail_url())
+ return ""
+
+@register.simple_tag
+def previous_in_gallery(photo, gallery):
+ prev = photo.get_previous_in_gallery(gallery)
+ if prev:
+ return '<a title="%s" href="%s"><img src="%s"/></a>' % (prev.title, prev.get_absolute_url(), prev.get_thumbnail_url())
+ return ""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/tests.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,218 @@
+import os
+import unittest
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.test import TestCase
+
+from models import *
+
+# Path to sample image
+RES_DIR = os.path.join(os.path.dirname(__file__), 'res')
+LANDSCAPE_IMAGE_PATH = os.path.join(RES_DIR, 'test_landscape.jpg')
+PORTRAIT_IMAGE_PATH = os.path.join(RES_DIR, 'test_portrait.jpg')
+SQUARE_IMAGE_PATH = os.path.join(RES_DIR, 'test_square.jpg')
+
+
+class TestPhoto(ImageModel):
+ """ Minimal ImageModel class for testing """
+ name = models.CharField(max_length=30)
+
+
+class PLTest(TestCase):
+ """ Base TestCase class """
+ def setUp(self):
+ self.s = PhotoSize(name='test', width=100, height=100)
+ self.s.save()
+ self.pl = TestPhoto(name='landscape')
+ self.pl.image.save(os.path.basename(LANDSCAPE_IMAGE_PATH),
+ ContentFile(open(LANDSCAPE_IMAGE_PATH, 'rb').read()))
+ self.pl.save()
+
+ def tearDown(self):
+ path = self.pl.image.path
+ self.pl.delete()
+ self.failIf(os.path.isfile(path))
+ self.s.delete()
+
+
+class PhotoTest(PLTest):
+ def test_new_photo(self):
+ self.assertEqual(TestPhoto.objects.count(), 1)
+ self.failUnless(os.path.isfile(self.pl.image.path))
+ self.assertEqual(os.path.getsize(self.pl.image.path),
+ os.path.getsize(LANDSCAPE_IMAGE_PATH))
+
+ #def test_exif(self):
+ # self.assert_(len(self.pl.EXIF.keys()) > 0)
+
+ def test_paths(self):
+ self.assertEqual(os.path.normpath(str(self.pl.cache_path())).lower(),
+ os.path.normpath(os.path.join(settings.MEDIA_ROOT,
+ PHOTOLOGUE_DIR,
+ 'photos',
+ 'cache')).lower())
+ self.assertEqual(self.pl.cache_url(),
+ settings.MEDIA_URL + PHOTOLOGUE_DIR + '/photos/cache')
+
+ def test_count(self):
+ for i in range(5):
+ self.pl.get_test_url()
+ self.assertEquals(self.pl.view_count, 0)
+ self.s.increment_count = True
+ self.s.save()
+ for i in range(5):
+ self.pl.get_test_url()
+ self.assertEquals(self.pl.view_count, 5)
+
+ def test_precache(self):
+ # set the thumbnail photo size to pre-cache
+ self.s.pre_cache = True
+ self.s.save()
+ # make sure it created the file
+ self.failUnless(os.path.isfile(self.pl.get_test_filename()))
+ self.s.pre_cache = False
+ self.s.save()
+ # clear the cache and make sure the file's deleted
+ self.pl.clear_cache()
+ self.failIf(os.path.isfile(self.pl.get_test_filename()))
+
+ def test_accessor_methods(self):
+ self.assertEquals(self.pl.get_test_photosize(), self.s)
+ self.assertEquals(self.pl.get_test_size(),
+ Image.open(self.pl.get_test_filename()).size)
+ self.assertEquals(self.pl.get_test_url(),
+ self.pl.cache_url() + '/' + \
+ self.pl._get_filename_for_size(self.s))
+ self.assertEquals(self.pl.get_test_filename(),
+ os.path.join(self.pl.cache_path(),
+ self.pl._get_filename_for_size(self.s)))
+
+
+class ImageResizeTest(PLTest):
+ def setUp(self):
+ super(ImageResizeTest, self).setUp()
+ self.pp = TestPhoto(name='portrait')
+ self.pp.image.save(os.path.basename(PORTRAIT_IMAGE_PATH),
+ ContentFile(open(PORTRAIT_IMAGE_PATH, 'rb').read()))
+ self.pp.save()
+ self.ps = TestPhoto(name='square')
+ self.ps.image.save(os.path.basename(SQUARE_IMAGE_PATH),
+ ContentFile(open(SQUARE_IMAGE_PATH, 'rb').read()))
+ self.ps.save()
+
+ def tearDown(self):
+ super(ImageResizeTest, self).tearDown()
+ self.pp.delete()
+ self.ps.delete()
+
+ def test_resize_to_fit(self):
+ self.assertEquals(self.pl.get_test_size(), (100, 75))
+ self.assertEquals(self.pp.get_test_size(), (75, 100))
+ self.assertEquals(self.ps.get_test_size(), (100, 100))
+
+ def test_resize_to_fit_width(self):
+ self.s.size = (100, 0)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (100, 75))
+ self.assertEquals(self.pp.get_test_size(), (100, 133))
+ self.assertEquals(self.ps.get_test_size(), (100, 100))
+
+ def test_resize_to_fit_width_enlarge(self):
+ self.s.size = (400, 0)
+ self.s.upscale = True
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (400, 300))
+ self.assertEquals(self.pp.get_test_size(), (400, 533))
+ self.assertEquals(self.ps.get_test_size(), (400, 400))
+
+ def test_resize_to_fit_height(self):
+ self.s.size = (0, 100)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (133, 100))
+ self.assertEquals(self.pp.get_test_size(), (75, 100))
+ self.assertEquals(self.ps.get_test_size(), (100, 100))
+
+ def test_resize_to_fit_height_enlarge(self):
+ self.s.size = (0, 400)
+ self.s.upscale = True
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (533, 400))
+ self.assertEquals(self.pp.get_test_size(), (300, 400))
+ self.assertEquals(self.ps.get_test_size(), (400, 400))
+
+ def test_resize_and_crop(self):
+ self.s.crop = True
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), self.s.size)
+ self.assertEquals(self.pp.get_test_size(), self.s.size)
+ self.assertEquals(self.ps.get_test_size(), self.s.size)
+
+ def test_resize_rounding_to_fit(self):
+ self.s.size = (113, 113)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (113, 85))
+ self.assertEquals(self.pp.get_test_size(), (85, 113))
+ self.assertEquals(self.ps.get_test_size(), (113, 113))
+
+ def test_resize_rounding_cropped(self):
+ self.s.size = (113, 113)
+ self.s.crop = True
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), self.s.size)
+ self.assertEquals(self.pp.get_test_size(), self.s.size)
+ self.assertEquals(self.ps.get_test_size(), self.s.size)
+
+ def test_resize_one_dimension_width(self):
+ self.s.size = (100, 150)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (100, 75))
+
+ def test_resize_one_dimension_height(self):
+ self.s.size = (200, 75)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (100, 75))
+
+ def test_resize_no_upscale(self):
+ self.s.size = (1000, 1000)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (200, 150))
+
+ def test_resize_no_upscale_mixed_height(self):
+ self.s.size = (400, 75)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (100, 75))
+
+ def test_resize_no_upscale_mixed_width(self):
+ self.s.size = (100, 300)
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (100, 75))
+
+ def test_resize_no_upscale_crop(self):
+ self.s.size = (1000, 1000)
+ self.s.crop = True
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (1000, 1000))
+
+ def test_resize_upscale(self):
+ self.s.size = (1000, 1000)
+ self.s.upscale = True
+ self.s.save()
+ self.assertEquals(self.pl.get_test_size(), (1000, 750))
+ self.assertEquals(self.pp.get_test_size(), (750, 1000))
+ self.assertEquals(self.ps.get_test_size(), (1000, 1000))
+
+
+class PhotoEffectTest(PLTest):
+ def test(self):
+ effect = PhotoEffect(name='test')
+ im = Image.open(self.pl.image.path)
+ self.assert_(isinstance(effect.pre_process(im), Image.Image))
+ self.assert_(isinstance(effect.post_process(im), Image.Image))
+ self.assert_(isinstance(effect.process(im), Image.Image))
+
+
+class PhotoSizeCacheTest(PLTest):
+ def test(self):
+ cache = PhotoSizeCache()
+ self.assertEqual(cache.sizes['test'], self.s)
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/urls.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,36 @@
+from django.conf import settings
+from django.conf.urls.defaults import *
+from models import *
+
+# Number of random images from the gallery to display.
+SAMPLE_SIZE = ":%s" % getattr(settings, 'GALLERY_SAMPLE_SIZE', 5)
+
+# galleries
+gallery_args = {'date_field': 'date_added', 'allow_empty': True, 'queryset': Gallery.objects.filter(is_public=True), 'extra_context':{'sample_size':SAMPLE_SIZE}}
+urlpatterns = patterns('django.views.generic.date_based',
+ url(r'^gallery/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\w{1,2})/(?P<slug>[\-\d\w]+)/$', 'object_detail', {'date_field': 'date_added', 'slug_field': 'title_slug', 'queryset': Gallery.objects.filter(is_public=True), 'extra_context':{'sample_size':SAMPLE_SIZE}}, name='pl-gallery-detail'),
+ url(r'^gallery/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\w{1,2})/$', 'archive_day', gallery_args, name='pl-gallery-archive-day'),
+ url(r'^gallery/(?P<year>\d{4})/(?P<month>[a-z]{3})/$', 'archive_month', gallery_args, name='pl-gallery-archive-month'),
+ url(r'^gallery/(?P<year>\d{4})/$', 'archive_year', gallery_args, name='pl-gallery-archive-year'),
+ url(r'^gallery/?$', 'archive_index', gallery_args, name='pl-gallery-archive'),
+)
+urlpatterns += patterns('django.views.generic.list_detail',
+ url(r'^gallery/(?P<slug>[\-\d\w]+)/$', 'object_detail', {'slug_field': 'title_slug', 'queryset': Gallery.objects.filter(is_public=True), 'extra_context':{'sample_size':SAMPLE_SIZE}}, name='pl-gallery'),
+ url(r'^gallery/page/(?P<page>[0-9]+)/$', 'object_list', {'queryset': Gallery.objects.filter(is_public=True), 'allow_empty': True, 'paginate_by': 5, 'extra_context':{'sample_size':SAMPLE_SIZE}}, name='pl-gallery-list'),
+)
+
+# photographs
+photo_args = {'date_field': 'date_added', 'allow_empty': True, 'queryset': Photo.objects.filter(is_public=True)}
+urlpatterns += patterns('django.views.generic.date_based',
+ url(r'^photo/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\w{1,2})/(?P<slug>[\-\d\w]+)/$', 'object_detail', {'date_field': 'date_added', 'slug_field': 'title_slug', 'queryset': Photo.objects.filter(is_public=True)}, name='pl-photo-detail'),
+ url(r'^photo/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\w{1,2})/$', 'archive_day', photo_args, name='pl-photo-archive-day'),
+ url(r'^photo/(?P<year>\d{4})/(?P<month>[a-z]{3})/$', 'archive_month', photo_args, name='pl-photo-archive-month'),
+ url(r'^photo/(?P<year>\d{4})/$', 'archive_year', photo_args, name='pl-photo-archive-year'),
+ url(r'^photo/$', 'archive_index', photo_args, name='pl-photo-archive'),
+)
+urlpatterns += patterns('django.views.generic.list_detail',
+ url(r'^photo/(?P<slug>[\-\d\w]+)/$', 'object_detail', {'slug_field': 'title_slug', 'queryset': Photo.objects.filter(is_public=True)}, name='pl-photo'),
+ url(r'^photo/page/(?P<page>[0-9]+)/$', 'object_list', {'queryset': Photo.objects.filter(is_public=True), 'allow_empty': True, 'paginate_by': 20}, name='pl-photo-list'),
+)
+
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/utils/EXIF.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,1568 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Library to extract EXIF information from digital camera image files
+# http://sourceforge.net/projects/exif-py/
+#
+# VERSION 1.0.7
+#
+# To use this library call with:
+# f = open(path_name, 'rb')
+# tags = EXIF.process_file(f)
+#
+# To ignore makerNote tags, pass the -q or --quick
+# command line arguments, or as
+# f = open(path_name, 'rb')
+# tags = EXIF.process_file(f, details=False)
+#
+# To stop processing after a certain tag is retrieved,
+# pass the -t TAG or --stop-tag TAG argument, or as
+# f = open(path_name, 'rb')
+# tags = EXIF.process_file(f, stop_tag='TAG')
+#
+# where TAG is a valid tag name, ex 'DateTimeOriginal'
+#
+# These are useful when you are retrieving a large list of images
+#
+# Returned tags will be a dictionary mapping names of EXIF tags to their
+# values in the file named by path_name. You can process the tags
+# as you wish. In particular, you can iterate through all the tags with:
+# for tag in tags.keys():
+# if tag not in ('JPEGThumbnail', 'TIFFThumbnail', 'Filename',
+# 'EXIF MakerNote'):
+# print "Key: %s, value %s" % (tag, tags[tag])
+# (This code uses the if statement to avoid printing out a few of the
+# tags that tend to be long or boring.)
+#
+# The tags dictionary will include keys for all of the usual EXIF
+# tags, and will also include keys for Makernotes used by some
+# cameras, for which we have a good specification.
+#
+# Note that the dictionary keys are the IFD name followed by the
+# tag name. For example:
+# 'EXIF DateTimeOriginal', 'Image Orientation', 'MakerNote FocusMode'
+#
+# Copyright (c) 2002-2007 Gene Cash All rights reserved
+# Copyright (c) 2007 Ianaré Sévi All rights reserved
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following
+# disclaimer in the documentation and/or other materials provided
+# with the distribution.
+#
+# 3. Neither the name of the authors nor the names of its contributors
+# may be used to endorse or promote products derived from this
+# software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+#
+# ----- See 'changes.txt' file for all contributors and changes ----- #
+#
+
+
+# Don't throw an exception when given an out of range character.
+def make_string(seq):
+ str = ""
+ for c in seq:
+ # Screen out non-printing characters
+ if 32 <= c and c < 256:
+ str += chr(c)
+ # If no printing chars
+ if not str:
+ return seq
+ return str
+
+# Special version to deal with the code in the first 8 bytes of a user comment.
+def make_string_uc(seq):
+ code = seq[0:8]
+ seq = seq[8:]
+ # Of course, this is only correct if ASCII, and the standard explicitly
+ # allows JIS and Unicode.
+ return make_string(seq)
+
+# field type descriptions as (length, abbreviation, full name) tuples
+FIELD_TYPES = (
+ (0, 'X', 'Proprietary'), # no such type
+ (1, 'B', 'Byte'),
+ (1, 'A', 'ASCII'),
+ (2, 'S', 'Short'),
+ (4, 'L', 'Long'),
+ (8, 'R', 'Ratio'),
+ (1, 'SB', 'Signed Byte'),
+ (1, 'U', 'Undefined'),
+ (2, 'SS', 'Signed Short'),
+ (4, 'SL', 'Signed Long'),
+ (8, 'SR', 'Signed Ratio'),
+ )
+
+# dictionary of main EXIF tag names
+# first element of tuple is tag name, optional second element is
+# another dictionary giving names to values
+EXIF_TAGS = {
+ 0x0100: ('ImageWidth', ),
+ 0x0101: ('ImageLength', ),
+ 0x0102: ('BitsPerSample', ),
+ 0x0103: ('Compression',
+ {1: 'Uncompressed TIFF',
+ 6: 'JPEG Compressed'}),
+ 0x0106: ('PhotometricInterpretation', ),
+ 0x010A: ('FillOrder', ),
+ 0x010D: ('DocumentName', ),
+ 0x010E: ('ImageDescription', ),
+ 0x010F: ('Make', ),
+ 0x0110: ('Model', ),
+ 0x0111: ('StripOffsets', ),
+ 0x0112: ('Orientation',
+ {1: 'Horizontal (normal)',
+ 2: 'Mirrored horizontal',
+ 3: 'Rotated 180',
+ 4: 'Mirrored vertical',
+ 5: 'Mirrored horizontal then rotated 90 CCW',
+ 6: 'Rotated 90 CW',
+ 7: 'Mirrored horizontal then rotated 90 CW',
+ 8: 'Rotated 90 CCW'}),
+ 0x0115: ('SamplesPerPixel', ),
+ 0x0116: ('RowsPerStrip', ),
+ 0x0117: ('StripByteCounts', ),
+ 0x011A: ('XResolution', ),
+ 0x011B: ('YResolution', ),
+ 0x011C: ('PlanarConfiguration', ),
+ 0x0128: ('ResolutionUnit',
+ {1: 'Not Absolute',
+ 2: 'Pixels/Inch',
+ 3: 'Pixels/Centimeter'}),
+ 0x012D: ('TransferFunction', ),
+ 0x0131: ('Software', ),
+ 0x0132: ('DateTime', ),
+ 0x013B: ('Artist', ),
+ 0x013E: ('WhitePoint', ),
+ 0x013F: ('PrimaryChromaticities', ),
+ 0x0156: ('TransferRange', ),
+ 0x0200: ('JPEGProc', ),
+ 0x0201: ('JPEGInterchangeFormat', ),
+ 0x0202: ('JPEGInterchangeFormatLength', ),
+ 0x0211: ('YCbCrCoefficients', ),
+ 0x0212: ('YCbCrSubSampling', ),
+ 0x0213: ('YCbCrPositioning', ),
+ 0x0214: ('ReferenceBlackWhite', ),
+ 0x828D: ('CFARepeatPatternDim', ),
+ 0x828E: ('CFAPattern', ),
+ 0x828F: ('BatteryLevel', ),
+ 0x8298: ('Copyright', ),
+ 0x829A: ('ExposureTime', ),
+ 0x829D: ('FNumber', ),
+ 0x83BB: ('IPTC/NAA', ),
+ 0x8769: ('ExifOffset', ),
+ 0x8773: ('InterColorProfile', ),
+ 0x8822: ('ExposureProgram',
+ {0: 'Unidentified',
+ 1: 'Manual',
+ 2: 'Program Normal',
+ 3: 'Aperture Priority',
+ 4: 'Shutter Priority',
+ 5: 'Program Creative',
+ 6: 'Program Action',
+ 7: 'Portrait Mode',
+ 8: 'Landscape Mode'}),
+ 0x8824: ('SpectralSensitivity', ),
+ 0x8825: ('GPSInfo', ),
+ 0x8827: ('ISOSpeedRatings', ),
+ 0x8828: ('OECF', ),
+ # print as string
+ 0x9000: ('ExifVersion', make_string),
+ 0x9003: ('DateTimeOriginal', ),
+ 0x9004: ('DateTimeDigitized', ),
+ 0x9101: ('ComponentsConfiguration',
+ {0: '',
+ 1: 'Y',
+ 2: 'Cb',
+ 3: 'Cr',
+ 4: 'Red',
+ 5: 'Green',
+ 6: 'Blue'}),
+ 0x9102: ('CompressedBitsPerPixel', ),
+ 0x9201: ('ShutterSpeedValue', ),
+ 0x9202: ('ApertureValue', ),
+ 0x9203: ('BrightnessValue', ),
+ 0x9204: ('ExposureBiasValue', ),
+ 0x9205: ('MaxApertureValue', ),
+ 0x9206: ('SubjectDistance', ),
+ 0x9207: ('MeteringMode',
+ {0: 'Unidentified',
+ 1: 'Average',
+ 2: 'CenterWeightedAverage',
+ 3: 'Spot',
+ 4: 'MultiSpot'}),
+ 0x9208: ('LightSource',
+ {0: 'Unknown',
+ 1: 'Daylight',
+ 2: 'Fluorescent',
+ 3: 'Tungsten',
+ 10: 'Flash',
+ 17: 'Standard Light A',
+ 18: 'Standard Light B',
+ 19: 'Standard Light C',
+ 20: 'D55',
+ 21: 'D65',
+ 22: 'D75',
+ 255: 'Other'}),
+ 0x9209: ('Flash', {0: 'No',
+ 1: 'Fired',
+ 5: 'Fired (?)', # no return sensed
+ 7: 'Fired (!)', # return sensed
+ 9: 'Fill Fired',
+ 13: 'Fill Fired (?)',
+ 15: 'Fill Fired (!)',
+ 16: 'Off',
+ 24: 'Auto Off',
+ 25: 'Auto Fired',
+ 29: 'Auto Fired (?)',
+ 31: 'Auto Fired (!)',
+ 32: 'Not Available'}),
+ 0x920A: ('FocalLength', ),
+ 0x9214: ('SubjectArea', ),
+ 0x927C: ('MakerNote', ),
+ # print as string
+ 0x9286: ('UserComment', make_string_uc), # First 8 bytes gives coding system e.g. ASCII vs. JIS vs Unicode
+ 0x9290: ('SubSecTime', ),
+ 0x9291: ('SubSecTimeOriginal', ),
+ 0x9292: ('SubSecTimeDigitized', ),
+ # print as string
+ 0xA000: ('FlashPixVersion', make_string),
+ 0xA001: ('ColorSpace', ),
+ 0xA002: ('ExifImageWidth', ),
+ 0xA003: ('ExifImageLength', ),
+ 0xA005: ('InteroperabilityOffset', ),
+ 0xA20B: ('FlashEnergy', ), # 0x920B in TIFF/EP
+ 0xA20C: ('SpatialFrequencyResponse', ), # 0x920C - -
+ 0xA20E: ('FocalPlaneXResolution', ), # 0x920E - -
+ 0xA20F: ('FocalPlaneYResolution', ), # 0x920F - -
+ 0xA210: ('FocalPlaneResolutionUnit', ), # 0x9210 - -
+ 0xA214: ('SubjectLocation', ), # 0x9214 - -
+ 0xA215: ('ExposureIndex', ), # 0x9215 - -
+ 0xA217: ('SensingMethod', ), # 0x9217 - -
+ 0xA300: ('FileSource',
+ {3: 'Digital Camera'}),
+ 0xA301: ('SceneType',
+ {1: 'Directly Photographed'}),
+ 0xA302: ('CVAPattern', ),
+ 0xA401: ('CustomRendered', ),
+ 0xA402: ('ExposureMode',
+ {0: 'Auto Exposure',
+ 1: 'Manual Exposure',
+ 2: 'Auto Bracket'}),
+ 0xA403: ('WhiteBalance',
+ {0: 'Auto',
+ 1: 'Manual'}),
+ 0xA404: ('DigitalZoomRatio', ),
+ 0xA405: ('FocalLengthIn35mm', ),
+ 0xA406: ('SceneCaptureType', ),
+ 0xA407: ('GainControl', ),
+ 0xA408: ('Contrast', ),
+ 0xA409: ('Saturation', ),
+ 0xA40A: ('Sharpness', ),
+ 0xA40C: ('SubjectDistanceRange', ),
+ }
+
+# interoperability tags
+INTR_TAGS = {
+ 0x0001: ('InteroperabilityIndex', ),
+ 0x0002: ('InteroperabilityVersion', ),
+ 0x1000: ('RelatedImageFileFormat', ),
+ 0x1001: ('RelatedImageWidth', ),
+ 0x1002: ('RelatedImageLength', ),
+ }
+
+# GPS tags (not used yet, haven't seen camera with GPS)
+GPS_TAGS = {
+ 0x0000: ('GPSVersionID', ),
+ 0x0001: ('GPSLatitudeRef', ),
+ 0x0002: ('GPSLatitude', ),
+ 0x0003: ('GPSLongitudeRef', ),
+ 0x0004: ('GPSLongitude', ),
+ 0x0005: ('GPSAltitudeRef', ),
+ 0x0006: ('GPSAltitude', ),
+ 0x0007: ('GPSTimeStamp', ),
+ 0x0008: ('GPSSatellites', ),
+ 0x0009: ('GPSStatus', ),
+ 0x000A: ('GPSMeasureMode', ),
+ 0x000B: ('GPSDOP', ),
+ 0x000C: ('GPSSpeedRef', ),
+ 0x000D: ('GPSSpeed', ),
+ 0x000E: ('GPSTrackRef', ),
+ 0x000F: ('GPSTrack', ),
+ 0x0010: ('GPSImgDirectionRef', ),
+ 0x0011: ('GPSImgDirection', ),
+ 0x0012: ('GPSMapDatum', ),
+ 0x0013: ('GPSDestLatitudeRef', ),
+ 0x0014: ('GPSDestLatitude', ),
+ 0x0015: ('GPSDestLongitudeRef', ),
+ 0x0016: ('GPSDestLongitude', ),
+ 0x0017: ('GPSDestBearingRef', ),
+ 0x0018: ('GPSDestBearing', ),
+ 0x0019: ('GPSDestDistanceRef', ),
+ 0x001A: ('GPSDestDistance', ),
+ }
+
+# Ignore these tags when quick processing
+# 0x927C is MakerNote Tags
+# 0x9286 is user comment
+IGNORE_TAGS=(0x9286, 0x927C)
+
+# http://tomtia.plala.jp/DigitalCamera/MakerNote/index.asp
+def nikon_ev_bias(seq):
+ # First digit seems to be in steps of 1/6 EV.
+ # Does the third value mean the step size? It is usually 6,
+ # but it is 12 for the ExposureDifference.
+ #
+ if seq == [252, 1, 6, 0]:
+ return "-2/3 EV"
+ if seq == [253, 1, 6, 0]:
+ return "-1/2 EV"
+ if seq == [254, 1, 6, 0]:
+ return "-1/3 EV"
+ if seq == [0, 1, 6, 0]:
+ return "0 EV"
+ if seq == [2, 1, 6, 0]:
+ return "+1/3 EV"
+ if seq == [3, 1, 6, 0]:
+ return "+1/2 EV"
+ if seq == [4, 1, 6, 0]:
+ return "+2/3 EV"
+ # Handle combinations not in the table.
+ a = seq[0]
+ # Causes headaches for the +/- logic, so special case it.
+ if a == 0:
+ return "0 EV"
+ if a > 127:
+ a = 256 - a
+ ret_str = "-"
+ else:
+ ret_str = "+"
+ b = seq[2] # Assume third value means the step size
+ whole = a / b
+ a = a % b
+ if whole != 0:
+ ret_str = ret_str + str(whole) + " "
+ if a == 0:
+ ret_str = ret_str + "EV"
+ else:
+ r = Ratio(a, b)
+ ret_str = ret_str + r.__repr__() + " EV"
+ return ret_str
+
+# Nikon E99x MakerNote Tags
+MAKERNOTE_NIKON_NEWER_TAGS={
+ 0x0001: ('MakernoteVersion', make_string), # Sometimes binary
+ 0x0002: ('ISOSetting', ),
+ 0x0003: ('ColorMode', ),
+ 0x0004: ('Quality', ),
+ 0x0005: ('Whitebalance', ),
+ 0x0006: ('ImageSharpening', ),
+ 0x0007: ('FocusMode', ),
+ 0x0008: ('FlashSetting', ),
+ 0x0009: ('AutoFlashMode', ),
+ 0x000B: ('WhiteBalanceBias', ),
+ 0x000C: ('WhiteBalanceRBCoeff', ),
+ 0x000D: ('ProgramShift', nikon_ev_bias),
+ # Nearly the same as the other EV vals, but step size is 1/12 EV (?)
+ 0x000E: ('ExposureDifference', nikon_ev_bias),
+ 0x000F: ('ISOSelection', ),
+ 0x0011: ('NikonPreview', ),
+ 0x0012: ('FlashCompensation', nikon_ev_bias),
+ 0x0013: ('ISOSpeedRequested', ),
+ 0x0016: ('PhotoCornerCoordinates', ),
+ # 0x0017: Unknown, but most likely an EV value
+ 0x0018: ('FlashBracketCompensationApplied', nikon_ev_bias),
+ 0x0019: ('AEBracketCompensationApplied', ),
+ 0x001A: ('ImageProcessing', ),
+ 0x0080: ('ImageAdjustment', ),
+ 0x0081: ('ToneCompensation', ),
+ 0x0082: ('AuxiliaryLens', ),
+ 0x0083: ('LensType', ),
+ 0x0084: ('LensMinMaxFocalMaxAperture', ),
+ 0x0085: ('ManualFocusDistance', ),
+ 0x0086: ('DigitalZoomFactor', ),
+ 0x0087: ('FlashMode',
+ {0x00: 'Did Not Fire',
+ 0x01: 'Fired, Manual',
+ 0x07: 'Fired, External',
+ 0x08: 'Fired, Commander Mode ',
+ 0x09: 'Fired, TTL Mode'}),
+ 0x0088: ('AFFocusPosition',
+ {0x0000: 'Center',
+ 0x0100: 'Top',
+ 0x0200: 'Bottom',
+ 0x0300: 'Left',
+ 0x0400: 'Right'}),
+ 0x0089: ('BracketingMode',
+ {0x00: 'Single frame, no bracketing',
+ 0x01: 'Continuous, no bracketing',
+ 0x02: 'Timer, no bracketing',
+ 0x10: 'Single frame, exposure bracketing',
+ 0x11: 'Continuous, exposure bracketing',
+ 0x12: 'Timer, exposure bracketing',
+ 0x40: 'Single frame, white balance bracketing',
+ 0x41: 'Continuous, white balance bracketing',
+ 0x42: 'Timer, white balance bracketing'}),
+ 0x008A: ('AutoBracketRelease', ),
+ 0x008B: ('LensFStops', ),
+ 0x008C: ('NEFCurve2', ),
+ 0x008D: ('ColorMode', ),
+ 0x008F: ('SceneMode', ),
+ 0x0090: ('LightingType', ),
+ 0x0091: ('ShotInfo', ), # First 4 bytes are probably a version number in ASCII
+ 0x0092: ('HueAdjustment', ),
+ # 0x0093: ('SaturationAdjustment', ),
+ 0x0094: ('Saturation', # Name conflict with 0x00AA !!
+ {-3: 'B&W',
+ -2: '-2',
+ -1: '-1',
+ 0: '0',
+ 1: '1',
+ 2: '2'}),
+ 0x0095: ('NoiseReduction', ),
+ 0x0096: ('NEFCurve2', ),
+ 0x0097: ('ColorBalance', ),
+ 0x0098: ('LensData', ), # First 4 bytes are a version number in ASCII
+ 0x0099: ('RawImageCenter', ),
+ 0x009A: ('SensorPixelSize', ),
+ 0x009C: ('Scene Assist', ),
+ 0x00A0: ('SerialNumber', ),
+ 0x00A2: ('ImageDataSize', ),
+ # A4: In NEF, looks like a 4 byte ASCII version number
+ 0x00A5: ('ImageCount', ),
+ 0x00A6: ('DeletedImageCount', ),
+ 0x00A7: ('TotalShutterReleases', ),
+ # A8: ExposureMode? JPG: First 4 bytes are probably a version number in ASCII
+ # But in a sample NEF, its 8 zeros, then the string "NORMAL"
+ 0x00A9: ('ImageOptimization', ),
+ 0x00AA: ('Saturation', ),
+ 0x00AB: ('DigitalVariProgram', ),
+ 0x00AC: ('ImageStabilization', ),
+ 0x00AD: ('Responsive AF', ), # 'AFResponse'
+ 0x0010: ('DataDump', ),
+ }
+
+MAKERNOTE_NIKON_OLDER_TAGS = {
+ 0x0003: ('Quality',
+ {1: 'VGA Basic',
+ 2: 'VGA Normal',
+ 3: 'VGA Fine',
+ 4: 'SXGA Basic',
+ 5: 'SXGA Normal',
+ 6: 'SXGA Fine'}),
+ 0x0004: ('ColorMode',
+ {1: 'Color',
+ 2: 'Monochrome'}),
+ 0x0005: ('ImageAdjustment',
+ {0: 'Normal',
+ 1: 'Bright+',
+ 2: 'Bright-',
+ 3: 'Contrast+',
+ 4: 'Contrast-'}),
+ 0x0006: ('CCDSpeed',
+ {0: 'ISO 80',
+ 2: 'ISO 160',
+ 4: 'ISO 320',
+ 5: 'ISO 100'}),
+ 0x0007: ('WhiteBalance',
+ {0: 'Auto',
+ 1: 'Preset',
+ 2: 'Daylight',
+ 3: 'Incandescent',
+ 4: 'Fluorescent',
+ 5: 'Cloudy',
+ 6: 'Speed Light'}),
+ }
+
+# decode Olympus SpecialMode tag in MakerNote
+def olympus_special_mode(v):
+ a={
+ 0: 'Normal',
+ 1: 'Unknown',
+ 2: 'Fast',
+ 3: 'Panorama'}
+ b={
+ 0: 'Non-panoramic',
+ 1: 'Left to right',
+ 2: 'Right to left',
+ 3: 'Bottom to top',
+ 4: 'Top to bottom'}
+ if v[0] not in a or v[2] not in b:
+ return v
+ return '%s - sequence %d - %s' % (a[v[0]], v[1], b[v[2]])
+
+MAKERNOTE_OLYMPUS_TAGS={
+ # ah HAH! those sneeeeeaky bastids! this is how they get past the fact
+ # that a JPEG thumbnail is not allowed in an uncompressed TIFF file
+ 0x0100: ('JPEGThumbnail', ),
+ 0x0200: ('SpecialMode', olympus_special_mode),
+ 0x0201: ('JPEGQual',
+ {1: 'SQ',
+ 2: 'HQ',
+ 3: 'SHQ'}),
+ 0x0202: ('Macro',
+ {0: 'Normal',
+ 1: 'Macro',
+ 2: 'SuperMacro'}),
+ 0x0203: ('BWMode',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x0204: ('DigitalZoom', ),
+ 0x0205: ('FocalPlaneDiagonal', ),
+ 0x0206: ('LensDistortionParams', ),
+ 0x0207: ('SoftwareRelease', ),
+ 0x0208: ('PictureInfo', ),
+ 0x0209: ('CameraID', make_string), # print as string
+ 0x0F00: ('DataDump', ),
+ 0x0300: ('PreCaptureFrames', ),
+ 0x0404: ('SerialNumber', ),
+ 0x1000: ('ShutterSpeedValue', ),
+ 0x1001: ('ISOValue', ),
+ 0x1002: ('ApertureValue', ),
+ 0x1003: ('BrightnessValue', ),
+ 0x1004: ('FlashMode', ),
+ 0x1004: ('FlashMode',
+ {2: 'On',
+ 3: 'Off'}),
+ 0x1005: ('FlashDevice',
+ {0: 'None',
+ 1: 'Internal',
+ 4: 'External',
+ 5: 'Internal + External'}),
+ 0x1006: ('ExposureCompensation', ),
+ 0x1007: ('SensorTemperature', ),
+ 0x1008: ('LensTemperature', ),
+ 0x100b: ('FocusMode',
+ {0: 'Auto',
+ 1: 'Manual'}),
+ 0x1017: ('RedBalance', ),
+ 0x1018: ('BlueBalance', ),
+ 0x101a: ('SerialNumber', ),
+ 0x1023: ('FlashExposureComp', ),
+ 0x1026: ('ExternalFlashBounce',
+ {0: 'No',
+ 1: 'Yes'}),
+ 0x1027: ('ExternalFlashZoom', ),
+ 0x1028: ('ExternalFlashMode', ),
+ 0x1029: ('Contrast int16u',
+ {0: 'High',
+ 1: 'Normal',
+ 2: 'Low'}),
+ 0x102a: ('SharpnessFactor', ),
+ 0x102b: ('ColorControl', ),
+ 0x102c: ('ValidBits', ),
+ 0x102d: ('CoringFilter', ),
+ 0x102e: ('OlympusImageWidth', ),
+ 0x102f: ('OlympusImageHeight', ),
+ 0x1034: ('CompressionRatio', ),
+ 0x1035: ('PreviewImageValid',
+ {0: 'No',
+ 1: 'Yes'}),
+ 0x1036: ('PreviewImageStart', ),
+ 0x1037: ('PreviewImageLength', ),
+ 0x1039: ('CCDScanMode',
+ {0: 'Interlaced',
+ 1: 'Progressive'}),
+ 0x103a: ('NoiseReduction',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x103b: ('InfinityLensStep', ),
+ 0x103c: ('NearLensStep', ),
+
+ # TODO - these need extra definitions
+ # http://search.cpan.org/src/EXIFTOOL/Image-ExifTool-6.90/html/TagNames/Olympus.html
+ 0x2010: ('Equipment', ),
+ 0x2020: ('CameraSettings', ),
+ 0x2030: ('RawDevelopment', ),
+ 0x2040: ('ImageProcessing', ),
+ 0x2050: ('FocusInfo', ),
+ 0x3000: ('RawInfo ', ),
+ }
+
+# 0x2020 CameraSettings
+MAKERNOTE_OLYMPUS_TAG_0x2020={
+ 0x0100: ('PreviewImageValid',
+ {0: 'No',
+ 1: 'Yes'}),
+ 0x0101: ('PreviewImageStart', ),
+ 0x0102: ('PreviewImageLength', ),
+ 0x0200: ('ExposureMode', {
+ 1: 'Manual',
+ 2: 'Program',
+ 3: 'Aperture-priority AE',
+ 4: 'Shutter speed priority AE',
+ 5: 'Program-shift'}),
+ 0x0201: ('AELock',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x0202: ('MeteringMode',
+ {2: 'Center Weighted',
+ 3: 'Spot',
+ 5: 'ESP',
+ 261: 'Pattern+AF',
+ 515: 'Spot+Highlight control',
+ 1027: 'Spot+Shadow control'}),
+ 0x0300: ('MacroMode',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x0301: ('FocusMode',
+ {0: 'Single AF',
+ 1: 'Sequential shooting AF',
+ 2: 'Continuous AF',
+ 3: 'Multi AF',
+ 10: 'MF'}),
+ 0x0302: ('FocusProcess',
+ {0: 'AF Not Used',
+ 1: 'AF Used'}),
+ 0x0303: ('AFSearch',
+ {0: 'Not Ready',
+ 1: 'Ready'}),
+ 0x0304: ('AFAreas', ),
+ 0x0401: ('FlashExposureCompensation', ),
+ 0x0500: ('WhiteBalance2',
+ {0: 'Auto',
+ 16: '7500K (Fine Weather with Shade)',
+ 17: '6000K (Cloudy)',
+ 18: '5300K (Fine Weather)',
+ 20: '3000K (Tungsten light)',
+ 21: '3600K (Tungsten light-like)',
+ 33: '6600K (Daylight fluorescent)',
+ 34: '4500K (Neutral white fluorescent)',
+ 35: '4000K (Cool white fluorescent)',
+ 48: '3600K (Tungsten light-like)',
+ 256: 'Custom WB 1',
+ 257: 'Custom WB 2',
+ 258: 'Custom WB 3',
+ 259: 'Custom WB 4',
+ 512: 'Custom WB 5400K',
+ 513: 'Custom WB 2900K',
+ 514: 'Custom WB 8000K', }),
+ 0x0501: ('WhiteBalanceTemperature', ),
+ 0x0502: ('WhiteBalanceBracket', ),
+ 0x0503: ('CustomSaturation', ), # (3 numbers: 1. CS Value, 2. Min, 3. Max)
+ 0x0504: ('ModifiedSaturation',
+ {0: 'Off',
+ 1: 'CM1 (Red Enhance)',
+ 2: 'CM2 (Green Enhance)',
+ 3: 'CM3 (Blue Enhance)',
+ 4: 'CM4 (Skin Tones)'}),
+ 0x0505: ('ContrastSetting', ), # (3 numbers: 1. Contrast, 2. Min, 3. Max)
+ 0x0506: ('SharpnessSetting', ), # (3 numbers: 1. Sharpness, 2. Min, 3. Max)
+ 0x0507: ('ColorSpace',
+ {0: 'sRGB',
+ 1: 'Adobe RGB',
+ 2: 'Pro Photo RGB'}),
+ 0x0509: ('SceneMode',
+ {0: 'Standard',
+ 6: 'Auto',
+ 7: 'Sport',
+ 8: 'Portrait',
+ 9: 'Landscape+Portrait',
+ 10: 'Landscape',
+ 11: 'Night scene',
+ 13: 'Panorama',
+ 16: 'Landscape+Portrait',
+ 17: 'Night+Portrait',
+ 19: 'Fireworks',
+ 20: 'Sunset',
+ 22: 'Macro',
+ 25: 'Documents',
+ 26: 'Museum',
+ 28: 'Beach&Snow',
+ 30: 'Candle',
+ 35: 'Underwater Wide1',
+ 36: 'Underwater Macro',
+ 39: 'High Key',
+ 40: 'Digital Image Stabilization',
+ 44: 'Underwater Wide2',
+ 45: 'Low Key',
+ 46: 'Children',
+ 48: 'Nature Macro'}),
+ 0x050a: ('NoiseReduction',
+ {0: 'Off',
+ 1: 'Noise Reduction',
+ 2: 'Noise Filter',
+ 3: 'Noise Reduction + Noise Filter',
+ 4: 'Noise Filter (ISO Boost)',
+ 5: 'Noise Reduction + Noise Filter (ISO Boost)'}),
+ 0x050b: ('DistortionCorrection',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x050c: ('ShadingCompensation',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x050d: ('CompressionFactor', ),
+ 0x050f: ('Gradation',
+ {'-1 -1 1': 'Low Key',
+ '0 -1 1': 'Normal',
+ '1 -1 1': 'High Key'}),
+ 0x0520: ('PictureMode',
+ {1: 'Vivid',
+ 2: 'Natural',
+ 3: 'Muted',
+ 256: 'Monotone',
+ 512: 'Sepia'}),
+ 0x0521: ('PictureModeSaturation', ),
+ 0x0522: ('PictureModeHue?', ),
+ 0x0523: ('PictureModeContrast', ),
+ 0x0524: ('PictureModeSharpness', ),
+ 0x0525: ('PictureModeBWFilter',
+ {0: 'n/a',
+ 1: 'Neutral',
+ 2: 'Yellow',
+ 3: 'Orange',
+ 4: 'Red',
+ 5: 'Green'}),
+ 0x0526: ('PictureModeTone',
+ {0: 'n/a',
+ 1: 'Neutral',
+ 2: 'Sepia',
+ 3: 'Blue',
+ 4: 'Purple',
+ 5: 'Green'}),
+ 0x0600: ('Sequence', ), # 2 or 3 numbers: 1. Mode, 2. Shot number, 3. Mode bits
+ 0x0601: ('PanoramaMode', ), # (2 numbers: 1. Mode, 2. Shot number)
+ 0x0603: ('ImageQuality2',
+ {1: 'SQ',
+ 2: 'HQ',
+ 3: 'SHQ',
+ 4: 'RAW'}),
+ 0x0901: ('ManometerReading', ),
+ }
+
+
+MAKERNOTE_CASIO_TAGS={
+ 0x0001: ('RecordingMode',
+ {1: 'Single Shutter',
+ 2: 'Panorama',
+ 3: 'Night Scene',
+ 4: 'Portrait',
+ 5: 'Landscape'}),
+ 0x0002: ('Quality',
+ {1: 'Economy',
+ 2: 'Normal',
+ 3: 'Fine'}),
+ 0x0003: ('FocusingMode',
+ {2: 'Macro',
+ 3: 'Auto Focus',
+ 4: 'Manual Focus',
+ 5: 'Infinity'}),
+ 0x0004: ('FlashMode',
+ {1: 'Auto',
+ 2: 'On',
+ 3: 'Off',
+ 4: 'Red Eye Reduction'}),
+ 0x0005: ('FlashIntensity',
+ {11: 'Weak',
+ 13: 'Normal',
+ 15: 'Strong'}),
+ 0x0006: ('Object Distance', ),
+ 0x0007: ('WhiteBalance',
+ {1: 'Auto',
+ 2: 'Tungsten',
+ 3: 'Daylight',
+ 4: 'Fluorescent',
+ 5: 'Shade',
+ 129: 'Manual'}),
+ 0x000B: ('Sharpness',
+ {0: 'Normal',
+ 1: 'Soft',
+ 2: 'Hard'}),
+ 0x000C: ('Contrast',
+ {0: 'Normal',
+ 1: 'Low',
+ 2: 'High'}),
+ 0x000D: ('Saturation',
+ {0: 'Normal',
+ 1: 'Low',
+ 2: 'High'}),
+ 0x0014: ('CCDSpeed',
+ {64: 'Normal',
+ 80: 'Normal',
+ 100: 'High',
+ 125: '+1.0',
+ 244: '+3.0',
+ 250: '+2.0'}),
+ }
+
+MAKERNOTE_FUJIFILM_TAGS={
+ 0x0000: ('NoteVersion', make_string),
+ 0x1000: ('Quality', ),
+ 0x1001: ('Sharpness',
+ {1: 'Soft',
+ 2: 'Soft',
+ 3: 'Normal',
+ 4: 'Hard',
+ 5: 'Hard'}),
+ 0x1002: ('WhiteBalance',
+ {0: 'Auto',
+ 256: 'Daylight',
+ 512: 'Cloudy',
+ 768: 'DaylightColor-Fluorescent',
+ 769: 'DaywhiteColor-Fluorescent',
+ 770: 'White-Fluorescent',
+ 1024: 'Incandescent',
+ 3840: 'Custom'}),
+ 0x1003: ('Color',
+ {0: 'Normal',
+ 256: 'High',
+ 512: 'Low'}),
+ 0x1004: ('Tone',
+ {0: 'Normal',
+ 256: 'High',
+ 512: 'Low'}),
+ 0x1010: ('FlashMode',
+ {0: 'Auto',
+ 1: 'On',
+ 2: 'Off',
+ 3: 'Red Eye Reduction'}),
+ 0x1011: ('FlashStrength', ),
+ 0x1020: ('Macro',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x1021: ('FocusMode',
+ {0: 'Auto',
+ 1: 'Manual'}),
+ 0x1030: ('SlowSync',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x1031: ('PictureMode',
+ {0: 'Auto',
+ 1: 'Portrait',
+ 2: 'Landscape',
+ 4: 'Sports',
+ 5: 'Night',
+ 6: 'Program AE',
+ 256: 'Aperture Priority AE',
+ 512: 'Shutter Priority AE',
+ 768: 'Manual Exposure'}),
+ 0x1100: ('MotorOrBracket',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x1300: ('BlurWarning',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x1301: ('FocusWarning',
+ {0: 'Off',
+ 1: 'On'}),
+ 0x1302: ('AEWarning',
+ {0: 'Off',
+ 1: 'On'}),
+ }
+
+MAKERNOTE_CANON_TAGS = {
+ 0x0006: ('ImageType', ),
+ 0x0007: ('FirmwareVersion', ),
+ 0x0008: ('ImageNumber', ),
+ 0x0009: ('OwnerName', ),
+ }
+
+# this is in element offset, name, optional value dictionary format
+MAKERNOTE_CANON_TAG_0x001 = {
+ 1: ('Macromode',
+ {1: 'Macro',
+ 2: 'Normal'}),
+ 2: ('SelfTimer', ),
+ 3: ('Quality',
+ {2: 'Normal',
+ 3: 'Fine',
+ 5: 'Superfine'}),
+ 4: ('FlashMode',
+ {0: 'Flash Not Fired',
+ 1: 'Auto',
+ 2: 'On',
+ 3: 'Red-Eye Reduction',
+ 4: 'Slow Synchro',
+ 5: 'Auto + Red-Eye Reduction',
+ 6: 'On + Red-Eye Reduction',
+ 16: 'external flash'}),
+ 5: ('ContinuousDriveMode',
+ {0: 'Single Or Timer',
+ 1: 'Continuous'}),
+ 7: ('FocusMode',
+ {0: 'One-Shot',
+ 1: 'AI Servo',
+ 2: 'AI Focus',
+ 3: 'MF',
+ 4: 'Single',
+ 5: 'Continuous',
+ 6: 'MF'}),
+ 10: ('ImageSize',
+ {0: 'Large',
+ 1: 'Medium',
+ 2: 'Small'}),
+ 11: ('EasyShootingMode',
+ {0: 'Full Auto',
+ 1: 'Manual',
+ 2: 'Landscape',
+ 3: 'Fast Shutter',
+ 4: 'Slow Shutter',
+ 5: 'Night',
+ 6: 'B&W',
+ 7: 'Sepia',
+ 8: 'Portrait',
+ 9: 'Sports',
+ 10: 'Macro/Close-Up',
+ 11: 'Pan Focus'}),
+ 12: ('DigitalZoom',
+ {0: 'None',
+ 1: '2x',
+ 2: '4x'}),
+ 13: ('Contrast',
+ {0xFFFF: 'Low',
+ 0: 'Normal',
+ 1: 'High'}),
+ 14: ('Saturation',
+ {0xFFFF: 'Low',
+ 0: 'Normal',
+ 1: 'High'}),
+ 15: ('Sharpness',
+ {0xFFFF: 'Low',
+ 0: 'Normal',
+ 1: 'High'}),
+ 16: ('ISO',
+ {0: 'See ISOSpeedRatings Tag',
+ 15: 'Auto',
+ 16: '50',
+ 17: '100',
+ 18: '200',
+ 19: '400'}),
+ 17: ('MeteringMode',
+ {3: 'Evaluative',
+ 4: 'Partial',
+ 5: 'Center-weighted'}),
+ 18: ('FocusType',
+ {0: 'Manual',
+ 1: 'Auto',
+ 3: 'Close-Up (Macro)',
+ 8: 'Locked (Pan Mode)'}),
+ 19: ('AFPointSelected',
+ {0x3000: 'None (MF)',
+ 0x3001: 'Auto-Selected',
+ 0x3002: 'Right',
+ 0x3003: 'Center',
+ 0x3004: 'Left'}),
+ 20: ('ExposureMode',
+ {0: 'Easy Shooting',
+ 1: 'Program',
+ 2: 'Tv-priority',
+ 3: 'Av-priority',
+ 4: 'Manual',
+ 5: 'A-DEP'}),
+ 23: ('LongFocalLengthOfLensInFocalUnits', ),
+ 24: ('ShortFocalLengthOfLensInFocalUnits', ),
+ 25: ('FocalUnitsPerMM', ),
+ 28: ('FlashActivity',
+ {0: 'Did Not Fire',
+ 1: 'Fired'}),
+ 29: ('FlashDetails',
+ {14: 'External E-TTL',
+ 13: 'Internal Flash',
+ 11: 'FP Sync Used',
+ 7: '2nd("Rear")-Curtain Sync Used',
+ 4: 'FP Sync Enabled'}),
+ 32: ('FocusMode',
+ {0: 'Single',
+ 1: 'Continuous'}),
+ }
+
+MAKERNOTE_CANON_TAG_0x004 = {
+ 7: ('WhiteBalance',
+ {0: 'Auto',
+ 1: 'Sunny',
+ 2: 'Cloudy',
+ 3: 'Tungsten',
+ 4: 'Fluorescent',
+ 5: 'Flash',
+ 6: 'Custom'}),
+ 9: ('SequenceNumber', ),
+ 14: ('AFPointUsed', ),
+ 15: ('FlashBias',
+ {0XFFC0: '-2 EV',
+ 0XFFCC: '-1.67 EV',
+ 0XFFD0: '-1.50 EV',
+ 0XFFD4: '-1.33 EV',
+ 0XFFE0: '-1 EV',
+ 0XFFEC: '-0.67 EV',
+ 0XFFF0: '-0.50 EV',
+ 0XFFF4: '-0.33 EV',
+ 0X0000: '0 EV',
+ 0X000C: '0.33 EV',
+ 0X0010: '0.50 EV',
+ 0X0014: '0.67 EV',
+ 0X0020: '1 EV',
+ 0X002C: '1.33 EV',
+ 0X0030: '1.50 EV',
+ 0X0034: '1.67 EV',
+ 0X0040: '2 EV'}),
+ 19: ('SubjectDistance', ),
+ }
+
+# extract multibyte integer in Motorola format (little endian)
+def s2n_motorola(str):
+ x = 0
+ for c in str:
+ x = (x << 8) | ord(c)
+ return x
+
+# extract multibyte integer in Intel format (big endian)
+def s2n_intel(str):
+ x = 0
+ y = 0L
+ for c in str:
+ x = x | (ord(c) << y)
+ y = y + 8
+ return x
+
+# ratio object that eventually will be able to reduce itself to lowest
+# common denominator for printing
+def gcd(a, b):
+ if b == 0:
+ return a
+ else:
+ return gcd(b, a % b)
+
+class Ratio:
+ def __init__(self, num, den):
+ self.num = num
+ self.den = den
+
+ def __repr__(self):
+ self.reduce()
+ if self.den == 1:
+ return str(self.num)
+ return '%d/%d' % (self.num, self.den)
+
+ def reduce(self):
+ div = gcd(self.num, self.den)
+ if div > 1:
+ self.num = self.num / div
+ self.den = self.den / div
+
+# for ease of dealing with tags
+class IFD_Tag:
+ def __init__(self, printable, tag, field_type, values, field_offset,
+ field_length):
+ # printable version of data
+ self.printable = printable
+ # tag ID number
+ self.tag = tag
+ # field type as index into FIELD_TYPES
+ self.field_type = field_type
+ # offset of start of field in bytes from beginning of IFD
+ self.field_offset = field_offset
+ # length of data field in bytes
+ self.field_length = field_length
+ # either a string or array of data items
+ self.values = values
+
+ def __str__(self):
+ return self.printable
+
+ def __repr__(self):
+ return '(0x%04X) %s=%s @ %d' % (self.tag,
+ FIELD_TYPES[self.field_type][2],
+ self.printable,
+ self.field_offset)
+
+# class that handles an EXIF header
+class EXIF_header:
+ def __init__(self, file, endian, offset, fake_exif, debug=0):
+ self.file = file
+ self.endian = endian
+ self.offset = offset
+ self.fake_exif = fake_exif
+ self.debug = debug
+ self.tags = {}
+
+ # convert slice to integer, based on sign and endian flags
+ # usually this offset is assumed to be relative to the beginning of the
+ # start of the EXIF information. For some cameras that use relative tags,
+ # this offset may be relative to some other starting point.
+ def s2n(self, offset, length, signed=0):
+ self.file.seek(self.offset+offset)
+ slice=self.file.read(length)
+ if self.endian == 'I':
+ val=s2n_intel(slice)
+ else:
+ val=s2n_motorola(slice)
+ # Sign extension ?
+ if signed:
+ msb=1L << (8*length-1)
+ if val & msb:
+ val=val-(msb << 1)
+ return val
+
+ # convert offset to string
+ def n2s(self, offset, length):
+ s = ''
+ for dummy in range(length):
+ if self.endian == 'I':
+ s = s + chr(offset & 0xFF)
+ else:
+ s = chr(offset & 0xFF) + s
+ offset = offset >> 8
+ return s
+
+ # return first IFD
+ def first_IFD(self):
+ return self.s2n(4, 4)
+
+ # return pointer to next IFD
+ def next_IFD(self, ifd):
+ entries=self.s2n(ifd, 2)
+ return self.s2n(ifd+2+12*entries, 4)
+
+ # return list of IFDs in header
+ def list_IFDs(self):
+ i=self.first_IFD()
+ a=[]
+ while i:
+ a.append(i)
+ i=self.next_IFD(i)
+ return a
+
+ # return list of entries in this IFD
+ def dump_IFD(self, ifd, ifd_name, dict=EXIF_TAGS, relative=0, stop_tag='UNDEF'):
+ entries=self.s2n(ifd, 2)
+ for i in range(entries):
+ # entry is index of start of this IFD in the file
+ entry = ifd + 2 + 12 * i
+ tag = self.s2n(entry, 2)
+
+ # get tag name early to avoid errors, help debug
+ tag_entry = dict.get(tag)
+ if tag_entry:
+ tag_name = tag_entry[0]
+ else:
+ tag_name = 'Tag 0x%04X' % tag
+
+ # ignore certain tags for faster processing
+ if not (not detailed and tag in IGNORE_TAGS):
+ field_type = self.s2n(entry + 2, 2)
+ if not 0 < field_type < len(FIELD_TYPES):
+ # unknown field type
+ raise ValueError('unknown type %d in tag 0x%04X' % (field_type, tag))
+ typelen = FIELD_TYPES[field_type][0]
+ count = self.s2n(entry + 4, 4)
+ offset = entry + 8
+ if count * typelen > 4:
+ # offset is not the value; it's a pointer to the value
+ # if relative we set things up so s2n will seek to the right
+ # place when it adds self.offset. Note that this 'relative'
+ # is for the Nikon type 3 makernote. Other cameras may use
+ # other relative offsets, which would have to be computed here
+ # slightly differently.
+ if relative:
+ tmp_offset = self.s2n(offset, 4)
+ offset = tmp_offset + ifd - self.offset + 4
+ if self.fake_exif:
+ offset = offset + 18
+ else:
+ offset = self.s2n(offset, 4)
+ field_offset = offset
+ if field_type == 2:
+ # special case: null-terminated ASCII string
+ if count != 0:
+ self.file.seek(self.offset + offset)
+ values = self.file.read(count)
+ values = values.strip().replace('\x00', '')
+ else:
+ values = ''
+ else:
+ values = []
+ signed = (field_type in [6, 8, 9, 10])
+ for dummy in range(count):
+ if field_type in (5, 10):
+ # a ratio
+ value = Ratio(self.s2n(offset, 4, signed),
+ self.s2n(offset + 4, 4, signed))
+ else:
+ value = self.s2n(offset, typelen, signed)
+ values.append(value)
+ offset = offset + typelen
+ # now "values" is either a string or an array
+ if count == 1 and field_type != 2:
+ printable=str(values[0])
+ else:
+ printable=str(values)
+ # compute printable version of values
+ if tag_entry:
+ if len(tag_entry) != 1:
+ # optional 2nd tag element is present
+ if callable(tag_entry[1]):
+ # call mapping function
+ printable = tag_entry[1](values)
+ else:
+ printable = ''
+ for i in values:
+ # use lookup table for this tag
+ printable += tag_entry[1].get(i, repr(i))
+
+ self.tags[ifd_name + ' ' + tag_name] = IFD_Tag(printable, tag,
+ field_type,
+ values, field_offset,
+ count * typelen)
+ if self.debug:
+ print ' debug: %s: %s' % (tag_name,
+ repr(self.tags[ifd_name + ' ' + tag_name]))
+
+ if tag_name == stop_tag:
+ break
+
+ # extract uncompressed TIFF thumbnail (like pulling teeth)
+ # we take advantage of the pre-existing layout in the thumbnail IFD as
+ # much as possible
+ def extract_TIFF_thumbnail(self, thumb_ifd):
+ entries = self.s2n(thumb_ifd, 2)
+ # this is header plus offset to IFD ...
+ if self.endian == 'M':
+ tiff = 'MM\x00*\x00\x00\x00\x08'
+ else:
+ tiff = 'II*\x00\x08\x00\x00\x00'
+ # ... plus thumbnail IFD data plus a null "next IFD" pointer
+ self.file.seek(self.offset+thumb_ifd)
+ tiff += self.file.read(entries*12+2)+'\x00\x00\x00\x00'
+
+ # fix up large value offset pointers into data area
+ for i in range(entries):
+ entry = thumb_ifd + 2 + 12 * i
+ tag = self.s2n(entry, 2)
+ field_type = self.s2n(entry+2, 2)
+ typelen = FIELD_TYPES[field_type][0]
+ count = self.s2n(entry+4, 4)
+ oldoff = self.s2n(entry+8, 4)
+ # start of the 4-byte pointer area in entry
+ ptr = i * 12 + 18
+ # remember strip offsets location
+ if tag == 0x0111:
+ strip_off = ptr
+ strip_len = count * typelen
+ # is it in the data area?
+ if count * typelen > 4:
+ # update offset pointer (nasty "strings are immutable" crap)
+ # should be able to say "tiff[ptr:ptr+4]=newoff"
+ newoff = len(tiff)
+ tiff = tiff[:ptr] + self.n2s(newoff, 4) + tiff[ptr+4:]
+ # remember strip offsets location
+ if tag == 0x0111:
+ strip_off = newoff
+ strip_len = 4
+ # get original data and store it
+ self.file.seek(self.offset + oldoff)
+ tiff += self.file.read(count * typelen)
+
+ # add pixel strips and update strip offset info
+ old_offsets = self.tags['Thumbnail StripOffsets'].values
+ old_counts = self.tags['Thumbnail StripByteCounts'].values
+ for i in range(len(old_offsets)):
+ # update offset pointer (more nasty "strings are immutable" crap)
+ offset = self.n2s(len(tiff), strip_len)
+ tiff = tiff[:strip_off] + offset + tiff[strip_off + strip_len:]
+ strip_off += strip_len
+ # add pixel strip to end
+ self.file.seek(self.offset + old_offsets[i])
+ tiff += self.file.read(old_counts[i])
+
+ self.tags['TIFFThumbnail'] = tiff
+
+ # decode all the camera-specific MakerNote formats
+
+ # Note is the data that comprises this MakerNote. The MakerNote will
+ # likely have pointers in it that point to other parts of the file. We'll
+ # use self.offset as the starting point for most of those pointers, since
+ # they are relative to the beginning of the file.
+ #
+ # If the MakerNote is in a newer format, it may use relative addressing
+ # within the MakerNote. In that case we'll use relative addresses for the
+ # pointers.
+ #
+ # As an aside: it's not just to be annoying that the manufacturers use
+ # relative offsets. It's so that if the makernote has to be moved by the
+ # picture software all of the offsets don't have to be adjusted. Overall,
+ # this is probably the right strategy for makernotes, though the spec is
+ # ambiguous. (The spec does not appear to imagine that makernotes would
+ # follow EXIF format internally. Once they did, it's ambiguous whether
+ # the offsets should be from the header at the start of all the EXIF info,
+ # or from the header at the start of the makernote.)
+ def decode_maker_note(self):
+ note = self.tags['EXIF MakerNote']
+ make = self.tags['Image Make'].printable
+ # model = self.tags['Image Model'].printable # unused
+
+ # Nikon
+ # The maker note usually starts with the word Nikon, followed by the
+ # type of the makernote (1 or 2, as a short). If the word Nikon is
+ # not at the start of the makernote, it's probably type 2, since some
+ # cameras work that way.
+ if make in ('NIKON', 'NIKON CORPORATION'):
+ if note.values[0:7] == [78, 105, 107, 111, 110, 0, 1]:
+ if self.debug:
+ print "Looks like a type 1 Nikon MakerNote."
+ self.dump_IFD(note.field_offset+8, 'MakerNote',
+ dict=MAKERNOTE_NIKON_OLDER_TAGS)
+ elif note.values[0:7] == [78, 105, 107, 111, 110, 0, 2]:
+ if self.debug:
+ print "Looks like a labeled type 2 Nikon MakerNote"
+ if note.values[12:14] != [0, 42] and note.values[12:14] != [42L, 0L]:
+ raise ValueError("Missing marker tag '42' in MakerNote.")
+ # skip the Makernote label and the TIFF header
+ self.dump_IFD(note.field_offset+10+8, 'MakerNote',
+ dict=MAKERNOTE_NIKON_NEWER_TAGS, relative=1)
+ else:
+ # E99x or D1
+ if self.debug:
+ print "Looks like an unlabeled type 2 Nikon MakerNote"
+ self.dump_IFD(note.field_offset, 'MakerNote',
+ dict=MAKERNOTE_NIKON_NEWER_TAGS)
+ return
+
+ # Olympus
+ if make.startswith('OLYMPUS'):
+ self.dump_IFD(note.field_offset+8, 'MakerNote',
+ dict=MAKERNOTE_OLYMPUS_TAGS)
+ # TODO
+ #for i in (('MakerNote Tag 0x2020', MAKERNOTE_OLYMPUS_TAG_0x2020),):
+ # self.decode_olympus_tag(self.tags[i[0]].values, i[1])
+ #return
+
+ # Casio
+ if make == 'Casio':
+ self.dump_IFD(note.field_offset, 'MakerNote',
+ dict=MAKERNOTE_CASIO_TAGS)
+ return
+
+ # Fujifilm
+ if make == 'FUJIFILM':
+ # bug: everything else is "Motorola" endian, but the MakerNote
+ # is "Intel" endian
+ endian = self.endian
+ self.endian = 'I'
+ # bug: IFD offsets are from beginning of MakerNote, not
+ # beginning of file header
+ offset = self.offset
+ self.offset += note.field_offset
+ # process note with bogus values (note is actually at offset 12)
+ self.dump_IFD(12, 'MakerNote', dict=MAKERNOTE_FUJIFILM_TAGS)
+ # reset to correct values
+ self.endian = endian
+ self.offset = offset
+ return
+
+ # Canon
+ if make == 'Canon':
+ self.dump_IFD(note.field_offset, 'MakerNote',
+ dict=MAKERNOTE_CANON_TAGS)
+ for i in (('MakerNote Tag 0x0001', MAKERNOTE_CANON_TAG_0x001),
+ ('MakerNote Tag 0x0004', MAKERNOTE_CANON_TAG_0x004)):
+ self.canon_decode_tag(self.tags[i[0]].values, i[1])
+ return
+
+ # decode Olympus MakerNote tag based on offset within tag
+ def olympus_decode_tag(self, value, dict):
+ pass
+
+ # decode Canon MakerNote tag based on offset within tag
+ # see http://www.burren.cx/david/canon.html by David Burren
+ def canon_decode_tag(self, value, dict):
+ for i in range(1, len(value)):
+ x=dict.get(i, ('Unknown', ))
+ if self.debug:
+ print i, x
+ name=x[0]
+ if len(x) > 1:
+ val=x[1].get(value[i], 'Unknown')
+ else:
+ val=value[i]
+ # it's not a real IFD Tag but we fake one to make everybody
+ # happy. this will have a "proprietary" type
+ self.tags['MakerNote '+name]=IFD_Tag(str(val), None, 0, None,
+ None, None)
+
+# process an image file (expects an open file object)
+# this is the function that has to deal with all the arbitrary nasty bits
+# of the EXIF standard
+def process_file(f, stop_tag='UNDEF', details=True, debug=False):
+ # yah it's cheesy...
+ global detailed
+ detailed = details
+
+ # by default do not fake an EXIF beginning
+ fake_exif = 0
+
+ # determine whether it's a JPEG or TIFF
+ data = f.read(12)
+ if data[0:4] in ['II*\x00', 'MM\x00*']:
+ # it's a TIFF file
+ f.seek(0)
+ endian = f.read(1)
+ f.read(1)
+ offset = 0
+ elif data[0:2] == '\xFF\xD8':
+ # it's a JPEG file
+ while data[2] == '\xFF' and data[6:10] in ('JFIF', 'JFXX', 'OLYM', 'Phot'):
+ length = ord(data[4])*256+ord(data[5])
+ f.read(length-8)
+ # fake an EXIF beginning of file
+ data = '\xFF\x00'+f.read(10)
+ fake_exif = 1
+ if data[2] == '\xFF' and data[6:10] == 'Exif':
+ # detected EXIF header
+ offset = f.tell()
+ endian = f.read(1)
+ else:
+ # no EXIF information
+ return {}
+ else:
+ # file format not recognized
+ return {}
+
+ # deal with the EXIF info we found
+ if debug:
+ print {'I': 'Intel', 'M': 'Motorola'}[endian], 'format'
+ hdr = EXIF_header(f, endian, offset, fake_exif, debug)
+ ifd_list = hdr.list_IFDs()
+ ctr = 0
+ for i in ifd_list:
+ if ctr == 0:
+ IFD_name = 'Image'
+ elif ctr == 1:
+ IFD_name = 'Thumbnail'
+ thumb_ifd = i
+ else:
+ IFD_name = 'IFD %d' % ctr
+ if debug:
+ print ' IFD %d (%s) at offset %d:' % (ctr, IFD_name, i)
+ hdr.dump_IFD(i, IFD_name, stop_tag=stop_tag)
+ # EXIF IFD
+ exif_off = hdr.tags.get(IFD_name+' ExifOffset')
+ if exif_off:
+ if debug:
+ print ' EXIF SubIFD at offset %d:' % exif_off.values[0]
+ hdr.dump_IFD(exif_off.values[0], 'EXIF', stop_tag=stop_tag)
+ # Interoperability IFD contained in EXIF IFD
+ intr_off = hdr.tags.get('EXIF SubIFD InteroperabilityOffset')
+ if intr_off:
+ if debug:
+ print ' EXIF Interoperability SubSubIFD at offset %d:' \
+ % intr_off.values[0]
+ hdr.dump_IFD(intr_off.values[0], 'EXIF Interoperability',
+ dict=INTR_TAGS, stop_tag=stop_tag)
+ # GPS IFD
+ gps_off = hdr.tags.get(IFD_name+' GPSInfo')
+ if gps_off:
+ if debug:
+ print ' GPS SubIFD at offset %d:' % gps_off.values[0]
+ hdr.dump_IFD(gps_off.values[0], 'GPS', dict=GPS_TAGS, stop_tag=stop_tag)
+ ctr += 1
+
+ # extract uncompressed TIFF thumbnail
+ thumb = hdr.tags.get('Thumbnail Compression')
+ if thumb and thumb.printable == 'Uncompressed TIFF':
+ hdr.extract_TIFF_thumbnail(thumb_ifd)
+
+ # JPEG thumbnail (thankfully the JPEG data is stored as a unit)
+ thumb_off = hdr.tags.get('Thumbnail JPEGInterchangeFormat')
+ if thumb_off:
+ f.seek(offset+thumb_off.values[0])
+ size = hdr.tags['Thumbnail JPEGInterchangeFormatLength'].values[0]
+ hdr.tags['JPEGThumbnail'] = f.read(size)
+
+ # deal with MakerNote contained in EXIF IFD
+ if 'EXIF MakerNote' in hdr.tags and detailed:
+ hdr.decode_maker_note()
+
+ # Sometimes in a TIFF file, a JPEG thumbnail is hidden in the MakerNote
+ # since it's not allowed in a uncompressed TIFF IFD
+ if 'JPEGThumbnail' not in hdr.tags:
+ thumb_off=hdr.tags.get('MakerNote JPEGThumbnail')
+ if thumb_off:
+ f.seek(offset+thumb_off.values[0])
+ hdr.tags['JPEGThumbnail']=file.read(thumb_off.field_length)
+
+ return hdr.tags
+
+
+# show command line usage
+def usage(exit_status):
+ msg = 'Usage: EXIF.py [OPTIONS] file1 [file2 ...]\n'
+ msg += 'Extract EXIF information from digital camera image files.\n\nOptions:\n'
+ msg += '-q --quick Do not process MakerNotes.\n'
+ msg += '-t TAG --stop-tag TAG Stop processing when this tag is retrieved.\n'
+ msg += '-d --debug Run in debug mode.\n'
+ print msg
+ sys.exit(exit_status)
+
+# library test/debug function (dump given files)
+if __name__ == '__main__':
+ import sys
+ import getopt
+
+ # parse command line options/arguments
+ try:
+ opts, args = getopt.getopt(sys.argv[1:], "hqdt:v", ["help", "quick", "debug", "stop-tag="])
+ except getopt.GetoptError:
+ usage(2)
+ if args == []:
+ usage(2)
+ detailed = True
+ stop_tag = 'UNDEF'
+ debug = False
+ for o, a in opts:
+ if o in ("-h", "--help"):
+ usage(0)
+ if o in ("-q", "--quick"):
+ detailed = False
+ if o in ("-t", "--stop-tag"):
+ stop_tag = a
+ if o in ("-d", "--debug"):
+ debug = True
+
+ # output info for each file
+ for filename in args:
+ try:
+ file=open(filename, 'rb')
+ except:
+ print "'%s' is unreadable\n"%filename
+ continue
+ print filename + ':'
+ # get the tags
+ data = process_file(file, stop_tag=stop_tag, details=detailed, debug=debug)
+ if not data:
+ print 'No EXIF information found'
+ continue
+
+ x=data.keys()
+ x.sort()
+ for i in x:
+ if i in ('JPEGThumbnail', 'TIFFThumbnail'):
+ continue
+ try:
+ print ' %s (%s): %s' % \
+ (i, FIELD_TYPES[data[i].field_type][2], data[i].printable)
+ except:
+ print 'error', i, '"', data[i], '"'
+ if 'JPEGThumbnail' in data:
+ print 'File has JPEG thumbnail'
+ print
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/utils/reflection.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,92 @@
+""" Function for generating web 2.0 style image reflection effects.
+
+Copyright (c) 2007, Justin C. Driscoll
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ 3. Neither the name of reflection.py nor the names of its contributors may be used
+ to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""
+
+try:
+ import Image
+ import ImageColor
+except ImportError:
+ try:
+ from PIL import Image
+ from PIL import ImageColor
+ except ImportError:
+ raise ImportError("The Python Imaging Library was not found.")
+
+
+def add_reflection(im, bgcolor="#00000", amount=0.4, opacity=0.6):
+ """ Returns the supplied PIL Image (im) with a reflection effect
+
+ bgcolor The background color of the reflection gradient
+ amount The height of the reflection as a percentage of the orignal image
+ opacity The initial opacity of the reflection gradient
+
+ Originally written for the Photologue image management system for Django
+ and Based on the original concept by Bernd Schlapsi
+
+ """
+ # convert bgcolor string to rgb value
+ background_color = ImageColor.getrgb(bgcolor)
+
+ # copy orignial image and flip the orientation
+ reflection = im.copy().transpose(Image.FLIP_TOP_BOTTOM)
+
+ # create a new image filled with the bgcolor the same size
+ background = Image.new("RGB", im.size, background_color)
+
+ # calculate our alpha mask
+ start = int(255 - (255 * opacity)) # The start of our gradient
+ steps = int(255 * amount) # the number of intermedite values
+ increment = (255 - start) / float(steps)
+ mask = Image.new('L', (1, 255))
+ for y in range(255):
+ if y < steps:
+ val = int(y * increment + start)
+ else:
+ val = 255
+ mask.putpixel((0, y), val)
+ alpha_mask = mask.resize(im.size)
+
+ # merge the reflection onto our background color using the alpha mask
+ reflection = Image.composite(background, reflection, alpha_mask)
+
+ # crop the reflection
+ reflection_height = int(im.size[1] * amount)
+ reflection = reflection.crop((0, 0, im.size[0], reflection_height))
+
+ # create new image sized to hold both the original image and the reflection
+ composite = Image.new("RGB", (im.size[0], im.size[1]+reflection_height), background_color)
+
+ # paste the orignal image and the reflection into the composite image
+ composite.paste(im, (0, 0))
+ composite.paste(reflection, (0, im.size[1]))
+
+ # return the image complete with reflection effect
+ return composite
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/photologue/utils/watermark.py Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,64 @@
+""" Function for applying watermarks to images.
+
+Original found here:
+http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362879
+
+"""
+
+try:
+ import Image
+ import ImageEnhance
+except ImportError:
+ try:
+ from PIL import Image
+ from PIL import ImageEnhance
+ except ImportError:
+ raise ImportError("The Python Imaging Library was not found.")
+
+def reduce_opacity(im, opacity):
+ """Returns an image with reduced opacity."""
+ assert opacity >= 0 and opacity <= 1
+ if im.mode != 'RGBA':
+ im = im.convert('RGBA')
+ else:
+ im = im.copy()
+ alpha = im.split()[3]
+ alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
+ im.putalpha(alpha)
+ return im
+
+def apply_watermark(im, mark, position, opacity=1):
+ """Adds a watermark to an image."""
+ if opacity < 1:
+ mark = reduce_opacity(mark, opacity)
+ if im.mode != 'RGBA':
+ im = im.convert('RGBA')
+ # create a transparent layer the size of the image and draw the
+ # watermark in that layer.
+ layer = Image.new('RGBA', im.size, (0,0,0,0))
+ if position == 'tile':
+ for y in range(0, im.size[1], mark.size[1]):
+ for x in range(0, im.size[0], mark.size[0]):
+ layer.paste(mark, (x, y))
+ elif position == 'scale':
+ # scale, but preserve the aspect ratio
+ ratio = min(
+ float(im.size[0]) / mark.size[0], float(im.size[1]) / mark.size[1])
+ w = int(mark.size[0] * ratio)
+ h = int(mark.size[1] * ratio)
+ mark = mark.resize((w, h))
+ layer.paste(mark, ((im.size[0] - w) / 2, (im.size[1] - h) / 2))
+ else:
+ layer.paste(mark, position)
+ # composite the watermark with the layer
+ return Image.composite(layer, im, layer)
+
+def test():
+ im = Image.open('test.png')
+ mark = Image.open('overlay.png')
+ watermark(im, mark, 'tile', 0.5).show()
+ watermark(im, mark, 'scale', 1.0).show()
+ watermark(im, mark, (100, 100), 0.5).show()
+
+if __name__ == '__main__':
+ test()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/web/static/css/admin_style.css Thu Jan 21 18:41:10 2010 +0100
@@ -0,0 +1,84 @@
+#admin_footer
+{
+clear: both;
+margin: 0;
+padding: .5em;
+/*background-color: #ddd;*/
+border-top: 1px solid gray;
+}
+
+.version
+{
+ text-align: right;
+ color: white;
+ font-size:9px;
+}
+
+.version a:link
+{
+ text-decoration: none;
+ color: white;
+ text-decoration: bold;
+ font-weight: bold;
+ border-bottom-style: none;
+}
+.version a:hover
+{
+ color: #2c8084;
+ text-decoration: none;
+ font-weight: bold;
+ border-bottom-width: 0px;
+ border-bottom-style: none;
+}
+
+.version a:active
+{
+ color: white;
+ text-decoration: none;
+ font-weight: bold;
+ border-bottom-style: none;
+}
+
+.version a:visited
+{
+ color: white;
+ font-weight: bold;
+ border-bottom-style: none;
+}
+
+.small
+{
+ font-size:9px;
+}
+
+.footer_img
+{
+ float: left;
+}
+
+.footer_img img
+{
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+.footer_img a
+{
+ color: white;
+}
+
+.footer_img a:link
+{
+ color: white;
+}
+
+.footer_img a:hover
+{
+ color: white;
+}
+
+.footer_img a:visited
+{
+ color: white;
+}
+
--- a/web/static/css/style.css Wed Jan 20 12:41:13 2010 +0100
+++ b/web/static/css/style.css Thu Jan 21 18:41:10 2010 +0100
@@ -164,6 +164,15 @@
border-top: 1px solid gray;
}
+#admin_footer
+{
+clear: both;
+margin: 0;
+padding: .5em;
+/*background-color: #ddd;*/
+border-top: 1px solid gray;
+}
+
.version
{
text-align: right;