[Migration] handle Episode.listeners = None in episode toplist
[mygpo.git] / mygpo / users / models.py
blob33ba63e05695d412fb6c18aeb7fbc28c103f538b
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 """
321 from mygpo.subscriptions import subscribe
323 # get all subscribed podcasts
324 podcasts = set(self.get_subscribed_podcasts())
326 # bring each client up to date, it it is subscribed to all podcasts
327 for client in self.client_set.all():
328 missing_podcasts = self.get_missing_podcasts(client, podcasts)
329 for podcast in missing_podcasts:
330 subscribe(podcast, self.user, client)
332 def get_subscribed_podcasts(self):
333 return Podcast.objects.filter(subscription__client__sync_group=self)
335 def get_missing_podcasts(self, client, all_podcasts):
336 """ the podcasts required to bring the device to the group's state """
337 client_podcasts = set(client.get_subscribed_podcasts())
338 return all_podcasts.difference(client_podcasts)
340 @property
341 def display_name(self):
342 clients = self.client_set.all()
343 return ', '.join(client.display_name for client in clients)
346 class Client(UUIDModel, DeleteableModel):
347 """ A client application """
349 DESKTOP = 'desktop'
350 LAPTOP = 'laptop'
351 MOBILE = 'mobile'
352 SERVER = 'server'
353 TABLET = 'tablet'
354 OTHER = 'other'
356 TYPES = (
357 (DESKTOP, _('Desktop')),
358 (LAPTOP, _('Laptop')),
359 (MOBILE, _('Cell phone')),
360 (SERVER, _('Server')),
361 (TABLET, _('Tablet')),
362 (OTHER, _('Other')),
365 # User-assigned ID; must be unique for the user
366 uid = models.CharField(max_length=64, validators=[UIDValidator()])
368 # the user to which the Client belongs
369 user = models.ForeignKey(settings.AUTH_USER_MODEL,
370 on_delete=models.CASCADE)
372 # User-assigned name
373 name = models.CharField(max_length=100, default='New Device')
375 # one of several predefined types
376 type = models.CharField(max_length=max(len(k) for k, v in TYPES),
377 choices=TYPES, default=OTHER)
379 # user-agent string from which the Client was last accessed (for writing)
380 user_agent = models.CharField(max_length=300, null=True, blank=True)
382 sync_group = models.ForeignKey(SyncGroup, null=True, blank=True,
383 on_delete=models.PROTECT)
385 class Meta:
386 unique_together = (
387 ('user', 'uid'),
390 @transaction.atomic
391 def sync_with(self, other):
392 """ Puts two devices in a common sync group"""
394 if self.user != other.user:
395 raise ValueError('the devices do not belong to the user')
397 if self.sync_group is not None and \
398 other.sync_group is not None and \
399 self.sync_group != other.sync_group:
400 # merge sync_groups
401 ogroup = other.sync_group
402 Client.objects.filter(sync_group=ogroup)\
403 .update(sync_group=self.sync_group)
404 ogroup.delete()
406 elif self.sync_group is None and \
407 other.sync_group is None:
408 sg = SyncGroup.objects.create(user=self.user)
409 other.sync_group = sg
410 other.save()
411 self.sync_group = sg
412 self.save()
414 elif self.sync_group is not None:
415 other.sync_group = self.sync_group
416 other.save()
418 elif other.sync_group is not None:
419 self.sync_group = other.sync_group
420 self.save()
422 def stop_sync(self):
423 """ Stop synchronisation with other clients """
424 sg = self.sync_group
426 logger.info('Stopping synchronisation of %r', self)
427 self.sync_group = None
428 self.save()
430 clients = Client.objects.filter(sync_group=sg)
431 logger.info('%d other clients remaining in sync group', len(clients))
433 if len(clients) < 2:
434 logger.info('Deleting sync group %r', sg)
435 for client in clients:
436 client.sync_group = None
437 client.save()
439 sg.delete()
441 def get_sync_targets(self):
442 """ Returns the devices and groups with which the device can be synced
444 Groups are represented as lists of devices """
446 sg = self.sync_group
448 user = UserProxy.objects.from_user(self.user)
449 for group in user.get_grouped_devices():
451 if self in group.devices and group.is_synced:
452 # the device's group can't be a sync-target
453 continue
455 elif group.is_synced:
456 yield group.devices
458 else:
459 # every unsynced device is a sync-target
460 for dev in group.devices:
461 if not dev == self:
462 yield dev
464 def get_subscribed_podcasts(self):
465 """ Returns all subscribed podcasts for the device
467 The attribute "url" contains the URL that was used when subscribing to
468 the podcast """
469 return Podcast.objects.filter(subscription__client=self)
471 def synced_with(self):
472 if not self.sync_group:
473 return []
475 return Client.objects.filter(sync_group=self.sync_group)\
476 .exclude(pk=self.pk)
478 @property
479 def display_name(self):
480 return self.name or self.uid
482 def __str__(self):
483 return '{} ({})'.format(self.name.encode('ascii', errors='replace'),
484 self.uid.encode('ascii', errors='replace'))
486 def __unicode__(self):
487 return u'{} ({})'.format(self.name, self.uid)
490 TOKEN_NAMES = ('subscriptions_token', 'favorite_feeds_token',
491 'publisher_update_token', 'userpage_token')
494 class TokenException(Exception):
495 pass
498 class HistoryEntry(object):
499 """ A class that can represent subscription and episode actions """
502 @classmethod
503 def from_action_dict(cls, action):
505 entry = HistoryEntry()
507 if 'timestamp' in action:
508 ts = action.pop('timestamp')
509 entry.timestamp = dateutil.parser.parse(ts)
511 for key, value in action.items():
512 setattr(entry, key, value)
514 return entry
517 @property
518 def playmark(self):
519 return getattr(self, 'position', None)
522 @classmethod
523 def fetch_data(cls, user, entries,
524 podcasts=None, episodes=None):
525 """ Efficiently loads additional data for a number of entries """
527 if podcasts is None:
528 # load podcast data
529 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
530 podcast_ids = filter(None, podcast_ids)
531 podcasts = Podcast.objects.filter(id__in=podcast_ids)\
532 .prefetch_related('slugs')
533 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
535 if episodes is None:
536 # load episode data
537 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
538 episode_ids = filter(None, episode_ids)
539 episodes = Episode.objects.filter(id__in=episode_ids)\
540 .select_related('podcast')\
541 .prefetch_related('slugs',
542 'podcast__slugs')
543 episodes = {episode.id.hex: episode for episode in episodes}
545 # load device data
546 # does not need pre-populated data because no db-access is required
547 device_ids = [getattr(x, 'device_id', None) for x in entries]
548 device_ids = filter(None, device_ids)
549 devices = {client.id.hex: client for client in user.client_set.all()}
552 for entry in entries:
553 podcast_id = getattr(entry, 'podcast_id', None)
554 entry.podcast = podcasts.get(podcast_id, None)
556 episode_id = getattr(entry, 'episode_id', None)
557 entry.episode = episodes.get(episode_id, None)
559 if hasattr(entry, 'user'):
560 entry.user = user
562 device = devices.get(getattr(entry, 'device_id', None), None)
563 entry.device = device
566 return entries
569 def create_missing_profile(sender, **kwargs):
570 """ Creates a UserProfile if a User doesn't have one """
571 user = kwargs['instance']
573 if not hasattr(user, 'profile'):
574 # TODO: remove uuid column once migration from CouchDB is complete
575 import uuid
576 profile = UserProfile.objects.create(user=user, uuid=uuid.uuid1())
577 user.profile = profile