[Subscriptions] add Subscription, HistoryEntry
[mygpo.git] / mygpo / users / models.py
blob97883f9f7251f4e6f34f9bc4dcc07b9be4e244eb
1 import re
2 import uuid
3 import collections
4 from datetime import datetime
5 import dateutil.parser
6 from itertools import imap
7 import random
8 import string
10 from couchdbkit.ext.django.schema import *
11 from uuidfield import UUIDField
13 from django.core.exceptions import ValidationError
14 from django.core.validators import RegexValidator
15 from django.db import transaction, models
16 from django.db.models import Q
17 from django.contrib.auth.models import User as DjangoUser
18 from django.contrib.auth import get_user_model
19 from django.utils.translation import ugettext_lazy as _
20 from django.conf import settings
21 from django.core.cache import cache
22 from django.contrib import messages
24 from mygpo.core.models import (TwitterModel, UUIDModel, SettingsModel,
25 GenericManager, DeleteableModel, )
26 from mygpo.podcasts.models import Podcast, Episode
27 from mygpo.utils import random_token
28 from mygpo.core.proxy import DocumentABCMeta, proxy_object
29 from mygpo.decorators import repeat_on_conflict
30 from mygpo.users.ratings import RatingMixin
31 from mygpo.users.subscriptions import (subscription_changes,
32 podcasts_for_states, get_subscribed_podcast_ids)
33 from mygpo.users.settings import FAV_FLAG, PUBLIC_SUB_PODCAST, SettingsMixin
34 from mygpo.db.couchdb.user import user_history, device_history
36 # make sure this code is executed at startup
37 from mygpo.users.signals import *
39 import logging
40 logger = logging.getLogger(__name__)
43 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
45 # TODO: derive from ValidationException?
46 class InvalidEpisodeActionAttributes(ValueError):
47 """ raised when the attribues of an episode action fail validation """
50 class SubscriptionException(Exception):
51 """ raised when a subscription can not be modified """
54 class DeviceDoesNotExist(Exception):
55 pass
58 class DeviceDeletedException(DeviceDoesNotExist):
59 pass
62 GroupedDevices = collections.namedtuple('GroupedDevices', 'is_synced devices')
65 class UIDValidator(RegexValidator):
66 """ Validates that the Device UID conforms to the given regex """
67 regex = RE_DEVICE_UID
68 message = 'Invalid Device ID'
69 code='invalid-uid'
72 class UserProxyQuerySet(models.QuerySet):
74 def by_username_or_email(self, username, email):
75 """ Queries for a User by username or email """
76 q = Q()
78 if username:
79 q |= Q(username=username)
81 elif email:
82 q |= Q(email=email)
84 if q:
85 return self.get(q)
86 else:
87 return self.none()
90 class UserProxyManager(GenericManager):
91 """ Manager for the UserProxy model """
93 def get_queryset(self):
94 return UserProxyQuerySet(self.model, using=self._db)
96 def from_user(self, user):
97 """ Get the UserProxy corresponding for the given User """
98 return self.get(pk=user.pk)
101 class UserProxy(DjangoUser):
103 objects = UserProxyManager()
105 class Meta:
106 proxy = True
108 @transaction.atomic
109 def activate(self):
110 self.is_active = True
111 self.save()
113 self.profile.activation_key = None
114 self.profile.save()
117 def get_grouped_devices(self):
118 """ Returns groups of synced devices and a unsynced group """
120 clients = Client.objects.filter(user=self, deleted=False)\
121 .order_by('-sync_group')
123 last_group = object()
124 group = None
126 for client in clients:
127 # check if we have just found a new group
128 if last_group != client.sync_group:
129 if group != None:
130 yield group
132 group = GroupedDevices(client.sync_group is not None, [])
134 last_group = client.sync_group
135 group.devices.append(client)
137 # yield remaining group
138 if group != None:
139 yield group
142 class UserProfile(TwitterModel, SettingsModel):
143 """ Additional information stored for a User """
145 # the user to which this profile belongs
146 user = models.OneToOneField(settings.AUTH_USER_MODEL,
147 related_name='profile')
149 # the CouchDB _id of the user
150 uuid = UUIDField(unique=True)
152 # if False, suggestions should be updated
153 suggestions_up_to_date = models.BooleanField(default=False)
155 # text the user entered about himeself
156 about = models.TextField(blank=True)
158 # Google email address for OAuth login
159 google_email = models.CharField(max_length=100, null=True)
161 # token for accessing subscriptions of this use
162 subscriptions_token = models.CharField(max_length=32, null=True,
163 default=random_token)
165 # token for accessing the favorite-episodes feed of this user
166 favorite_feeds_token = models.CharField(max_length=32, null=True,
167 default=random_token)
169 # token for automatically updating feeds published by this user
170 publisher_update_token = models.CharField(max_length=32, null=True,
171 default=random_token)
173 # token for accessing the userpage of this user
174 userpage_token = models.CharField(max_length=32, null=True,
175 default=random_token)
177 # key for activating the user
178 activation_key = models.CharField(max_length=40, null=True)
180 def get_token(self, token_name):
181 """ returns a token """
183 if token_name not in TOKEN_NAMES:
184 raise TokenException('Invalid token name %s' % token_name)
186 return getattr(self, token_name)
189 class Suggestions(Document, RatingMixin):
190 user = StringProperty(required=True)
191 user_oldid = IntegerProperty()
192 podcasts = StringListProperty()
193 blacklist = StringListProperty()
196 def get_podcasts(self, count=None):
197 User = get_user_model()
198 user = User.objects.get(profile__uuid=self.user)
199 # TODO: re-include later on
200 #subscriptions = get_subscribed_podcast_ids(user)
201 subscriptions = []
203 ids = filter(lambda x: not x in self.blacklist + subscriptions, self.podcasts)
204 if count:
205 ids = ids[:count]
207 podcasts = Podcast.objects.filter(id__in=ids).prefetch_related('slugs')
208 return filter(lambda x: x and x.title, podcasts)
211 def __repr__(self):
212 if not self._id:
213 return super(Suggestions, self).__repr__()
214 else:
215 return '%d Suggestions for %s (%s)' % \
216 (len(self.podcasts), self.user, self._id)
219 class EpisodeAction(DocumentSchema):
221 One specific action to an episode. Must
222 always be part of a EpisodeUserState
225 action = StringProperty(required=True)
227 # walltime of the event (assigned by the uploading client, defaults to now)
228 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
230 # upload time of the event
231 upload_timestamp = IntegerProperty(required=True)
233 device_oldid = IntegerProperty(required=False)
234 device = StringProperty()
235 started = IntegerProperty()
236 playmark = IntegerProperty()
237 total = IntegerProperty()
239 def __eq__(self, other):
240 if not isinstance(other, EpisodeAction):
241 return False
242 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
243 'total')
244 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
247 def to_history_entry(self):
248 entry = HistoryEntry()
249 entry.action = self.action
250 entry.timestamp = self.timestamp
251 entry.device_id = self.device
252 entry.started = self.started
253 entry.position = self.playmark
254 entry.total = self.total
255 return entry
259 def validate_time_values(self):
260 """ Validates allowed combinations of time-values """
262 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
264 # Key found, but must not be supplied (no play action!)
265 if self.action != 'play':
266 for key in PLAY_ACTION_KEYS:
267 if getattr(self, key, None) is not None:
268 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
270 # Sanity check: If started or total are given, require playmark
271 if ((self.started is not None) or (self.total is not None)) and \
272 self.playmark is None:
273 raise InvalidEpisodeActionAttributes('started and total require position')
275 # Sanity check: total and playmark can only appear together
276 if ((self.total is not None) or (self.started is not None)) and \
277 ((self.total is None) or (self.started is None)):
278 raise InvalidEpisodeActionAttributes('total and started can only appear together')
281 def __repr__(self):
282 return '%s-Action on %s at %s (in %s)' % \
283 (self.action, self.device, self.timestamp, self._id)
286 def __hash__(self):
287 return hash(frozenset([self.action, self.timestamp, self.device,
288 self.started, self.playmark, self.total]))
291 class Chapter(Document):
292 """ A user-entered episode chapter """
294 device = StringProperty()
295 created = DateTimeProperty()
296 start = IntegerProperty(required=True)
297 end = IntegerProperty(required=True)
298 label = StringProperty()
299 advertisement = BooleanProperty()
302 def __repr__(self):
303 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
304 self.start, self.end)
307 class EpisodeUserState(Document, SettingsMixin):
309 Contains everything a user has done with an Episode
312 episode = StringProperty(required=True)
313 actions = SchemaListProperty(EpisodeAction)
314 user_oldid = IntegerProperty()
315 user = StringProperty(required=True)
316 ref_url = StringProperty(required=True)
317 podcast_ref_url = StringProperty(required=True)
318 merged_ids = StringListProperty()
319 chapters = SchemaListProperty(Chapter)
320 podcast = StringProperty(required=True)
324 def add_actions(self, actions):
325 map(EpisodeAction.validate_time_values, actions)
326 self.actions = list(self.actions) + actions
327 self.actions = list(set(self.actions))
328 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
331 def is_favorite(self):
332 return self.get_wksetting(FAV_FLAG)
335 def set_favorite(self, set_to=True):
336 self.settings[FAV_FLAG.name] = set_to
339 def get_history_entries(self):
340 return imap(EpisodeAction.to_history_entry, self.actions)
343 def __repr__(self):
344 return 'Episode-State %s (in %s)' % \
345 (self.episode, self._id)
347 def __eq__(self, other):
348 if not isinstance(other, EpisodeUserState):
349 return False
351 return (self.episode == other.episode and
352 self.user == other.user)
356 class SubscriptionAction(Document):
357 action = StringProperty()
358 timestamp = DateTimeProperty(default=datetime.utcnow)
359 device = StringProperty()
362 __metaclass__ = DocumentABCMeta
365 def __cmp__(self, other):
366 return cmp(self.timestamp, other.timestamp)
368 def __eq__(self, other):
369 return self.action == other.action and \
370 self.timestamp == other.timestamp and \
371 self.device == other.device
373 def __hash__(self):
374 return hash(self.action) + hash(self.timestamp) + hash(self.device)
376 def __repr__(self):
377 return '<SubscriptionAction %s on %s at %s>' % (
378 self.action, self.device, self.timestamp)
381 class PodcastUserState(Document, SettingsMixin):
383 Contains everything that a user has done
384 with a specific podcast and all its episodes
387 podcast = StringProperty(required=True)
388 user_oldid = IntegerProperty()
389 user = StringProperty(required=True)
390 actions = SchemaListProperty(SubscriptionAction)
391 tags = StringListProperty()
392 ref_url = StringProperty(required=True)
393 disabled_devices = StringListProperty()
394 merged_ids = StringListProperty()
397 def remove_device(self, device):
399 Removes all actions from the podcast state that refer to the
400 given device
402 self.actions = filter(lambda a: a.device != device.id, self.actions)
405 def subscribe(self, device):
406 action = SubscriptionAction()
407 action.action = 'subscribe'
408 action.device = device.id.hex
409 self.add_actions([action])
412 def unsubscribe(self, device):
413 action = SubscriptionAction()
414 action.action = 'unsubscribe'
415 action.device = device.id.hex
416 self.add_actions([action])
419 def add_actions(self, actions):
420 self.actions = list(set(self.actions + actions))
421 self.actions = sorted(self.actions)
424 def add_tags(self, tags):
425 self.tags = list(set(self.tags + tags))
428 def set_device_state(self, devices):
429 disabled_devices = [device.id for device in devices if device.deleted]
430 #self.disabled_devices = disabled_devices
433 def get_change_between(self, device_id, since, until):
435 Returns the change of the subscription status for the given device
436 between the two timestamps.
438 The change is given as either 'subscribe' (the podcast has been
439 subscribed), 'unsubscribed' (the podcast has been unsubscribed) or
440 None (no change)
443 device_actions = filter(lambda x: x.device == device_id, self.actions)
444 before = filter(lambda x: x.timestamp <= since, device_actions)
445 after = filter(lambda x: x.timestamp <= until, device_actions)
447 # nothing happened, so there can be no change
448 if not after:
449 return None
451 then = before[-1] if before else None
452 now = after[-1]
454 if then is None:
455 if now.action != 'unsubscribe':
456 return now.action
457 elif then.action != now.action:
458 return now.action
459 return None
462 def get_subscribed_device_ids(self):
463 """ device Ids on which the user subscribed to the podcast """
464 devices = set()
466 for action in self.actions:
467 if action.action == "subscribe":
468 if not action.device in self.disabled_devices:
469 devices.add(action.device)
470 else:
471 if action.device in devices:
472 devices.remove(action.device)
474 return devices
477 def is_subscribed_on(self, device):
478 """ checks if the podcast is subscribed on the given device """
480 for action in reversed(self.actions):
481 if not action.device == device.id.hex:
482 continue
484 # we only need to check the latest action for the device
485 return (action.action == 'subscribe')
487 # we haven't found any matching action
488 return False
491 def is_public(self):
492 return self.get_wksetting(PUBLIC_SUB_PODCAST)
495 def __eq__(self, other):
496 if other is None:
497 return False
499 return self.podcast == other.podcast and \
500 self.user == other.user
502 def __repr__(self):
503 return 'Podcast %s for User %s (%s)' % \
504 (self.podcast, self.user, self._id)
507 class SyncGroup(models.Model):
508 """ A group of Clients """
510 user = models.ForeignKey(settings.AUTH_USER_MODEL,
511 on_delete=models.CASCADE)
513 def sync(self):
514 """ Sync the group, ie bring all members up-to-date """
516 group_state = self.get_group_state()
518 for device in SyncGroup.objects.filter(sync_group=self):
519 sync_actions = self.get_sync_actions(device, group_state)
520 device.apply_sync_actions(sync_actions)
522 def get_group_state(self):
523 """ Returns the group's subscription state
525 The state is represented by the latest actions for each podcast """
527 devices = Client.objects.filter(sync_group=self)
528 state = {}
530 for d in devices:
531 actions = dict(d.get_latest_changes())
532 for podcast_id, action in actions.items():
533 if not podcast_id in state or \
534 action.timestamp > state[podcast_id].timestamp:
535 state[podcast_id] = action
537 return state
539 def get_sync_actions(self, device, group_state):
540 """ Get the actions required to bring the device to the group's state
542 After applying the actions the device reflects the group's state """
544 # Filter those that describe actual changes to the current state
545 add, rem = [], []
546 current_state = dict(device.get_latest_changes())
548 for podcast_id, action in group_state.items():
550 # Sync-Actions must be newer than current state
551 if podcast_id in current_state and \
552 action.timestamp <= current_state[podcast_id].timestamp:
553 continue
555 # subscribe only what hasn't been subscribed before
556 if action.action == 'subscribe' and \
557 (podcast_id not in current_state or \
558 current_state[podcast_id].action == 'unsubscribe'):
559 add.append(podcast_id)
561 # unsubscribe only what has been subscribed before
562 elif action.action == 'unsubscribe' and \
563 podcast_id in current_state and \
564 current_state[podcast_id].action == 'subscribe':
565 rem.append(podcast_id)
567 return add, rem
571 class Client(UUIDModel, DeleteableModel):
572 """ A client application """
574 DESKTOP = 'desktop'
575 LAPTOP = 'laptop'
576 MOBILE = 'mobile'
577 SERVER = 'server'
578 TABLET = 'tablet'
579 OTHER = 'other'
581 TYPES = (
582 (DESKTOP, _('Desktop')),
583 (LAPTOP, _('Laptop')),
584 (MOBILE, _('Cell phone')),
585 (SERVER, _('Server')),
586 (TABLET, _('Tablet')),
587 (OTHER, _('Other')),
590 # User-assigned ID; must be unique for the user
591 uid = models.CharField(max_length=64, validators=[UIDValidator()])
593 # the user to which the Client belongs
594 user = models.ForeignKey(settings.AUTH_USER_MODEL,
595 on_delete=models.CASCADE)
597 # User-assigned name
598 name = models.CharField(max_length=100, default='New Device')
600 # one of several predefined types
601 type = models.CharField(max_length=max(len(k) for k, v in TYPES),
602 choices=TYPES, default=OTHER)
604 # user-agent string from which the Client was last accessed (for writing)
605 user_agent = models.CharField(max_length=300, null=True, blank=True)
607 sync_group = models.ForeignKey(SyncGroup, null=True,
608 on_delete=models.PROTECT)
610 class Meta:
611 unique_together = (
612 ('user', 'uid'),
615 @transaction.atomic
616 def sync_with(self, other):
617 """ Puts two devices in a common sync group"""
619 if self.user != other.user:
620 raise ValueError('the devices do not belong to the user')
622 if self.sync_group is not None and \
623 other.sync_group is not None and \
624 self.sync_group != other.sync_group:
625 # merge sync_groups
626 ogroup = other.sync_group
627 Client.objects.filter(sync_group=ogroup)\
628 .update(sync_group=self.sync_group)
629 ogroup.delete()
631 elif self.sync_group is None and \
632 other.sync_group is None:
633 sg = SyncGroup.objects.create(user=self.user)
634 other.sync_group = sg
635 other.save()
636 self.sync_group = sg
637 self.save()
639 elif self.sync_group is not None:
640 self.sync_group = other.sync_group
641 self.save()
643 elif other.sync_group is not None:
644 other.sync_group = self.sync_group
645 other.save()
647 def stop_sync(self):
648 """ Stop synchronisation with other clients """
649 sg = self.sync_group
651 logger.info('Stopping synchronisation of %r', self)
652 self.sync_group = None
653 self.save()
655 clients = Client.objects.filter(sync_group=sg)
656 logger.info('%d other clients remaining in sync group', len(clients))
658 if len(clients) < 2:
659 logger.info('Deleting sync group %r', sg)
660 for client in clients:
661 client.sync_group = None
662 client.save()
664 sg.delete()
666 def get_sync_targets(self):
667 """ Returns the devices and groups with which the device can be synced
669 Groups are represented as lists of devices """
671 sg = self.sync_group
673 user = UserProxy.objects.from_user(self.user)
674 for group in user.get_grouped_devices():
676 if self in group.devices:
677 # the device's group can't be a sync-target
678 continue
680 elif group.is_synced:
681 yield group.devices
683 else:
684 # every unsynced device is a sync-target
685 for dev in group.devices:
686 if not dev == self:
687 yield dev
689 def apply_sync_actions(self, sync_actions):
690 """ Applies the sync-actions to the client """
692 from mygpo.db.couchdb.podcast_state import subscribe, unsubscribe
693 from mygpo.users.models import SubscriptionException
694 add, rem = sync_actions
696 podcasts = Podcast.objects.filter(id__in=(add+rem))
697 podcasts = {podcast.id: podcast for podcast in podcasts}
699 for podcast_id in add:
700 podcast = podcasts.get(podcast_id, None)
701 if podcast is None:
702 continue
703 try:
704 subscribe(podcast, self.user, self)
705 except SubscriptionException as e:
706 logger.warn('Web: %(username)s: cannot sync device: %(error)s' %
707 dict(username=self.user.username, error=repr(e)))
709 for podcast_id in rem:
710 podcast = podcasts.get(podcast_id, None)
711 if not podcast:
712 continue
714 try:
715 unsubscribe(podcast, self.user, self)
716 except SubscriptionException as e:
717 logger.warn('Web: %(username)s: cannot sync device: %(error)s' %
718 dict(username=self.user.username, error=repr(e)))
720 def get_subscription_changes(self, since, until):
722 Returns the subscription changes for the device as two lists.
723 The first lists contains the Ids of the podcasts that have been
724 subscribed to, the second list of those that have been unsubscribed
725 from.
728 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
729 podcast_states = podcast_states_for_device(self.id.hex)
730 return subscription_changes(self.id.hex, podcast_states, since, until)
732 def get_latest_changes(self):
733 from mygpo.db.couchdb.podcast_state import podcast_states_for_device
734 podcast_states = podcast_states_for_device(self.id.hex)
735 for p_state in podcast_states:
736 actions = filter(lambda x: x.device == self.id.hex, reversed(p_state.actions))
737 if actions:
738 yield (p_state.podcast, actions[0])
740 def get_subscribed_podcast_ids(self):
741 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_device
742 states = get_subscribed_podcast_states_by_device(self)
743 return [state.podcast for state in states]
745 def get_subscribed_podcasts(self):
746 """ Returns all subscribed podcasts for the device
748 The attribute "url" contains the URL that was used when subscribing to
749 the podcast """
750 from mygpo.db.couchdb.podcast_state import get_subscribed_podcast_states_by_device
751 states = get_subscribed_podcast_states_by_device(self)
752 return podcasts_for_states(states)
754 def synced_with(self):
755 if not self.sync_group:
756 return []
758 return Client.objects.filter(sync_group=self.sync_group)\
759 .exclude(pk=self.pk)
761 def __str__(self):
762 return '{} ({})'.format(self.name.encode('ascii', errors='replace'),
763 self.uid.encode('ascii', errors='replace'))
765 def __unicode__(self):
766 return u'{} ({})'.format(self.name, self.uid)
769 class Device(Document, SettingsMixin):
770 id = StringProperty(default=lambda: uuid.uuid4().hex)
771 oldid = IntegerProperty(required=False)
772 uid = StringProperty(required=True)
773 name = StringProperty(required=True, default='New Device')
774 type = StringProperty(required=True, default='other')
775 deleted = BooleanProperty(default=False)
776 user_agent = StringProperty()
780 def __hash__(self):
781 return hash(frozenset([self.id, self.uid, self.name, self.type, self.deleted]))
784 def __eq__(self, other):
785 return self.id == other.id
788 def __repr__(self):
789 return '<{cls} {id}>'.format(cls=self.__class__.__name__, id=self.id)
793 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
794 'publisher_update_token', 'userpage_token')
797 class TokenException(Exception):
798 pass
801 class History(object):
803 def __init__(self, user, device):
804 self.user = user
805 self.device = device
808 def __getitem__(self, key):
810 if isinstance(key, slice):
811 start = key.start or 0
812 length = key.stop - start
813 else:
814 start = key
815 length = 1
817 if self.device:
818 return device_history(self.user, self.device, start, length)
820 else:
821 return user_history(self.user, start, length)
825 class HistoryEntry(object):
826 """ A class that can represent subscription and episode actions """
829 @classmethod
830 def from_action_dict(cls, action):
832 entry = HistoryEntry()
834 if 'timestamp' in action:
835 ts = action.pop('timestamp')
836 entry.timestamp = dateutil.parser.parse(ts)
838 for key, value in action.items():
839 setattr(entry, key, value)
841 return entry
844 @property
845 def playmark(self):
846 return getattr(self, 'position', None)
849 @classmethod
850 def fetch_data(cls, user, entries,
851 podcasts=None, episodes=None):
852 """ Efficiently loads additional data for a number of entries """
854 if podcasts is None:
855 # load podcast data
856 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
857 podcast_ids = filter(None, podcast_ids)
858 podcasts = Podcast.objects.filter(id__in=podcast_ids)\
859 .prefetch_related('slugs')
860 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
862 if episodes is None:
863 # load episode data
864 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
865 episode_ids = filter(None, episode_ids)
866 episodes = Episode.objects.filter(id__in=episode_ids)\
867 .select_related('podcast')\
868 .prefetch_related('slugs',
869 'podcast__slugs')
870 episodes = {episode.id.hex: episode for episode in episodes}
872 # load device data
873 # does not need pre-populated data because no db-access is required
874 device_ids = [getattr(x, 'device_id', None) for x in entries]
875 device_ids = filter(None, device_ids)
876 devices = {client.id.hex: client for client in user.client_set.all()}
879 for entry in entries:
880 podcast_id = getattr(entry, 'podcast_id', None)
881 entry.podcast = podcasts.get(podcast_id, None)
883 episode_id = getattr(entry, 'episode_id', None)
884 entry.episode = episodes.get(episode_id, None)
886 if hasattr(entry, 'user'):
887 entry.user = user
889 device = devices.get(getattr(entry, 'device_id', None), None)
890 entry.device = device
893 return entries
896 def create_missing_profile(sender, **kwargs):
897 """ Creates a UserProfile if a User doesn't have one """
898 user = kwargs['instance']
900 if not hasattr(user, 'profile'):
901 # TODO: remove uuid column once migration from CouchDB is complete
902 import uuid
903 profile = UserProfile.objects.create(user=user, uuid=uuid.uuid1())
904 user.profile = profile