1 from __future__
import unicode_literals
8 from uuidfield
import UUIDField
10 from django
.core
.validators
import RegexValidator
11 from django
.db
import transaction
, models
12 from django
.db
.models
import Q
13 from django
.contrib
.auth
.models
import User
as DjangoUser
14 from django
.utils
.translation
import ugettext_lazy
as _
15 from django
.conf
import settings
17 from mygpo
.core
.models
import (TwitterModel
, UUIDModel
, SettingsModel
,
18 GenericManager
, DeleteableModel
, )
19 from mygpo
.podcasts
.models
import Podcast
, Episode
20 from mygpo
.utils
import random_token
23 logger
= logging
.getLogger(__name__
)
26 RE_DEVICE_UID
= re
.compile(r
'^[\w.-]+$')
28 # TODO: derive from ValidationException?
29 class InvalidEpisodeActionAttributes(ValueError):
30 """ raised when the attribues of an episode action fail validation """
33 class SubscriptionException(Exception):
34 """ raised when a subscription can not be modified """
37 GroupedDevices
= collections
.namedtuple('GroupedDevices', 'is_synced devices')
40 class UIDValidator(RegexValidator
):
41 """ Validates that the Device UID conforms to the given regex """
43 message
= 'Invalid Device ID'
47 class UserProxyQuerySet(models
.QuerySet
):
49 def by_username_or_email(self
, username
, email
):
50 """ Queries for a User by username or email """
54 q |
= Q(username
=username
)
65 class UserProxyManager(GenericManager
):
66 """ Manager for the UserProxy model """
68 def get_queryset(self
):
69 return UserProxyQuerySet(self
.model
, using
=self
._db
)
71 def from_user(self
, user
):
72 """ Get the UserProxy corresponding for the given User """
73 return self
.get(pk
=user
.pk
)
76 class UserProxy(DjangoUser
):
78 objects
= UserProxyManager()
88 self
.profile
.activation_key
= None
92 def get_grouped_devices(self
):
93 """ Returns groups of synced devices and a unsynced group """
95 clients
= Client
.objects
.filter(user
=self
, deleted
=False)\
96 .order_by('-sync_group')
101 for client
in clients
:
102 # check if we have just found a new group
103 if last_group
!= client
.sync_group
:
107 group
= GroupedDevices(client
.sync_group
is not None, [])
109 last_group
= client
.sync_group
110 group
.devices
.append(client
)
112 # yield remaining group
117 class UserProfile(TwitterModel
, SettingsModel
):
118 """ Additional information stored for a User """
120 # the user to which this profile belongs
121 user
= models
.OneToOneField(settings
.AUTH_USER_MODEL
,
122 related_name
='profile')
124 # the CouchDB _id of the user
125 uuid
= UUIDField(unique
=True)
127 # if False, suggestions should be updated
128 suggestions_up_to_date
= models
.BooleanField(default
=False)
130 # text the user entered about himeself
131 about
= models
.TextField(blank
=True)
133 # Google email address for OAuth login
134 google_email
= models
.CharField(max_length
=100, null
=True)
136 # token for accessing subscriptions of this use
137 subscriptions_token
= models
.CharField(max_length
=32, null
=True,
138 default
=random_token
)
140 # token for accessing the favorite-episodes feed of this user
141 favorite_feeds_token
= models
.CharField(max_length
=32, null
=True,
142 default
=random_token
)
144 # token for automatically updating feeds published by this user
145 publisher_update_token
= models
.CharField(max_length
=32, null
=True,
146 default
=random_token
)
148 # token for accessing the userpage of this user
149 userpage_token
= models
.CharField(max_length
=32, null
=True,
150 default
=random_token
)
152 # key for activating the user
153 activation_key
= models
.CharField(max_length
=40, null
=True)
155 def get_token(self
, token_name
):
156 """ returns a token """
158 if token_name
not in TOKEN_NAMES
:
159 raise TokenException('Invalid token name %s' % token_name
)
161 return getattr(self
, token_name
)
163 def create_new_token(self
, token_name
):
164 """ resets a token """
166 if token_name
not in TOKEN_NAMES
:
167 raise TokenException('Invalid token name %s' % token_name
)
169 setattr(self
, token_name
, random_token())
172 class SyncGroup(models
.Model
):
173 """ A group of Clients """
175 user
= models
.ForeignKey(settings
.AUTH_USER_MODEL
,
176 on_delete
=models
.CASCADE
)
179 """ Sync the group, ie bring all members up-to-date """
180 from mygpo
.subscriptions
import subscribe
182 # get all subscribed podcasts
183 podcasts
= set(self
.get_subscribed_podcasts())
185 # bring each client up to date, it it is subscribed to all podcasts
186 for client
in self
.client_set
.all():
187 missing_podcasts
= self
.get_missing_podcasts(client
, podcasts
)
188 for podcast
in missing_podcasts
:
189 subscribe(podcast
, self
.user
, client
)
191 def get_subscribed_podcasts(self
):
192 return Podcast
.objects
.filter(subscription__client__sync_group
=self
)
194 def get_missing_podcasts(self
, client
, all_podcasts
):
195 """ the podcasts required to bring the device to the group's state """
196 client_podcasts
= set(client
.get_subscribed_podcasts())
197 return all_podcasts
.difference(client_podcasts
)
200 def display_name(self
):
201 clients
= self
.client_set
.all()
202 return ', '.join(client
.display_name
for client
in clients
)
205 class Client(UUIDModel
, DeleteableModel
):
206 """ A client application """
216 (DESKTOP
, _('Desktop')),
217 (LAPTOP
, _('Laptop')),
218 (MOBILE
, _('Cell phone')),
219 (SERVER
, _('Server')),
220 (TABLET
, _('Tablet')),
224 # User-assigned ID; must be unique for the user
225 uid
= models
.CharField(max_length
=64, validators
=[UIDValidator()])
227 # the user to which the Client belongs
228 user
= models
.ForeignKey(settings
.AUTH_USER_MODEL
,
229 on_delete
=models
.CASCADE
)
232 name
= models
.CharField(max_length
=100, default
='New Device')
234 # one of several predefined types
235 type = models
.CharField(max_length
=max(len(k
) for k
, v
in TYPES
),
236 choices
=TYPES
, default
=OTHER
)
238 # user-agent string from which the Client was last accessed (for writing)
239 user_agent
= models
.CharField(max_length
=300, null
=True, blank
=True)
241 sync_group
= models
.ForeignKey(SyncGroup
, null
=True, blank
=True,
242 on_delete
=models
.PROTECT
)
250 def sync_with(self
, other
):
251 """ Puts two devices in a common sync group"""
253 if self
.user
!= other
.user
:
254 raise ValueError('the devices do not belong to the user')
256 if self
.sync_group
is not None and \
257 other
.sync_group
is not None and \
258 self
.sync_group
!= other
.sync_group
:
260 ogroup
= other
.sync_group
261 Client
.objects
.filter(sync_group
=ogroup
)\
262 .update(sync_group
=self
.sync_group
)
265 elif self
.sync_group
is None and \
266 other
.sync_group
is None:
267 sg
= SyncGroup
.objects
.create(user
=self
.user
)
268 other
.sync_group
= sg
273 elif self
.sync_group
is not None:
274 other
.sync_group
= self
.sync_group
277 elif other
.sync_group
is not None:
278 self
.sync_group
= other
.sync_group
282 """ Stop synchronisation with other clients """
285 logger
.info('Stopping synchronisation of %r', self
)
286 self
.sync_group
= None
289 clients
= Client
.objects
.filter(sync_group
=sg
)
290 logger
.info('%d other clients remaining in sync group', len(clients
))
293 logger
.info('Deleting sync group %r', sg
)
294 for client
in clients
:
295 client
.sync_group
= None
300 def get_sync_targets(self
):
301 """ Returns the devices and groups with which the device can be synced
303 Groups are represented as lists of devices """
307 user
= UserProxy
.objects
.from_user(self
.user
)
308 for group
in user
.get_grouped_devices():
310 if self
in group
.devices
and group
.is_synced
:
311 # the device's group can't be a sync-target
314 elif group
.is_synced
:
318 # every unsynced device is a sync-target
319 for dev
in group
.devices
:
323 def get_subscribed_podcasts(self
):
324 """ Returns all subscribed podcasts for the device
326 The attribute "url" contains the URL that was used when subscribing to
328 return Podcast
.objects
.filter(subscription__client
=self
)
330 def synced_with(self
):
331 if not self
.sync_group
:
334 return Client
.objects
.filter(sync_group
=self
.sync_group
)\
338 def display_name(self
):
339 return self
.name
or self
.uid
342 return '{} ({})'.format(self
.name
.encode('ascii', errors
='replace'),
343 self
.uid
.encode('ascii', errors
='replace'))
345 def __unicode__(self
):
346 return u
'{} ({})'.format(self
.name
, self
.uid
)
349 TOKEN_NAMES
= ('subscriptions_token', 'favorite_feeds_token',
350 'publisher_update_token', 'userpage_token')
353 class TokenException(Exception):
357 class HistoryEntry(object):
358 """ A class that can represent subscription and episode actions """
362 def from_action_dict(cls
, action
):
364 entry
= HistoryEntry()
366 if 'timestamp' in action
:
367 ts
= action
.pop('timestamp')
368 entry
.timestamp
= dateutil
.parser
.parse(ts
)
370 for key
, value
in action
.items():
371 setattr(entry
, key
, value
)
378 return getattr(self
, 'position', None)
382 def fetch_data(cls
, user
, entries
,
383 podcasts
=None, episodes
=None):
384 """ Efficiently loads additional data for a number of entries """
388 podcast_ids
= [getattr(x
, 'podcast_id', None) for x
in entries
]
389 podcast_ids
= filter(None, podcast_ids
)
390 podcasts
= Podcast
.objects
.filter(id__in
=podcast_ids
)\
391 .prefetch_related('slugs')
392 podcasts
= {podcast
.id.hex: podcast
for podcast
in podcasts
}
396 episode_ids
= [getattr(x
, 'episode_id', None) for x
in entries
]
397 episode_ids
= filter(None, episode_ids
)
398 episodes
= Episode
.objects
.filter(id__in
=episode_ids
)\
399 .select_related('podcast')\
400 .prefetch_related('slugs',
402 episodes
= {episode
.id.hex: episode
for episode
in episodes
}
405 # does not need pre-populated data because no db-access is required
406 device_ids
= [getattr(x
, 'device_id', None) for x
in entries
]
407 device_ids
= filter(None, device_ids
)
408 devices
= {client
.id.hex: client
for client
in user
.client_set
.all()}
411 for entry
in entries
:
412 podcast_id
= getattr(entry
, 'podcast_id', None)
413 entry
.podcast
= podcasts
.get(podcast_id
, None)
415 episode_id
= getattr(entry
, 'episode_id', None)
416 entry
.episode
= episodes
.get(episode_id
, None)
418 if hasattr(entry
, 'user'):
421 device
= devices
.get(getattr(entry
, 'device_id', None), None)
422 entry
.device
= device
428 def create_missing_profile(sender
, **kwargs
):
429 """ Creates a UserProfile if a User doesn't have one """
430 user
= kwargs
['instance']
432 if not hasattr(user
, 'profile'):
433 # TODO: remove uuid column once migration from CouchDB is complete
435 profile
= UserProfile
.objects
.create(user
=user
, uuid
=uuid
.uuid1())
436 user
.profile
= profile