diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2aa50fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2010 +Team23 GbR / David Danier +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 Team23 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 HOLDER 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/django_hits/__init__.py b/django_hits/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/django_hits/__init__.py @@ -0,0 +1 @@ + diff --git a/django_hits/migrations/0001_initial.py b/django_hits/migrations/0001_initial.py new file mode 100644 index 0000000..abc70f6 --- /dev/null +++ b/django_hits/migrations/0001_initial.py @@ -0,0 +1,108 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding model 'Hit' + db.create_table('django_hits_hit', ( + ('visits', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + ('object_pk', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'], null=True)), + ('views', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + )) + db.send_create_signal('django_hits', ['Hit']) + + # Adding unique constraint on 'Hit', fields ['content_type', 'object_pk'] + db.create_unique('django_hits_hit', ['content_type_id', 'object_pk']) + + # Adding model 'HitLog' + db.create_table('django_hits_hitlog', ( + ('ip', self.gf('django.db.models.fields.IPAddressField')(max_length=15, null=True)), + ('when', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + ('hit', self.gf('django.db.models.fields.related.ForeignKey')(related_name='log', to=orm['django_hits.Hit'])), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='hits_log', null=True, to=orm['auth.User'])), + )) + db.send_create_signal('django_hits', ['HitLog']) + + # Adding unique constraint on 'HitLog', fields ['hit', 'user', 'ip'] + db.create_unique('django_hits_hitlog', ['hit_id', 'user_id', 'ip']) + + + def backwards(self, orm): + + # Deleting model 'Hit' + db.delete_table('django_hits_hit') + + # Removing unique constraint on 'Hit', fields ['content_type', 'object_pk'] + db.delete_unique('django_hits_hit', ['content_type_id', 'object_pk']) + + # Deleting model 'HitLog' + db.delete_table('django_hits_hitlog') + + # Removing unique constraint on 'HitLog', fields ['hit', 'user', 'ip'] + db.delete_unique('django_hits_hitlog', ['hit_id', 'user_id', 'ip']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'django_hits.hit': { + 'Meta': {'unique_together': "(('content_type', 'object_pk'),)", 'object_name': 'Hit'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_pk': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'views': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'visits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'django_hits.hitlog': { + 'Meta': {'unique_together': "(('hit', 'user', 'ip'),)", 'object_name': 'HitLog'}, + 'hit': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'log'", 'to': "orm['django_hits.Hit']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ip': ('django.db.models.fields.IPAddressField', [], {'max_length': '15', 'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hits_log'", 'null': 'True', 'to': "orm['auth.User']"}), + 'when': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}) + } + } + + complete_apps = ['django_hits'] diff --git a/django_hits/migrations/__init__.py b/django_hits/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_hits/models.py b/django_hits/models.py new file mode 100644 index 0000000..ce87455 --- /dev/null +++ b/django_hits/models.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.contrib.auth.models import User +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from datetime import datetime, timedelta + +from django.db.models import signals + + +class HitManager(models.Manager): + def get_for(self, obj): + from django.db import backend + if isinstance(obj, models.Model): + content_type = ContentType.objects.get_for_model(obj.__class__) + object_pk = getattr(obj, obj._meta.pk.column) + try: + return self.get_or_create(content_type=content_type, object_pk=object_pk)[0] + except backend.IntegrityError: # catch race condition + return self.get(content_type=content_type, object_pk=object_pk) + elif isinstance(obj, (str, unicode)): + try: + return self.get_or_create(content_type__isnull=True, object_pk=obj)[0] + except backend.IntegrityError: # catch race condition + return self.get(content_type__isnull=True, object_pk=obj) + else: + raise Exception("Don't know what to do with this obj!?") + + def hit(self, obj, user, ip): + hit = self.get_for(obj) + hit.hit(user, ip) + return hit + + +class Hit(models.Model): + content_type = models.ForeignKey(ContentType, null=True) + object_pk = models.CharField(max_length=255) # TextField not possible, because unique_together is needed, must be enough + content_object = generic.GenericForeignKey(ct_field="content_type", fk_field="object_pk") + views = models.PositiveIntegerField(default=0) # page hits/views + visits = models.PositiveIntegerField(default=0) # unique visits + + objects = HitManager() + + # TODO: Transaction-Management needed? + @transaction.commit_manually + def hit(self, user, ip): + from django.db import backend + if self.has_hit_from(user, ip): + self.update_hit_from(user, ip) + Hit.objects.filter(pk=self.pk).update(views=models.F('views') + 1) + self.views += 1 + transaction.commit() + return True + try: + self.log.create(user=user, ip=ip) + except backend.IntegrityError: # catch race condition + # log-extry was already created + # happens when users double-click or reload to fast + # (we ignore this) + transaction.rollback() + return False + Hit.objects.filter(pk=self.pk).update(views=models.F('views') + 1, visits=models.F('visits') + 1) + self.views += 1 + self.visits += 1 + transaction.commit() + return True + + def has_hit_from(self, user, ip): + self.clear_log() + if self.log.filter(user=user, ip=ip).count(): + return True + else: + return False + + def update_hit_from(self, user, ip): + self.log.filter(user=user, ip=ip).update(when=datetime.now()) + + def clear_log(self): + timespan = datetime.now() - timedelta(days=30) + for l in self.log.filter(when__lt=timespan).order_by('-when')[25:]: + l.delete() + + class Meta: + unique_together = (('content_type', 'object_pk'),) + + +class HitLog(models.Model): + hit = models.ForeignKey(Hit, related_name='log') + user = models.ForeignKey(User, related_name='hits_log', null=True) + ip = models.IPAddressField(null=True) + when = models.DateTimeField(default=datetime.now) + + class Meta: + unique_together = (('hit', 'user', 'ip'),) + diff --git a/django_hits/templatetags/__init__.py b/django_hits/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/django_hits/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/django_hits/templatetags/hit_tags.py b/django_hits/templatetags/hit_tags.py new file mode 100644 index 0000000..71aa3be --- /dev/null +++ b/django_hits/templatetags/hit_tags.py @@ -0,0 +1,82 @@ +from django import template +from django_hits.models import Hit + +register = template.Library() + + +class HitNode(template.Node): + def __init__(self, context_var_name, var_name, count): + self.context_var_name = context_var_name + self.var_name = var_name + self.count = count + + def render(self, context): + if not hasattr(context, '_hit_cache_'): + context._hit_cache_ = {} + try: + obj = self.context_var_name.resolve(context) + user = template.Variable('user').resolve(context) + except template.VariableDoesNotExist: + return '' + if user.is_anonymous(): + user = None + count = self.count + ip = None + if count: + try: + request = template.Variable('request').resolve(context) + if 'REMOTE_ADDR' in request.META: + ip = request.META['REMOTE_ADDR'] + except template.VariableDoesNotExist: + pass + was_counted = False + if obj in context._hit_cache_: + hit, was_counted = context._hit_cache_[obj] + else: + hit = Hit.objects.get_for(obj) + if count and not was_counted: + was_counted = hit.hit(user, ip) + context._hit_cache_[obj] = (hit, was_counted) + if self.var_name: + context[self.var_name] = hit + return '' + + +@register.tag +def get_hit(parser, token): + ''' + {% get_hit for obj as hit %} + {% get_hit for "static_page" as hit %} + ''' + tokens = token.split_contents() + if not len(tokens) in (5,): + raise template.TemplateSyntaxError, "%r tag requires 4 or 5" % tokens[0] + if tokens[1] != 'for': + raise template.TemplateSyntaxError, "Second argument in %r tag must be 'for'" % tokens[0] + context_var_name = parser.compile_filter(tokens[2]) + if tokens[3] != 'as': + raise template.TemplateSyntaxError, "Fourth argument in %r must be 'as'" % tokens[0] + var_name = tokens[4] + return HitNode(context_var_name, var_name, False) + +@register.tag +def count_hit(parser, token): + ''' + {% count_hit for obj %} + {% count_hit for obj as hit %} + {% count_hit for "static_page" %} + {% count_hit for "static_page" as hit %} + ''' + tokens = token.split_contents() + if not len(tokens) in (3, 5,): + raise template.TemplateSyntaxError, "%r tag requires 4 or 5" % tokens[0] + if tokens[1] != 'for': + raise template.TemplateSyntaxError, "Second argument in %r tag must be 'for'" % tokens[0] + context_var_name = parser.compile_filter(tokens[2]) + var_name = None + if len(tokens) > 3: + if tokens[3] != 'as': + raise template.TemplateSyntaxError, "Fourth argument in %r must be 'as'" % tokens[0] + var_name = tokens[4] + return HitNode(context_var_name, var_name, True) +