diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CONTRIBUTORS.txt @@ -0,0 +1 @@ + diff --git a/INSTALL.txt b/INSTALL.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/INSTALL.txt @@ -0,0 +1 @@ + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..56f2f5e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,29 @@ +Copyright (c) 2008, Eric Florenzano +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * 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. + * Neither the name of the author nor the names of other + 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 --git a/README.txt b/README.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/README.txt @@ -0,0 +1 @@ + diff --git a/avatar/__init__.py b/avatar/__init__.py new file mode 100644 index 0000000..58dc4ee --- /dev/null +++ b/avatar/__init__.py @@ -0,0 +1,15 @@ +from django.contrib.auth.models import User +from models import Avatar +from django.dispatch import dispatcher +from django.db.models import signals + +def create_avatar(sender, instance): + avatar, created = Avatar.objects.get_or_create(user=instance) + avatar.save() +def delete_avatar(sender, instance): + try: + Avatar.objects.get(user=instance).delete() + except Avatar.DoesNotExist: + pass +dispatcher.connect(create_avatar, sender=User, signal=signals.post_save) +dispatcher.connect(delete_avatar, sender=User, signal=signals.post_delete) diff --git a/avatar/admin.py b/avatar/admin.py new file mode 100644 index 0000000..8d2d073 --- /dev/null +++ b/avatar/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from models import Avatar + +admin.site.register(Avatar) \ No newline at end of file diff --git a/avatar/default.jpg b/avatar/default.jpg new file mode 100644 index 0000000..37a6276 Binary files /dev/null and b/avatar/default.jpg differ diff --git a/avatar/management/__init__.py b/avatar/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/avatar/management/__init__.py @@ -0,0 +1 @@ + diff --git a/avatar/management/commands/__init__.py b/avatar/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/avatar/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/avatar/management/commands/import_gravatars.py b/avatar/management/commands/import_gravatars.py new file mode 100644 index 0000000..0383277 --- /dev/null +++ b/avatar/management/commands/import_gravatars.py @@ -0,0 +1,51 @@ +import os +import os.path + +from urllib2 import urlopen +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management.base import NoArgsCommand + +try: + from hashlib import md5 +except ImportError: + from md5 import new as md5 + +def hashify(inp): + return md5(inp).hexdigest().lower() + +class Command(NoArgsCommand): + help = "Import avatars from Gravatar, and store them locally." + + def handle_noargs(self, **options): + # I'm OK with bare exceptions here, since we want totally graceful and + # aggressive failure on a massive import like this. + for user in User.objects.all(): + if user.email: + url = "http://www.gravatar.com/avatar/%s" % hashify(user.email) + try: + data = urlopen(url).read() + except: + print "Errored on opening URL: %s" % url + continue + else: + print "%s has no e-mail address specified." % user.username + continue + dirname = os.path.join(settings.MEDIA_ROOT, 'avatars') + try: + os.makedirs(dirname) + except OSError: + pass + filename = "%s.jpg" % user.avatar.email_hash + full_filename = os.path.join(dirname, filename) + try: + f = open(full_filename, 'w') + f.write(data) + f.close() + user.avatar.avatar = full_filename + user.avatar.save() + print "Imported Gravatar for %s" % user.username + except: + print "Error on writing to file: %s" % full_filename + continue + \ No newline at end of file diff --git a/avatar/models.py b/avatar/models.py new file mode 100644 index 0000000..d5f5dd1 --- /dev/null +++ b/avatar/models.py @@ -0,0 +1,31 @@ +import os.path + +from django.db import models +from django.contrib.auth.models import User + +try: + from hashlib import md5 +except ImportError: + from md5 import new as md5 + +RATING_CHOICES = ( + ('g', 'G'), + ('pg', 'PG'), + ('r', 'R'), + ('x', 'X'), +) + +class Avatar(models.Model): + email_hash = models.CharField(max_length=128, blank=True) + user = models.OneToOneField(User) + # This should have a subdirectory of each user's avatar, but first we need + # patch #5361 to land into Trunk. + avatar = models.FileField(upload_to='avatars/', default='DEFAULT') + rating = models.CharField(choices=RATING_CHOICES, max_length=2, default='g') + + def __unicode__(self): + return u'Gravatar for %s' % self.user + + def save(self): + self.email_hash = md5(self.user.email).hexdigest().lower() + super(Avatar, self).save() \ No newline at end of file diff --git a/avatar/templates/avatar/base.html b/avatar/templates/avatar/base.html new file mode 100644 index 0000000..79ea0d4 --- /dev/null +++ b/avatar/templates/avatar/base.html @@ -0,0 +1,8 @@ + + + {% block title %}django-avatar{% endblock %} + + + {% block content %}{% endblock %} + + \ No newline at end of file diff --git a/avatar/templates/avatar/change.html b/avatar/templates/avatar/change.html new file mode 100644 index 0000000..a00e42b --- /dev/null +++ b/avatar/templates/avatar/change.html @@ -0,0 +1,10 @@ +{% extends "avatar/base.html" %} + +{% block content %} +

Your current avatar looks like this:

+ +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/avatar/templates/avatar/confirm_delete.html b/avatar/templates/avatar/confirm_delete.html new file mode 100644 index 0000000..916c399 --- /dev/null +++ b/avatar/templates/avatar/confirm_delete.html @@ -0,0 +1,10 @@ +{% extends "avatar/base.html" %} + +{% block content %} +

Are you sure that you would like to delete the avatar of {{ avatar.user.username }}?

+
+ + +
+ No +{% endblock %} \ No newline at end of file diff --git a/avatar/urls.py b/avatar/urls.py new file mode 100644 index 0000000..764d020 --- /dev/null +++ b/avatar/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('avatar', + url('^change/$', 'views.change', name='avatar_change'), + url('^delete/$', 'views.delete', name='avatar_delete'), + url('^(\w+)/$', 'views.img', name='avatar_img'), +) diff --git a/avatar/views.py b/avatar/views.py new file mode 100644 index 0000000..5f7b175 --- /dev/null +++ b/avatar/views.py @@ -0,0 +1,140 @@ +import os +import os.path +import tempfile +import ImageFile +import Image +import shutil + +from models import Avatar +from urllib2 import urlopen +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.conf import settings +from django.shortcuts import get_object_or_404, render_to_response +from django.template import RequestContext +from django.core.urlresolvers import reverse +from django.contrib.auth.decorators import login_required + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +MAX_MEGABYTES = getattr(settings, 'AVATAR_MAX_FILESIZE', 10) +MAX_WIDTH = getattr(settings, 'AVATAR_MAX_WIDTH', 512) +DEFAULT_WIDTH = getattr(settings, 'AVATAR_DEFAULT_WIDTH', 80) + +def _get_next(request): + """ + The part that's the least straightforward about views in this module is how they + determine their redirects after they have finished computation. + + In short, they will try and determine the next place to go in the following order: + + 1. If there is a variable named ``next`` in the *POST* parameters, the view will + redirect to that variable's value. + 2. If there is a variable named ``next`` in the *GET* parameters, the view will + redirect to that variable's value. + 3. If Django can determine the previous page from the HTTP headers, the view will + redirect to that previous page. + 4. Otherwise, the view raise a 404 Not Found. + """ + next = request.POST.get('next', request.GET.get('next', request.META.get('HTTP_REFERER', None))) + if not next or next == request.path: + raise Http404 # No next url was supplied in GET or POST. + return next + +def img(request, email_hash, resize_method=Image.ANTIALIAS): + if email_hash.endswith('.jpg'): + email_hash = email_hash[:-4] + try: + size = int(request.GET.get('s', DEFAULT_WIDTH)) + except ValueError: + size = DEFAULT_WIDTH + if size > MAX_WIDTH: + size = MAX_WIDTH + rating = request.GET.get('r', 'g') # Unused, for now. + default = request.GET.get('d', '') + data = None + try: + avatar = Avatar.objects.get(email_hash=email_hash) + try: + data = open(avatar.get_avatar_filename(), 'r').read() + except IOError: + pass + except Avatar.DoesNotExist: + if default: + try: + data = urlopen(default).read() + except: #TODO: Fix this hardcore + pass + if not data: + filename = os.path.join(os.path.dirname(__file__), 'default.jpg') + data = open(filename, 'r').read() + p = ImageFile.Parser() + p.feed(data) + try: + image = p.close() + except IOError: + filename = os.path.join(os.path.dirname(__file__), 'default.jpg') + try: + return HttpResponse(open(filename, 'r').read(), mimetype='image/jpeg') + except: #TODO: Fix this hardcore + # Is this the right response after so many other things have failed? + raise Http404 + (width, height) = image.size + if width > height: + diff = (width - height) / 2 + image = image.crop((diff, 0, width - diff, height)) + else: + diff = (height - width) / 2 + image = image.crop((0, diff, width, height - diff)) + image = image.resize((size, size), resize_method) + response = HttpResponse(mimetype='image/jpeg') + image.save(response, "JPEG") + return response + +def change(request, extra_context={}, next_override=None): + if request.method == "POST": + dirname = os.path.join(settings.MEDIA_ROOT, 'avatars') + filename = "%s.jpg" % request.user.avatar.email_hash + full_filename = os.path.join(dirname, filename) + (destination, destination_path) = tempfile.mkstemp() + for i, chunk in enumerate(request.FILES['avatar'].chunks()): + if i * 16 == MAX_MEGABYTES: + raise Http404 + os.write(destination, chunk) + os.close(destination) + shutil.move(destination_path, full_filename) + request.user.avatar.avatar = full_filename + request.user.avatar.save() + return HttpResponseRedirect(next_override or _get_next(request)) + return render_to_response( + 'avatar/change.html', + extra_context, + context_instance = RequestContext( + request, + { 'avatar': request.user.avatar, + 'next': next_override or _get_next(request) } + ) + ) +change = login_required(change) + +def delete(request, extra_context={}, next_override=None): + if request.method == 'POST': + # Should we really delete a OneToOneField? + # I think just set image to default. + # request.user.avatar.delete() + request.user.avatar.avatar = "DEFAULT" + request.user.avatar.save() + next = next_override or _get_next(request) + return HttpResponseRedirect(next) + return render_to_response( + 'avatar/confirm_delete.html', + extra_context, + context_instance = RequestContext( + request, + { 'avatar': request.user.avatar, + 'next': next_override or _get_next(request) } + ) + ) +delete = login_required(delete) \ No newline at end of file