# HG changeset patch # User Brian Neal # Date 1334439629 18000 # Node ID e2868ad47a1ef68c965cd96886d69442ad84cd8d # Parent f26cdda0ad8ba12fed61d14900c7bdddc018a58f For Django 1.4, using the new manage.py. diff -r f26cdda0ad8b -r e2868ad47a1e articles/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/articles/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,21 @@ +""" +Automatic admin definitions for the articles application. + +""" +from django.contrib import admin +from django.conf import settings + +from articles.models import Article + + +class ArticleAdmin(admin.ModelAdmin): + save_on_top = True + list_filter = ['date'] + list_display = ['title', 'date'] + search_fields = ['text', 'title'] + + class Media: + js = settings.THIRD_PARTY_JS['tiny_mce'] + + +admin.site.register(Article, ArticleAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e articles/management/commands/import_old_articles.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/articles/management/commands/import_old_articles.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,59 @@ +""" +import_old_articles.py - For importing articles from the older version of this +website. + +""" +import datetime + +from django.conf import settings +from django.core.management.base import LabelCommand +from django.utils import simplejson as json +from django.utils.html import linebreaks +import textile + +from articles.models import Article + + +class Command(LabelCommand): + args = '' + help = 'Imports older articles in JSON format' + + def handle_label(self, filename, **options): + """ + Process the file of older articles in JSON. Convert to the new model + scheme. + + """ + with open(filename, 'rb') as f: + items = json.load(f) + + for item in items: + if item['model'] == 'band.article': + self.process_item(item) + + def process_item(self, item): + + fields = item['fields'] + + content = fields['text'].strip() + if fields['markup_enabled']: + text = textile.textile(content, encoding='utf-8', output='utf-8') + else: + text = linebreaks(fields['text']) + + source = linebreaks(fields['source'].strip()) + + pdf = fields['pdf'].strip() + if pdf: + pdf = u"%s%s" % (settings.MEDIA_URL, pdf.replace('\\', '/')) + + article = Article( + id=item['pk'], + title=fields['title'].strip(), + date=datetime.datetime.strptime(fields['date'], '%Y-%m-%d'), + text=text, + source=source, + url=fields['url'].strip(), + pdf=pdf) + + article.save() diff -r f26cdda0ad8b -r e2868ad47a1e articles/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/articles/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,28 @@ +""" +Models for the articles application. + +""" +from django.db import models + + +class Article(models.Model): + title = models.CharField(max_length=64) + date = models.DateTimeField(db_index=True) + text = models.TextField() + source = models.TextField(help_text="Enter the source/author for the " + "article, copyright info, etc; it will appear under the article.") + url = models.URLField(blank=True, + help_text = 'Link to original article; optional') + pdf = models.FileField(upload_to = 'pdf/articles/%Y/%m/%d/', blank=True, + help_text="If you want to make the original article available as " + "a PDF download, you may upload it here.") + + def __unicode__(self): + return self.title + + class Meta: + ordering = ['-date'] + + @models.permalink + def get_absolute_url(self): + return ('articles-item', [], {'pk': str(self.id)}) diff -r f26cdda0ad8b -r e2868ad47a1e articles/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/articles/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,20 @@ +""" +Urls for the articles application. + +""" +from django.conf.urls import patterns, url +from django.views.generic import DetailView, ListView + +from articles.models import Article + + +urlpatterns = patterns('', + url(r'^$', + ListView.as_view( + model=Article, + context_object_name='article_list'), + name='articles-index'), + url(r'^(?P\d+)/$', + DetailView.as_view(model=Article, context_object_name='article'), + name='articles-item') +) diff -r f26cdda0ad8b -r e2868ad47a1e articles/views.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/articles/views.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,1 @@ +# Create your views here. diff -r f26cdda0ad8b -r e2868ad47a1e band/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/band/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,83 @@ +""" +Automatic admin definitions for the band models. + +""" +from django.contrib import admin + +from band.models import Album +from band.models import AlbumMerchant +from band.models import AlbumTrack +from band.models import Gear +from band.models import LabelRelease +from band.models import Member +from band.models import Merchandise +from band.models import RecordLabel + + +class GearInline(admin.TabularInline): + model = Gear + + +class GearAdmin(admin.ModelAdmin): + list_display = ['item', 'member'] + list_filter = ['member'] + + +class MemberAdmin(admin.ModelAdmin): + list_display = ['name', 'instrument', 'is_active'] + inlines = [GearInline] + + +class AlbumTrackInline(admin.TabularInline): + model = AlbumTrack + + +class AlbumTrackAdmin(admin.ModelAdmin): + list_display = ['track_name', 'album'] + list_filter = ['album'] + + +class LabelReleaseInline(admin.TabularInline): + model = LabelRelease + + +class LabelReleaseAdmin(admin.ModelAdmin): + list_display = ['catalog_number', 'album', 'record_label', 'release_date'] + list_filter = ['record_label', 'album'] + + +class RecordLabelAdmin(admin.ModelAdmin): + inlines = [LabelReleaseInline] + + +class AlbumMerchantInline(admin.TabularInline): + model = AlbumMerchant + + +class AlbumMerchantAdmin(admin.ModelAdmin): + list_display = ['name', 'album'] + list_filter = ['album'] + + +class AlbumAdmin(admin.ModelAdmin): + save_on_top = True + inlines = [ + AlbumTrackInline, + LabelReleaseInline, + AlbumMerchantInline, + ] + + +class MerchandiseAdmin(admin.ModelAdmin): + list_display = ['name', 'price', 'in_stock'] + list_filter = ['in_stock'] + + +admin.site.register(Gear, GearAdmin) +admin.site.register(Member, MemberAdmin) +admin.site.register(AlbumTrack, AlbumTrackAdmin) +admin.site.register(LabelRelease, LabelReleaseAdmin) +admin.site.register(RecordLabel, RecordLabelAdmin) +admin.site.register(AlbumMerchant, AlbumMerchantAdmin) +admin.site.register(Album, AlbumAdmin) +admin.site.register(Merchandise, MerchandiseAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e band/management/commands/import_old_band.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/band/management/commands/import_old_band.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,149 @@ +""" +import_old_band.py - For importing band models from the older version of this +website. + +""" +import datetime + +from django.core.management.base import LabelCommand +from django.utils import simplejson as json +from django.utils.html import linebreaks +from photologue.models import Photo + +from band.models import (Member, Gear, RecordLabel, Album, AlbumTrack, + LabelRelease, AlbumMerchant, Merchandise) + + +class Command(LabelCommand): + args = '' + help = 'Imports older band model objects in JSON format' + + members = {} + albums = {} + labels = {} + + def handle_label(self, filename, **options): + """ + Process the file of older model objects in JSON. Convert to the new model + scheme. + + """ + with open(filename, 'rb') as f: + items = json.load(f) + + for item in items: + if item['model'] == 'band.member': + self.process_member(item) + elif item['model'] == 'band.gear': + self.process_gear(item) + elif item['model'] == 'band.record_label': + self.process_record_label(item) + elif item['model'] == 'band.album': + self.process_album(item) + elif item['model'] == 'band.album_track': + self.process_track(item) + elif item['model'] == 'band.label_release': + self.process_release(item) + elif item['model'] == 'band.album_merchant': + self.process_merchant(item) + elif item['model'] == 'band.merchandise': + self.process_merch(item) + + def process_member(self, item): + + fields = item['fields'] + + start_date = datetime.datetime.strptime(fields['start_date'], '%Y-%m-%d') + + if fields['end_date'] == u'1985-01-01': + end_date = None + else: + end_date = datetime.datetime.strptime(fields['end_date'], '%Y-%m-%d') + + member = Member(id=item['pk'], + name=fields['name'], + nickname=fields['nickname'], + instrument=fields['instrument'], + bio=fields['bio'], + photo=fields['photo'], + order=int(fields['order']), + is_active=fields['is_active'], + start_date=start_date, + end_date=end_date, + email=fields['email']) + member.save() + self.members[member.id] = member + + def process_gear(self, item): + fields = item['fields'] + + gear = Gear(id=item['pk'], + member=self.members[int(fields['member'])], + item=fields['item']) + gear.save() + + def process_record_label(self, item): + fields = item['fields'] + + label = RecordLabel(id=item['pk'], + url=fields['url'], + name=fields['name']) + label.save() + self.labels[label.id] = label + + def process_album(self, item): + fields = item['fields'] + + photo = Photo.objects.get(id=fields['photo']) + + album = Album(id=item['pk'], + title=fields['title'], + photo=photo, + desc=fields['desc']) + album.save() + self.albums[album.id] = album + + def process_track(self, item): + fields = item['fields'] + + track = AlbumTrack(id=item['pk'], + album=self.albums[int(fields['album'])], + track_number=int(fields['track_number']), + track_name=fields['track_name']) + track.save() + + def process_release(self, item): + fields = item['fields'] + + release = LabelRelease(id=item['pk'], + record_label=self.labels[int(fields['record_label'])], + album=self.albums[int(fields['album'])], + catalog_number=fields['catalog_number'], + release_date=datetime.datetime.strptime( + fields['release_date'], '%Y-%m-%d')) + + release.save() + + def process_merchant(self, item): + fields = item['fields'] + + merchant = AlbumMerchant(id=item['pk'], + album=self.albums[int(fields['album'])], + url=fields['url'], + name=fields['name']) + + merchant.save() + + def process_merch(self, item): + fields = item['fields'] + + photo = Photo.objects.get(id=fields['photo']) + + merch = Merchandise(id=item['pk'], + name=fields['name'], + desc=fields['desc'], + price=fields['price'], + in_stock=fields['in_stock'], + photo=photo) + + merch.save() diff -r f26cdda0ad8b -r e2868ad47a1e band/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/band/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,111 @@ +""" +Models for the band application. + +""" +from django.db import models + +from photologue.models import Photo + + +class Member(models.Model): + name = models.CharField(max_length=50) + nickname = models.CharField(max_length=50, blank=True) + instrument = models.CharField(max_length=255) + bio = models.TextField(blank=True) + photo = models.ImageField(upload_to='images/bio/', blank=True) + order = models.SmallIntegerField( + help_text = "Controls order of display; lower numbers displayed first") + is_active = models.BooleanField() + start_date = models.DateField() + end_date = models.DateField(blank=True, + null=True, + help_text="Only used if the member is not active") + email = models.EmailField() + + def __unicode__(self): + return self.name + + class Meta: + ordering = ['-is_active', 'name'] + + +class Gear(models.Model): + member = models.ForeignKey(Member) + item = models.CharField(max_length=255) + + def __unicode__(self): + return self.item + + class Meta: + verbose_name_plural = 'Gear List' + + +class RecordLabel(models.Model): + name = models.CharField(max_length=64) + url = models.URLField(max_length=200) + + def __unicode__(self): + return self.name + + +class Album(models.Model): + title = models.CharField(max_length=64) + photo = models.ForeignKey(Photo) + desc = models.TextField(blank=True) + + def __unicode__(self): + return self.title + + +class AlbumTrack(models.Model): + album = models.ForeignKey(Album, related_name='tracks') + track_number = models.SmallIntegerField() + track_name = models.CharField(max_length=64) + + def __unicode__(self): + return self.track_name + + class Meta: + verbose_name = 'Album Track' + ordering = ['album', 'track_number'] + + +class LabelRelease(models.Model): + record_label = models.ForeignKey(RecordLabel) + album = models.ForeignKey(Album, related_name='labels') + catalog_number = models.CharField(max_length=32) + release_date = models.DateField() + + def __unicode__(self): + return u'%s %s %s' % (self.record_label.name, self.album.title, + self.catalog_number) + + class Meta: + verbose_name = 'Label Release' + + +class AlbumMerchant(models.Model): + album = models.ForeignKey(Album, related_name='merchants') + name = models.CharField(max_length=64) + url = models.URLField(max_length=200) + + def __unicode__(self): + return u'%s (%s)' % (self.name, self.album.title) + + class Meta: + verbose_name = 'Album Merchant' + ordering = ['name'] + + +class Merchandise(models.Model): + name = models.CharField(max_length=64) + desc = models.TextField() + price = models.DecimalField(max_digits=5, decimal_places=2) + in_stock = models.BooleanField() + photo = models.ForeignKey(Photo) + + def __unicode__(self): + return self.name + + class Meta: + verbose_name_plural = "Merchandise" diff -r f26cdda0ad8b -r e2868ad47a1e band/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/band/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,13 @@ +""" +Urls for the band application. + +""" +from django.conf.urls import patterns, url + +urlpatterns = patterns('band.views', + url(r'^bio/$', 'bio', name='band-bio'), + url(r'^buy/$', 'buy', name='band-buy'), + url(r'^contact/$', 'contact', name='band-contact'), + url(r'^photos/$', 'photos_index', name='band-photo_index'), + url(r'^photos/(\d+)$', 'photo_detail', name='band-photo_detail'), +) diff -r f26cdda0ad8b -r e2868ad47a1e band/views.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/band/views.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,57 @@ +""" +Views for the band application. + +""" +import random + +from django.shortcuts import render +from django.shortcuts import get_object_or_404 +from photologue.models import Gallery +from photologue.models import Photo + +from band.models import Member, Merchandise, Album + + +def bio(request): + members = Member.objects.exclude(is_active=0) + return render(request, 'band/bio.html', {'members': members}) + + +def photos_index(request): + galleries = Gallery.objects.values('title', 'id').order_by('-id') + + photo_ids = Photo.objects.filter(is_public=True).values_list('id', + flat=True) + photo_ids = random.sample(photo_ids, 4) + random_photos = Photo.objects.filter(id__in=photo_ids) + + return render(request, 'band/photos.html', { + 'galleries': galleries, + 'random_photos': random_photos, + }) + + +def photo_detail(request, id): + gallery = get_object_or_404(Gallery, pk=id) + photos = gallery.photos.order_by('id') + + return render(request, 'band/photo_detail.html', { + 'gallery': gallery, + 'photos': photos, + }) + + +def buy(request): + albums = Album.objects.all().order_by('-id') + merchandise = Merchandise.objects.all().order_by('-id') + return render(request, 'band/buy.html', { + 'albums': albums, + 'merchandise': merchandise, + }) + + +def contact(request): + band = Member.objects.exclude(is_active=0).order_by('order') + return render(request, 'band/contact.html', { + 'band': band, + }) diff -r f26cdda0ad8b -r e2868ad47a1e email_list/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,41 @@ +""" +Automatic admin definitions for the email_list application. + +""" +from django.contrib import admin +from django.conf.urls import patterns, url +from django.shortcuts import render, redirect + +from email_list.models import Subscriber +from email_list.forms import AdminEmailForm + + +class SubscriberAdmin(admin.ModelAdmin): + list_display = ['__unicode__', 'location', 'status'] + list_filter = ['status'] + search_fields = ['name', 'email'] + + def get_urls(self): + urls = super(SubscriberAdmin, self).get_urls() + my_urls = patterns('', + url(r'^send_mail/$', + self.admin_site.admin_view(self.send_mail), + name='email_list-admin_mail'), + ) + return my_urls + urls + + def send_mail(self, request): + if request.method == 'POST': + form = AdminEmailForm(request.POST) + if form.is_valid(): + n = form.save() + msg = '%d mailing list emails sent' % n + self.message_user(request, msg) + return redirect('admin:index') + else: + form = AdminEmailForm() + + return render(request, 'email_list/admin_mail.html', {'form': form}) + + +admin.site.register(Subscriber, SubscriberAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e email_list/forms.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/forms.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,155 @@ +""" +Forms for the email_list application. + +""" +from django import forms +from django.conf import settings +from django.core.urlresolvers import reverse +from django.core.mail import send_mail, send_mass_mail +from django.template.loader import render_to_string + +from email_list.models import Subscriber + + +SUBSCRIBE_OPTS = [('sub', 'Subscribe'), ('unsub', 'Unsubscribe')] + +ALREADY_SUBSCRIBED = "This email address is already subscribed." +NOT_SUBSCRIBED = "This email address is not on our list." + + +class SubscriberForm(forms.Form): + name = forms.CharField(max_length=64, required=False) + email = forms.EmailField() + location = forms.CharField(max_length=64, required=False) + option = forms.ChoiceField(choices=SUBSCRIBE_OPTS) + + def clean(self): + """ + This method ensures the appropriate action can be carried out and raises + a validation error if not. + + """ + email = self.cleaned_data['email'] + + if self.cleaned_data['option'] == 'sub': + # is the user already subscribed (active)? + try: + subscriber = Subscriber.objects.get(email=email) + except Subscriber.DoesNotExist: + subscriber = Subscriber(email=email, + name=self.cleaned_data['name'], + location=self.cleaned_data['location']) + else: + if subscriber.is_active(): + raise forms.ValidationError(ALREADY_SUBSCRIBED) + else: + # is the user already unsubscribed or not subscribed? + try: + subscriber = Subscriber.objects.get(email=email) + except Subscriber.DoesNotExist: + raise forms.ValidationError(NOT_SUBSCRIBED) + + # save the subscriber away for a future process() call + self.subscriber = subscriber + + return self.cleaned_data + + def is_subscribe(self): + """ + This function can be called after an is_valid() call to determine if the + request was for a subscribe or unsubscribe. + + """ + return self.cleaned_data['option'] == 'sub' + + def process(self): + """ + Call this function if is_valid() returns True. It carries out the user's + subscription request. + + """ + if self.is_subscribe(): + self.subscriber.set_pending() + else: + self.subscriber.set_leaving() + + self.subscriber.save() + send_email(self.subscriber) + + +class AdminEmailForm(forms.Form): + subject = forms.CharField(max_length=255, required=True, label='Subject:', + widget=forms.TextInput(attrs={'class': 'vTextField required', + 'size': '120'})) + message = forms.CharField(label='Message:', + widget=forms.Textarea(attrs={'class': 'vLargeTextField required'})) + + def __init__(self, *args, **kwargs): + initial = kwargs.pop('initial', {}) + if 'subject' not in initial: + initial['subject'] = '[%s] ' % settings.BAND_CONFIG['BAND_NAME'] + kwargs['initial'] = initial + + super(AdminEmailForm, self).__init__(*args, **kwargs) + + def save(self): + """ + Call this function if is_valid() to send the mass email. + Returns the number of mails sent. + + """ + subject = self.cleaned_data['subject'] + message = self.cleaned_data['message'] + return send_mail_to_subscribers(subject, message) + + +def send_email(subscriber): + """ + This function sends out the appropriate email for the given subscriber. + + """ + config = settings.BAND_CONFIG + band = config['BAND_NAME'] + from_email = config['BAND_EMAIL'] + + url = "http://%s%s" % (config['BAND_DOMAIN'], + reverse('email_list-confirm', kwargs={'key': subscriber.key})) + + if subscriber.is_pending(): + email_template = 'email_list/email_subscribe.txt' + else: + email_template = 'email_list/email_unsubscribe.txt' + + msg = render_to_string(email_template, { + 'band': band, + 'url': url, + 'band_domain': config['BAND_DOMAIN'], + }) + + subject = "[%s] Mailing List Confirmation" % band + + send_mail(subject, msg, from_email, [subscriber.email]) + + +def send_mail_to_subscribers(subject, message): + """ + Send an email to each mailing list subscriber with the given subject and + message. + Returns the number of messages sent. + + """ + config = settings.BAND_CONFIG + unsubscribe_url = "http://%s%s" % (config['BAND_DOMAIN'], + reverse('email_list-main')) + + msg = render_to_string('email_list/mailing_list.txt', { + 'band': config['BAND_NAME'], + 'unsubscribe_url': unsubscribe_url, + 'message': message, + }) + + mail_data = [(subject, msg, config['BAND_EMAIL'], [subscriber]) for + subscriber in Subscriber.objects.values_list('email', flat=True)] + + send_mass_mail(mail_data) + return len(mail_data) diff -r f26cdda0ad8b -r e2868ad47a1e email_list/management/commands/import_old_email_list.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/management/commands/import_old_email_list.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,51 @@ +""" +import_old_email_list.py - For importing the email list from the older version +of this website. + +""" +import datetime + +from django.core.management.base import LabelCommand +from django.utils import simplejson as json + +from email_list.models import Subscriber + + +class Command(LabelCommand): + args = '' + help = 'Imports older email list data in JSON format' + + def handle_label(self, filename, **options): + """ + Process the file of older email list data in JSON. Convert to the new + model scheme. + + """ + with open(filename, 'rb') as f: + items = json.load(f) + + for item in items: + if item['model'] == 'band.fan': + self.process_item(item) + + def process_item(self, item): + + fields = item['fields'] + + # Only process 'active' subscribers because the new application + # has changed the key generation scheme. + + if fields['status'] != 'A': + return + + subscriber = Subscriber( + id=item['pk'], + name=fields['name'].strip(), + email=fields['email'].strip(), + location=fields['location'].strip(), + status='A', + key='', + status_date=datetime.datetime.strptime( + fields['status_date'], '%Y-%m-%d')) + + subscriber.save() diff -r f26cdda0ad8b -r e2868ad47a1e email_list/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,69 @@ +""" +Models for the email_list application. + +""" +import base64 +import datetime +import hashlib + +from django.db import models +from django.conf import settings + + +class Subscriber(models.Model): + status_codes = [('P', 'Pending'), ('A', 'Active'), ('L', 'Leaving')] + key_length = 28 + + name = models.CharField(max_length=64, blank=True) + email = models.EmailField(db_index=True) + location = models.CharField(max_length=64, blank=True) + status = models.CharField(max_length=1, choices=status_codes, default='A') + key = models.CharField(max_length=key_length, editable=False, blank=True, + db_index=True) + status_date = models.DateTimeField(editable=False) + + class Meta: + ordering = ['name', 'email'] + + def __unicode__(self): + if self.name: + return u'%s <%s>' % (self.name, self.email) + return self.email + + def save(self, *args, **kwargs): + if not self.pk and not self.status_date: + self.status_date = datetime.datetime.now() + + super(Subscriber, self).save(*args, **kwargs) + + def set_pending(self): + self.status = 'P' + self.status_date = datetime.datetime.now() + self.gen_key() + + def set_active(self): + self.status = 'A' + self.status_date = datetime.datetime.now() + self.key = '' + + def set_leaving(self): + self.status = 'L' + self.status_date = datetime.datetime.now() + self.gen_key() + + def is_pending(self): + return self.status == 'P' + + def is_leaving(self): + return self.status == 'L' + + def is_active(self): + return self.status == 'A' + + def gen_key(self): + source = (settings.SECRET_KEY + self.email + self.name + self.location + + self.status + self.status_date.isoformat()) + + sha = hashlib.sha1() + sha.update(source) + self.key = base64.urlsafe_b64encode(sha.digest()) diff -r f26cdda0ad8b -r e2868ad47a1e email_list/tests/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/tests/__init__.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,2 @@ +from model_tests import * +from view_tests import * diff -r f26cdda0ad8b -r e2868ad47a1e email_list/tests/model_tests.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/tests/model_tests.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,76 @@ +""" +Model tests for the email_list application. + +""" +import datetime + +from django.test import TestCase + +from email_list.models import Subscriber + + +class SubscriberTestCase(TestCase): + + def test_auto_save(self): + + sub = Subscriber(name='', location='', email='test@example.com') + sub.save() + + now = datetime.datetime.now() + self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) + + self.assertTrue(sub.status == 'A') + self.assertTrue(sub.is_active()) + self.failIf(sub.is_pending()) + self.failIf(sub.is_leaving()) + + def test_set_pending(self): + + sub = Subscriber(name='', location='', email='test@example.com') + sub.set_pending() + + now = datetime.datetime.now() + self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) + + self.assertTrue(sub.status == 'P') + self.failIf(sub.is_active()) + self.assertTrue(sub.is_pending()) + self.failIf(sub.is_leaving()) + + self.assertTrue(len(sub.key) == sub.key_length) + + def test_set_active(self): + + sub = Subscriber(name='', location='', email='test@example.com') + sub.set_active() + + now = datetime.datetime.now() + self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) + + self.assertTrue(sub.status == 'A') + self.assertTrue(sub.is_active()) + self.failIf(sub.is_pending()) + self.failIf(sub.is_leaving()) + + def test_set_leaving(self): + + sub = Subscriber(name='', location='', email='test@example.com') + sub.set_leaving() + + now = datetime.datetime.now() + self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) + + self.assertTrue(sub.status == 'L') + self.failIf(sub.is_active()) + self.failIf(sub.is_pending()) + self.assertTrue(sub.is_leaving()) + + self.assertTrue(len(sub.key) == sub.key_length) + + def test_gen_key(self): + + sub = Subscriber(name='', location='', email='test@example.com') + sub.status_date = datetime.datetime.now() + sub.gen_key() + self.assertTrue(len(sub.key) == sub.key_length) + diff -r f26cdda0ad8b -r e2868ad47a1e email_list/tests/view_tests.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/tests/view_tests.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,213 @@ +""" +View tests for the email_list application. + +""" +from django.test import TestCase +from django.core.urlresolvers import reverse +from django.core import mail + +from email_list.models import Subscriber +import email_list.forms + + +SUB_PARAMS = { + 'name': 'John Doe', + 'email': 'j.doe@example.com', + 'location': 'USA', + 'option': 'sub' +} + +UNSUB_PARAMS = { + 'name': '', + 'email': SUB_PARAMS['email'], + 'location': '', + 'option': 'unsub' +} + +class EmailListTestCase(TestCase): + + def test_already_subscribed(self): + """ + Test that subscribing twice fails with a form error. + + """ + sub = Subscriber(email=SUB_PARAMS['email']) + sub.set_active() + sub.save() + + # Post a subscribe request + + response = self.client.post( + reverse('email_list-main'), + SUB_PARAMS, + follow=True) + + self.assertTrue(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 0) + self.assertContains(response, email_list.forms.ALREADY_SUBSCRIBED) + + def test_not_subscribed(self): + """ + Test that unsubscribing without being subscribed fails with a form error. + + """ + # Post a unsubscribe request + + response = self.client.post( + reverse('email_list-main'), + UNSUB_PARAMS, + follow=True) + + self.assertTrue(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 0) + self.assertContains(response, email_list.forms.NOT_SUBSCRIBED) + + def test_normal_cycle(self): + """ + Test a normal subscribe and unsubscribe cycle. + + """ + self.do_test_subscribe() + self.do_test_unsubscribe() + + def test_subscribe_if_pending(self): + """ + Ensure you can subscribe if you are already pending. + + """ + sub = Subscriber(email=SUB_PARAMS['email']) + sub.set_pending() + sub.save() + self.do_test_subscribe() + + def test_subscribe_if_leaving(self): + """ + Ensure you can subscribe if you are leaving. + + """ + sub = Subscriber(email=SUB_PARAMS['email']) + sub.set_leaving() + sub.save() + self.do_test_subscribe() + + def test_unsubscribe_if_leaving(self): + """ + Ensure you can unsubscribe if you are already leaving. + + """ + sub = Subscriber(email=SUB_PARAMS['email']) + sub.set_leaving() + sub.save() + self.do_test_unsubscribe() + + def do_test_subscribe(self): + # Get the form view + response = self.client.get(reverse('email_list-main')) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'email_list/subscribe_form.html') + + # Post a subscribe request + + response = self.client.post( + reverse('email_list-main'), + SUB_PARAMS, + follow=True) + + self.assertTrue(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 1) + self.assertEqual(response.redirect_chain[0][0], + 'http://testserver' + reverse('email_list-request_subscribe')) + self.assertEqual(response.redirect_chain[0][1], 302) + + # verify subscriber is in pending state + + try: + subscriber = Subscriber.objects.get(email=SUB_PARAMS['email']) + except Subscriber.DoesNotExist: + self.fail("No pending subscriber") + + self.assertTrue(subscriber.is_pending()) + + # test email sent + self.do_test_email(subscriber) + + # simulate a confirm click + + response = self.client.get( + reverse('email_list-confirm', kwargs={'key': subscriber.key}), + follow=True) + + self.assertTrue(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 1) + self.assertEqual(response.redirect_chain[0][0], + 'http://testserver' + reverse('email_list-subscribed')) + self.assertEqual(response.redirect_chain[0][1], 302) + + # verify active user + try: + subscriber = Subscriber.objects.get(email=SUB_PARAMS['email']) + except Subscriber.DoesNotExist: + self.fail("No active subscriber") + + self.assertTrue(subscriber.is_active()) + + def do_test_unsubscribe(self): + # Get the form view + response = self.client.get(reverse('email_list-main')) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'email_list/subscribe_form.html') + + # Post a unsubscribe request + + response = self.client.post( + reverse('email_list-main'), + UNSUB_PARAMS, + follow=True) + + self.assertTrue(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 1) + self.assertEqual(response.redirect_chain[0][0], + 'http://testserver' + reverse('email_list-request_unsubscribe')) + self.assertEqual(response.redirect_chain[0][1], 302) + + # verify subscriber is in leaving state + + try: + subscriber = Subscriber.objects.get(email=UNSUB_PARAMS['email']) + except Subscriber.DoesNotExist: + self.fail("No pending subscriber") + + self.assertTrue(subscriber.is_leaving()) + + # test email sent + self.do_test_email(subscriber) + + # simulate a click to unsubscribe + + response = self.client.get( + reverse('email_list-confirm', kwargs={'key': subscriber.key}), + follow=True) + + self.assertTrue(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 1) + self.assertEqual(response.redirect_chain[0][0], + 'http://testserver' + reverse('email_list-unsubscribed')) + self.assertEqual(response.redirect_chain[0][1], 302) + + # verify subscription has been removed + + self.assertRaises(Subscriber.DoesNotExist, Subscriber.objects.get, + email=UNSUB_PARAMS['email']) + + def do_test_email(self, subscriber): + """ + Tests to see if the confirmation email was sent. + + """ + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].to), 1) + self.assertEqual(mail.outbox[0].to[0], subscriber.email) + self.assertTrue(str(mail.outbox[0].message()).count(subscriber.key) > 0) + + # clear outbox + mail.outbox = [] diff -r f26cdda0ad8b -r e2868ad47a1e email_list/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,28 @@ +""" +Urls for the email_list application. + +""" +from django.conf.urls import patterns, url +from django.views.generic import TemplateView + + +urlpatterns = patterns('email_list.views', + url(r'^$', + 'mailing_list', + name='email_list-main'), + url(r'^confirm/(?P[-a-zA-Z0-9_=]{28})/$', + 'confirm', + name='email_list-confirm'), + url(r'^request/subscribe/$', + TemplateView.as_view(template_name='email_list/subscribe_request.html'), + name='email_list-request_subscribe'), + url(r'^request/unsubscribe/$', + TemplateView.as_view(template_name='email_list/unsubscribe_request.html'), + name='email_list-request_unsubscribe'), + url(r'^subscribed/$', + TemplateView.as_view(template_name='email_list/subscribed.html'), + name='email_list-subscribed'), + url(r'^unsubscribed/$', + TemplateView.as_view(template_name='email_list/unsubscribed.html'), + name='email_list-unsubscribed'), +) diff -r f26cdda0ad8b -r e2868ad47a1e email_list/views.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/email_list/views.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,56 @@ +""" +Views for the email_list application. + +""" +import logging + +from django.http import HttpResponseServerError +from django.shortcuts import render, redirect, get_object_or_404 + +from email_list.forms import SubscriberForm +from email_list.models import Subscriber + + +logger = logging.getLogger(__name__) + + +def mailing_list(request): + """ + The main view for handling email list actions (subscribe or unsubscribe). + + """ + if request.method == 'POST': + form = SubscriberForm(request.POST) + if form.is_valid(): + form.process() + + if form.is_subscribe(): + return redirect('email_list-request_subscribe') + else: + return redirect('email_list-request_unsubscribe') + + else: + form = SubscriberForm() + + return render(request, 'email_list/subscribe_form.html', {'form': form}) + + +def confirm(request, key): + """ + This view handles the confirmation of a subscribe or unsubscribe action. + + """ + subscriber = get_object_or_404(Subscriber, key=key) + + if subscriber.is_pending(): + subscriber.set_active() + subscriber.save() + return redirect('email_list-subscribed') + elif subscriber.is_leaving(): + subscriber.delete() + return redirect('email_list-unsubscribed') + + # This should not happen + logger.error("Trying to confirm subscriber %d, but status is %s", + subscriber.pk, subscriber.status) + return HttpResponseServerError() diff -r f26cdda0ad8b -r e2868ad47a1e gigs/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gigs/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,57 @@ +""" +Automatic admin definitions for the gigs application. + +""" +from django.contrib import admin + +from gigs.models import Band, City, Country, Gig, State, Venue + + +class CityInline(admin.TabularInline): + model = City + + +class CityAdmin(admin.ModelAdmin): + list_display = ['name', 'state', 'country'] + list_filter = ['state'] + search_fields = ['name'] + + def queryset(self, request): + qs = super(CityAdmin, self).queryset(request) + return qs.select_related('state', 'country') + + +class StateAdmin(admin.ModelAdmin): + inlines = [CityInline] + + +class VenueAdmin(admin.ModelAdmin): + list_filter = ['city__name'] + list_display = ['name', 'city'] + search_fields = ['name'] + + def queryset(self, request): + qs = super(VenueAdmin, self).queryset(request) + return qs.select_related('city', 'city__state', 'city__country') + + +class BandAdmin(admin.ModelAdmin): + search_fields = ['name'] + + +class GigAdmin(admin.ModelAdmin): + list_filter = ['date'] + save_on_top = True + filter_horizontal = ['bands'] + + def queryset(self, request): + qs = super(GigAdmin, self).queryset(request) + return qs.select_related('venue') + + +admin.site.register(Band, BandAdmin) +admin.site.register(City, CityAdmin) +admin.site.register(Country) +admin.site.register(Gig, GigAdmin) +admin.site.register(State, StateAdmin) +admin.site.register(Venue, VenueAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e gigs/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gigs/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,97 @@ +""" +Models for the gigs application. + +""" +from django.db import models +from django.contrib.localflavor.us.models import USStateField +from django.contrib.localflavor.us.models import PhoneNumberField + +from photologue.models import Photo + + +class Country(models.Model): + name = models.CharField(max_length=64) + + class Meta: + ordering = ['name'] + verbose_name_plural = 'Countries' + + def __unicode__(self): + return self.name + + +class State(models.Model): + name = models.CharField(max_length=16) + abbrev = USStateField() + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class City(models.Model): + name = models.CharField(max_length=50) + state = models.ForeignKey(State, null=True, blank=True) + country = models.ForeignKey(Country, null=True, blank=True) + + class Meta: + verbose_name_plural = 'Cities' + ordering = ['name'] + + def __unicode__(self): + if self.state: + return u'%s, %s' % (self.name, self.state.abbrev) + + return self.name + + +class Venue(models.Model): + name = models.CharField(max_length=50) + url = models.URLField(blank=True) + address = models.CharField(max_length=255, blank=True) + phone = PhoneNumberField(help_text="Format: XXX-XXX-XXXX", blank=True) + city = models.ForeignKey(City) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class Band(models.Model): + name = models.CharField(max_length=64) + url = models.URLField(blank=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class Gig(models.Model): + title = models.CharField(max_length=50, blank=True, + help_text="Optional; e.g. Some Festival") + url = models.URLField(blank=True, + help_text="Optional; e.g. Some Festival's Website") + date = models.DateField(db_index=True) + time = models.TimeField(null=True, blank=True) + venue = models.ForeignKey(Venue, null=True, blank=True) + notes = models.TextField(blank=True) + bands = models.ManyToManyField(Band, blank=True) + flyer = models.ForeignKey(Photo, null=True, blank=True, + related_name='gig_flyers') + + def __unicode__(self): + if self.title: + return u'%s %s %s' % (self.date.strftime('%m/%d/%Y'), self.title, self.venue.name) + elif self.venue: + return u'%s %s' % (self.date.strftime('%m/%d/%Y'), self.venue.name) + else: + return self.date.strftime('%m/%d/%Y') + + class Meta: + ordering = ['-date', 'time'] diff -r f26cdda0ad8b -r e2868ad47a1e gigs/templatetags/gig_tags.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gigs/templatetags/gig_tags.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,22 @@ +""" +Template tags for the gigs application. + +""" +import datetime + +from django import template + +from gigs.models import Gig + + +register = template.Library() + + +@register.inclusion_tag('gigs/upcoming_gigs_tag.html') +def upcoming_gigs(): + + gigs = Gig.objects.filter(date__gte=datetime.date.today).select_related( + 'flyer', 'venue', 'venue__city', 'venue__city__state', + 'venue__city__country').order_by('date')[:10] + + return {'gigs': gigs} diff -r f26cdda0ad8b -r e2868ad47a1e gigs/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gigs/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,19 @@ +""" +Urls for the gigs application. + +""" +from django.conf.urls import patterns, url +from django.views.generic import ListView + +from gigs.models import Gig + + +urlpatterns = patterns('', + url(r'^$', 'gigs.views.gigs', name='gigs-index'), + url(r'^flyers/$', + ListView.as_view( + queryset=Gig.objects.exclude(flyer__isnull=True).select_related('flyer'), + template_name='gigs/flyers.html', + context_object_name='gig_list'), + name='gigs-flyers') +) diff -r f26cdda0ad8b -r e2868ad47a1e gigs/views.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gigs/views.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,76 @@ +""" +Views for the gigs application. + +""" +import collections +import datetime + +from django.db import connection +from django.shortcuts import render + +from gigs.models import Band, Gig + + +def gigs(request): + """ + This view function renders the main gigs view, showing upcoming and past + gigs as well as some statistics. + + """ + today = datetime.date.today() + gigs = Gig.objects.select_related('venue', 'flyer', 'venue__city', + 'venue__city__state', 'venue__city__country') + upcoming = [] + previous = [] + + # To avoid many, many database hits in the template, we get all the + # bands out at once. We also get the many-to-many intermediate table + # that Django generated for us so we can associate bands to gigs. + # Since we don't know about this table we drop into raw SQL to get + # the contents. + + bands = dict((band.id, band) for band in Band.objects.all()) + cursor = connection.cursor() + cursor.execute('SELECT * FROM gigs_gig_bands') + gig_bands = collections.defaultdict(list) + for row in cursor.fetchall(): + gig_bands[row[1]].append(bands[row[2]]) + + for gig in gigs: + gig.bands_ = gig_bands[gig.id] + if gig.date >= today: + upcoming.append(gig) + else: + previous.append(gig) + + upcoming.reverse() + + stats = {} + venues = set() + cities = set() + states = set() + countries = set() + for gig in previous: + venues.add(gig.venue.id) + cities.add(gig.venue.city.id) + if gig.venue.city.state: + states.add(gig.venue.city.state.id) + if gig.venue.city.country: + countries.add(gig.venue.city.country.id) + + stats['count'] = len(previous) + stats['venues'] = len(venues) + stats['cities'] = len(cities) + stats['states'] = len(states) + stats['countries'] = len(countries) + stats['bands'] = len(bands) + + flyer_gigs = Gig.objects.exclude(flyer__isnull = True).select_related( + 'venue', 'flyer').order_by('-date') + + return render(request, 'gigs/gigs.html', { + 'upcoming' : upcoming, + 'previous' : previous, + 'stats' : stats, + 'flyer_gigs' : flyer_gigs, + }) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/apache/madeira.wsgi --- a/madeira/apache/madeira.wsgi Sat Apr 14 15:49:28 2012 -0500 +++ b/madeira/apache/madeira.wsgi Sat Apr 14 16:40:29 2012 -0500 @@ -7,7 +7,6 @@ site.addsitedir('/home/var/django-sites/virtualenvs/madeira/lib/python2.5/site-packages') sys.path.append('/home/var/django-sites/virtualenvs/madeira/madeira') -sys.path.append('/home/var/django-sites/virtualenvs/madeira/madeira/madeira') os.environ['PYTHON_EGG_CACHE'] = '/home/var/django-sites/virtualenvs/madeira/eggs/' diff -r f26cdda0ad8b -r e2868ad47a1e madeira/articles/admin.py --- a/madeira/articles/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,21 +0,0 @@ -""" -Automatic admin definitions for the articles application. - -""" -from django.contrib import admin -from django.conf import settings - -from articles.models import Article - - -class ArticleAdmin(admin.ModelAdmin): - save_on_top = True - list_filter = ['date'] - list_display = ['title', 'date'] - search_fields = ['text', 'title'] - - class Media: - js = settings.THIRD_PARTY_JS['tiny_mce'] - - -admin.site.register(Article, ArticleAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/articles/management/commands/import_old_articles.py --- a/madeira/articles/management/commands/import_old_articles.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,59 +0,0 @@ -""" -import_old_articles.py - For importing articles from the older version of this -website. - -""" -import datetime - -from django.conf import settings -from django.core.management.base import LabelCommand -from django.utils import simplejson as json -from django.utils.html import linebreaks -import textile - -from articles.models import Article - - -class Command(LabelCommand): - args = '' - help = 'Imports older articles in JSON format' - - def handle_label(self, filename, **options): - """ - Process the file of older articles in JSON. Convert to the new model - scheme. - - """ - with open(filename, 'rb') as f: - items = json.load(f) - - for item in items: - if item['model'] == 'band.article': - self.process_item(item) - - def process_item(self, item): - - fields = item['fields'] - - content = fields['text'].strip() - if fields['markup_enabled']: - text = textile.textile(content, encoding='utf-8', output='utf-8') - else: - text = linebreaks(fields['text']) - - source = linebreaks(fields['source'].strip()) - - pdf = fields['pdf'].strip() - if pdf: - pdf = u"%s%s" % (settings.MEDIA_URL, pdf.replace('\\', '/')) - - article = Article( - id=item['pk'], - title=fields['title'].strip(), - date=datetime.datetime.strptime(fields['date'], '%Y-%m-%d'), - text=text, - source=source, - url=fields['url'].strip(), - pdf=pdf) - - article.save() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/articles/models.py --- a/madeira/articles/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,28 +0,0 @@ -""" -Models for the articles application. - -""" -from django.db import models - - -class Article(models.Model): - title = models.CharField(max_length=64) - date = models.DateTimeField(db_index=True) - text = models.TextField() - source = models.TextField(help_text="Enter the source/author for the " - "article, copyright info, etc; it will appear under the article.") - url = models.URLField(blank=True, - help_text = 'Link to original article; optional') - pdf = models.FileField(upload_to = 'pdf/articles/%Y/%m/%d/', blank=True, - help_text="If you want to make the original article available as " - "a PDF download, you may upload it here.") - - def __unicode__(self): - return self.title - - class Meta: - ordering = ['-date'] - - @models.permalink - def get_absolute_url(self): - return ('articles-item', [], {'pk': str(self.id)}) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/articles/urls.py --- a/madeira/articles/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -""" -Urls for the articles application. - -""" -from django.conf.urls import patterns, url -from django.views.generic import DetailView, ListView - -from articles.models import Article - - -urlpatterns = patterns('', - url(r'^$', - ListView.as_view( - model=Article, - context_object_name='article_list'), - name='articles-index'), - url(r'^(?P\d+)/$', - DetailView.as_view(model=Article, context_object_name='article'), - name='articles-item') -) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/articles/views.py --- a/madeira/articles/views.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -# Create your views here. diff -r f26cdda0ad8b -r e2868ad47a1e madeira/band/admin.py --- a/madeira/band/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,83 +0,0 @@ -""" -Automatic admin definitions for the band models. - -""" -from django.contrib import admin - -from band.models import Album -from band.models import AlbumMerchant -from band.models import AlbumTrack -from band.models import Gear -from band.models import LabelRelease -from band.models import Member -from band.models import Merchandise -from band.models import RecordLabel - - -class GearInline(admin.TabularInline): - model = Gear - - -class GearAdmin(admin.ModelAdmin): - list_display = ['item', 'member'] - list_filter = ['member'] - - -class MemberAdmin(admin.ModelAdmin): - list_display = ['name', 'instrument', 'is_active'] - inlines = [GearInline] - - -class AlbumTrackInline(admin.TabularInline): - model = AlbumTrack - - -class AlbumTrackAdmin(admin.ModelAdmin): - list_display = ['track_name', 'album'] - list_filter = ['album'] - - -class LabelReleaseInline(admin.TabularInline): - model = LabelRelease - - -class LabelReleaseAdmin(admin.ModelAdmin): - list_display = ['catalog_number', 'album', 'record_label', 'release_date'] - list_filter = ['record_label', 'album'] - - -class RecordLabelAdmin(admin.ModelAdmin): - inlines = [LabelReleaseInline] - - -class AlbumMerchantInline(admin.TabularInline): - model = AlbumMerchant - - -class AlbumMerchantAdmin(admin.ModelAdmin): - list_display = ['name', 'album'] - list_filter = ['album'] - - -class AlbumAdmin(admin.ModelAdmin): - save_on_top = True - inlines = [ - AlbumTrackInline, - LabelReleaseInline, - AlbumMerchantInline, - ] - - -class MerchandiseAdmin(admin.ModelAdmin): - list_display = ['name', 'price', 'in_stock'] - list_filter = ['in_stock'] - - -admin.site.register(Gear, GearAdmin) -admin.site.register(Member, MemberAdmin) -admin.site.register(AlbumTrack, AlbumTrackAdmin) -admin.site.register(LabelRelease, LabelReleaseAdmin) -admin.site.register(RecordLabel, RecordLabelAdmin) -admin.site.register(AlbumMerchant, AlbumMerchantAdmin) -admin.site.register(Album, AlbumAdmin) -admin.site.register(Merchandise, MerchandiseAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/band/management/commands/import_old_band.py --- a/madeira/band/management/commands/import_old_band.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,149 +0,0 @@ -""" -import_old_band.py - For importing band models from the older version of this -website. - -""" -import datetime - -from django.core.management.base import LabelCommand -from django.utils import simplejson as json -from django.utils.html import linebreaks -from photologue.models import Photo - -from band.models import (Member, Gear, RecordLabel, Album, AlbumTrack, - LabelRelease, AlbumMerchant, Merchandise) - - -class Command(LabelCommand): - args = '' - help = 'Imports older band model objects in JSON format' - - members = {} - albums = {} - labels = {} - - def handle_label(self, filename, **options): - """ - Process the file of older model objects in JSON. Convert to the new model - scheme. - - """ - with open(filename, 'rb') as f: - items = json.load(f) - - for item in items: - if item['model'] == 'band.member': - self.process_member(item) - elif item['model'] == 'band.gear': - self.process_gear(item) - elif item['model'] == 'band.record_label': - self.process_record_label(item) - elif item['model'] == 'band.album': - self.process_album(item) - elif item['model'] == 'band.album_track': - self.process_track(item) - elif item['model'] == 'band.label_release': - self.process_release(item) - elif item['model'] == 'band.album_merchant': - self.process_merchant(item) - elif item['model'] == 'band.merchandise': - self.process_merch(item) - - def process_member(self, item): - - fields = item['fields'] - - start_date = datetime.datetime.strptime(fields['start_date'], '%Y-%m-%d') - - if fields['end_date'] == u'1985-01-01': - end_date = None - else: - end_date = datetime.datetime.strptime(fields['end_date'], '%Y-%m-%d') - - member = Member(id=item['pk'], - name=fields['name'], - nickname=fields['nickname'], - instrument=fields['instrument'], - bio=fields['bio'], - photo=fields['photo'], - order=int(fields['order']), - is_active=fields['is_active'], - start_date=start_date, - end_date=end_date, - email=fields['email']) - member.save() - self.members[member.id] = member - - def process_gear(self, item): - fields = item['fields'] - - gear = Gear(id=item['pk'], - member=self.members[int(fields['member'])], - item=fields['item']) - gear.save() - - def process_record_label(self, item): - fields = item['fields'] - - label = RecordLabel(id=item['pk'], - url=fields['url'], - name=fields['name']) - label.save() - self.labels[label.id] = label - - def process_album(self, item): - fields = item['fields'] - - photo = Photo.objects.get(id=fields['photo']) - - album = Album(id=item['pk'], - title=fields['title'], - photo=photo, - desc=fields['desc']) - album.save() - self.albums[album.id] = album - - def process_track(self, item): - fields = item['fields'] - - track = AlbumTrack(id=item['pk'], - album=self.albums[int(fields['album'])], - track_number=int(fields['track_number']), - track_name=fields['track_name']) - track.save() - - def process_release(self, item): - fields = item['fields'] - - release = LabelRelease(id=item['pk'], - record_label=self.labels[int(fields['record_label'])], - album=self.albums[int(fields['album'])], - catalog_number=fields['catalog_number'], - release_date=datetime.datetime.strptime( - fields['release_date'], '%Y-%m-%d')) - - release.save() - - def process_merchant(self, item): - fields = item['fields'] - - merchant = AlbumMerchant(id=item['pk'], - album=self.albums[int(fields['album'])], - url=fields['url'], - name=fields['name']) - - merchant.save() - - def process_merch(self, item): - fields = item['fields'] - - photo = Photo.objects.get(id=fields['photo']) - - merch = Merchandise(id=item['pk'], - name=fields['name'], - desc=fields['desc'], - price=fields['price'], - in_stock=fields['in_stock'], - photo=photo) - - merch.save() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/band/models.py --- a/madeira/band/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -""" -Models for the band application. - -""" -from django.db import models - -from photologue.models import Photo - - -class Member(models.Model): - name = models.CharField(max_length=50) - nickname = models.CharField(max_length=50, blank=True) - instrument = models.CharField(max_length=255) - bio = models.TextField(blank=True) - photo = models.ImageField(upload_to='images/bio/', blank=True) - order = models.SmallIntegerField( - help_text = "Controls order of display; lower numbers displayed first") - is_active = models.BooleanField() - start_date = models.DateField() - end_date = models.DateField(blank=True, - null=True, - help_text="Only used if the member is not active") - email = models.EmailField() - - def __unicode__(self): - return self.name - - class Meta: - ordering = ['-is_active', 'name'] - - -class Gear(models.Model): - member = models.ForeignKey(Member) - item = models.CharField(max_length=255) - - def __unicode__(self): - return self.item - - class Meta: - verbose_name_plural = 'Gear List' - - -class RecordLabel(models.Model): - name = models.CharField(max_length=64) - url = models.URLField(max_length=200) - - def __unicode__(self): - return self.name - - -class Album(models.Model): - title = models.CharField(max_length=64) - photo = models.ForeignKey(Photo) - desc = models.TextField(blank=True) - - def __unicode__(self): - return self.title - - -class AlbumTrack(models.Model): - album = models.ForeignKey(Album, related_name='tracks') - track_number = models.SmallIntegerField() - track_name = models.CharField(max_length=64) - - def __unicode__(self): - return self.track_name - - class Meta: - verbose_name = 'Album Track' - ordering = ['album', 'track_number'] - - -class LabelRelease(models.Model): - record_label = models.ForeignKey(RecordLabel) - album = models.ForeignKey(Album, related_name='labels') - catalog_number = models.CharField(max_length=32) - release_date = models.DateField() - - def __unicode__(self): - return u'%s %s %s' % (self.record_label.name, self.album.title, - self.catalog_number) - - class Meta: - verbose_name = 'Label Release' - - -class AlbumMerchant(models.Model): - album = models.ForeignKey(Album, related_name='merchants') - name = models.CharField(max_length=64) - url = models.URLField(max_length=200) - - def __unicode__(self): - return u'%s (%s)' % (self.name, self.album.title) - - class Meta: - verbose_name = 'Album Merchant' - ordering = ['name'] - - -class Merchandise(models.Model): - name = models.CharField(max_length=64) - desc = models.TextField() - price = models.DecimalField(max_digits=5, decimal_places=2) - in_stock = models.BooleanField() - photo = models.ForeignKey(Photo) - - def __unicode__(self): - return self.name - - class Meta: - verbose_name_plural = "Merchandise" diff -r f26cdda0ad8b -r e2868ad47a1e madeira/band/urls.py --- a/madeira/band/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,13 +0,0 @@ -""" -Urls for the band application. - -""" -from django.conf.urls import patterns, url - -urlpatterns = patterns('band.views', - url(r'^bio/$', 'bio', name='band-bio'), - url(r'^buy/$', 'buy', name='band-buy'), - url(r'^contact/$', 'contact', name='band-contact'), - url(r'^photos/$', 'photos_index', name='band-photo_index'), - url(r'^photos/(\d+)$', 'photo_detail', name='band-photo_detail'), -) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/band/views.py --- a/madeira/band/views.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,57 +0,0 @@ -""" -Views for the band application. - -""" -import random - -from django.shortcuts import render -from django.shortcuts import get_object_or_404 -from photologue.models import Gallery -from photologue.models import Photo - -from band.models import Member, Merchandise, Album - - -def bio(request): - members = Member.objects.exclude(is_active=0) - return render(request, 'band/bio.html', {'members': members}) - - -def photos_index(request): - galleries = Gallery.objects.values('title', 'id').order_by('-id') - - photo_ids = Photo.objects.filter(is_public=True).values_list('id', - flat=True) - photo_ids = random.sample(photo_ids, 4) - random_photos = Photo.objects.filter(id__in=photo_ids) - - return render(request, 'band/photos.html', { - 'galleries': galleries, - 'random_photos': random_photos, - }) - - -def photo_detail(request, id): - gallery = get_object_or_404(Gallery, pk=id) - photos = gallery.photos.order_by('id') - - return render(request, 'band/photo_detail.html', { - 'gallery': gallery, - 'photos': photos, - }) - - -def buy(request): - albums = Album.objects.all().order_by('-id') - merchandise = Merchandise.objects.all().order_by('-id') - return render(request, 'band/buy.html', { - 'albums': albums, - 'merchandise': merchandise, - }) - - -def contact(request): - band = Member.objects.exclude(is_active=0).order_by('order') - return render(request, 'band/contact.html', { - 'band': band, - }) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/admin.py --- a/madeira/email_list/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,41 +0,0 @@ -""" -Automatic admin definitions for the email_list application. - -""" -from django.contrib import admin -from django.conf.urls import patterns, url -from django.shortcuts import render, redirect - -from email_list.models import Subscriber -from email_list.forms import AdminEmailForm - - -class SubscriberAdmin(admin.ModelAdmin): - list_display = ['__unicode__', 'location', 'status'] - list_filter = ['status'] - search_fields = ['name', 'email'] - - def get_urls(self): - urls = super(SubscriberAdmin, self).get_urls() - my_urls = patterns('', - url(r'^send_mail/$', - self.admin_site.admin_view(self.send_mail), - name='email_list-admin_mail'), - ) - return my_urls + urls - - def send_mail(self, request): - if request.method == 'POST': - form = AdminEmailForm(request.POST) - if form.is_valid(): - n = form.save() - msg = '%d mailing list emails sent' % n - self.message_user(request, msg) - return redirect('admin:index') - else: - form = AdminEmailForm() - - return render(request, 'email_list/admin_mail.html', {'form': form}) - - -admin.site.register(Subscriber, SubscriberAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/forms.py --- a/madeira/email_list/forms.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,155 +0,0 @@ -""" -Forms for the email_list application. - -""" -from django import forms -from django.conf import settings -from django.core.urlresolvers import reverse -from django.core.mail import send_mail, send_mass_mail -from django.template.loader import render_to_string - -from email_list.models import Subscriber - - -SUBSCRIBE_OPTS = [('sub', 'Subscribe'), ('unsub', 'Unsubscribe')] - -ALREADY_SUBSCRIBED = "This email address is already subscribed." -NOT_SUBSCRIBED = "This email address is not on our list." - - -class SubscriberForm(forms.Form): - name = forms.CharField(max_length=64, required=False) - email = forms.EmailField() - location = forms.CharField(max_length=64, required=False) - option = forms.ChoiceField(choices=SUBSCRIBE_OPTS) - - def clean(self): - """ - This method ensures the appropriate action can be carried out and raises - a validation error if not. - - """ - email = self.cleaned_data['email'] - - if self.cleaned_data['option'] == 'sub': - # is the user already subscribed (active)? - try: - subscriber = Subscriber.objects.get(email=email) - except Subscriber.DoesNotExist: - subscriber = Subscriber(email=email, - name=self.cleaned_data['name'], - location=self.cleaned_data['location']) - else: - if subscriber.is_active(): - raise forms.ValidationError(ALREADY_SUBSCRIBED) - else: - # is the user already unsubscribed or not subscribed? - try: - subscriber = Subscriber.objects.get(email=email) - except Subscriber.DoesNotExist: - raise forms.ValidationError(NOT_SUBSCRIBED) - - # save the subscriber away for a future process() call - self.subscriber = subscriber - - return self.cleaned_data - - def is_subscribe(self): - """ - This function can be called after an is_valid() call to determine if the - request was for a subscribe or unsubscribe. - - """ - return self.cleaned_data['option'] == 'sub' - - def process(self): - """ - Call this function if is_valid() returns True. It carries out the user's - subscription request. - - """ - if self.is_subscribe(): - self.subscriber.set_pending() - else: - self.subscriber.set_leaving() - - self.subscriber.save() - send_email(self.subscriber) - - -class AdminEmailForm(forms.Form): - subject = forms.CharField(max_length=255, required=True, label='Subject:', - widget=forms.TextInput(attrs={'class': 'vTextField required', - 'size': '120'})) - message = forms.CharField(label='Message:', - widget=forms.Textarea(attrs={'class': 'vLargeTextField required'})) - - def __init__(self, *args, **kwargs): - initial = kwargs.pop('initial', {}) - if 'subject' not in initial: - initial['subject'] = '[%s] ' % settings.BAND_CONFIG['BAND_NAME'] - kwargs['initial'] = initial - - super(AdminEmailForm, self).__init__(*args, **kwargs) - - def save(self): - """ - Call this function if is_valid() to send the mass email. - Returns the number of mails sent. - - """ - subject = self.cleaned_data['subject'] - message = self.cleaned_data['message'] - return send_mail_to_subscribers(subject, message) - - -def send_email(subscriber): - """ - This function sends out the appropriate email for the given subscriber. - - """ - config = settings.BAND_CONFIG - band = config['BAND_NAME'] - from_email = config['BAND_EMAIL'] - - url = "http://%s%s" % (config['BAND_DOMAIN'], - reverse('email_list-confirm', kwargs={'key': subscriber.key})) - - if subscriber.is_pending(): - email_template = 'email_list/email_subscribe.txt' - else: - email_template = 'email_list/email_unsubscribe.txt' - - msg = render_to_string(email_template, { - 'band': band, - 'url': url, - 'band_domain': config['BAND_DOMAIN'], - }) - - subject = "[%s] Mailing List Confirmation" % band - - send_mail(subject, msg, from_email, [subscriber.email]) - - -def send_mail_to_subscribers(subject, message): - """ - Send an email to each mailing list subscriber with the given subject and - message. - Returns the number of messages sent. - - """ - config = settings.BAND_CONFIG - unsubscribe_url = "http://%s%s" % (config['BAND_DOMAIN'], - reverse('email_list-main')) - - msg = render_to_string('email_list/mailing_list.txt', { - 'band': config['BAND_NAME'], - 'unsubscribe_url': unsubscribe_url, - 'message': message, - }) - - mail_data = [(subject, msg, config['BAND_EMAIL'], [subscriber]) for - subscriber in Subscriber.objects.values_list('email', flat=True)] - - send_mass_mail(mail_data) - return len(mail_data) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/management/commands/import_old_email_list.py --- a/madeira/email_list/management/commands/import_old_email_list.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,51 +0,0 @@ -""" -import_old_email_list.py - For importing the email list from the older version -of this website. - -""" -import datetime - -from django.core.management.base import LabelCommand -from django.utils import simplejson as json - -from email_list.models import Subscriber - - -class Command(LabelCommand): - args = '' - help = 'Imports older email list data in JSON format' - - def handle_label(self, filename, **options): - """ - Process the file of older email list data in JSON. Convert to the new - model scheme. - - """ - with open(filename, 'rb') as f: - items = json.load(f) - - for item in items: - if item['model'] == 'band.fan': - self.process_item(item) - - def process_item(self, item): - - fields = item['fields'] - - # Only process 'active' subscribers because the new application - # has changed the key generation scheme. - - if fields['status'] != 'A': - return - - subscriber = Subscriber( - id=item['pk'], - name=fields['name'].strip(), - email=fields['email'].strip(), - location=fields['location'].strip(), - status='A', - key='', - status_date=datetime.datetime.strptime( - fields['status_date'], '%Y-%m-%d')) - - subscriber.save() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/models.py --- a/madeira/email_list/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,69 +0,0 @@ -""" -Models for the email_list application. - -""" -import base64 -import datetime -import hashlib - -from django.db import models -from django.conf import settings - - -class Subscriber(models.Model): - status_codes = [('P', 'Pending'), ('A', 'Active'), ('L', 'Leaving')] - key_length = 28 - - name = models.CharField(max_length=64, blank=True) - email = models.EmailField(db_index=True) - location = models.CharField(max_length=64, blank=True) - status = models.CharField(max_length=1, choices=status_codes, default='A') - key = models.CharField(max_length=key_length, editable=False, blank=True, - db_index=True) - status_date = models.DateTimeField(editable=False) - - class Meta: - ordering = ['name', 'email'] - - def __unicode__(self): - if self.name: - return u'%s <%s>' % (self.name, self.email) - return self.email - - def save(self, *args, **kwargs): - if not self.pk and not self.status_date: - self.status_date = datetime.datetime.now() - - super(Subscriber, self).save(*args, **kwargs) - - def set_pending(self): - self.status = 'P' - self.status_date = datetime.datetime.now() - self.gen_key() - - def set_active(self): - self.status = 'A' - self.status_date = datetime.datetime.now() - self.key = '' - - def set_leaving(self): - self.status = 'L' - self.status_date = datetime.datetime.now() - self.gen_key() - - def is_pending(self): - return self.status == 'P' - - def is_leaving(self): - return self.status == 'L' - - def is_active(self): - return self.status == 'A' - - def gen_key(self): - source = (settings.SECRET_KEY + self.email + self.name + self.location + - self.status + self.status_date.isoformat()) - - sha = hashlib.sha1() - sha.update(source) - self.key = base64.urlsafe_b64encode(sha.digest()) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/tests/__init__.py --- a/madeira/email_list/tests/__init__.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,2 +0,0 @@ -from model_tests import * -from view_tests import * diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/tests/model_tests.py --- a/madeira/email_list/tests/model_tests.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,76 +0,0 @@ -""" -Model tests for the email_list application. - -""" -import datetime - -from django.test import TestCase - -from email_list.models import Subscriber - - -class SubscriberTestCase(TestCase): - - def test_auto_save(self): - - sub = Subscriber(name='', location='', email='test@example.com') - sub.save() - - now = datetime.datetime.now() - self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) - - self.assertTrue(sub.status == 'A') - self.assertTrue(sub.is_active()) - self.failIf(sub.is_pending()) - self.failIf(sub.is_leaving()) - - def test_set_pending(self): - - sub = Subscriber(name='', location='', email='test@example.com') - sub.set_pending() - - now = datetime.datetime.now() - self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) - - self.assertTrue(sub.status == 'P') - self.failIf(sub.is_active()) - self.assertTrue(sub.is_pending()) - self.failIf(sub.is_leaving()) - - self.assertTrue(len(sub.key) == sub.key_length) - - def test_set_active(self): - - sub = Subscriber(name='', location='', email='test@example.com') - sub.set_active() - - now = datetime.datetime.now() - self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) - - self.assertTrue(sub.status == 'A') - self.assertTrue(sub.is_active()) - self.failIf(sub.is_pending()) - self.failIf(sub.is_leaving()) - - def test_set_leaving(self): - - sub = Subscriber(name='', location='', email='test@example.com') - sub.set_leaving() - - now = datetime.datetime.now() - self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2)) - - self.assertTrue(sub.status == 'L') - self.failIf(sub.is_active()) - self.failIf(sub.is_pending()) - self.assertTrue(sub.is_leaving()) - - self.assertTrue(len(sub.key) == sub.key_length) - - def test_gen_key(self): - - sub = Subscriber(name='', location='', email='test@example.com') - sub.status_date = datetime.datetime.now() - sub.gen_key() - self.assertTrue(len(sub.key) == sub.key_length) - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/tests/view_tests.py --- a/madeira/email_list/tests/view_tests.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,213 +0,0 @@ -""" -View tests for the email_list application. - -""" -from django.test import TestCase -from django.core.urlresolvers import reverse -from django.core import mail - -from email_list.models import Subscriber -import email_list.forms - - -SUB_PARAMS = { - 'name': 'John Doe', - 'email': 'j.doe@example.com', - 'location': 'USA', - 'option': 'sub' -} - -UNSUB_PARAMS = { - 'name': '', - 'email': SUB_PARAMS['email'], - 'location': '', - 'option': 'unsub' -} - -class EmailListTestCase(TestCase): - - def test_already_subscribed(self): - """ - Test that subscribing twice fails with a form error. - - """ - sub = Subscriber(email=SUB_PARAMS['email']) - sub.set_active() - sub.save() - - # Post a subscribe request - - response = self.client.post( - reverse('email_list-main'), - SUB_PARAMS, - follow=True) - - self.assertTrue(response.status_code, 200) - self.assertEqual(len(response.redirect_chain), 0) - self.assertContains(response, email_list.forms.ALREADY_SUBSCRIBED) - - def test_not_subscribed(self): - """ - Test that unsubscribing without being subscribed fails with a form error. - - """ - # Post a unsubscribe request - - response = self.client.post( - reverse('email_list-main'), - UNSUB_PARAMS, - follow=True) - - self.assertTrue(response.status_code, 200) - self.assertEqual(len(response.redirect_chain), 0) - self.assertContains(response, email_list.forms.NOT_SUBSCRIBED) - - def test_normal_cycle(self): - """ - Test a normal subscribe and unsubscribe cycle. - - """ - self.do_test_subscribe() - self.do_test_unsubscribe() - - def test_subscribe_if_pending(self): - """ - Ensure you can subscribe if you are already pending. - - """ - sub = Subscriber(email=SUB_PARAMS['email']) - sub.set_pending() - sub.save() - self.do_test_subscribe() - - def test_subscribe_if_leaving(self): - """ - Ensure you can subscribe if you are leaving. - - """ - sub = Subscriber(email=SUB_PARAMS['email']) - sub.set_leaving() - sub.save() - self.do_test_subscribe() - - def test_unsubscribe_if_leaving(self): - """ - Ensure you can unsubscribe if you are already leaving. - - """ - sub = Subscriber(email=SUB_PARAMS['email']) - sub.set_leaving() - sub.save() - self.do_test_unsubscribe() - - def do_test_subscribe(self): - # Get the form view - response = self.client.get(reverse('email_list-main')) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'email_list/subscribe_form.html') - - # Post a subscribe request - - response = self.client.post( - reverse('email_list-main'), - SUB_PARAMS, - follow=True) - - self.assertTrue(response.status_code, 200) - self.assertEqual(len(response.redirect_chain), 1) - self.assertEqual(response.redirect_chain[0][0], - 'http://testserver' + reverse('email_list-request_subscribe')) - self.assertEqual(response.redirect_chain[0][1], 302) - - # verify subscriber is in pending state - - try: - subscriber = Subscriber.objects.get(email=SUB_PARAMS['email']) - except Subscriber.DoesNotExist: - self.fail("No pending subscriber") - - self.assertTrue(subscriber.is_pending()) - - # test email sent - self.do_test_email(subscriber) - - # simulate a confirm click - - response = self.client.get( - reverse('email_list-confirm', kwargs={'key': subscriber.key}), - follow=True) - - self.assertTrue(response.status_code, 200) - self.assertEqual(len(response.redirect_chain), 1) - self.assertEqual(response.redirect_chain[0][0], - 'http://testserver' + reverse('email_list-subscribed')) - self.assertEqual(response.redirect_chain[0][1], 302) - - # verify active user - try: - subscriber = Subscriber.objects.get(email=SUB_PARAMS['email']) - except Subscriber.DoesNotExist: - self.fail("No active subscriber") - - self.assertTrue(subscriber.is_active()) - - def do_test_unsubscribe(self): - # Get the form view - response = self.client.get(reverse('email_list-main')) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'email_list/subscribe_form.html') - - # Post a unsubscribe request - - response = self.client.post( - reverse('email_list-main'), - UNSUB_PARAMS, - follow=True) - - self.assertTrue(response.status_code, 200) - self.assertEqual(len(response.redirect_chain), 1) - self.assertEqual(response.redirect_chain[0][0], - 'http://testserver' + reverse('email_list-request_unsubscribe')) - self.assertEqual(response.redirect_chain[0][1], 302) - - # verify subscriber is in leaving state - - try: - subscriber = Subscriber.objects.get(email=UNSUB_PARAMS['email']) - except Subscriber.DoesNotExist: - self.fail("No pending subscriber") - - self.assertTrue(subscriber.is_leaving()) - - # test email sent - self.do_test_email(subscriber) - - # simulate a click to unsubscribe - - response = self.client.get( - reverse('email_list-confirm', kwargs={'key': subscriber.key}), - follow=True) - - self.assertTrue(response.status_code, 200) - self.assertEqual(len(response.redirect_chain), 1) - self.assertEqual(response.redirect_chain[0][0], - 'http://testserver' + reverse('email_list-unsubscribed')) - self.assertEqual(response.redirect_chain[0][1], 302) - - # verify subscription has been removed - - self.assertRaises(Subscriber.DoesNotExist, Subscriber.objects.get, - email=UNSUB_PARAMS['email']) - - def do_test_email(self, subscriber): - """ - Tests to see if the confirmation email was sent. - - """ - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(len(mail.outbox[0].to), 1) - self.assertEqual(mail.outbox[0].to[0], subscriber.email) - self.assertTrue(str(mail.outbox[0].message()).count(subscriber.key) > 0) - - # clear outbox - mail.outbox = [] diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/urls.py --- a/madeira/email_list/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,28 +0,0 @@ -""" -Urls for the email_list application. - -""" -from django.conf.urls import patterns, url -from django.views.generic import TemplateView - - -urlpatterns = patterns('email_list.views', - url(r'^$', - 'mailing_list', - name='email_list-main'), - url(r'^confirm/(?P[-a-zA-Z0-9_=]{28})/$', - 'confirm', - name='email_list-confirm'), - url(r'^request/subscribe/$', - TemplateView.as_view(template_name='email_list/subscribe_request.html'), - name='email_list-request_subscribe'), - url(r'^request/unsubscribe/$', - TemplateView.as_view(template_name='email_list/unsubscribe_request.html'), - name='email_list-request_unsubscribe'), - url(r'^subscribed/$', - TemplateView.as_view(template_name='email_list/subscribed.html'), - name='email_list-subscribed'), - url(r'^unsubscribed/$', - TemplateView.as_view(template_name='email_list/unsubscribed.html'), - name='email_list-unsubscribed'), -) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/email_list/views.py --- a/madeira/email_list/views.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,56 +0,0 @@ -""" -Views for the email_list application. - -""" -import logging - -from django.http import HttpResponseServerError -from django.shortcuts import render, redirect, get_object_or_404 - -from email_list.forms import SubscriberForm -from email_list.models import Subscriber - - -logger = logging.getLogger(__name__) - - -def mailing_list(request): - """ - The main view for handling email list actions (subscribe or unsubscribe). - - """ - if request.method == 'POST': - form = SubscriberForm(request.POST) - if form.is_valid(): - form.process() - - if form.is_subscribe(): - return redirect('email_list-request_subscribe') - else: - return redirect('email_list-request_unsubscribe') - - else: - form = SubscriberForm() - - return render(request, 'email_list/subscribe_form.html', {'form': form}) - - -def confirm(request, key): - """ - This view handles the confirmation of a subscribe or unsubscribe action. - - """ - subscriber = get_object_or_404(Subscriber, key=key) - - if subscriber.is_pending(): - subscriber.set_active() - subscriber.save() - return redirect('email_list-subscribed') - elif subscriber.is_leaving(): - subscriber.delete() - return redirect('email_list-unsubscribed') - - # This should not happen - logger.error("Trying to confirm subscriber %d, but status is %s", - subscriber.pk, subscriber.status) - return HttpResponseServerError() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/gigs/admin.py --- a/madeira/gigs/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,57 +0,0 @@ -""" -Automatic admin definitions for the gigs application. - -""" -from django.contrib import admin - -from gigs.models import Band, City, Country, Gig, State, Venue - - -class CityInline(admin.TabularInline): - model = City - - -class CityAdmin(admin.ModelAdmin): - list_display = ['name', 'state', 'country'] - list_filter = ['state'] - search_fields = ['name'] - - def queryset(self, request): - qs = super(CityAdmin, self).queryset(request) - return qs.select_related('state', 'country') - - -class StateAdmin(admin.ModelAdmin): - inlines = [CityInline] - - -class VenueAdmin(admin.ModelAdmin): - list_filter = ['city__name'] - list_display = ['name', 'city'] - search_fields = ['name'] - - def queryset(self, request): - qs = super(VenueAdmin, self).queryset(request) - return qs.select_related('city', 'city__state', 'city__country') - - -class BandAdmin(admin.ModelAdmin): - search_fields = ['name'] - - -class GigAdmin(admin.ModelAdmin): - list_filter = ['date'] - save_on_top = True - filter_horizontal = ['bands'] - - def queryset(self, request): - qs = super(GigAdmin, self).queryset(request) - return qs.select_related('venue') - - -admin.site.register(Band, BandAdmin) -admin.site.register(City, CityAdmin) -admin.site.register(Country) -admin.site.register(Gig, GigAdmin) -admin.site.register(State, StateAdmin) -admin.site.register(Venue, VenueAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/gigs/models.py --- a/madeira/gigs/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,97 +0,0 @@ -""" -Models for the gigs application. - -""" -from django.db import models -from django.contrib.localflavor.us.models import USStateField -from django.contrib.localflavor.us.models import PhoneNumberField - -from photologue.models import Photo - - -class Country(models.Model): - name = models.CharField(max_length=64) - - class Meta: - ordering = ['name'] - verbose_name_plural = 'Countries' - - def __unicode__(self): - return self.name - - -class State(models.Model): - name = models.CharField(max_length=16) - abbrev = USStateField() - - class Meta: - ordering = ['name'] - - def __unicode__(self): - return self.name - - -class City(models.Model): - name = models.CharField(max_length=50) - state = models.ForeignKey(State, null=True, blank=True) - country = models.ForeignKey(Country, null=True, blank=True) - - class Meta: - verbose_name_plural = 'Cities' - ordering = ['name'] - - def __unicode__(self): - if self.state: - return u'%s, %s' % (self.name, self.state.abbrev) - - return self.name - - -class Venue(models.Model): - name = models.CharField(max_length=50) - url = models.URLField(blank=True) - address = models.CharField(max_length=255, blank=True) - phone = PhoneNumberField(help_text="Format: XXX-XXX-XXXX", blank=True) - city = models.ForeignKey(City) - - class Meta: - ordering = ['name'] - - def __unicode__(self): - return self.name - - -class Band(models.Model): - name = models.CharField(max_length=64) - url = models.URLField(blank=True) - - class Meta: - ordering = ['name'] - - def __unicode__(self): - return self.name - - -class Gig(models.Model): - title = models.CharField(max_length=50, blank=True, - help_text="Optional; e.g. Some Festival") - url = models.URLField(blank=True, - help_text="Optional; e.g. Some Festival's Website") - date = models.DateField(db_index=True) - time = models.TimeField(null=True, blank=True) - venue = models.ForeignKey(Venue, null=True, blank=True) - notes = models.TextField(blank=True) - bands = models.ManyToManyField(Band, blank=True) - flyer = models.ForeignKey(Photo, null=True, blank=True, - related_name='gig_flyers') - - def __unicode__(self): - if self.title: - return u'%s %s %s' % (self.date.strftime('%m/%d/%Y'), self.title, self.venue.name) - elif self.venue: - return u'%s %s' % (self.date.strftime('%m/%d/%Y'), self.venue.name) - else: - return self.date.strftime('%m/%d/%Y') - - class Meta: - ordering = ['-date', 'time'] diff -r f26cdda0ad8b -r e2868ad47a1e madeira/gigs/templatetags/gig_tags.py --- a/madeira/gigs/templatetags/gig_tags.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,22 +0,0 @@ -""" -Template tags for the gigs application. - -""" -import datetime - -from django import template - -from gigs.models import Gig - - -register = template.Library() - - -@register.inclusion_tag('gigs/upcoming_gigs_tag.html') -def upcoming_gigs(): - - gigs = Gig.objects.filter(date__gte=datetime.date.today).select_related( - 'flyer', 'venue', 'venue__city', 'venue__city__state', - 'venue__city__country').order_by('date')[:10] - - return {'gigs': gigs} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/gigs/urls.py --- a/madeira/gigs/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,19 +0,0 @@ -""" -Urls for the gigs application. - -""" -from django.conf.urls import patterns, url -from django.views.generic import ListView - -from gigs.models import Gig - - -urlpatterns = patterns('', - url(r'^$', 'gigs.views.gigs', name='gigs-index'), - url(r'^flyers/$', - ListView.as_view( - queryset=Gig.objects.exclude(flyer__isnull=True).select_related('flyer'), - template_name='gigs/flyers.html', - context_object_name='gig_list'), - name='gigs-flyers') -) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/gigs/views.py --- a/madeira/gigs/views.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,76 +0,0 @@ -""" -Views for the gigs application. - -""" -import collections -import datetime - -from django.db import connection -from django.shortcuts import render - -from gigs.models import Band, Gig - - -def gigs(request): - """ - This view function renders the main gigs view, showing upcoming and past - gigs as well as some statistics. - - """ - today = datetime.date.today() - gigs = Gig.objects.select_related('venue', 'flyer', 'venue__city', - 'venue__city__state', 'venue__city__country') - upcoming = [] - previous = [] - - # To avoid many, many database hits in the template, we get all the - # bands out at once. We also get the many-to-many intermediate table - # that Django generated for us so we can associate bands to gigs. - # Since we don't know about this table we drop into raw SQL to get - # the contents. - - bands = dict((band.id, band) for band in Band.objects.all()) - cursor = connection.cursor() - cursor.execute('SELECT * FROM gigs_gig_bands') - gig_bands = collections.defaultdict(list) - for row in cursor.fetchall(): - gig_bands[row[1]].append(bands[row[2]]) - - for gig in gigs: - gig.bands_ = gig_bands[gig.id] - if gig.date >= today: - upcoming.append(gig) - else: - previous.append(gig) - - upcoming.reverse() - - stats = {} - venues = set() - cities = set() - states = set() - countries = set() - for gig in previous: - venues.add(gig.venue.id) - cities.add(gig.venue.city.id) - if gig.venue.city.state: - states.add(gig.venue.city.state.id) - if gig.venue.city.country: - countries.add(gig.venue.city.country.id) - - stats['count'] = len(previous) - stats['venues'] = len(venues) - stats['cities'] = len(cities) - stats['states'] = len(states) - stats['countries'] = len(countries) - stats['bands'] = len(bands) - - flyer_gigs = Gig.objects.exclude(flyer__isnull = True).select_related( - 'venue', 'flyer').order_by('-date') - - return render(request, 'gigs/gigs.html', { - 'upcoming' : upcoming, - 'previous' : previous, - 'stats' : stats, - 'flyer_gigs' : flyer_gigs, - }) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/manage.py --- a/madeira/manage.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,11 +0,0 @@ -#!/usr/bin/env python -from django.core.management import execute_manager -try: - import settings # Assumed to be in the same directory. -except ImportError: - import sys - sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) - sys.exit(1) - -if __name__ == "__main__": - execute_manager(settings) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/mp3/admin.py --- a/madeira/mp3/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,29 +0,0 @@ -""" -Automatic admin definitions for the models in the mp3 application. - -""" -from django.contrib import admin -from django.conf import settings - -from mp3.models import Collection, Song - - -class SongInline(admin.TabularInline): - model = Song - - -class CollectionAdmin(admin.ModelAdmin): - list_filter = ['date_added'] - list_display = ['title', 'date_added'] - inlines = [SongInline] - - class Media: - js = settings.THIRD_PARTY_JS['tiny_mce'] - - -class SongAdmin(admin.ModelAdmin): - list_display = ['title', 'collection'] - - -admin.site.register(Collection, CollectionAdmin) -admin.site.register(Song, SongAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/mp3/management/commands/import_old_mp3.py --- a/madeira/mp3/management/commands/import_old_mp3.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,64 +0,0 @@ -""" -import_old_mp3.py - For importing mp3 data from the older version of this -website. - -""" -import datetime - -from django.core.management.base import LabelCommand -from django.utils import simplejson as json -from django.utils.html import linebreaks - -from mp3.models import Collection, Song - - -class Command(LabelCommand): - args = '' - help = 'Imports older mp3 & mp3 sets in JSON format' - - collections = {} - - def handle_label(self, filename, **options): - """ - Process the file of older mp3 & mp3 sets in JSON. Convert to the new - model scheme. - - """ - with open(filename, 'rb') as f: - items = json.load(f) - - for item in items: - if item['model'] == 'band.mp3_set': - self.process_mp3_set(item) - - for item in items: - if item['model'] == 'band.mp3': - self.process_mp3(item) - - def process_mp3_set(self, item): - - fields = item['fields'] - - description = linebreaks(fields['text'].strip()) - - coll = Collection( - id=item['pk'], - title=fields['title'].strip(), - date_added=datetime.datetime.strptime(fields['date'], '%Y-%m-%d'), - description=description) - - coll.save() - self.collections[coll.pk] = coll - - def process_mp3(self, item): - - fields = item['fields'] - - song = Song( - id=item['pk'], - title=fields['title'].strip(), - description=fields['desc'].strip(), - file=fields['file'], - collection=self.collections[fields['mp3_set']], - ) - song.save() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/mp3/models.py --- a/madeira/mp3/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,39 +0,0 @@ -""" -Models for the mp3 application. - -""" -from django.db import models - - -class Collection(models.Model): - """ - This model represents a collection of songs. - - """ - title = models.CharField(max_length=64) - description = models.TextField() - date_added = models.DateTimeField() - - class Meta: - ordering = ['-date_added'] - - def __unicode__(self): - return self.title - - -class Song(models.Model): - """ - This model represents an uploaded song file. - - """ - title = models.CharField(max_length=64) - description = models.CharField(max_length=255, blank=True) - file = models.FileField(upload_to='mp3s/%Y/%m/%d/') - collection = models.ForeignKey(Collection) - - class Meta: - ordering = ['title'] - - def __unicode__(self): - return self.title - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/mp3/urls.py --- a/madeira/mp3/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,18 +0,0 @@ -""" -Urls for the mp3 application. - -""" -from django.conf.urls import patterns, url -from django.views.generic import ListView - -from mp3.models import Collection - - -urlpatterns = patterns('', - url(r'^$', - ListView.as_view( - model=Collection, - paginate_by=10, - context_object_name='collection_list'), - name='mp3-index'), -) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/news/admin.py --- a/madeira/news/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,19 +0,0 @@ -""" -Automatic admin definitions for the models in the news application. - -""" -from django.contrib import admin -from django.conf import settings - -from news.models import News - - -class NewsAdmin(admin.ModelAdmin): - list_filter = ['date'] - search_fields = ['title', 'content'] - save_on_top = True - - class Media: - js = settings.THIRD_PARTY_JS['tiny_mce'] - -admin.site.register(News, NewsAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/news/management/commands/import_old_news.py --- a/madeira/news/management/commands/import_old_news.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,65 +0,0 @@ -""" -import_old_news.py - For importing news stories from the older version of this -website. - -""" -import datetime - -from django.conf import settings -from django.core.management.base import LabelCommand -from django.utils import simplejson as json -from django.utils.html import linebreaks -import textile - -from news.models import News - -IMG_TAG = u"""\ -{title}""" - - -class Command(LabelCommand): - args = '' - help = 'Imports older news stories in JSON format' - - def handle_label(self, filename, **options): - """ - Process the file of older news stories in JSON. Convert to the new model - scheme. - - """ - with open(filename, 'rb') as f: - items = json.load(f) - - for item in items: - if item['model'] == 'band.news': - self.process_item(item) - - def process_item(self, item): - - fields = item['fields'] - - content = fields['text'].strip() - if fields['markup_enabled']: - content = textile.textile(content, encoding='utf-8', output='utf-8') - else: - content = linebreaks(fields['text']) - - author = fields['author'].strip() - if author: - content += u"

\u2013 %s" % author - - image = fields['photo'].strip() - if image: - caption = fields['photo_caption'].strip() - caption = caption if caption else 'Image' - - src = u"%s%s" % (settings.MEDIA_URL, image) - - content = IMG_TAG.format(src=src, title=caption) + content - - news = News(id=item['pk'], - title=fields['title'].strip(), - date=datetime.datetime.strptime(fields['date'], '%Y-%m-%d'), - content=content) - - news.save() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/news/models.py --- a/madeira/news/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,29 +0,0 @@ -""" -Models for the news application. - -""" -from django.db import models - - -class News(models.Model): - """ - This model represents all the info we store about each news entry. - - """ - title = models.CharField(max_length=128, blank=True) - date = models.DateTimeField(db_index=True) - content = models.TextField() - - def __unicode__(self): - date_str = self.date.strftime('%m/%d/%Y') - if self.title: - return u'%s %s' % (date_str, self.title) - return u'%s' % date_str - - @models.permalink - def get_absolute_url(self): - return ('news-item', [], {'pk': str(self.id)}) - - class Meta: - verbose_name_plural = 'News' - ordering = ['-date'] diff -r f26cdda0ad8b -r e2868ad47a1e madeira/news/urls.py --- a/madeira/news/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,21 +0,0 @@ -""" -Urls for the news application. - -""" -from django.conf.urls import patterns, url -from django.views.generic import DetailView, ListView - -from news.models import News - - -urlpatterns = patterns('', - url(r'^$', - ListView.as_view( - model=News, - paginate_by=10, - context_object_name='news_list'), - name='news-index'), - url(r'^story/(?P\d+)/$', - DetailView.as_view(model=News, context_object_name='story'), - name='news-item') -) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/LICENSE.txt --- a/madeira/photologue/LICENSE.txt Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,27 +0,0 @@ -Copyright (c) 2007-2008, 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 django-photologue 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. diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/README.txt --- a/madeira/photologue/README.txt Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,38 +0,0 @@ -Installation - -Step 1 - Download Photologue - -Photologue can be downloaded below or from the project page. Older versions are also available from the project page and users who like to live on the edge can checkout a copy of the latest trunk revision. - -Step 2 - Add Photologue To Your Project - -Copy the entire Photologue application folder (the folder named 'photologue' that contains 'models.py') to a location on your Python path such as your project root. Your project root is typically the directory where your 'settings.py' is found. - -Step 3 - Configure Your Settings - -Add 'photologue' to your INSTALLED_APPS setting: - - INSTALLED_APPS = ( - # ...other installed applications, - 'photologue', - ) - -Confirm that your MEDIA_ROOT and MEDIA_URL settings are correct. - -If you want to tweak things even more you can also over-ride a few default settings (optional, see documentation for more information on the available settings). - -Step 4 - Register Photologue with the Django Admin - -Add the following to your projects urls.py file: - - from django.contrib import admin - - admin.autodiscover() - -Step 4 - Sync Your Database - -Run the 'manage.py syndb' command to create the appropriate tables. After the database in initialized, Photologue will walk you through creating some default models. - -Additional documentation available here: - -http://code.google.com/p/django-photologue/w/list diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/admin.py --- a/madeira/photologue/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,60 +0,0 @@ -""" 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'] - list_per_page = 10 - prepopulated_fields = {'title_slug': ('title',)} - -class PhotoEffectAdmin(admin.ModelAdmin): - list_display = ('name', 'description', 'admin_sample') - fieldsets = ( - (None, { - 'fields': ('name', 'description') - }), - ('Adjustments', { - 'fields': ('color', 'brightness', 'contrast', 'sharpness') - }), - ('Filters', { - 'fields': ('filters',) - }), - ('Reflection', { - 'fields': ('reflection_size', 'reflection_strength', 'background_color') - }), - ) - -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 diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/locale/pl/LC_MESSAGES/django.mo Binary file madeira/photologue/locale/pl/LC_MESSAGES/django.mo has changed diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/locale/pl/LC_MESSAGES/django.po --- a/madeira/photologue/locale/pl/LC_MESSAGES/django.po Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,419 +0,0 @@ -# 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 , 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 \n" -"Language-Team: Jakub WiÅ›niowski \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" - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/management/__init__.py --- a/madeira/photologue/management/__init__.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/management/commands/__init__.py --- a/madeira/photologue/management/commands/__init__.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,37 +0,0 @@ -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 \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/management/commands/plcache.py --- a/madeira/photologue/management/commands/plcache.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,43 +0,0 @@ -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) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/management/commands/plcreatesize.py --- a/madeira/photologue/management/commands/plcreatesize.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,13 +0,0 @@ -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) \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/management/commands/plflush.py --- a/madeira/photologue/management/commands/plflush.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -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) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/management/commands/plinit.py --- a/madeira/photologue/management/commands/plinit.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,30 +0,0 @@ -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 diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/models.py --- a/madeira/photologue/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,700 +0,0 @@ -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): - super(GalleryUpload, self).save() - self.process_zipfile() - super(GalleryUpload, self).delete() - - 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() - - -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'' % \ - (self.get_absolute_url(), func()) - else: - return u'' % \ - (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.view_count += 1 - self.save(update=True) - 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 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 - # 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: - 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': - im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True) - else: - im.save(im_filename) - 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, update=False): - if update: - models.Model.save(self) - return - 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() - self.pre_cache() - - def delete(self): - 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, update=False): - if self.title_slug is None: - self.title_slug = slugify(self.title) - super(Photo, self).save(update) - - 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) - - -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'' % 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): - try: - os.remove(self.sample_filename()) - except: - pass - models.Model.save(self) - 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 - super(PhotoEffect, self).delete() - - -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): - if self.width + self.height <= 0: - raise ValueError(_('A PhotoSize must have a positive height or width.')) - super(PhotoSize, self).save() - PhotoSizeCache().reset() - self.clear_cache() - - def delete(self): - 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) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/res/sample.jpg Binary file madeira/photologue/res/sample.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/res/test_landscape.jpg Binary file madeira/photologue/res/test_landscape.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/res/test_portrait.jpg Binary file madeira/photologue/res/test_portrait.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/res/test_square.jpg Binary file madeira/photologue/res/test_square.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/gallery_archive.html --- a/madeira/photologue/templates/photologue/gallery_archive.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,26 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Latest Photo Galleries{% endblock %} - -{% block content %} - -

Latest Photo Galleries

- -{% if latest %} - {% for gallery in latest %} - - {% endfor %} -{% else %} -

No galleries were found.

-{% endif %} - -

View all galleries.

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/gallery_archive_day.html --- a/madeira/photologue/templates/photologue/gallery_archive_day.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,26 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Galleries for {{ day|date }}{% endblock %} - -{% block content %} - -

Galleries for {{ day|date }}

- -{% if object_list %} - {% for gallery in object_list %} - - {% endfor %} -{% else %} -

No galleries were found.

-{% endif %} - -

View all galleries.

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/gallery_archive_month.html --- a/madeira/photologue/templates/photologue/gallery_archive_month.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,26 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Galleries for {{ month|date:"F Y" }}{% endblock %} - -{% block content %} - -

Galleries for {{ month|date:"F Y" }}

- -{% if object_list %} - {% for gallery in object_list %} - - {% endfor %} -{% else %} -

No galleries were found.

-{% endif %} - -

View all galleries.

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/gallery_archive_year.html --- a/madeira/photologue/templates/photologue/gallery_archive_year.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,16 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Galleries for {{ year }}{% endblock %} - -{% block content %} - -

Galleries for {{ year }}

- - -

View all galleries.

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/gallery_detail.html --- a/madeira/photologue/templates/photologue/gallery_detail.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,19 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}{{ object.title }}{% endblock %} - -{% block content %} - -

{{ object.title }}

-

Originally published {{ object.date_added|date:"l, F jS, Y" }}

-{% if object.description %}

{{ object.description }}

{% endif %} - -

View all galleries

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/gallery_list.html --- a/madeira/photologue/templates/photologue/gallery_list.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,31 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}All Galleries{% endblock %} - -{% block content %} - -

All galleries

- -{% if object_list %} - {% for gallery in object_list %} - - {% endfor %} -{% else %} -

No galleries were found.

-{% endif %} - -{% if is_paginated %} -

{{ hits }} galleries total.

-
-

{% if has_previous %}Previous | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| Next{% endif %}

-
-{% endif %} - -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/photo_archive.html --- a/madeira/photologue/templates/photologue/photo_archive.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Latest Photos{% endblock %} - -{% block content %} - -

Latest Photos

- -{% if latest %} - {% for photo in latest %} - - {% endfor %} -{% else %} -

No photos were found.

-{% endif %} -

View all photographs

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/photo_archive_day.html --- a/madeira/photologue/templates/photologue/photo_archive_day.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Photos for {{ day|date }}{% endblock %} - -{% block content %} - -

Photos for {{ day|date }}

- -{% if object_list %} - {% for photo in object_list %} - - {% endfor %} -{% else %} -

No photos were found.

-{% endif %} -

View all photographs

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/photo_archive_month.html --- a/madeira/photologue/templates/photologue/photo_archive_month.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Photos for {{ month|date:"F Y" }}{% endblock %} - -{% block content %} - -

Photos for {{ month|date:"F Y" }}

- -{% if object_list %} - {% for photo in object_list %} - - {% endfor %} -{% else %} -

No photos were found.

-{% endif %} -

View all photographs

- -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/photo_archive_year.html --- a/madeira/photologue/templates/photologue/photo_archive_year.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,14 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}Galleries for {{ year }}{% endblock %} - -{% block content %} - -

Photos for {{ year }}

- - -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/photo_detail.html --- a/madeira/photologue/templates/photologue/photo_detail.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,21 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}{{ object.title }}{% endblock %} - -{% block content %} - -

{{ object.title }}

- -{% if object.public_galleries %} -

This photo is found in the following galleries:

-
    -{% for gallery in object.public_galleries %} -
  1. {{ gallery.title }}
  2. -{% endfor %} -
-{% endif %} - -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/photo_list.html --- a/madeira/photologue/templates/photologue/photo_list.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,26 +0,0 @@ -{% extends "photologue/root.html" %} - -{% block title %}All Photos{% endblock %} - -{% block content %} - -

All Photos

- -{% if object_list %} - {% for photo in object_list %} - - {% endfor %} -{% else %} -

No photos were found.

-{% endif %} - -{% if is_paginated %} -

{{ hits }} photos total.

-
-

{% if has_previous %}Previous | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| Next{% endif %}

-
-{% endif %} - -{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/templates/photologue/root.html --- a/madeira/photologue/templates/photologue/root.html Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -{% extends "base.html" %} \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/tests.py --- a/madeira/photologue/tests.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,218 +0,0 @@ -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 = (2000, 0) - self.s.upscale = True - self.s.save() - self.assertEquals(self.pl.get_test_size(), (2000, 1500)) - self.assertEquals(self.pp.get_test_size(), (2000, 2667)) - self.assertEquals(self.ps.get_test_size(), (2000, 2000)) - - 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, 2000) - self.s.upscale = True - self.s.save() - self.assertEquals(self.pl.get_test_size(), (2667, 2000)) - self.assertEquals(self.pp.get_test_size(), (1500, 2000)) - self.assertEquals(self.ps.get_test_size(), (2000, 2000)) - - 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 = (1500, 1200) - self.s.save() - self.assertEquals(self.pl.get_test_size(), (1500, 1125)) - - def test_resize_one_dimension_height(self): - self.s.size = (1600, 1100) - self.s.save() - self.assertEquals(self.pl.get_test_size(), (1467, 1100)) - - def test_resize_no_upscale(self): - self.s.size = (2000, 2000) - self.s.save() - self.assertEquals(self.pl.get_test_size(), (1600, 1200)) - - def test_resize_no_upscale_mixed_height(self): - self.s.size = (3200, 600) - self.s.save() - self.assertEquals(self.pl.get_test_size(), (800, 600)) - - def test_resize_no_upscale_mixed_width(self): - self.s.size = (800, 2400) - self.s.save() - self.assertEquals(self.pl.get_test_size(), (800, 600)) - - def test_resize_no_upscale_crop(self): - self.s.size = (2000, 2000) - self.s.crop = True - self.s.save() - self.assertEquals(self.pl.get_test_size(), (2000, 2000)) - - def test_resize_upscale(self): - self.s.size = (2000, 2000) - self.s.upscale = True - self.s.save() - self.assertEquals(self.pl.get_test_size(), (2000, 1500)) - self.assertEquals(self.pp.get_test_size(), (1500, 2000)) - self.assertEquals(self.ps.get_test_size(), (2000, 2000)) - - -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) - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/urls.py --- a/madeira/photologue/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,36 +0,0 @@ -from django.conf import settings -from django.conf.urls 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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/(?P[\-\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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/$', 'archive_day', gallery_args, name='pl-gallery-archive-day'), - url(r'^gallery/(?P\d{4})/(?P[a-z]{3})/$', 'archive_month', gallery_args, name='pl-gallery-archive-month'), - url(r'^gallery/(?P\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[\-\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[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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/(?P[\-\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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/$', 'archive_day', photo_args, name='pl-photo-archive-day'), - url(r'^photo/(?P\d{4})/(?P[a-z]{3})/$', 'archive_month', photo_args, name='pl-photo-archive-month'), - url(r'^photo/(?P\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[\-\d\w]+)/$', 'object_detail', {'slug_field': 'title_slug', 'queryset': Photo.objects.filter(is_public=True)}, name='pl-photo'), - url(r'^photo/page/(?P[0-9]+)/$', 'object_list', {'queryset': Photo.objects.filter(is_public=True), 'allow_empty': True, 'paginate_by': 20}, name='pl-photo-list'), -) - - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/utils/EXIF.py --- a/madeira/photologue/utils/EXIF.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1568 +0,0 @@ -#!/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 - diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/utils/reflection.py --- a/madeira/photologue/utils/reflection.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,93 +0,0 @@ -""" 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 - \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e madeira/photologue/utils/watermark.py --- a/madeira/photologue/utils/watermark.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,64 +0,0 @@ -""" 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() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/pl-admin.py --- a/madeira/pl-admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,116 +0,0 @@ -import getopt, sys - -try: - import settings # Assumed to be in the same directory. - from django.core.management import setup_environ -except ImportError: - import sys - sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) - sys.exit(1) - - -def precache(sizes=[], reset=False): - # setup django environment - setup_environ(settings) - - # import models - from photologue.models import Photo, PhotoSize, PhotoSizeCache - - cache = PhotoSizeCache() - - print 'Caching photos, this may take a while...' - - for photo in Photo.objects.all(): - if len(sizes): - for size in sizes: - photosize = cache.sizes.get(size, None) - if photosize is None: - print '\nA photosize named "%s" was not found...' % size - else: - if reset: - photo.remove_size(photosize) - photo.create_size(photosize) - else: - for size in caches.sizes.values(): - if reset: - Photo.remove_size(photosize) - photo.create_size(photosize) - - print ' Complete.' - sys.exit(2) - - -def reset(): - # setup django environment - setup_environ(settings) - - # import models - from photologue.models import Photo, PhotoSize - - print 'Reseting photo cache, this may take a while...' - - for photo in Photo.objects.all(): - photo.clear_cache() - - print ' Complete.' - sys.exit(2) - - -def usage(): - print """ - -pl-admin.py - Photologue administration script. - -Available Commands: - pl-admin.py create Resizes and caches all defined photo sizes for each image. - pl-admin.py reset Removes all cached images. - -Options: - --reset (-r) If calling create the script will clear the existing photo cache - before regenerating the specified size (or sizes) - --size (-s) The name of a photosize generate - -Usage: - pl-admin.py [options] command - -Examples: - pl-admin.py -r -s=thumbnail create - pl-admin.py -s=thumbnail -s=display create - pl-admin.py reset - -""" - -def main(): - try: - opts, args = getopt.getopt(sys.argv[1:], "hrs:", - ["help", "reset", "sizes="]) - except getopt.GetoptError, err: - print str(err) - usage() - sys.exit(2) - r = False - s = [] - for o, a in opts: - if o in ("-h", "--help"): - usage() - sys.exit(2) - if o in ("-r", "--reset"): - r = True - elif o in ("-s", "--sizes"): - s.append(a.strip('=')) - else: - usage() - sys.exit(2) - - if len(args) == 1: - command = args[0] - if command == 'create': - precache(s, r) - elif command == 'reset': - reset() - - usage() - - -if __name__ == '__main__': - main() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/settings/base.py --- a/madeira/settings/base.py Sat Apr 14 15:49:28 2012 -0500 +++ b/madeira/settings/base.py Sat Apr 14 16:40:29 2012 -0500 @@ -77,7 +77,7 @@ # Don't forget to use absolute paths, not relative paths. TEMPLATE_DIRS = [ os.path.join(PROJECT_PATH, 'templates'), - os.path.join(PROJECT_PATH, 'photologue', 'templates'), + os.path.abspath(os.path.join(PROJECT_PATH, '..', 'photologue', 'templates')), ] TEMPLATE_CONTEXT_PROCESSORS = [ diff -r f26cdda0ad8b -r e2868ad47a1e madeira/settings/local.py --- a/madeira/settings/local.py Sat Apr 14 15:49:28 2012 -0500 +++ b/madeira/settings/local.py Sat Apr 14 16:40:29 2012 -0500 @@ -3,7 +3,7 @@ The contents of this file will vary depending on the local installation. """ -from settings.base import * +from madeira.settings.base import * DATABASES = { 'default': { diff -r f26cdda0ad8b -r e2868ad47a1e madeira/settings/production.py --- a/madeira/settings/production.py Sat Apr 14 15:49:28 2012 -0500 +++ b/madeira/settings/production.py Sat Apr 14 16:40:29 2012 -0500 @@ -1,6 +1,6 @@ # Django production settings for the madeira project. -from settings.base import * +from madeira.settings.base import * DEBUG = False TEMPLATE_DEBUG = DEBUG diff -r f26cdda0ad8b -r e2868ad47a1e madeira/settings/test.py --- a/madeira/settings/test.py Sat Apr 14 15:49:28 2012 -0500 +++ b/madeira/settings/test.py Sat Apr 14 16:40:29 2012 -0500 @@ -4,8 +4,8 @@ http://blog.davidziegler.net/post/370368042/test-database-settings-in-django """ -from settings.base import * -from settings.local import * +from madeira.settings.base import * +from madeira.settings.local import * DATABASES = { 'default': { diff -r f26cdda0ad8b -r e2868ad47a1e madeira/videos/admin.py --- a/madeira/videos/admin.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,29 +0,0 @@ -""" -Automatic admin definitions for the models in the videos application. - -""" -from django.contrib import admin -from django.conf import settings - -from videos.models import Collection, Video - - -class VideoInline(admin.StackedInline): - model = Video - - -class CollectionAdmin(admin.ModelAdmin): - list_filter = ['date_added'] - list_display = ['title', 'date_added'] - inlines = [VideoInline] - - class Media: - js = ['js/videos/videos_admin.js'] + settings.THIRD_PARTY_JS['tiny_mce'] - - -class VideoAdmin(admin.ModelAdmin): - list_display = ['title', 'collection'] - - -admin.site.register(Collection, CollectionAdmin) -admin.site.register(Video, VideoAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e madeira/videos/management/commands/import_old_videos.py --- a/madeira/videos/management/commands/import_old_videos.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,69 +0,0 @@ -""" -import_old_videos.py - For importing video data from the older version of this -website. - -""" -import datetime - -from django.core.management.base import LabelCommand -from django.utils import simplejson as json -from django.utils.html import linebreaks - -from videos.models import Collection, Video - - -class Command(LabelCommand): - args = '' - help = 'Imports older video & video sets in JSON format' - - collections = {} - - def handle_label(self, filename, **options): - """ - Process the file of older video & video sets in JSON. Convert to the new - model scheme. - - """ - with open(filename, 'rb') as f: - items = json.load(f) - - for item in items: - if item['model'] == 'band.video_set': - self.process_set(item) - - for item in items: - if item['model'] == 'band.video': - self.process_video(item) - - def process_set(self, item): - - fields = item['fields'] - - description = linebreaks(fields['text'].strip()) - - # there are several sets with the same date, so to get the ordering - # right, add the pk as seconds. - - date_added = datetime.datetime.strptime(fields['date'], '%Y-%m-%d') - date_added += datetime.timedelta(seconds=int(item['pk'])) - - coll = Collection( - id=item['pk'], - title=fields['title'].strip(), - date_added=date_added, - description=description) - - coll.save() - self.collections[coll.pk] = coll - - def process_video(self, item): - - fields = item['fields'] - - video = Video( - id=item['pk'], - title=fields['title'].strip(), - embed_code=fields['embed_code'], - collection=self.collections[fields['video_set']], - ) - video.save() diff -r f26cdda0ad8b -r e2868ad47a1e madeira/videos/models.py --- a/madeira/videos/models.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,42 +0,0 @@ -""" -Models for the videos application. - -""" -from django.db import models - - -class Collection(models.Model): - """ - This model represents a collection of videos. - - """ - title = models.CharField(max_length=64) - description = models.TextField() - date_added = models.DateTimeField() - - class Meta: - ordering = ['-date_added'] - - def __unicode__(self): - return self.title - - @models.permalink - def get_absolute_url(self): - return ('videos-item', [], {'pk': str(self.id)}) - - -class Video(models.Model): - """ - This model represents a video clip hosted on a remote video sharing site - (e.g. YouTube). - - """ - title = models.CharField(max_length=64) - embed_code = models.TextField() - collection = models.ForeignKey(Collection) - - class Meta: - ordering = ['title'] - - def __unicode__(self): - return self.title diff -r f26cdda0ad8b -r e2868ad47a1e madeira/videos/static/js/videos/videos_admin.js --- a/madeira/videos/static/js/videos/videos_admin.js Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,3 +0,0 @@ -django.jQuery(document).ready(function() { - django.jQuery('.embed_code textarea').addClass('mceNoEditor'); -}); diff -r f26cdda0ad8b -r e2868ad47a1e madeira/videos/urls.py --- a/madeira/videos/urls.py Sat Apr 14 15:49:28 2012 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -""" -Urls for the videos application. - -""" -from django.conf.urls import patterns, url -from django.views.generic import DetailView, ListView - -from videos.models import Collection - - -urlpatterns = patterns('', - url(r'^$', - ListView.as_view( - model=Collection, - context_object_name='collection_list'), - name='videos-index'), - url(r'^(?P\d+)/$', - DetailView.as_view(model=Collection, context_object_name='collection'), - name='videos-item') -) diff -r f26cdda0ad8b -r e2868ad47a1e manage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/manage.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "madeira.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff -r f26cdda0ad8b -r e2868ad47a1e mp3/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mp3/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,29 @@ +""" +Automatic admin definitions for the models in the mp3 application. + +""" +from django.contrib import admin +from django.conf import settings + +from mp3.models import Collection, Song + + +class SongInline(admin.TabularInline): + model = Song + + +class CollectionAdmin(admin.ModelAdmin): + list_filter = ['date_added'] + list_display = ['title', 'date_added'] + inlines = [SongInline] + + class Media: + js = settings.THIRD_PARTY_JS['tiny_mce'] + + +class SongAdmin(admin.ModelAdmin): + list_display = ['title', 'collection'] + + +admin.site.register(Collection, CollectionAdmin) +admin.site.register(Song, SongAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e mp3/management/commands/import_old_mp3.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mp3/management/commands/import_old_mp3.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,64 @@ +""" +import_old_mp3.py - For importing mp3 data from the older version of this +website. + +""" +import datetime + +from django.core.management.base import LabelCommand +from django.utils import simplejson as json +from django.utils.html import linebreaks + +from mp3.models import Collection, Song + + +class Command(LabelCommand): + args = '' + help = 'Imports older mp3 & mp3 sets in JSON format' + + collections = {} + + def handle_label(self, filename, **options): + """ + Process the file of older mp3 & mp3 sets in JSON. Convert to the new + model scheme. + + """ + with open(filename, 'rb') as f: + items = json.load(f) + + for item in items: + if item['model'] == 'band.mp3_set': + self.process_mp3_set(item) + + for item in items: + if item['model'] == 'band.mp3': + self.process_mp3(item) + + def process_mp3_set(self, item): + + fields = item['fields'] + + description = linebreaks(fields['text'].strip()) + + coll = Collection( + id=item['pk'], + title=fields['title'].strip(), + date_added=datetime.datetime.strptime(fields['date'], '%Y-%m-%d'), + description=description) + + coll.save() + self.collections[coll.pk] = coll + + def process_mp3(self, item): + + fields = item['fields'] + + song = Song( + id=item['pk'], + title=fields['title'].strip(), + description=fields['desc'].strip(), + file=fields['file'], + collection=self.collections[fields['mp3_set']], + ) + song.save() diff -r f26cdda0ad8b -r e2868ad47a1e mp3/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mp3/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,39 @@ +""" +Models for the mp3 application. + +""" +from django.db import models + + +class Collection(models.Model): + """ + This model represents a collection of songs. + + """ + title = models.CharField(max_length=64) + description = models.TextField() + date_added = models.DateTimeField() + + class Meta: + ordering = ['-date_added'] + + def __unicode__(self): + return self.title + + +class Song(models.Model): + """ + This model represents an uploaded song file. + + """ + title = models.CharField(max_length=64) + description = models.CharField(max_length=255, blank=True) + file = models.FileField(upload_to='mp3s/%Y/%m/%d/') + collection = models.ForeignKey(Collection) + + class Meta: + ordering = ['title'] + + def __unicode__(self): + return self.title + diff -r f26cdda0ad8b -r e2868ad47a1e mp3/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mp3/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,18 @@ +""" +Urls for the mp3 application. + +""" +from django.conf.urls import patterns, url +from django.views.generic import ListView + +from mp3.models import Collection + + +urlpatterns = patterns('', + url(r'^$', + ListView.as_view( + model=Collection, + paginate_by=10, + context_object_name='collection_list'), + name='mp3-index'), +) diff -r f26cdda0ad8b -r e2868ad47a1e news/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/news/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,19 @@ +""" +Automatic admin definitions for the models in the news application. + +""" +from django.contrib import admin +from django.conf import settings + +from news.models import News + + +class NewsAdmin(admin.ModelAdmin): + list_filter = ['date'] + search_fields = ['title', 'content'] + save_on_top = True + + class Media: + js = settings.THIRD_PARTY_JS['tiny_mce'] + +admin.site.register(News, NewsAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e news/management/commands/import_old_news.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/news/management/commands/import_old_news.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,65 @@ +""" +import_old_news.py - For importing news stories from the older version of this +website. + +""" +import datetime + +from django.conf import settings +from django.core.management.base import LabelCommand +from django.utils import simplejson as json +from django.utils.html import linebreaks +import textile + +from news.models import News + +IMG_TAG = u"""\ +{title}""" + + +class Command(LabelCommand): + args = '' + help = 'Imports older news stories in JSON format' + + def handle_label(self, filename, **options): + """ + Process the file of older news stories in JSON. Convert to the new model + scheme. + + """ + with open(filename, 'rb') as f: + items = json.load(f) + + for item in items: + if item['model'] == 'band.news': + self.process_item(item) + + def process_item(self, item): + + fields = item['fields'] + + content = fields['text'].strip() + if fields['markup_enabled']: + content = textile.textile(content, encoding='utf-8', output='utf-8') + else: + content = linebreaks(fields['text']) + + author = fields['author'].strip() + if author: + content += u"

\u2013 %s" % author + + image = fields['photo'].strip() + if image: + caption = fields['photo_caption'].strip() + caption = caption if caption else 'Image' + + src = u"%s%s" % (settings.MEDIA_URL, image) + + content = IMG_TAG.format(src=src, title=caption) + content + + news = News(id=item['pk'], + title=fields['title'].strip(), + date=datetime.datetime.strptime(fields['date'], '%Y-%m-%d'), + content=content) + + news.save() diff -r f26cdda0ad8b -r e2868ad47a1e news/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/news/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,29 @@ +""" +Models for the news application. + +""" +from django.db import models + + +class News(models.Model): + """ + This model represents all the info we store about each news entry. + + """ + title = models.CharField(max_length=128, blank=True) + date = models.DateTimeField(db_index=True) + content = models.TextField() + + def __unicode__(self): + date_str = self.date.strftime('%m/%d/%Y') + if self.title: + return u'%s %s' % (date_str, self.title) + return u'%s' % date_str + + @models.permalink + def get_absolute_url(self): + return ('news-item', [], {'pk': str(self.id)}) + + class Meta: + verbose_name_plural = 'News' + ordering = ['-date'] diff -r f26cdda0ad8b -r e2868ad47a1e news/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/news/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,21 @@ +""" +Urls for the news application. + +""" +from django.conf.urls import patterns, url +from django.views.generic import DetailView, ListView + +from news.models import News + + +urlpatterns = patterns('', + url(r'^$', + ListView.as_view( + model=News, + paginate_by=10, + context_object_name='news_list'), + name='news-index'), + url(r'^story/(?P\d+)/$', + DetailView.as_view(model=News, context_object_name='story'), + name='news-item') +) diff -r f26cdda0ad8b -r e2868ad47a1e photologue/LICENSE.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/LICENSE.txt Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,27 @@ +Copyright (c) 2007-2008, 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 django-photologue 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. diff -r f26cdda0ad8b -r e2868ad47a1e photologue/README.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/README.txt Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,38 @@ +Installation + +Step 1 - Download Photologue + +Photologue can be downloaded below or from the project page. Older versions are also available from the project page and users who like to live on the edge can checkout a copy of the latest trunk revision. + +Step 2 - Add Photologue To Your Project + +Copy the entire Photologue application folder (the folder named 'photologue' that contains 'models.py') to a location on your Python path such as your project root. Your project root is typically the directory where your 'settings.py' is found. + +Step 3 - Configure Your Settings + +Add 'photologue' to your INSTALLED_APPS setting: + + INSTALLED_APPS = ( + # ...other installed applications, + 'photologue', + ) + +Confirm that your MEDIA_ROOT and MEDIA_URL settings are correct. + +If you want to tweak things even more you can also over-ride a few default settings (optional, see documentation for more information on the available settings). + +Step 4 - Register Photologue with the Django Admin + +Add the following to your projects urls.py file: + + from django.contrib import admin + + admin.autodiscover() + +Step 4 - Sync Your Database + +Run the 'manage.py syndb' command to create the appropriate tables. After the database in initialized, Photologue will walk you through creating some default models. + +Additional documentation available here: + +http://code.google.com/p/django-photologue/w/list diff -r f26cdda0ad8b -r e2868ad47a1e photologue/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,60 @@ +""" 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'] + list_per_page = 10 + prepopulated_fields = {'title_slug': ('title',)} + +class PhotoEffectAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'admin_sample') + fieldsets = ( + (None, { + 'fields': ('name', 'description') + }), + ('Adjustments', { + 'fields': ('color', 'brightness', 'contrast', 'sharpness') + }), + ('Filters', { + 'fields': ('filters',) + }), + ('Reflection', { + 'fields': ('reflection_size', 'reflection_strength', 'background_color') + }), + ) + +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 diff -r f26cdda0ad8b -r e2868ad47a1e photologue/locale/pl/LC_MESSAGES/django.mo Binary file photologue/locale/pl/LC_MESSAGES/django.mo has changed diff -r f26cdda0ad8b -r e2868ad47a1e photologue/locale/pl/LC_MESSAGES/django.po --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/locale/pl/LC_MESSAGES/django.po Sat Apr 14 16:40:29 2012 -0500 @@ -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 , 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 \n" +"Language-Team: Jakub WiÅ›niowski \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" + diff -r f26cdda0ad8b -r e2868ad47a1e photologue/management/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/management/__init__.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,1 @@ + diff -r f26cdda0ad8b -r e2868ad47a1e photologue/management/commands/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/management/commands/__init__.py Sat Apr 14 16:40:29 2012 -0500 @@ -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 \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e photologue/management/commands/plcache.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/management/commands/plcache.py Sat Apr 14 16:40:29 2012 -0500 @@ -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) diff -r f26cdda0ad8b -r e2868ad47a1e photologue/management/commands/plcreatesize.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/management/commands/plcreatesize.py Sat Apr 14 16:40:29 2012 -0500 @@ -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) \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e photologue/management/commands/plflush.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/management/commands/plflush.py Sat Apr 14 16:40:29 2012 -0500 @@ -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) diff -r f26cdda0ad8b -r e2868ad47a1e photologue/management/commands/plinit.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/management/commands/plinit.py Sat Apr 14 16:40:29 2012 -0500 @@ -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 diff -r f26cdda0ad8b -r e2868ad47a1e photologue/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,700 @@ +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): + super(GalleryUpload, self).save() + self.process_zipfile() + super(GalleryUpload, self).delete() + + 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() + + +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'' % \ + (self.get_absolute_url(), func()) + else: + return u'' % \ + (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.view_count += 1 + self.save(update=True) + 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 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 + # 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: + 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': + im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True) + else: + im.save(im_filename) + 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, update=False): + if update: + models.Model.save(self) + return + 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() + self.pre_cache() + + def delete(self): + 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, update=False): + if self.title_slug is None: + self.title_slug = slugify(self.title) + super(Photo, self).save(update) + + 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) + + +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'' % 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): + try: + os.remove(self.sample_filename()) + except: + pass + models.Model.save(self) + 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 + super(PhotoEffect, self).delete() + + +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): + if self.width + self.height <= 0: + raise ValueError(_('A PhotoSize must have a positive height or width.')) + super(PhotoSize, self).save() + PhotoSizeCache().reset() + self.clear_cache() + + def delete(self): + 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) diff -r f26cdda0ad8b -r e2868ad47a1e photologue/res/sample.jpg Binary file photologue/res/sample.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e photologue/res/test_landscape.jpg Binary file photologue/res/test_landscape.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e photologue/res/test_portrait.jpg Binary file photologue/res/test_portrait.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e photologue/res/test_square.jpg Binary file photologue/res/test_square.jpg has changed diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/gallery_archive.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/gallery_archive.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}Latest Photo Galleries{% endblock %} + +{% block content %} + +

Latest Photo Galleries

+ +{% if latest %} + {% for gallery in latest %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +

View all galleries.

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/gallery_archive_day.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/gallery_archive_day.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ day|date }}{% endblock %} + +{% block content %} + +

Galleries for {{ day|date }}

+ +{% if object_list %} + {% for gallery in object_list %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +

View all galleries.

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/gallery_archive_month.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/gallery_archive_month.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ month|date:"F Y" }}{% endblock %} + +{% block content %} + +

Galleries for {{ month|date:"F Y" }}

+ +{% if object_list %} + {% for gallery in object_list %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +

View all galleries.

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/gallery_archive_year.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/gallery_archive_year.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,16 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ year }}{% endblock %} + +{% block content %} + +

Galleries for {{ year }}

+ + +

View all galleries.

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/gallery_detail.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/gallery_detail.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,19 @@ +{% extends "photologue/root.html" %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block content %} + +

{{ object.title }}

+

Originally published {{ object.date_added|date:"l, F jS, Y" }}

+{% if object.description %}

{{ object.description }}

{% endif %} + +

View all galleries

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/gallery_list.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/gallery_list.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,31 @@ +{% extends "photologue/root.html" %} + +{% block title %}All Galleries{% endblock %} + +{% block content %} + +

All galleries

+ +{% if object_list %} + {% for gallery in object_list %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +{% if is_paginated %} +

{{ hits }} galleries total.

+
+

{% if has_previous %}Previous | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| Next{% endif %}

+
+{% endif %} + +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/photo_archive.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/photo_archive.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,20 @@ +{% extends "photologue/root.html" %} + +{% block title %}Latest Photos{% endblock %} + +{% block content %} + +

Latest Photos

+ +{% if latest %} + {% for photo in latest %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} +

View all photographs

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/photo_archive_day.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/photo_archive_day.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,20 @@ +{% extends "photologue/root.html" %} + +{% block title %}Photos for {{ day|date }}{% endblock %} + +{% block content %} + +

Photos for {{ day|date }}

+ +{% if object_list %} + {% for photo in object_list %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} +

View all photographs

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/photo_archive_month.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/photo_archive_month.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,20 @@ +{% extends "photologue/root.html" %} + +{% block title %}Photos for {{ month|date:"F Y" }}{% endblock %} + +{% block content %} + +

Photos for {{ month|date:"F Y" }}

+ +{% if object_list %} + {% for photo in object_list %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} +

View all photographs

+ +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/photo_archive_year.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/photo_archive_year.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,14 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ year }}{% endblock %} + +{% block content %} + +

Photos for {{ year }}

+ + +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/photo_detail.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/photo_detail.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,21 @@ +{% extends "photologue/root.html" %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block content %} + +

{{ object.title }}

+ +{% if object.public_galleries %} +

This photo is found in the following galleries:

+
    +{% for gallery in object.public_galleries %} +
  1. {{ gallery.title }}
  2. +{% endfor %} +
+{% endif %} + +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/photo_list.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/photo_list.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}All Photos{% endblock %} + +{% block content %} + +

All Photos

+ +{% if object_list %} + {% for photo in object_list %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} + +{% if is_paginated %} +

{{ hits }} photos total.

+
+

{% if has_previous %}Previous | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| Next{% endif %}

+
+{% endif %} + +{% endblock %} diff -r f26cdda0ad8b -r e2868ad47a1e photologue/templates/photologue/root.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/templates/photologue/root.html Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,1 @@ +{% extends "base.html" %} \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e photologue/tests.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/tests.py Sat Apr 14 16:40:29 2012 -0500 @@ -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 = (2000, 0) + self.s.upscale = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (2000, 1500)) + self.assertEquals(self.pp.get_test_size(), (2000, 2667)) + self.assertEquals(self.ps.get_test_size(), (2000, 2000)) + + 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, 2000) + self.s.upscale = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (2667, 2000)) + self.assertEquals(self.pp.get_test_size(), (1500, 2000)) + self.assertEquals(self.ps.get_test_size(), (2000, 2000)) + + 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 = (1500, 1200) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (1500, 1125)) + + def test_resize_one_dimension_height(self): + self.s.size = (1600, 1100) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (1467, 1100)) + + def test_resize_no_upscale(self): + self.s.size = (2000, 2000) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (1600, 1200)) + + def test_resize_no_upscale_mixed_height(self): + self.s.size = (3200, 600) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (800, 600)) + + def test_resize_no_upscale_mixed_width(self): + self.s.size = (800, 2400) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (800, 600)) + + def test_resize_no_upscale_crop(self): + self.s.size = (2000, 2000) + self.s.crop = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (2000, 2000)) + + def test_resize_upscale(self): + self.s.size = (2000, 2000) + self.s.upscale = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (2000, 1500)) + self.assertEquals(self.pp.get_test_size(), (1500, 2000)) + self.assertEquals(self.ps.get_test_size(), (2000, 2000)) + + +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) + diff -r f26cdda0ad8b -r e2868ad47a1e photologue/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,36 @@ +from django.conf import settings +from django.conf.urls 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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/(?P[\-\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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/$', 'archive_day', gallery_args, name='pl-gallery-archive-day'), + url(r'^gallery/(?P\d{4})/(?P[a-z]{3})/$', 'archive_month', gallery_args, name='pl-gallery-archive-month'), + url(r'^gallery/(?P\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[\-\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[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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/(?P[\-\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\d{4})/(?P[a-z]{3})/(?P\w{1,2})/$', 'archive_day', photo_args, name='pl-photo-archive-day'), + url(r'^photo/(?P\d{4})/(?P[a-z]{3})/$', 'archive_month', photo_args, name='pl-photo-archive-month'), + url(r'^photo/(?P\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[\-\d\w]+)/$', 'object_detail', {'slug_field': 'title_slug', 'queryset': Photo.objects.filter(is_public=True)}, name='pl-photo'), + url(r'^photo/page/(?P[0-9]+)/$', 'object_list', {'queryset': Photo.objects.filter(is_public=True), 'allow_empty': True, 'paginate_by': 20}, name='pl-photo-list'), +) + + diff -r f26cdda0ad8b -r e2868ad47a1e photologue/utils/EXIF.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/utils/EXIF.py Sat Apr 14 16:40:29 2012 -0500 @@ -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 + diff -r f26cdda0ad8b -r e2868ad47a1e photologue/utils/reflection.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/utils/reflection.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,93 @@ +""" 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 + \ No newline at end of file diff -r f26cdda0ad8b -r e2868ad47a1e photologue/utils/watermark.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/photologue/utils/watermark.py Sat Apr 14 16:40:29 2012 -0500 @@ -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() diff -r f26cdda0ad8b -r e2868ad47a1e pl-admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pl-admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,116 @@ +import getopt, sys + +try: + import settings # Assumed to be in the same directory. + from django.core.management import setup_environ +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) + sys.exit(1) + + +def precache(sizes=[], reset=False): + # setup django environment + setup_environ(settings) + + # import models + from photologue.models import Photo, PhotoSize, PhotoSizeCache + + cache = PhotoSizeCache() + + print 'Caching photos, this may take a while...' + + for photo in Photo.objects.all(): + if len(sizes): + for size in sizes: + photosize = cache.sizes.get(size, None) + if photosize is None: + print '\nA photosize named "%s" was not found...' % size + else: + if reset: + photo.remove_size(photosize) + photo.create_size(photosize) + else: + for size in caches.sizes.values(): + if reset: + Photo.remove_size(photosize) + photo.create_size(photosize) + + print ' Complete.' + sys.exit(2) + + +def reset(): + # setup django environment + setup_environ(settings) + + # import models + from photologue.models import Photo, PhotoSize + + print 'Reseting photo cache, this may take a while...' + + for photo in Photo.objects.all(): + photo.clear_cache() + + print ' Complete.' + sys.exit(2) + + +def usage(): + print """ + +pl-admin.py - Photologue administration script. + +Available Commands: + pl-admin.py create Resizes and caches all defined photo sizes for each image. + pl-admin.py reset Removes all cached images. + +Options: + --reset (-r) If calling create the script will clear the existing photo cache + before regenerating the specified size (or sizes) + --size (-s) The name of a photosize generate + +Usage: + pl-admin.py [options] command + +Examples: + pl-admin.py -r -s=thumbnail create + pl-admin.py -s=thumbnail -s=display create + pl-admin.py reset + +""" + +def main(): + try: + opts, args = getopt.getopt(sys.argv[1:], "hrs:", + ["help", "reset", "sizes="]) + except getopt.GetoptError, err: + print str(err) + usage() + sys.exit(2) + r = False + s = [] + for o, a in opts: + if o in ("-h", "--help"): + usage() + sys.exit(2) + if o in ("-r", "--reset"): + r = True + elif o in ("-s", "--sizes"): + s.append(a.strip('=')) + else: + usage() + sys.exit(2) + + if len(args) == 1: + command = args[0] + if command == 'create': + precache(s, r) + elif command == 'reset': + reset() + + usage() + + +if __name__ == '__main__': + main() diff -r f26cdda0ad8b -r e2868ad47a1e videos/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/videos/admin.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,29 @@ +""" +Automatic admin definitions for the models in the videos application. + +""" +from django.contrib import admin +from django.conf import settings + +from videos.models import Collection, Video + + +class VideoInline(admin.StackedInline): + model = Video + + +class CollectionAdmin(admin.ModelAdmin): + list_filter = ['date_added'] + list_display = ['title', 'date_added'] + inlines = [VideoInline] + + class Media: + js = ['js/videos/videos_admin.js'] + settings.THIRD_PARTY_JS['tiny_mce'] + + +class VideoAdmin(admin.ModelAdmin): + list_display = ['title', 'collection'] + + +admin.site.register(Collection, CollectionAdmin) +admin.site.register(Video, VideoAdmin) diff -r f26cdda0ad8b -r e2868ad47a1e videos/management/commands/import_old_videos.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/videos/management/commands/import_old_videos.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,69 @@ +""" +import_old_videos.py - For importing video data from the older version of this +website. + +""" +import datetime + +from django.core.management.base import LabelCommand +from django.utils import simplejson as json +from django.utils.html import linebreaks + +from videos.models import Collection, Video + + +class Command(LabelCommand): + args = '' + help = 'Imports older video & video sets in JSON format' + + collections = {} + + def handle_label(self, filename, **options): + """ + Process the file of older video & video sets in JSON. Convert to the new + model scheme. + + """ + with open(filename, 'rb') as f: + items = json.load(f) + + for item in items: + if item['model'] == 'band.video_set': + self.process_set(item) + + for item in items: + if item['model'] == 'band.video': + self.process_video(item) + + def process_set(self, item): + + fields = item['fields'] + + description = linebreaks(fields['text'].strip()) + + # there are several sets with the same date, so to get the ordering + # right, add the pk as seconds. + + date_added = datetime.datetime.strptime(fields['date'], '%Y-%m-%d') + date_added += datetime.timedelta(seconds=int(item['pk'])) + + coll = Collection( + id=item['pk'], + title=fields['title'].strip(), + date_added=date_added, + description=description) + + coll.save() + self.collections[coll.pk] = coll + + def process_video(self, item): + + fields = item['fields'] + + video = Video( + id=item['pk'], + title=fields['title'].strip(), + embed_code=fields['embed_code'], + collection=self.collections[fields['video_set']], + ) + video.save() diff -r f26cdda0ad8b -r e2868ad47a1e videos/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/videos/models.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,42 @@ +""" +Models for the videos application. + +""" +from django.db import models + + +class Collection(models.Model): + """ + This model represents a collection of videos. + + """ + title = models.CharField(max_length=64) + description = models.TextField() + date_added = models.DateTimeField() + + class Meta: + ordering = ['-date_added'] + + def __unicode__(self): + return self.title + + @models.permalink + def get_absolute_url(self): + return ('videos-item', [], {'pk': str(self.id)}) + + +class Video(models.Model): + """ + This model represents a video clip hosted on a remote video sharing site + (e.g. YouTube). + + """ + title = models.CharField(max_length=64) + embed_code = models.TextField() + collection = models.ForeignKey(Collection) + + class Meta: + ordering = ['title'] + + def __unicode__(self): + return self.title diff -r f26cdda0ad8b -r e2868ad47a1e videos/static/js/videos/videos_admin.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/videos/static/js/videos/videos_admin.js Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,3 @@ +django.jQuery(document).ready(function() { + django.jQuery('.embed_code textarea').addClass('mceNoEditor'); +}); diff -r f26cdda0ad8b -r e2868ad47a1e videos/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/videos/urls.py Sat Apr 14 16:40:29 2012 -0500 @@ -0,0 +1,20 @@ +""" +Urls for the videos application. + +""" +from django.conf.urls import patterns, url +from django.views.generic import DetailView, ListView + +from videos.models import Collection + + +urlpatterns = patterns('', + url(r'^$', + ListView.as_view( + model=Collection, + context_object_name='collection_list'), + name='videos-index'), + url(r'^(?P\d+)/$', + DetailView.as_view(model=Collection, context_object_name='collection'), + name='videos-item') +)