Commits

Anonymous committed 223973a

Initial import

  • Participants
  • Parent commits 70ccd2e

Comments (0)

Files changed (20)

pendulum/__init__.py

+from django.db.models.signals import pre_save
+from django.core.exceptions import ValidationError
+from pendulum.models import Entry
+
+APP_TITLE = 'Pendulum: Time Clock'
+
+def validate_entry_callback(sender, instance, **kwargs):
+    if instance.start_time and instance.end_time:
+        if instance.start_time > instance.end_time:
+            raise ValidationError('The entry must start before it ends!')
+
+#pre_save.connect(validate_entry_callback, sender=Entry)

pendulum/admin.py

+from django.contrib import admin
+from pendulum.models import PendulumConfiguration, Activity, Entry, Project
+
+class PendulumConfigurationAdmin(admin.ModelAdmin):
+    list_display = ('site',
+                    'current_mode',
+                    'is_monthly',
+                    'month_start',
+                    'install_date',
+                    'period_length')
+    list_filter = ['is_monthly']
+    search_fields = ['site']
+    fieldsets = (
+        (None, {
+            'fields': ('site',)
+        }),
+        ('Month-Long Periods', {
+            'fields': ('is_monthly', 'month_start'),
+        }),
+        ('Fixed-Length Periods', {
+            'fields': ('install_date', 'period_length'),
+        })
+    )
+
+class ActivityAdmin(admin.ModelAdmin):
+    model = Activity
+    list_display = ('code', 'name', 'log_count', 'total_hours')
+
+class EntryAdmin(admin.ModelAdmin):
+    model = Entry
+    list_display = ('user', 'project', 'activity', 'start_time', 'end_time', 'hours')
+    list_filter = ['project']
+    search_fields = ['user', 'project', 'activity', 'comments']
+    date_hierarchy = 'start_time'
+
+class ProjectAdmin(admin.ModelAdmin):
+    model = Project
+    list_display = ('name', 'is_active', 'log_count', 'total_hours')
+
+admin.site.register(PendulumConfiguration, PendulumConfigurationAdmin)
+admin.site.register(Activity, ActivityAdmin)
+admin.site.register(Entry, EntryAdmin)
+admin.site.register(Project, ProjectAdmin)

pendulum/fixtures/activities.json

+[
+    {
+        "pk": 1,
+        "model": "pendulum.Activity",
+        "fields": {
+            "code": "FUN",
+            "name": "Fun and Games"
+        }
+    },
+    {
+        "pk": 2,
+        "model": "pendulum.Activity",
+        "fields": {
+            "code": "WRK",
+            "name": "Work"
+        }
+    }
+]

pendulum/fixtures/entries.json

+[
+    {
+        "pk": 1,
+        "model": "pendulum.pendulumconfiguration",
+        "fields": {
+            "is_monthly": true,
+            "month_start": 1,
+            "install_date": null,
+            "site": 1,
+            "period_length": null
+        }
+    },
+    {
+        "pk": 1,
+        "model": "pendulum.Entry",
+        "fields": {
+            "user": 2,
+            "project": 1,
+            "activity": 1,
+            "start_time": "2008-04-28 22:00",
+            "seconds_paused": 0,
+            "comments": "Entry owned by another user",
+            "site": 1
+        }
+    },
+    {
+        "pk": 2,
+        "model": "pendulum.Entry",
+        "fields": {
+            "user": 1,
+            "project": 2,
+            "activity": 2,
+            "start_time": "2008-04-28 22:00",
+            "seconds_paused": 0,
+            "pause_time": "2008-04-29 22:00",
+            "comments": "Paused entry owned by test user",
+            "site": 1
+        }
+    },
+    {
+        "pk": 3,
+        "model": "pendulum.Entry",
+        "fields": {
+            "user": 1,
+            "project": 1,
+            "activity": 1,
+            "start_time": "2008-04-28 22:00",
+            "end_time": "2009-04-29 21:15",
+            "seconds_paused": 0,
+            "comments": "Closed entry",
+            "site": 1
+        }
+    },
+    {
+        "pk": 4,
+        "model": "pendulum.Entry",
+        "fields": {
+            "user": 1,
+            "project": 2,
+            "activity": 2,
+            "start_time": "2008-04-28 22:00",
+            "seconds_paused": 300,
+            "comments": "Classic, 5-minute paused entry",
+            "site": 1
+        }
+    }
+]

pendulum/fixtures/projects.json

+[
+    {
+        "pk": 1,
+        "model": "pendulum.Project",
+        "fields": {
+            "name": "Sample Project 1",
+            "is_active": 1
+        }
+    },
+    {
+        "pk": 2,
+        "model": "pendulum.Project",
+        "fields": {
+            "name": "Sample Project 2",
+            "is_active": 1
+        }
+    },
+    {
+        "pk": 3,
+        "model": "pendulum.Project",
+        "fields": {
+            "name": "Sample Inactive Project",
+            "is_active": 0
+        }
+    }
+]

pendulum/fixtures/users.json

+[
+    {
+        "pk": 100,
+        "model": "auth.group",
+        "fields": {
+            "name": "Pendulum",
+            "permissions": [
+                37,
+                40,
+                42,
+                41,
+                38,
+                39
+            ]
+        }
+    },
+    {
+        "pk": 1,
+        "model": "auth.User",
+        "fields": {
+            "username": "testuser",
+            "first_name": "Test",
+            "last_name": "User",
+            "email": "test.user@test.com",
+            "password": "sha1$12345$c553b125c1f87134911fe18e02f29c7ea7027303",
+            "is_staff": 0,
+            "is_active": 1,
+            "is_superuser": 0,
+            "groups": [
+                100
+            ],
+            "user_permissions": []
+        }
+    },
+    {
+        "pk": 2,
+        "model": "auth.User",
+        "fields": {
+            "username": "inactiveuser",
+            "first_name": "Inactive",
+            "last_name": "User",
+            "email": "inactive.user@test.com",
+            "password": "sha1$12345$c553b125c1f87134911fe18e02f29c7ea7027303",
+            "is_staff": 0,
+            "is_active": 0,
+            "is_superuser": 0,
+            "groups": [
+                100
+            ],
+            "user_permissions": []
+        }
+    },
+    {
+        "pk": 3,
+        "model": "auth.User",
+        "fields": {
+            "username": "inactivestaff",
+            "first_name": "Inactive",
+            "last_name": "Staff",
+            "email": "inactive.staff@test.com",
+            "password": "sha1$12345$c553b125c1f87134911fe18e02f29c7ea7027303",
+            "is_staff": 1,
+            "is_active": 0,
+            "is_superuser": 0,
+            "groups": [
+                100
+            ],
+            "user_permissions": []
+        }
+    },
+    {
+        "pk": 4,
+        "model": "auth.User",
+        "fields": {
+            "username": "activestaff",
+            "first_name": "Active",
+            "last_name": "Staff",
+            "email": "active.staff@test.com",
+            "password": "sha1$12345$c553b125c1f87134911fe18e02f29c7ea7027303",
+            "is_staff": 1,
+            "is_active": 1,
+            "is_superuser": 0,
+            "groups": [
+                100
+            ],
+            "user_permissions": []
+        }
+    },
+    {
+        "pk": 5,
+        "model": "auth.User",
+        "fields": {
+            "username": "inactivesuper",
+            "first_name": "Inactive",
+            "last_name": "Super",
+            "email": "inactive.super@test.com",
+            "password": "sha1$12345$c553b125c1f87134911fe18e02f29c7ea7027303",
+            "is_staff": 0,
+            "is_active": 0,
+            "is_superuser": 1,
+            "groups": [
+                100
+            ],
+            "user_permissions": []
+        }
+    },
+    {
+        "pk": 6,
+        "model": "auth.User",
+        "fields": {
+            "username": "activesuper",
+            "first_name": "Active",
+            "last_name": "Super",
+            "email": "active.super@test.com",
+            "password": "sha1$12345$c553b125c1f87134911fe18e02f29c7ea7027303",
+            "is_staff": 0,
+            "is_active": 1,
+            "is_superuser": 1,
+            "groups": [
+                100
+            ],
+            "user_permissions": []
+        }
+    }
+]

pendulum/forms.py

+from django import forms
+from pendulum.models import Project, Activity, Entry
+from datetime import datetime
+
+class ClockInForm(forms.Form):
+    """
+    Allow users to clock in
+    """
+
+    project = forms.ModelChoiceField(queryset=Project.objects.active())
+
+class ClockOutForm(forms.Form):
+    """
+    Allow users to clock out
+    """
+
+    activity = forms.ModelChoiceField(queryset=Activity.objects.all(),
+                                      required=False)
+    comments = forms.CharField(widget=forms.Textarea,
+                               required=False)
+
+class AddUpdateEntryForm(forms.ModelForm):
+    """
+    This form will provide a way for users to add missed log entries and to
+    update existing log entries.
+    """
+
+    class Meta:
+        model = Entry
+        exclude = ('user', 'seconds_paused', 'pause_time', 'site')
+
+    def clean_start_time(self):
+        """
+        Make sure that the start time is always before the end time
+        """
+        start = self.cleaned_data['start_time']
+
+        try:
+            end = self.cleaned_data['end_time']
+
+            if start >= end:
+                raise forms.ValidationError('The entry must start before it ends!')
+        except KeyError:
+            pass
+
+        if start > datetime.now():
+            raise forms.ValidationError('You cannot add entries in the future!')
+
+        return start
+
+    def clean_end_time(self):
+        """
+        Make sure no one tries to add entries that end in the future
+        """
+        try:
+            start = self.cleaned_data['start_time']
+        except KeyError:
+            raise forms.ValidationError('Please enter an start time.')
+
+        try:
+            end = self.cleaned_data['end_time']
+            if not end: raise Exception
+        except:
+            raise forms.ValidationError('Please enter an end time.')
+
+        if end > datetime.now():
+            raise forms.ValidationError('You cannot clock out in the future!')
+
+        if start >= end:
+            raise forms.ValidationError('The entry must start before it ends!')
+
+        return end

pendulum/middleware.py

+from django.contrib.auth.views import login
+from django.http import HttpResponseRedirect
+from django.core.urlresolvers import reverse
+
+class PendulumMiddleware:
+    """
+    This middleware ensures that anyone trying to access Pendulum must be logged in
+    """
+    def process_request(self, request):
+        entry_url = reverse('pendulum-entries')
+
+        if request.path[:len(entry_url)] == entry_url and request.user.is_anonymous():
+            if request.POST:
+                return login(request)
+            else:
+                return HttpResponseRedirect('/accounts/login/?next=%s' % request.path)

pendulum/models.py

+from django.db import models
+from django.contrib.sites.models import Site
+from django.contrib.auth.models import User
+from datetime import datetime, date, timedelta
+from pendulum.utils import determine_period
+
+class PendulumConfiguration(models.Model):
+    """
+    This will hold a single record that maintains the configuration of the
+    application.  In the admin interface, if the configuration is marked as
+    "Is Monthly", that will take precedence over the "Install date" option (even
+    if the install_date and period_length fields have values).  If you wish to
+    use the fixed-length (install_date + period_length) setup, uncheck the
+    is_monthly field.
+    """
+
+    # tie the configuration to one site in particular
+    site = models.OneToOneField(Site, help_text="""Please choose the site that these settings will apply to.""")
+
+    """
+    this represents whether the application will look for all entries in a
+    month-long period
+    """
+    is_monthly = models.BooleanField(default=True, help_text="""If you check this box, you will be forced to use the monthly mode.  Uncheck it to use fixed-length period""")
+
+    """
+    this is used in conjunction with the monthly setup; end date is assumed
+    to be month_start - 1.  For example, if the periods begin on the 16th of
+    each month, the end date would be assumed to be the 15th of each month
+    """
+    month_start = models.PositiveIntegerField(default=1, blank=True, null=True,
+                                              help_text="""Enter 1 for periods that begin on the 1st day of each month and end on the last day of each month.  Alternatively, enter any other number (between 2 and 31) for the day of the month that periods start.  For example, enter 16 for periods that begin on the 16th of each month and end on the 15th of the following month.""")
+
+    """
+    install_date represents the date the software was installed and began
+    being used.  period_length represents the number of days in a period.  Week-
+    long periods would have a period_length of 7.  Two week-long periods would
+    be 14 days.  You get the idea.  These should be able to handle _most_
+    situations (maybe not all).
+    """
+    install_date = models.DateField(blank=True, null=True, help_text="""The date that Pendulum was installed.  Does not necessarily have to be the date, just a date to be used as a reference point for adding the number of days from period length below.  For example, if you have periods with a fixed length of 2 weeks, enter 14 days for period length and choose any Sunday to be the install date.""")
+    period_length = models.PositiveIntegerField(blank=True, null=True, help_text="""The number of days in the fixed-length period.  For example, enter 7 days for 1-week periods or 28 for 4-week long periods.""")
+
+    def __unicode__(self):
+        return u'Pendulum Configuration for %s' % self.site
+
+    def __current_mode(self):
+        if self.is_monthly:
+            return u'Month-long'
+        else:
+            return u'Fixed-length'
+    current_mode = property(__current_mode)
+
+class ProjectManager(models.Manager):
+    """
+    Return all active projects.
+    """
+    def get_query_set(self):
+        return super(ProjectManager, self).get_query_set().filter(sites__exact=Site.objects.get_current())
+
+    def active(self):
+        return self.get_query_set().filter(is_active=True)
+
+class Project(models.Model):
+    """
+    This class will keep track of different projects that one may clock into
+    """
+
+    name = models.CharField(max_length=100, unique=True,
+                            help_text="""Please enter a name for this project.""")
+    description = models.TextField(blank=True, null=True,
+                                   help_text="""If necessary, enter something to describe the project.""")
+    is_active = models.BooleanField(default=True,
+                                    help_text="""Should this project be available for users to clock into?""")
+    sites = models.ManyToManyField(Site, related_name='pendulum_projects',
+                                  help_text="""Choose the site(s) that will display this project.""")
+    date_added = models.DateTimeField(auto_now_add=True)
+    date_updated = models.DateTimeField(auto_now=True)
+
+    objects = ProjectManager()
+
+    def __unicode__(self):
+        """
+        The string representation of an instance of this class
+        """
+        return self.name
+
+    def log_count(self):
+        """
+        Determine the number of entries associated with this project
+        """
+        return self.entries.all().count()
+
+    def __total_hours(self):
+        """
+        Determine the number of hours spent working on each project
+        """
+        times = [e.total_hours for e in self.entries.all()]
+        return '%.02f' % sum(times)
+    total_hours = property(__total_hours)
+
+    class Meta:
+        ordering = ['name', 'date_added']
+
+class ActivityManager(models.Manager):
+    """
+    Return all active activities.
+    """
+    def get_query_set(self):
+        return super(ActivityManager, self).get_query_set().filter(sites__exact=Site.objects.get_current())
+
+class Activity(models.Model):
+    """
+    Represents different types of activity: debugging, developing,
+    brainstorming, QA, etc...
+    """
+
+    code = models.CharField(max_length=5, unique=True,
+                            help_text="""Enter a short code to describe the type of activity that took place.""")
+    name = models.CharField(max_length=50,
+                            help_text="""Now enter a more meaningful name for the activity.""")
+    sites = models.ManyToManyField(Site, related_name='pendulum_activities',
+                                  help_text="""Choose the site(s) that will display this activity.""")
+
+    objects = ActivityManager()
+
+    def __unicode__(self):
+        """
+        The string representation of an instance of this class
+        """
+        return self.name
+
+    def __log_count(self):
+        """
+        Determine the number of entries associated with this activity
+        """
+        return self.entries.all().count()
+    log_count = property(__log_count)
+
+    def __total_hours(self):
+        """
+        Determine the number of hours spent doing each type of activity
+        """
+        times = [e.total_hours for e in self.entries.all()]
+        return '%.02f' % sum(times)
+    total_hours = property(__total_hours)
+
+    class Meta:
+        ordering = ['name']
+        verbose_name_plural = 'activities'
+
+class EntryManager(models.Manager):
+    #def get_query_set(self):
+    #    return super(EntryManager, self).get_query_set().filter(site__exact=Site.objects.get_current())
+
+    def current(self, user=None):
+        """
+        This will pull back any log entries for the current period.
+        """
+        try:
+            set = self.get_query_set().filter(start_time__range=determine_period())
+        except PendulumConfiguration.DoesNotExist:
+            raise Exception, "Please configure Pendulum!"
+        else:
+            if user:
+                return set.filter(user=user)
+            return set
+
+    def previous(self, delta, user=None):
+        set = self.get_query_set().filter(start_time__range=determine_period(delta=delta))
+
+        if user:
+            return set.filter(user=user)
+        return set
+
+class Entry(models.Model):
+    """
+    This class is where all of the time logs are taken care of
+    """
+
+    user = models.ForeignKey(User, related_name='pendulum_entries')
+    project = models.ForeignKey(Project,
+                                limit_choices_to={'is_active': True,
+                                                  'sites': Site.objects.get_current()},
+                                related_name='entries')
+    activity = models.ForeignKey(Activity, blank=True, null=True, related_name='entries')
+    start_time = models.DateTimeField()
+    end_time = models.DateTimeField(blank=True, null=True)
+    seconds_paused = models.PositiveIntegerField(default=0)
+    pause_time = models.DateTimeField(blank=True, null=True)
+    comments = models.TextField(blank=True, null=True)
+    date_updated = models.DateTimeField(auto_now=True)
+    site = models.ForeignKey(Site, related_name='pendulum_entries')
+
+    objects = EntryManager()
+
+    def __total_hours(self):
+        """
+        Determine the total number of hours worked in this entry
+        """
+        if self.start_time and self.end_time:
+            # only calculate when the start and end are defined
+            delta = self.end_time - self.start_time
+            seconds = delta.seconds - self.seconds_paused
+        else:
+            seconds = 0
+            delta = 0
+
+        return seconds / 3600.0 + delta.days * 24
+    total_hours = property(__total_hours)
+
+    def __hours(self):
+        """
+        Print the hours in a nice, rounded format
+        """
+        return "%.02f" % self.total_hours
+    hours = property(__hours)
+
+    def __is_paused(self):
+        """
+        Determine whether or not this entry is paused
+        """
+        return self.pause_time != None
+    is_paused = property(__is_paused)
+
+    def pause(self):
+        """
+        If this entry is not paused, pause it.
+        """
+        if not self.is_paused:
+            self.pause_time = datetime.now()
+
+    def unpause(self):
+        """
+        If this entry is paused, unpause it
+        """
+        if self.is_paused:
+            delta = datetime.now() - self.pause_time
+            self.seconds_paused += delta.seconds
+            self.pause_time = None
+
+    def toggle_paused(self):
+        """
+        Toggle the paused state of this entry.  If the entry is already paused,
+        it will be unpaused; if it is not paused, it will be paused.
+        """
+        if self.is_paused:
+            self.unpause()
+        else:
+            self.pause()
+
+    def __is_closed(self):
+        """
+        Determine whether this entry has been closed or not
+        """
+        return self.end_time != None
+    is_closed = property(__is_closed)
+
+    def clock_in(self, user, project):
+        """
+        Set this entry up for saving the first time, as an open entry.
+        """
+        if not self.is_closed:
+            self.user = user
+            self.project = project
+            self.site = Site.objects.get_current()
+            self.start_time = datetime.now()
+
+    def clock_out(self, activity, comments):
+        """
+        Save some vital pieces of information about this entry upon closing
+        """
+        if self.is_paused:
+            self.unpause()
+
+        if not self.is_closed:
+            self.end_time = datetime.now()
+            self.activity = activity
+            self.comments = comments
+
+    def __delete_key(self):
+        """
+        Make it a little more interesting for deleting logs
+        """
+        salt = '%i-%i-apple-%s-sauce' % (self.id, self.is_paused, self.is_closed)
+        try:
+            import hashlib
+        except ImportError:
+            import sha
+            key = sha.new(salt).hexdigest()
+        else:
+            key = hashlib.sha1(salt).hexdigest()
+        return key
+    delete_key = property(__delete_key)
+
+    def __unicode__(self):
+        """
+        The string representation of an instance of this class
+        """
+        return '%s on %s' % (self.user, self.project)
+
+    class Meta:
+        ordering = ['-start_time']
+        verbose_name_plural = 'entries'
+        permissions = (
+            ('can_clock_in', 'Can use Pendulum to clock in'),
+            ('can_pause', 'Can pause and unpause log entries'),
+            ('can_clock_out', 'Can use Pendulum to clock out'),
+        )
+
+# Add a utility method to the User class that will tell whether or not a
+# particular user has any unclosed entries
+User.clocked_in = property(lambda user: user.pendulum_entries.filter(end_time__isnull=True).count() > 0)

pendulum/pendulum.kpf

+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Komodo Project File - DO NOT EDIT -->
+<project id="558e3249-f36c-4e82-89ad-2a513617ff49" kpf_version="4" name="pendulum.kpf">
+<preference-set idref="558e3249-f36c-4e82-89ad-2a513617ff49">
+  <boolean id="import_live">1</boolean>
+</preference-set>
+</project>

pendulum/templates/pendulum/add_update_entry.html

+{% extends 'pendulum/pendulum_base.html' %}
+
+{% block title %}{{ add_update }} Entry{% endblock %}
+
+{% block content %}
+<h2>{{ add_update }} Entry</h2>
+
+{{ block.super }}
+
+<form action="{{ callback }}" method="post">
+<table>{{ form }}</table>
+<input type="submit" value="{{ add_update }} Entry" />
+</form>
+{% endblock %}

pendulum/templates/pendulum/clock_in.html

+{% extends 'pendulum/pendulum_base.html' %}
+
+{% block title %}Clock In{% endblock %}
+
+{% block content %}
+<h2>Clock Into Project</h2>
+
+{{ block.super }}
+
+<form action="{% url pendulum-clock-in %}" method="post">
+<table>
+{{ form }}
+{% if user.clocked_in %}<tr>
+
+    <td colspan="2">
+        You are currently clocked into another project.  Check the box below to pause the other entry.
+    </td>
+</tr>
+<tr>
+    <td colspan="2">
+        <input type="checkbox" id="pause_logs" name="pause_open" value="1" />
+        <label for="pause_logs">Pause open log</label>
+    </td>
+</tr>
+{% endif %}
+</table>
+<input type="submit" value="Clock In" />
+</form>
+{% endblock %}

pendulum/templates/pendulum/clock_out.html

+{% extends 'pendulum/pendulum_base.html' %}
+
+{% block content %}
+{{ block.super }}
+
+<h2>Clock Out of Project</h2>
+
+<form action="{% url pendulum-clock-out entry.id %}" method="post">
+<table>
+{{ form }}
+</table>
+<input type="submit" value="Clock Out" />
+</form>
+{% endblock %}

pendulum/templates/pendulum/delete_entry.html

+{% extends 'pendulum/pendulum_base.html' %}
+
+{% block title %}Delete Entry{% endblock %}
+
+{% block content %}
+<h2>Delete Entry</h2>
+
+{{ block.super }}
+
+<form action="{% url pendulum-delete entry.id %}" method="post">
+<h3>Are you sure you want to delete this entry?</h3>
+<input type="submit" value="Yes" />
+<input type="button" value="No" onclick="javascript:history.go(-1)" />
+
+<input type="hidden" name="key" value="{{ entry.delete_key }}" />
+</form>
+{% endblock %}

pendulum/templates/pendulum/entry_list.html

+{% extends 'pendulum/pendulum_base.html' %}
+
+{% block title %}Pendulum Entries{% endblock %}
+
+{% block content %}
+<h2>Pendulum Entries</h2>
+
+{{ block.super }}
+
+<table id="entry-table">
+    <tr>
+        <th colspan="7">
+            <a href="{% url pendulum-previous-entries previous_period %}" title="Go to the previous period">&laquo;</a>
+            {{ period.0|date:"N j, Y" }} -
+            {{ period.1|date:"N j, Y" }}
+            {% if has_next %}
+            <a href="{% url pendulum-previous-entries next_period %}" title="Go to the next period">&raquo;</a>
+            {% else %}
+            &raquo;
+            {% endif %}
+        </th>
+    </tr>
+    <tr>
+        <th>Date</th>
+        <th>Start Time</th>
+        <th>End Time</th>
+        <th>Hours</th>
+        <th>Project</th>
+        <th colspan="2">Activity</th>
+    </tr>
+    {% for entry in entries %}<tr class="{% cycle odd,even as rowclass %}">
+        <td class="entry-date">
+            {% if entry.is_closed %}<a href="{% url pendulum-update entry.id %}" title="Update this entry">{% endif %}{{ entry.start_time|date:"d M Y" }}{% if entry.is_closed %}</a>{% endif %}
+        </td>
+        <td class="entry-start">{{ entry.start_time|date:"P" }}</td>
+        <td class="entry-end">{% if entry.end_time %}
+            {{ entry.end_time|date:"P" }}{% else %}
+            <a href="{% url pendulum-clock-out entry.id %}">Clock Out</a>
+        {% endif %}</td>
+        <td class="entry-hours">{% if entry.end_time %}
+            {{ entry.hours }}{% else %}
+            {% if entry.is_paused %}
+            <a href="{% url pendulum-toggle-paused entry.id %}">Unpause</a>
+            {% else %}
+            <a href="{% url pendulum-toggle-paused entry.id %}">Pause</a>
+            {% endif %}
+        {% endif %}</td>
+        <td class="entry-project">{{ entry.project }}</td>
+        <td class="entry-activity">
+            {% if entry.activity.code %}
+            {{ entry.activity.code }}
+            {% else %}
+            ...
+            {% endif %}
+        </td>
+        <td class="entry-admin">
+            {% if entry.is_closed %}<a href="{% url pendulum-update entry.id %}">&delta;</a>{% endif %}
+            <a href="{% url pendulum-delete entry.id %}">&times;</a>
+        </td>
+    </tr>
+    {% if entry.comments %}<tr class="{{ rowclass }}">
+        <td colspan="6" class="entry-comments">{{ entry.comments }}</td>
+    </tr>{% endif %}{% endfor %}
+</table>
+
+{% endblock %}

pendulum/templates/pendulum/pendulum_base.html

+{% extends 'base.html' %}
+
+{% block content %}
+{% block pendulum-controls %}
+<ul class="pendulum-controls">
+    <li>Logged in as <strong>{{ user }}</strong></li>
+    <li><a href="{% url pendulum-clock-in %}">Clock In</a></li>
+    <li><a href="{% url pendulum-add %}">Add Entry</a></li>
+    <li><a href="{% url pendulum-entries %}">Current Entries</a></li>
+    <li><a href="/accounts/logout/">Log Out</a></li>
+</ul>
+{% endblock %}
+
+{% if messages %}<div>
+    <ul id="django-messages">
+        {% for msg in messages %}<li>{{ msg }}</li>{% endfor %}
+    </ul>
+</div>{% endif %}
+{% endblock %}

pendulum/tests.py

+"""
+This is a set of unit tests to make sure the timeclock application works
+after making "improvements" or modifications to the code.
+"""
+
+from django.test import TestCase
+from django.test.client import Client
+from django.core.urlresolvers import reverse
+from django.core.exceptions import ValidationError
+from pendulum.utils import determine_period
+from pendulum.models import Entry, Project, Activity
+from datetime import datetime, timedelta
+
+VALID_USER, VALID_PASSWORD = 'testuser', 'password'
+
+def ffd(date):
+    """
+    Form-friendly date formatter
+    """
+    return date.strftime('%Y-%m-%d %H:%M')
+
+class DetermineDatesTestCase(TestCase):
+    """
+    Make sure the period date boundary function is working properly.  Currently
+    the range calculator will go from the first day of the month to the last
+    day of the same month.  Eventually, this should test for configurable
+    period lengths.
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+
+    def setUp(self):
+        self.client = Client()
+
+    def testDeterminePeriod(self):
+        # try some dates
+        dates_to_try = (
+            datetime(2005, 12, 13), datetime(2006, 4, 12),
+            datetime(2006, 7, 19),  datetime(2007, 1, 9),
+            datetime(2007, 5, 21),  datetime(2007, 6, 10),
+            datetime(2007, 6, 26),  datetime(2007, 7, 2),
+            datetime(2007, 7, 31),  datetime(2007, 9, 6),
+            datetime(2007, 12, 2),  datetime(2008, 1, 30),
+            datetime(2008, 2, 27),  datetime(2008, 6, 6),
+        )
+
+        expected_results = [
+            (datetime(2005, 12, 1), datetime(2005, 12, 31)),    # 13 dec 05
+            (datetime(2006, 4, 1), datetime(2006, 4, 30)),      # 12 apr 06
+            (datetime(2006, 7, 1), datetime(2006, 7, 31)),      # 19 jul 06
+            (datetime(2007, 1, 1), datetime(2007, 1, 31)),      # 9 jan 07
+            (datetime(2007, 5, 1), datetime(2007, 5, 31)),      # 21 may 07
+            (datetime(2007, 6, 1), datetime(2007, 6, 30)),      # 10 jun 07
+            (datetime(2007, 6, 1), datetime(2007, 6, 30)),      # 26 jun 07
+            (datetime(2007, 7, 1), datetime(2007, 7, 31)),      # 2 jul 07
+            (datetime(2007, 7, 1), datetime(2007, 7, 31)),      # 31 jul 07
+            (datetime(2007, 9, 1), datetime(2007, 9, 30)),      # 6 sept 07
+            (datetime(2007, 12, 1), datetime(2007, 12, 31)),    # 2 dec 07
+            (datetime(2008, 1, 1), datetime(2008, 1, 31)),      # 30 jan 08
+            (datetime(2008, 2, 1), datetime(2008, 2, 29)),      # 27 feb 08
+            (datetime(2008, 6, 1), datetime(2008, 6, 30)),      # 6 jun 08
+        ]
+
+        count = 0
+        for date in dates_to_try:
+            start, end = determine_period(date)
+
+            exp_s, exp_e = expected_results[count]
+
+            # make sure the resulting start date matches the expected value
+            self.assertEquals(start.year, exp_s.year)
+            self.assertEquals(start.month, exp_s.month)
+            self.assertEquals(start.day, exp_s.day)
+
+            # make sure the resulting end date matches the expected value
+            self.assertEquals(end.year, exp_e.year)
+            self.assertEquals(end.month, exp_e.month)
+            self.assertEquals(end.day, exp_e.day)
+
+            # increment the counter so we can get the correct expected results
+            count += 1
+
+class ClockInTestCase(TestCase):
+    """
+    Check to make sure the code for clocking in works properly.
+    Rules for clocking in:
+    - User must be logged in
+    - An active project must be provided
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+
+    def setUp(self):
+        self.client = Client()
+
+    def get_response(self):
+        """
+        Retrieve the response of a GET request
+        """
+        return self.client.get(reverse('pendulum-clock-in'))
+
+    def post_response(self, args):
+        """
+        Retrieve the response of a POST request with specified parameters
+        """
+        return self.client.post(reverse('pendulum-clock-in'), args)
+
+    def testClockIn(self):
+        clock_in_url = reverse('pendulum-clock-in')
+
+        # try simply getting to the clock in page, where you choose a project
+        # should redirect to the login page
+        response = self.get_response()
+        self.assertEquals(response.status_code, 302)
+
+        # unauthorized access, try to login with invalid user
+        response = self.client.login(username='invaliduser', password='invalid')
+        self.assertFalse(response)
+
+        # now try to login with an inactive account
+        response = self.client.login(username='inactiveuser', password=VALID_PASSWORD)
+        self.assertFalse(response)
+
+        # try to login with valid username and invalid password
+        response = self.client.login(username=VALID_USER, password='invalid')
+        self.assertFalse(response)
+
+        # now try to login with a valid username and password
+        response = self.client.login(username=VALID_USER, password=VALID_PASSWORD)
+        self.assertTrue(response)
+
+        # after a successful login, try to get the page where you choose the
+        # project
+        response = self.get_response()
+        self.assertEquals(response.status_code, 200)
+
+        # now try clocking in without having selected a project
+        response = self.post_response({})
+        self.assertEquals(response.status_code, 200)
+
+        # try to clock in to a project that is inactive
+        response = self.post_response({'project': 3})
+        self.assertEquals(response.status_code, 200)
+
+        # and finally clocking in with a project selected
+        response = self.post_response({'project': 1})
+        self.assertEquals(response.status_code, 302)
+
+        # make sure that there is at least one log entry
+        entries = Entry.objects.current()
+        self.assertTrue(len(entries) >= 1)
+
+class PauseTestCase(TestCase):
+    """
+    Check to make sure that entries can be paused and unpaused as expected.
+    Rules for pausing an entry:
+    - Must be owned by user
+    - If paused, unpause it
+    - Entry must be open
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+    first_run = True
+
+    def setUp(self):
+        self.client = Client()
+
+        if self.first_run:
+            # try pausing an entry before being logged in
+            response = self.get_response(2)
+            self.assertEquals(response.status_code, 302)
+
+        # log in
+        response = self.client.login(username=VALID_USER, password=VALID_PASSWORD)
+        self.assertTrue(response)
+
+        if self.first_run:
+            # try pausing an entry that doesn't exist
+            response = self.get_response(1000)
+            self.assertEquals(response.status_code, 302)
+
+            self.first_run = False
+
+    def get_response(self, id):
+        """
+        Retrieve the response of a GET request
+        """
+        return self.client.get(reverse('pendulum-toggle-paused', args=[id]))
+
+    def testPauseOtherUsersEntry(self):
+        #--------------------------------------------------
+        # 1. ENTRY THAT BELONGS TO OTHER USER
+        id = 1
+
+        # check to make sure that log entry isn't paused
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_paused)
+
+        # try pausing an entry that doesn't belong to the current user
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # check to make sure that log entry still isn't paused
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_paused)
+
+    def testAlreadyPausedEntry(self):
+        #--------------------------------------------------
+        # 2. ENTRY THAT IS ALREADY PAUSED
+        id = 2
+
+        # check to make sure that log entry is paused
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_paused)
+
+        # try pausing an already paused entry
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # check to make sure that log entry is no longer paused
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_paused)
+
+    def testAlreadyClosedEntry(self):
+        #--------------------------------------------------
+        # 3. ENTRY THAT IS ALREADY CLOSED
+        id = 3
+
+        # check to make sure that log entry is closed and not paused
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+        # try pausing an already closed entry
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # check to make sure that log entry is still closed and not paused
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+    def testOpenUnpausedEntry(self):
+        #--------------------------------------------------
+        # 4. ENTRY THAT IS OPEN AND NOT PAUSED
+        id = 4
+
+        # check to make sure that log entry is open and not paused
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+        # try pausing an open entry owned by the user
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # make sure the entry is still open but now paused
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_closed)
+        self.assertTrue(entry.is_paused)
+
+class ClockOutTestCase(TestCase):
+    """
+    Make sure that entries can be closed properly.
+    Rules for clocking out:
+    - Entry must belong to user
+    - Entry must be open
+    - Entry may be paused, but must be unpaused after being closed
+    - Must provide an activity type
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+    first_run = True
+
+    def setUp(self):
+        self.client = Client()
+
+        if self.first_run:
+            # try closing an entry before being logged in
+            response = self.get_response(2)
+            self.assertEquals(response.status_code, 302)
+
+        # log in
+        response = self.client.login(username=VALID_USER, password=VALID_PASSWORD)
+        self.assertTrue(response)
+
+        if self.first_run:
+            # try closing an entry that doesn't exist
+            response = self.get_response(1000)
+            self.assertEquals(response.status_code, 302)
+
+            self.first_run = False
+
+    def get_response(self, id):
+        """
+        Retrieve the response of a GET request
+        """
+        return self.client.get(reverse('pendulum-clock-out', args=[id]))
+
+    def post_response(self, id, args):
+        """
+        Retrieve the response of a POST request with specified parameters
+        """
+        return self.client.post(reverse('pendulum-clock-out', args=[id]), args)
+
+    def testCloseOtherUsersEntry(self):
+        #--------------------------------------------------
+        # 1. ENTRY THAT BELONGS TO OTHER USER
+        id = 1
+
+        # check to make sure that log entry isn't closed
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_closed)
+
+        # try closing an entry that doesn't belong to the user
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # try to close without posting any information
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 302)
+
+        # try to close posting no activity
+        response = self.post_response(id, {'comments': "closing the entry"})
+        self.assertEquals(response.status_code, 302)
+
+        # try to close posting minimal information
+        response = self.post_response(id, {'activity': 1})
+        self.assertEquals(response.status_code, 302)
+
+        # make sure the entry is still open
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_closed)
+
+    def testClosePausedEntry(self):
+        #--------------------------------------------------
+        # 2. ENTRY THAT IS PAUSED
+        id = 2
+
+        # check to make sure that log entry isn't closed
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_paused)
+        self.assertFalse(entry.is_closed)
+
+        # try closing an entry that is paused
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 200)
+
+        # try to close without posting any information
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 200)
+
+        # try to close posting no activity
+        response = self.post_response(id, {'comments': "closing the entry"})
+        self.assertEquals(response.status_code, 200)
+
+        # try to close posting minimal information
+        response = self.post_response(id, {'activity': 1})
+        self.assertEquals(response.status_code, 302)
+
+        # make sure the entry is still open
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+    def testCloseAlreadyClosedEntry(self):
+        #--------------------------------------------------
+        # 3. ENTRY THAT IS ALREADY CLOSED
+        id = 3
+
+        # check to make sure that log entry is closed
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+
+        # try closing an entry that is closed
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # try to close without posting any information
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 302)
+
+        # try to close posting no activity
+        response = self.post_response(id, {'comments': "closing the entry"})
+        self.assertEquals(response.status_code, 302)
+
+        # try to close posting minimal information
+        response = self.post_response(id, {'activity': 1})
+        self.assertEquals(response.status_code, 302)
+
+        # make sure the entry is still closed
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+    def testCloseOpenUnpausedEntry(self):
+        #--------------------------------------------------
+        # 4. ENTRY THAT IS OPEN AND NOT PAUSED
+        id = 4
+
+        # check to make sure that log entry isn't closed
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_paused)
+        self.assertFalse(entry.is_closed)
+
+        # try closing an entry that is not paused
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 200)
+
+        # try to close without posting any information
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 200)
+
+        # try to close posting no activity
+        response = self.post_response(id, {'comments': "closing the entry"})
+        self.assertEquals(response.status_code, 200)
+
+        # try to close posting minimal information
+        response = self.post_response(id, {'activity': 1})
+        self.assertEquals(response.status_code, 302)
+
+        # make sure the entry is still open
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+class UpdateEntryTestCase(TestCase):
+    """
+    Make sure that the code for updating a closed entry works as expected.
+    Rules for updating an entry:
+    - Owned by user
+    - Closed
+    - Cannot start in the future
+    - Cannot end in the future
+    - Start must be before end
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+    first_run = True
+
+    def setUp(self):
+        self.client = Client()
+
+        if self.first_run:
+            # try updating an entry before being logged in
+            response = self.get_response(2)
+            self.assertEquals(response.status_code, 302)
+
+        # log in
+        response = self.client.login(username=VALID_USER, password=VALID_PASSWORD)
+        self.assertTrue(response)
+
+        if self.first_run:
+            # try updating an entry that doesn't exist
+            response = self.get_response(1000)
+            self.assertEquals(response.status_code, 302)
+
+            self.first_run = False
+
+    def get_response(self, id):
+        """
+        Retrieve the response of a GET request
+        """
+        return self.client.get(reverse('pendulum-update', args=[id]))
+
+    def post_response(self, id, args):
+        """
+        Retrieve the response of a POST request with specified parameters
+        """
+        return self.client.post(reverse('pendulum-update', args=[id]), args)
+
+    def testUpdateOtherUsersEntry(self):
+        #--------------------------------------------------
+        # 1. ENTRY THAT BELONGS TO OTHER USER
+        id = 1
+        now = datetime.now()
+
+        # check to make sure that log entry isn't closed
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_closed)
+
+        # try to get the form to update it
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # try to manually post information
+        response = self.post_response(id, {'start_time': ffd(now + timedelta(hours=-5)),
+                                           'end_time': ffd(now)})
+        self.assertEquals(response.status_code, 302)
+
+        again = Entry.objects.get(pk=id)
+        self.assertEquals(entry.start_time, again.start_time)
+        self.assertEquals(entry.end_time, again.end_time)
+
+    def testUpdatePausedEntry(self):
+        #--------------------------------------------------
+        # 2. ENTRY THAT IS PAUSED
+        id = 2
+        now = datetime.now()
+
+        # get a paused entry, and make sure it's paused
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_paused)
+
+        # try to get the form to update its information
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # try to update it with no information specified
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 302)
+
+        # try to update it with a little information specified
+        response = self.post_response(id, {'project': 2,
+                                           'comments': 'Updating the entry'})
+        self.assertEquals(response.status_code, 302)
+
+        # try to update it with all required information
+        response = self.post_response(id, {'project': 1,
+                                           'activity': 1,
+                                           'start_time': ffd(now + timedelta(hours=-5)),
+                                           'end_time': ffd(now)})
+        self.assertEquals(response.status_code, 302)
+
+        # pull back the entry, and make sure it hasn't changed
+        again = Entry.objects.get(pk=id)
+        self.assertEquals(entry.project, again.project)
+        self.assertEquals(entry.activity, again.activity)
+        self.assertEquals(entry.start_time, again.start_time)
+        self.assertEquals(entry.end_time, again.end_time)
+        self.assertEquals(entry.comments, again.comments)
+
+    def testUpdateAlreadyClosedEntry(self):
+        #--------------------------------------------------
+        # 3. ENTRY THAT IS ALREADY CLOSED
+        id = 3
+        now = datetime.now()
+        values = {'project': 2,
+                  'activity': 2,
+                  'start_time': ffd(now + timedelta(hours=-2)),
+                  'end_time': ffd(now),
+                  'comments': 'New comments'}
+
+        # make sure the entry is already closed
+        entry = Entry.objects.get(pk=id)
+        self.assertTrue(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+        # try to update the new entry with not enough information
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 200)
+
+        # try various combinations of incomplete data
+        response = self.post_response(id, {'project': values['project']})
+        self.assertEquals(response.status_code, 200)
+
+        response = self.post_response(id, {'project': values['project'],
+                                           'activity': values['activity']})
+        self.assertEquals(response.status_code, 200)
+
+        response = self.post_response(id, {'project': values['project'],
+                                           'activity': values['activity'],
+                                           'start_time': values['start_time']})
+        self.assertEquals(response.status_code, 200)
+
+        response = self.post_response(id, {'activity': values['activity'],
+                                           'start_time': values['start_time'],
+                                           'end_time': values['end_time']})
+        self.assertEquals(response.status_code, 200)
+
+        #response = self.post_response(id, {'project': values['project'],
+        #                                   'start_time': values['start_time'],
+        #                                   'end_time': values['end_time']})
+        #self.assertEquals(response.status_code, 200)
+
+        response = self.post_response(id, {'project': values['project'],
+                                           'activity': values['activity'],
+                                           'end_time': values['end_time']})
+        self.assertEquals(response.status_code, 200)
+
+        # update the entry with new information
+        response = self.post_response(id, values)
+        self.assertEquals(response.status_code, 302)
+
+        # make sure the information is just as I want it to be
+        entry = Entry.objects.get(pk=id)
+        self.assertEquals(entry.project.id, values['project'])
+        self.assertEquals(entry.activity.id, values['activity'])
+        self.assertEquals(ffd(entry.start_time), values['start_time'])
+        self.assertEquals(ffd(entry.end_time), values['end_time'])
+        self.assertEquals(entry.comments, values['comments'])
+
+    def testUpdateOpenUnpausedEntry(self):
+        #--------------------------------------------------
+        # 4. ENTRY THAT IS OPEN AND NOT PAUSED
+        id = 4
+        now = datetime.now()
+
+        # get an open entry, and make sure it's not paused
+        entry = Entry.objects.get(pk=id)
+        self.assertFalse(entry.is_closed)
+        self.assertFalse(entry.is_paused)
+
+        # try to get the form to update its information
+        response = self.get_response(id)
+        self.assertEquals(response.status_code, 302)
+
+        # try to update it with no information specified
+        response = self.post_response(id, {})
+        self.assertEquals(response.status_code, 302)
+
+        # try to update it with a little information specified
+        response = self.post_response(id, {'project': 2,
+                                           'comments': 'Updating the entry'})
+        self.assertEquals(response.status_code, 302)
+
+        # try to update it with all required information
+        response = self.post_response(id, {'project': 1,
+                                           'activity': 1,
+                                           'start_time': ffd(now + timedelta(hours=-5)),
+                                           'end_time': ffd(now)})
+        self.assertEquals(response.status_code, 302)
+
+        # pull back the entry, and make sure it hasn't changed
+        again = Entry.objects.get(pk=id)
+        self.assertEquals(entry.project, again.project)
+        self.assertEquals(entry.activity, again.activity)
+        self.assertEquals(entry.start_time, again.start_time)
+        self.assertEquals(entry.end_time, again.end_time)
+        self.assertEquals(entry.comments, again.comments)
+
+        # now update it again, just to make sure
+        values = {'project': 1,
+                  'activity': 1,
+                  'start_time': ffd(now + timedelta(hours=-2)),
+                  'end_time': ffd(now + timedelta(hours=-1)),
+                  'comments': 'New comments'}
+        response = self.post_response(id, values)
+        self.assertEquals(response.status_code, 302)
+
+        # pull back the entry, and make sure it has changed
+        again = Entry.objects.get(pk=id)
+        self.assertNotEquals(again.project.id, values['project'])
+        self.assertNotEquals(again.activity.id, values['activity'])
+        self.assertNotEquals(again.start_time, values['start_time'])
+        self.assertNotEquals(again.end_time, values['end_time'])
+        self.assertNotEquals(again.comments, values['comments'])
+
+    def testSetStartInFuture(self):
+        """
+        Try to update a good, closed entry to start and end in the future
+        """
+        id = 3
+        now = datetime.now()
+        values = {'project': 2,
+                  'activity': 2,
+                  'start_time': ffd(now + timedelta(hours=2)),
+                  'end_time': ffd(now + timedelta(hours=5)),
+                  'comments': 'New comments'}
+
+        response = self.post_response(id, values)
+        self.assertEquals(response.status_code, 200)
+
+        # make sure the information is still as in the fixture
+        entry = Entry.objects.get(pk=id)
+        self.assertNotEquals(entry.project.id, values['project'])
+        self.assertNotEquals(entry.activity.id, values['activity'])
+        self.assertNotEquals(ffd(entry.start_time), values['start_time'])
+        self.assertNotEquals(ffd(entry.end_time), values['end_time'])
+        self.assertNotEquals(entry.comments, values['comments'])
+
+    def testSetEndInFuture(self):
+        """
+        Try to update a good, closed entry to end in the future
+        """
+        id = 3
+        now = datetime.now()
+        values = {'project': 2,
+                  'activity': 2,
+                  'start_time': ffd(now + timedelta(hours=-2)),
+                  'end_time': ffd(now + timedelta(hours=1)),
+                  'comments': 'New comments'}
+
+        response = self.post_response(id, values)
+        self.assertEquals(response.status_code, 200)
+
+        # make sure the information is still as in the fixture
+        entry = Entry.objects.get(pk=id)
+        self.assertNotEquals(entry.project.id, values['project'])
+        self.assertNotEquals(entry.activity.id, values['activity'])
+        self.assertNotEquals(ffd(entry.start_time), values['start_time'])
+        self.assertNotEquals(ffd(entry.end_time), values['end_time'])
+        self.assertNotEquals(entry.comments, values['comments'])
+
+    def testSetStartAfterEnd(self):
+        """
+        Try to update a good, closed entry to start after it ends
+        """
+        id = 3
+        now = datetime.now()
+        values = {'project': 2,
+                  'activity': 2,
+                  'start_time': ffd(now + timedelta(hours=2)),
+                  'end_time': ffd(now),
+                  'comments': 'New comments'}
+
+        response = self.post_response(id, values)
+        self.assertEquals(response.status_code, 200)
+
+        # make sure the information is still as in the fixture
+        entry = Entry.objects.get(pk=id)
+        self.assertNotEquals(entry.project.id, values['project'])
+        self.assertNotEquals(entry.activity.id, values['activity'])
+        self.assertNotEquals(ffd(entry.start_time), values['start_time'])
+        self.assertNotEquals(ffd(entry.end_time), values['end_time'])
+        self.assertNotEquals(entry.comments, values['comments'])
+
+class AddEntryTestCase(TestCase):
+    """
+    Rules for adding an entry:
+    - User is logged in
+    - Project is specified
+    - Start time is in the past
+    - End time is in the past
+    - Start time is before end time
+    - Activity is specified
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+    first_run = True
+
+    def setUp(self):
+        self.client = Client()
+
+        if self.first_run:
+            # try adding an entry before being logged in
+            response = self.get_response()
+            self.assertEquals(response.status_code, 302)
+
+            self.first_run = False
+
+        # log in
+        response = self.client.login(username=VALID_USER, password=VALID_PASSWORD)
+        self.assertTrue(response)
+
+    def get_response(self):
+        """
+        Retrieve the response of a GET request
+        """
+        return self.client.get(reverse('pendulum-add'))
+
+    def post_response(self, args):
+        """
+        Retrieve the response of a POST request with specified parameters
+        """
+        return self.client.post(reverse('pendulum-add'), args)
+
+    def testAddEntryForm(self):
+        # try to get the add log form
+        response = self.get_response()
+        self.assertEquals(response.status_code, 200)
+
+    def testAddBlankEntry(self):
+        start_count = Entry.objects.all().count()
+
+        # try to create an entry with no information
+        response = self.post_response({})
+        self.assertEquals(response.status_code, 200)
+
+        # just make sure that no entries were actually added
+        end_count = Entry.objects.all().count()
+        self.assertEquals(start_count, end_count)
+
+    def testAddNotEnoughInfoEntry(self):
+        start_count = Entry.objects.all().count()
+
+        # try adding an entry without enough information
+        response = self.post_response({'comments': 'Adding a new entry'})
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with a bit more information
+        response = self.post_response({'project': 1,
+                                       'comments': 'Adding a new entry'
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with a bit more information
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'comments': 'Adding a new entry'
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with a bit more information
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': datetime(2008, 4, 30, 21, 00),
+                                       'comments': 'Adding a new entry'
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with a bit more information
+        response = self.post_response({'activity': 1,
+                                       'start_time': datetime(2008, 4, 30, 21, 00),
+                                       'end_time': datetime(2008, 4, 30, 22, 00),
+                                       'comments': 'Adding a new entry'
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        ## try adding an entry with a bit more information
+        #response = self.post_response({'project': 1,
+        #                               'start_time': datetime(2008, 4, 30, 21, 00),
+        #                               'end_time': datetime(2008, 4, 30, 22, 00),
+        #                               'comments': 'Adding a new entry'
+        #                              })
+        #self.assertEquals(response.status_code, 302)
+
+        # try adding an entry with a bit more information
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'end_time': datetime(2008, 4, 30, 22, 00),
+                                       'comments': 'Adding a new entry'
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # just make sure that no entries were actually added
+        end_count = Entry.objects.all().count()
+        self.assertEquals(start_count, end_count)
+
+    def testAddJustEnoughEntry(self):
+        now = datetime.now()
+        start_count = Entry.objects.all().count()
+
+        # try adding an entry with just enough information
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': ffd(now + timedelta(hours=-5)),
+                                       'end_time': ffd(now)
+                                      })
+        self.assertEquals(response.status_code, 302)
+
+        # just make sure that no entries were actually added
+        end_count = Entry.objects.all().count()
+        self.assertEquals(start_count + 1, end_count)
+
+    def testAddAllInfoEntry(self):
+        now = datetime.now()
+        start_count = Entry.objects.all().count()
+
+        # try adding an entry with just enough information
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': ffd(now + timedelta(hours=-5)),
+                                       'end_time': ffd(now),
+                                       'comments': 'A new entry!'
+                                      })
+        self.assertEquals(response.status_code, 302)
+
+        # just make sure that no entries were actually added
+        end_count = Entry.objects.all().count()
+        self.assertEquals(start_count + 1, end_count)
+
+    def testAddWithInvalidDates(self):
+        now = datetime.now()
+        start_count = Entry.objects.all().count()
+
+        # try adding an entry with a start time in the future
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': ffd(now + timedelta(days=5)),
+                                       'end_time': ffd(now + timedelta(days=5, hours=1))
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with an end time in the future
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': ffd(now),
+                                       'end_time': ffd(now + timedelta(hours=5))
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with a start time after the end time
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': ffd(now + timedelta(hours=5)),
+                                       'end_time': ffd(now)
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # try adding an entry with the same start and end time
+        response = self.post_response({'project': 1,
+                                       'activity': 1,
+                                       'start_time': ffd(now),
+                                       'end_time': ffd(now)
+                                      })
+        self.assertEquals(response.status_code, 200)
+
+        # just make sure that no entries were actually added
+        end_count = Entry.objects.all().count()
+        self.assertEquals(start_count, end_count)
+
+class RemoveEntryTestCase(TestCase):
+    """
+    Test the functionality for removing an entry
+    Rules for removal:
+    - Owned by user
+    - The user will be prompted to confirm their decision
+    """
+
+    fixtures = ['activities', 'projects', 'users', 'entries']
+
+    def setUp(self):
+        self.client = Client()
+
+        # try removing an entry before being logged in
+        response = self.get_response(4)
+        self.assertEquals(response.status_code, 302)
+
+        # log in
+        response = self.client.login(username=VALID_USER, password=VALID_PASSWORD)
+        self.assertTrue(response)
+
+        # try removing an entry that does not exist
+        response = self.get_response(1000)
+        self.assertEquals(response.status_code, 302)
+
+    def get_response(self, id):
+        """
+        Retrieve the response of a GET request
+        """
+        return self.client.get(reverse('pendulum-delete', args=[id]))
+
+    def post_response(self, id, args):
+        """
+        Retrieve the response of a POST request with specified parameters
+        """
+        return self.client.post(reverse('pendulum-delete', args=[id]), args)
+
+    def testRemoveOtherUsersEntry(self):
+        #--------------------------------------------------
+        # 1. ENTRY THAT BELONGS TO ANOTHER USER
+        self.performWithIdAndCodes(1, 302, 302)
+
+    def testRemoveClosedEntry(self):
+        #--------------------------------------------------
+        # 2. ENTRY THAT IS CLOSED
+        self.performWithIdAndCodes(2, 200, 302)
+
+    def testRemovePausedEntry(self):
+        #--------------------------------------------------
+        # 3. ENTRY THAT IS PAUSED
+        self.performWithIdAndCodes(3, 200, 302)
+
+    def testRemoveOpenEntry(self):
+        #--------------------------------------------------
+        # 4. ENTRY THAT IS OPEN
+        self.performWithIdAndCodes(4, 200, 302)
+
+    def performWithIdAndCodes(self, id, get, post):
+        entry = Entry.objects.get(pk=id)
+        self.assertEquals(self.get_response(id).status_code, get)
+        self.assertEquals(self.post_response(id, {'key': entry.delete_key}).status_code, post)
+from django.conf.urls.defaults import *
+from pendulum.models import Entry
+from pendulum import views
+
+urlpatterns = patterns('',
+    url(r'^$', views.view_entries, name='pendulum-entries'),
+    url(r'^period/(?P<delta>\d+)/$', views.view_entries, name='pendulum-previous-entries'),
+    url(r'^clockin/$', views.clock_in, name='pendulum-clock-in'),
+    url(r'^clockout/(?P<entry_id>\d+)/$', views.clock_out, name='pendulum-clock-out'),
+    url(r'^toggle/(?P<entry_id>\d+)/$', views.toggle_paused, name='pendulum-toggle-paused'),
+    url(r'^add/$', views.add_entry, name='pendulum-add'),
+    url(r'^update/(?P<entry_id>\d+)/$', views.update_entry, name='pendulum-update'),
+    url(r'^delete/(?P<entry_id>\d+)/$', views.delete_entry, name='pendulum-delete'),
+)

pendulum/utils.py

+from django.contrib.sites.models import Site
+from datetime import date, datetime, timedelta
+import calendar
+
+def determine_period(the_date=date.today(), delta=0):
+    """
+    Determine the start and end date for an accounting period.  If a date
+    is passed in, that date will be used to determine the accounting period.
+    If no date is passed in, the current date will be used.
+    """
+    delta = int(delta)
+
+    try:
+        # attempt to get the configuration for the current site
+        site = Site.objects.get_current()
+        config = site.pendulumconfiguration
+    except:
+        raise Exception('Please configure Pendulum for %s!' % site)
+
+    if config.is_monthly and (config.month_start >= 1 and config.month_start <= 31):
+        if config.month_start == 1:
+            # if the periods start on the first of the month, just use the first
+            # and last days of each month for the period
+            if delta > 0:
+                diff = the_date.month - delta
+                if diff < 1:
+                    # determine how many years to go back
+                    years = abs(diff / 12)
+
+                    # determine how many months to go back
+                    months = delta - (years * 12)
+                    if months == the_date.month:
+                        # don't give an invalid month
+                        months = -1
+                        years += 1
+
+                    # now set the year and month
+                    year, month = the_date.year - years, the_date.month - months
+                else:
+                    year, month = the_date.year, diff
+            else:
+                year, month = the_date.year, the_date.month
+
+            num_days = calendar.monthrange(year, month)[1]
+            sy, sm, sd = year, month, 1
+            ey, em, ed = year, month, num_days
+        else:
+            # if the periods don't start on the first of the month, try to
+            # figure out which days are required
+
+            sy, sm, sd = the_date.year, the_date.month, config.month_start
+
+            # now take the delta into account
+            if delta > 0:
+                diff = sm - delta
+                if diff < 1:
+                    # determine how many years to go back
+                    years = abs(diff / 12)
+
+                    # determine how many months to go back
+                    months = delta - (years * 12)
+                    if months == sm:
+                        # don't give an invalid month
+                        months = -1
+                        years += 1
+
+                    # now set the year and month
+                    sy, sm = sy - years, sm - months
+                else:
+                    sm = diff
+
+            if the_date.day >= config.month_start:
+                # if we are already into the period that began this month
+                if sm == 12:
+                    # see if the period spans into the next year
+                    ey, em = sy + 1, 1
+                else:
+                    # if not, just add a month and subtract a day
+                    ey, em = sy, em + 1
+            else:
+                # if we are in the period that ends this month
+                if sm == 1:
+                    # and we're in January, set the start to last december
+                    sy, sm = sy - 1, 12
+                    ey, em = sy + 1, 1
+                else:
+                    # otherwise, just keep it simple
+                    sm = sm - 1
+                    ey, em = sy, sm + 1
+
+            ed = sd - 1
+
+            # this should handle funky situations where a period begins on the
+            # 31st of a month or whatever...
+            num_days = calendar.monthrange(ey, em)[1]
+            if ed > num_days:
+                ed = num_days
+
+    elif config.install_date and config.period_length:
+        # if we have periods with a set number of days...
+        # find out how many days have passed since the installation date
+        diff = the_date - config.install_date
+
+        # find out how many days are left over after dividing the number of days
+        # since installation by the length of the period
+        days_into_period = diff.days % config.period_length
+
+        # determine the start date of the period
+        start = the_date - timedelta(days=days_into_period)
+
+        # now take into account the delta
+        if delta > 0:
+            start = start - timedelta(days=(delta * config.period_length))
+            end = start + timedelta(days=config.period_length - 1)
+        else:
+            # determine the end date of the period
+            end = the_date + timedelta(days=(config.period_length - days_into_period - 1))
+
+        sy, sm, sd = start.year, start.month, start.day
+        ey, em, ed = end.year, end.month, end.day
+    else:
+        raise Exception('Invalid Pendulum configuration for %s' % site)
+
+    start_date = datetime(sy, sm, sd, 0, 0, 0)
+    end_date = datetime(ey, em, ed, 23, 59, 59)
+
+    return (start_date, end_date)

pendulum/views.py

+from django.template import RequestContext
+from django.shortcuts import render_to_response
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse, HttpResponseRedirect, Http404
+from django.contrib.auth.decorators import login_required, permission_required
+from django.contrib.sites.models import Site
+from pendulum.forms import ClockInForm, ClockOutForm, AddUpdateEntryForm
+from pendulum.models import Entry
+from pendulum.utils import determine_period
+from datetime import datetime
+
+@login_required
+def view_entries(request, delta=0):
+    """
+    Pull back a list of all entries for the current period for the current user
+    """
+    delta = int(delta)
+
+    if delta:
+        # we only go back in time, not forward :)
+        if delta < 0: raise Http404
+
+        # we have a delta, so show previous entries according to the delta
+        entries = Entry.objects.previous(delta, request.user)
+        next = delta - 1
+        has_next = True
+    else:
+        # no delta, so just show the current entries
+        entries = Entry.objects.current(request.user)
+        next = None
+        has_next = False
+
+    return render_to_response('pendulum/entry_list.html',
+                              {'entries': entries,
+                               'period': determine_period(delta=delta),
+                               'is_current': delta != 0,
+                               'next_period': next,
+                               'has_next': has_next,
+                               'previous_period': delta + 1},
+                              context_instance=RequestContext(request))
+
+@permission_required('pendulum.can_clock_in')
+def clock_in(request):
+    """
+    Let a user clock in.  If this method is called via a GET request, a blank
+    form is displayed to the user which has a single field where they choose
+    the project they will be working on.  If the method is invoked via a POST
+    request, the posted form data are validated.  If the data are valid, a new
+    log entry is created and the user is redirected to the entry list.  If the
+    data are invalid, the user is presented with the same form until they abort
+    or enter valid data.
+    """
+
+    if request.method == 'POST':
+        # populate the form with the posted values
+        form = ClockInForm(request.POST)
+
+        # validate the form data
+        if form.is_valid():
+            # the form is valid, so create the new entry
+            e = Entry()
+            e.clock_in(request.user, form.cleaned_data['project'])
+
+            # if the user chose to pause any open entries, pause them
+            if request.POST.get('pause_open', '0') == '1':
+                open = Entry.objects.current().filter(user=request.user,
+                                                      end_time__isnull=True,
+                                                      pause_time__isnull=True)
+                for log in open:
+                    log.pause()
+                    log.save()
+
+            e.save()
+
+            # create a message that may be displayed to the user
+            request.user.message_set.create(message='You have clocked into %s' % e.project)
+            return HttpResponseRedirect(reverse('pendulum-entries'))
+        else: