[Users] clean up imports
[mygpo.git] / mygpo / users / models.py
blob9ab35bded47e8a23496a11fbde73f2fd0d58ba59
1 from __future__ import unicode_literals
3 import re
4 import uuid
5 import collections
6 from datetime import datetime
7 import dateutil.parser
8 from itertools import imap
9 import string
11 from couchdbkit.ext.django.schema import *
12 from uuidfield import UUIDField
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.utils.translation import ugettext_lazy as _
19 from django.conf import settings
21 from mygpo.core.models import (TwitterModel, UUIDModel, SettingsModel,
22 GenericManager, DeleteableModel, )
23 from mygpo.podcasts.models import Podcast, Episode
24 from mygpo.utils import random_token
25 from mygpo.users.settings import FAV_FLAG, SettingsMixin
27 import logging
28 logger = logging.getLogger(__name__)
31 RE_DEVICE_UID = re.compile(r'^[\w.-]+$')
33 # TODO: derive from ValidationException?
34 class InvalidEpisodeActionAttributes(ValueError):
35 """ raised when the attribues of an episode action fail validation """
38 class SubscriptionException(Exception):
39 """ raised when a subscription can not be modified """
42 GroupedDevices = collections.namedtuple('GroupedDevices', 'is_synced devices')
45 class UIDValidator(RegexValidator):
46 """ Validates that the Device UID conforms to the given regex """
47 regex = RE_DEVICE_UID
48 message = 'Invalid Device ID'
49 code='invalid-uid'
52 class UserProxyQuerySet(models.QuerySet):
54 def by_username_or_email(self, username, email):
55 """ Queries for a User by username or email """
56 q = Q()
58 if username:
59 q |= Q(username=username)
61 elif email:
62 q |= Q(email=email)
64 if q:
65 return self.get(q)
66 else:
67 return self.none()
70 class UserProxyManager(GenericManager):
71 """ Manager for the UserProxy model """
73 def get_queryset(self):
74 return UserProxyQuerySet(self.model, using=self._db)
76 def from_user(self, user):
77 """ Get the UserProxy corresponding for the given User """
78 return self.get(pk=user.pk)
81 class UserProxy(DjangoUser):
83 objects = UserProxyManager()
85 class Meta:
86 proxy = True
88 @transaction.atomic
89 def activate(self):
90 self.is_active = True
91 self.save()
93 self.profile.activation_key = None
94 self.profile.save()
97 def get_grouped_devices(self):
98 """ Returns groups of synced devices and a unsynced group """
100 clients = Client.objects.filter(user=self, deleted=False)\
101 .order_by('-sync_group')
103 last_group = object()
104 group = None
106 for client in clients:
107 # check if we have just found a new group
108 if last_group != client.sync_group:
109 if group != None:
110 yield group
112 group = GroupedDevices(client.sync_group is not None, [])
114 last_group = client.sync_group
115 group.devices.append(client)
117 # yield remaining group
118 if group != None:
119 yield group
122 class UserProfile(TwitterModel, SettingsModel):
123 """ Additional information stored for a User """
125 # the user to which this profile belongs
126 user = models.OneToOneField(settings.AUTH_USER_MODEL,
127 related_name='profile')
129 # the CouchDB _id of the user
130 uuid = UUIDField(unique=True)
132 # if False, suggestions should be updated
133 suggestions_up_to_date = models.BooleanField(default=False)
135 # text the user entered about himeself
136 about = models.TextField(blank=True)
138 # Google email address for OAuth login
139 google_email = models.CharField(max_length=100, null=True)
141 # token for accessing subscriptions of this use
142 subscriptions_token = models.CharField(max_length=32, null=True,
143 default=random_token)
145 # token for accessing the favorite-episodes feed of this user
146 favorite_feeds_token = models.CharField(max_length=32, null=True,
147 default=random_token)
149 # token for automatically updating feeds published by this user
150 publisher_update_token = models.CharField(max_length=32, null=True,
151 default=random_token)
153 # token for accessing the userpage of this user
154 userpage_token = models.CharField(max_length=32, null=True,
155 default=random_token)
157 # key for activating the user
158 activation_key = models.CharField(max_length=40, null=True)
160 def get_token(self, token_name):
161 """ returns a token """
163 if token_name not in TOKEN_NAMES:
164 raise TokenException('Invalid token name %s' % token_name)
166 return getattr(self, token_name)
168 def create_new_token(self, token_name):
169 """ resets a token """
171 if token_name not in TOKEN_NAMES:
172 raise TokenException('Invalid token name %s' % token_name)
174 setattr(self, token_name, random_token())
177 class EpisodeAction(DocumentSchema):
179 One specific action to an episode. Must
180 always be part of a EpisodeUserState
183 action = StringProperty(required=True)
185 # walltime of the event (assigned by the uploading client, defaults to now)
186 timestamp = DateTimeProperty(required=True, default=datetime.utcnow)
188 # upload time of the event
189 upload_timestamp = IntegerProperty(required=True)
191 device_oldid = IntegerProperty(required=False)
192 device = StringProperty()
193 started = IntegerProperty()
194 playmark = IntegerProperty()
195 total = IntegerProperty()
197 def __eq__(self, other):
198 if not isinstance(other, EpisodeAction):
199 return False
200 vals = ('action', 'timestamp', 'device', 'started', 'playmark',
201 'total')
202 return all([getattr(self, v, None) == getattr(other, v, None) for v in vals])
205 def to_history_entry(self):
206 entry = HistoryEntry()
207 entry.action = self.action
208 entry.timestamp = self.timestamp
209 entry.device_id = self.device
210 entry.started = self.started
211 entry.position = self.playmark
212 entry.total = self.total
213 return entry
217 def validate_time_values(self):
218 """ Validates allowed combinations of time-values """
220 PLAY_ACTION_KEYS = ('playmark', 'started', 'total')
222 # Key found, but must not be supplied (no play action!)
223 if self.action != 'play':
224 for key in PLAY_ACTION_KEYS:
225 if getattr(self, key, None) is not None:
226 raise InvalidEpisodeActionAttributes('%s only allowed in play actions' % key)
228 # Sanity check: If started or total are given, require playmark
229 if ((self.started is not None) or (self.total is not None)) and \
230 self.playmark is None:
231 raise InvalidEpisodeActionAttributes('started and total require position')
233 # Sanity check: total and playmark can only appear together
234 if ((self.total is not None) or (self.started is not None)) and \
235 ((self.total is None) or (self.started is None)):
236 raise InvalidEpisodeActionAttributes('total and started can only appear together')
239 def __repr__(self):
240 return '%s-Action on %s at %s (in %s)' % \
241 (self.action, self.device, self.timestamp, self._id)
244 def __hash__(self):
245 return hash(frozenset([self.action, self.timestamp, self.device,
246 self.started, self.playmark, self.total]))
249 class Chapter(Document):
250 """ A user-entered episode chapter """
252 device = StringProperty()
253 created = DateTimeProperty()
254 start = IntegerProperty(required=True)
255 end = IntegerProperty(required=True)
256 label = StringProperty()
257 advertisement = BooleanProperty()
260 def __repr__(self):
261 return '<%s %s (%d-%d)>' % (self.__class__.__name__, self.label,
262 self.start, self.end)
265 class EpisodeUserState(Document, SettingsMixin):
267 Contains everything a user has done with an Episode
270 episode = StringProperty(required=True)
271 actions = SchemaListProperty(EpisodeAction)
272 user_oldid = IntegerProperty()
273 user = StringProperty(required=True)
274 ref_url = StringProperty(required=True)
275 podcast_ref_url = StringProperty(required=True)
276 merged_ids = StringListProperty()
277 chapters = SchemaListProperty(Chapter)
278 podcast = StringProperty(required=True)
282 def add_actions(self, actions):
283 map(EpisodeAction.validate_time_values, actions)
284 self.actions = list(self.actions) + actions
285 self.actions = list(set(self.actions))
286 self.actions = sorted(self.actions, key=lambda x: x.timestamp)
289 def is_favorite(self):
290 return self.get_wksetting(FAV_FLAG)
293 def set_favorite(self, set_to=True):
294 self.settings[FAV_FLAG.name] = set_to
297 def get_history_entries(self):
298 return imap(EpisodeAction.to_history_entry, self.actions)
301 def __repr__(self):
302 return 'Episode-State %s (in %s)' % \
303 (self.episode, self._id)
305 def __eq__(self, other):
306 if not isinstance(other, EpisodeUserState):
307 return False
309 return (self.episode == other.episode and
310 self.user == other.user)
313 class SyncGroup(models.Model):
314 """ A group of Clients """
316 user = models.ForeignKey(settings.AUTH_USER_MODEL,
317 on_delete=models.CASCADE)
319 def sync(self):
320 """ Sync the group, ie bring all members up-to-date """
322 # get all subscribed podcasts
323 podcasts = set(self.get_subscribed_podcasts())
325 # bring each client up to date, it it is subscribed to all podcasts
326 for client in self.client_set.all():
327 missing_podcasts = self.get_missing_podcasts(client, podcasts)
328 for podcast in missing_podcasts:
329 subscribe(podcast, self.user, client)
331 def get_subscribed_podcasts(self):
332 return Podcast.objects.filter(subscription__device__sync_group=self)
334 def get_missing_podcasts(self, client, all_podcasts):
335 """ the podcasts required to bring the device to the group's state """
336 client_podcasts = set(client.get_subscribed_podcasts())
337 return all_podcasts.difference(client_podcasts)
339 @property
340 def display_name(self):
341 clients = self.client_set.all()
342 return ', '.join(client.display_name for client in clients)
345 class Client(UUIDModel, DeleteableModel):
346 """ A client application """
348 DESKTOP = 'desktop'
349 LAPTOP = 'laptop'
350 MOBILE = 'mobile'
351 SERVER = 'server'
352 TABLET = 'tablet'
353 OTHER = 'other'
355 TYPES = (
356 (DESKTOP, _('Desktop')),
357 (LAPTOP, _('Laptop')),
358 (MOBILE, _('Cell phone')),
359 (SERVER, _('Server')),
360 (TABLET, _('Tablet')),
361 (OTHER, _('Other')),
364 # User-assigned ID; must be unique for the user
365 uid = models.CharField(max_length=64, validators=[UIDValidator()])
367 # the user to which the Client belongs
368 user = models.ForeignKey(settings.AUTH_USER_MODEL,
369 on_delete=models.CASCADE)
371 # User-assigned name
372 name = models.CharField(max_length=100, default='New Device')
374 # one of several predefined types
375 type = models.CharField(max_length=max(len(k) for k, v in TYPES),
376 choices=TYPES, default=OTHER)
378 # user-agent string from which the Client was last accessed (for writing)
379 user_agent = models.CharField(max_length=300, null=True, blank=True)
381 sync_group = models.ForeignKey(SyncGroup, null=True,
382 on_delete=models.PROTECT)
384 class Meta:
385 unique_together = (
386 ('user', 'uid'),
389 @transaction.atomic
390 def sync_with(self, other):
391 """ Puts two devices in a common sync group"""
393 if self.user != other.user:
394 raise ValueError('the devices do not belong to the user')
396 if self.sync_group is not None and \
397 other.sync_group is not None and \
398 self.sync_group != other.sync_group:
399 # merge sync_groups
400 ogroup = other.sync_group
401 Client.objects.filter(sync_group=ogroup)\
402 .update(sync_group=self.sync_group)
403 ogroup.delete()
405 elif self.sync_group is None and \
406 other.sync_group is None:
407 sg = SyncGroup.objects.create(user=self.user)
408 other.sync_group = sg
409 other.save()
410 self.sync_group = sg
411 self.save()
413 elif self.sync_group is not None:
414 self.sync_group = other.sync_group
415 self.save()
417 elif other.sync_group is not None:
418 other.sync_group = self.sync_group
419 other.save()
421 def stop_sync(self):
422 """ Stop synchronisation with other clients """
423 sg = self.sync_group
425 logger.info('Stopping synchronisation of %r', self)
426 self.sync_group = None
427 self.save()
429 clients = Client.objects.filter(sync_group=sg)
430 logger.info('%d other clients remaining in sync group', len(clients))
432 if len(clients) < 2:
433 logger.info('Deleting sync group %r', sg)
434 for client in clients:
435 client.sync_group = None
436 client.save()
438 sg.delete()
440 def get_sync_targets(self):
441 """ Returns the devices and groups with which the device can be synced
443 Groups are represented as lists of devices """
445 sg = self.sync_group
447 user = UserProxy.objects.from_user(self.user)
448 for group in user.get_grouped_devices():
450 if self in group.devices:
451 # the device's group can't be a sync-target
452 continue
454 elif group.is_synced:
455 yield group.devices
457 else:
458 # every unsynced device is a sync-target
459 for dev in group.devices:
460 if not dev == self:
461 yield dev
463 def get_subscribed_podcasts(self):
464 """ Returns all subscribed podcasts for the device
466 The attribute "url" contains the URL that was used when subscribing to
467 the podcast """
468 return Podcast.objects.filter(subscription__client=self)
470 def synced_with(self):
471 if not self.sync_group:
472 return []
474 return Client.objects.filter(sync_group=self.sync_group)\
475 .exclude(pk=self.pk)
477 @property
478 def display_name(self):
479 return self.name or self.uid
481 def __str__(self):
482 return '{} ({})'.format(self.name.encode('ascii', errors='replace'),
483 self.uid.encode('ascii', errors='replace'))
485 def __unicode__(self):
486 return u'{} ({})'.format(self.name, self.uid)
489 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
490 'publisher_update_token', 'userpage_token')
493 class TokenException(Exception):
494 pass
497 class HistoryEntry(object):
498 """ A class that can represent subscription and episode actions """
501 @classmethod
502 def from_action_dict(cls, action):
504 entry = HistoryEntry()
506 if 'timestamp' in action:
507 ts = action.pop('timestamp')
508 entry.timestamp = dateutil.parser.parse(ts)
510 for key, value in action.items():
511 setattr(entry, key, value)
513 return entry
516 @property
517 def playmark(self):
518 return getattr(self, 'position', None)
521 @classmethod
522 def fetch_data(cls, user, entries,
523 podcasts=None, episodes=None):
524 """ Efficiently loads additional data for a number of entries """
526 if podcasts is None:
527 # load podcast data
528 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
529 podcast_ids = filter(None, podcast_ids)
530 podcasts = Podcast.objects.filter(id__in=podcast_ids)\
531 .prefetch_related('slugs')
532 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
534 if episodes is None:
535 # load episode data
536 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
537 episode_ids = filter(None, episode_ids)
538 episodes = Episode.objects.filter(id__in=episode_ids)\
539 .select_related('podcast')\
540 .prefetch_related('slugs',
541 'podcast__slugs')
542 episodes = {episode.id.hex: episode for episode in episodes}
544 # load device data
545 # does not need pre-populated data because no db-access is required
546 device_ids = [getattr(x, 'device_id', None) for x in entries]
547 device_ids = filter(None, device_ids)
548 devices = {client.id.hex: client for client in user.client_set.all()}
551 for entry in entries:
552 podcast_id = getattr(entry, 'podcast_id', None)
553 entry.podcast = podcasts.get(podcast_id, None)
555 episode_id = getattr(entry, 'episode_id', None)
556 entry.episode = episodes.get(episode_id, None)
558 if hasattr(entry, 'user'):
559 entry.user = user
561 device = devices.get(getattr(entry, 'device_id', None), None)
562 entry.device = device
565 return entries
568 def create_missing_profile(sender, **kwargs):
569 """ Creates a UserProfile if a User doesn't have one """
570 user = kwargs['instance']
572 if not hasattr(user, 'profile'):
573 # TODO: remove uuid column once migration from CouchDB is complete
574 import uuid
575 profile = UserProfile.objects.create(user=user, uuid=uuid.uuid1())
576 user.profile = profile