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 """
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
)
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 """
357 (DESKTOP
, _('Desktop')),
358 (LAPTOP
, _('Laptop')),
359 (MOBILE
, _('Cell phone')),
360 (SERVER
, _('Server')),
361 (TABLET
, _('Tablet')),
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
)
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
)
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
:
401 ogroup
= other
.sync_group
402 Client
.objects
.filter(sync_group
=ogroup
)\
403 .update(sync_group
=self
.sync_group
)
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
414 elif self
.sync_group
is not None:
415 other
.sync_group
= self
.sync_group
418 elif other
.sync_group
is not None:
419 self
.sync_group
= other
.sync_group
423 """ Stop synchronisation with other clients """
426 logger
.info('Stopping synchronisation of %r', self
)
427 self
.sync_group
= None
430 clients
= Client
.objects
.filter(sync_group
=sg
)
431 logger
.info('%d other clients remaining in sync group', len(clients
))
434 logger
.info('Deleting sync group %r', sg
)
435 for client
in clients
:
436 client
.sync_group
= None
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 """
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
455 elif group
.is_synced
:
459 # every unsynced device is a sync-target
460 for dev
in group
.devices
:
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
469 return Podcast
.objects
.filter(subscription__client
=self
)
471 def synced_with(self
):
472 if not self
.sync_group
:
475 return Client
.objects
.filter(sync_group
=self
.sync_group
)\
479 def display_name(self
):
480 return self
.name
or self
.uid
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):
498 class HistoryEntry(object):
499 """ A class that can represent subscription and episode actions """
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
)
519 return getattr(self
, 'position', None)
523 def fetch_data(cls
, user
, entries
,
524 podcasts
=None, episodes
=None):
525 """ Efficiently loads additional data for a number of entries """
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
}
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',
543 episodes
= {episode
.id.hex: episode
for episode
in episodes
}
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'):
562 device
= devices
.get(getattr(entry
, 'device_id', None), None)
563 entry
.device
= device
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
576 profile
= UserProfile
.objects
.create(user
=user
, uuid
=uuid
.uuid1())
577 user
.profile
= profile