1 from __future__
import unicode_literals
6 from datetime
import datetime
8 from itertools
import imap
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
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 """
48 message
= 'Invalid Device ID'
52 class UserProxyQuerySet(models
.QuerySet
):
54 def by_username_or_email(self
, username
, email
):
55 """ Queries for a User by username or email """
59 q |
= Q(username
=username
)
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()
93 self
.profile
.activation_key
= None
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()
106 for client
in clients
:
107 # check if we have just found a new group
108 if last_group
!= client
.sync_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
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
):
200 vals
= ('action', 'timestamp', 'device', 'started', 'playmark',
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
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')
240 return '%s-Action on %s at %s (in %s)' % \
241 (self
.action
, self
.device
, self
.timestamp
, self
._id
)
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()
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
)
302 return 'Episode-State %s (in %s)' % \
303 (self
.episode
, self
._id
)
305 def __eq__(self
, other
):
306 if not isinstance(other
, EpisodeUserState
):
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
)
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
)
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 """
356 (DESKTOP
, _('Desktop')),
357 (LAPTOP
, _('Laptop')),
358 (MOBILE
, _('Cell phone')),
359 (SERVER
, _('Server')),
360 (TABLET
, _('Tablet')),
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
)
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
)
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
:
400 ogroup
= other
.sync_group
401 Client
.objects
.filter(sync_group
=ogroup
)\
402 .update(sync_group
=self
.sync_group
)
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
413 elif self
.sync_group
is not None:
414 self
.sync_group
= other
.sync_group
417 elif other
.sync_group
is not None:
418 other
.sync_group
= self
.sync_group
422 """ Stop synchronisation with other clients """
425 logger
.info('Stopping synchronisation of %r', self
)
426 self
.sync_group
= None
429 clients
= Client
.objects
.filter(sync_group
=sg
)
430 logger
.info('%d other clients remaining in sync group', len(clients
))
433 logger
.info('Deleting sync group %r', sg
)
434 for client
in clients
:
435 client
.sync_group
= None
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 """
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
454 elif group
.is_synced
:
458 # every unsynced device is a sync-target
459 for dev
in group
.devices
:
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
468 return Podcast
.objects
.filter(subscription__client
=self
)
470 def synced_with(self
):
471 if not self
.sync_group
:
474 return Client
.objects
.filter(sync_group
=self
.sync_group
)\
478 def display_name(self
):
479 return self
.name
or self
.uid
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):
497 class HistoryEntry(object):
498 """ A class that can represent subscription and episode actions """
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
)
518 return getattr(self
, 'position', None)
522 def fetch_data(cls
, user
, entries
,
523 podcasts
=None, episodes
=None):
524 """ Efficiently loads additional data for a number of entries """
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
}
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',
542 episodes
= {episode
.id.hex: episode
for episode
in episodes
}
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'):
561 device
= devices
.get(getattr(entry
, 'device_id', None), None)
562 entry
.device
= device
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
575 profile
= UserProfile
.objects
.create(user
=user
, uuid
=uuid
.uuid1())
576 user
.profile
= profile