1

I am working on a library system to manage certain items in our office, I don't need a full-blown integrated library system so I decided to hand roll one with Django.

Below is a simplified version of my model:

class ItemObjects(models.Model):

# Static Variables
IN_STATUS        = 'Available'
OUT_STATUS       = 'Checked out'
MISSING          = 'Missing'
STATUS_CHOICES   = (
    (IN_STATUS,  'Available'),
    (OUT_STATUS, 'Checked out'),
    (MISSING,    'Missing'),
)

# Fields
slug             = models.SlugField(unique=True)
date_added       = models.DateField(auto_now_add=True)
last_checkin     = models.DateTimeField(editable=False, null=True)
last_checkout    = models.DateTimeField(editable=False, null=True)
last_activity    = models.DateTimeField(editable=False, null=True)
status           = models.CharField(choices=STATUS_CHOICES, default=IN_STATUS, max_length=25)
who_has          = models.OneToOneField(User, blank=True, null=True)
times_out        = models.PositiveIntegerField(default=0, editable=False)
notes            = models.CharField(blank=True, max_length=500)
history          = models.TextField(blank=True, editable=False)
pending_checkin  = models.BooleanField(default=False)
pending_transfer = models.BooleanField(default=False)

At first I was using a method on ItemObject to process checking out an item to a user and who_has was an EmailField because I couldn't get a CharfField to populate with the logged in user's name, but I figured using a OneToOneField is probably closer to the "right" way to do this.. While who_has was an EmailField, the following method worked:

    def check_out_itemobject(self, user):
        user_profile                     = user.get_profile()
        if self.status == 'Available' and self.who_has == '':
            self.status                  = 'Checked out'
            self.who_has                 = user.email
            self.last_checkout           = datetime.datetime.now()
            self.last_activity           = datetime.datetime.now()
            self.times_out               += 1
            if self.history == '':
                self.history             += "%s" % user_profile.full_name
            else:
                self.history             += ", %s" % user_profile.full_name
            if user_profile.history == '':
                user_profile.history     += self.title
            else:
                user_profile.history     += ", %s" % self.title
        else:
            return False # Not sure is this is "right"
        user_profile.save()
        super(ItemObjects, self).save()

Now that I am using a OneToOneField this doesn't work, so I started looking at using a subclass of ModelForm but none of the cases I saw here on SO seemed to apply for what I am trying to do; my form would be a button, and that's it. Here are some of the questions I looked at:

Django: saving multiple modelforms simultaneously (complex case)

(Django) (Foreign Key Issues) model.person_id May not be NULL

django update modelform

So was I on the right track with a sort of altered save() method, or would a ModelForm subclass be the way to go?

EDIT/UPDATE: Many thanks to @ChrisPratt!

So I am trying to get Chris Pratt's suggestion for showing ItemHistory to work, but when I try to render it on a page I get an AttributeError that states "'User' object has no attribute 'timestamp'". So my question is, why is it complaining about a User object when last_activity is an attribute on the ItemObject object ?

My view:

@login_required
def item_detail(request, slug):
    item      = get_object_or_404(Item, slug=slug)
    i_history = item.last_activity
    user      = request.user

    return render_to_response('items/item_detail.html',
                              { 'item'     : item,
                                'i_history': i_history,
                                'user'     : user })

I do not see why a User object is coming up at this point.

EDIT2: Nevermind, history is clearly a M2M field whose target is User. That's why!

Community
  • 1
  • 1
HristosT
  • 13
  • 4

1 Answers1

3

Assuming users will log in and check out books to themselves, then what you most likely want is a ForeignKey to User. A book will only have one User at any given time, but presumably Users could check out other items as well. If there is some limit, even if the limit is actually one per user, it would be better to validate this in the model's clean method. Something like:

def clean(self):
    if self.who_has and self.who_has.itemobject_set.count() >= LIMIT:
        raise ValidationError('You have already checked out your maximum amount of items.')

Now, you checkout method has a number of issues. First, status should be a defined set of choices, not just random strings.

class ItemObject(models.Model):
    AVAILABLE = 1
    CHECKED_OUT = 2
    STATUS_CHOICES = (
        (AVAILABLE, 'Available'),
        (CHECKED_OUT, 'Checked Out'),
    )

    ...

    status = models.PositiveIntegerField(choices=STATUS_CHOICES, default=AVAILABLE)

Then, you can run your checks like:

if self.status == self.STATUS_AVAILABLE:
    self.status = self.STATUS_CHECKED_OUT

You could use strings and a CharField instead if you like, as well. The key is to decouple the static text from your code, which allows much greater flexibility in your app going forward.

Next, history needs to be a ManyToManyField. Right now, your "history" is only who last checked the item out or what the last item the user checked out was, and as a result is pretty useless.

class ItemObject(models.Model):
    ...
    history = models.ManyToManyField(User, through='ItemHistory', related_name='item_history', blank=True)

class ItemHistory(models.Model):
    CHECKED_OUT = 1
    RETURNED = 2
    ACTIVITY_CHOICES = (
        (CHECKED_OUT, 'Checked Out'),
        (RETURNED, 'Returned'),
    )

    item = models.ForeignKey(ItemObject)
    user = models.ForeignKey(User)
    activity = models.PostiveIntegerField(choices=ACTIVITY_CHOICES)
    timestamp = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['-timestamp'] # latest first

Which then allows you to get full histories:

some_item.history.all()
some_user.item_history.all()

To add a new history, you would do:

ItemHistory.objects.create(item=some_item, user=some_user, activity=ItemHistory.CHECKED_OUT)

The auto_now_add attribute ensures that the timestamp is automatically set when the relationship is created.

You could then actually get rid of the last_checkout and last_activity fields entirely and use something like the following:

class ItemObject(models.Model):
    ...
    def _last_checkout(self):
        try:
            return self.history.filter(activity=ItemHistory.CHECKED_OUT)[0].timestamp
        except IndexError:
            return None
    last_checkout = property(_last_checkout)

    def _last_activity(self):
        try:
            return self.history.all()[0].timestamp
        except IndexError:
            return None
    last_activity = property(_last_activity)

And, you can then use them as normal:

    some_item.last_checkout

Finally, your checkout method is not an override of save so it's not appropriate to call super(ItemObject, self).save(). Just use self.save() instead.

Chris Pratt
  • 232,153
  • 36
  • 385
  • 444
  • Just a couple comments: 1) I'd recommend `related_name='history'` for `ItemObject.history`, since it's the `User`'s history. 2) could you do `some_item.history.create(user=some_user, activity=ItemHistory.CHECKED_OUT)` instead of going through `ItemHistory.objects`? 3) I'd recommend using the `@property` decorator for `last_checkout`. – Mike DeSimone Nov 18 '11 at 17:08
  • Thanks Chris Pratt! I guess I should have clarified, but we are not imposing a checkout limit. I like what you did with the history, going to give it a try now!! EDIT: also thanks to Mike DeSimone! – HristosT Nov 18 '11 at 17:12
  • I chose `item_history` instead of `history` for the related name because it's foreseeable that `User` might have other "histories" associated with it. `ItemObject` will ever only have one "history", so the shortened form was fine there. – Chris Pratt Nov 18 '11 at 17:27
  • @Chris, I didn't mention this because I didn't think it was relevant, but the `ItemObject` model is actually an `Abstract` model which I then subclass to specific item types. Am I correct in thinking I would need to make a separate `ForeignKey` for each `ItemObject` subclass, or will one `ForeignKey` to `ItemObject` suffice? – HristosT Nov 18 '11 at 17:30
  • Oh, yeah, that would be a problem. You would need a generic foreign key on the through table, but I don't think that's possible. Even if it was, it'd probably be nightmare to maintain. You could try creating a foreign key for each subclass on the through table with `blank=True, null=True` on each, but that seems clunky as well. Your only other option would be to forgo making the class abstract and use MTI (multiple table inheritance), or it's possible that depending on what your subclasses do, you might be able to make those proxy models and just have one table for `ItemObject`. – Chris Pratt Nov 18 '11 at 17:37
  • Thanks Chris, the subclasses introduce new fields that are specific to that item. For example a Game would have a developer and a platform whereas a book would not, and so forth. So given that, I think that one `ForeignKey` to `ItemObject` may work. – HristosT Nov 18 '11 at 18:00
  • You can only have one `ForeignKey` to `ItemObject` if you make `ItemObject` an actual model instead of abstract, and use MTI. You can't create a foreign key to an abstract model. – Chris Pratt Nov 18 '11 at 18:02
  • I see, time to RTFM a bit more then. Thanks! – HristosT Nov 18 '11 at 18:19