8 from django
.core
.validators
import RegexValidator
9 from django
.db
import transaction
, models
10 from django
.db
.models
import Q
11 from django
.contrib
.auth
.models
import User
as DjangoUser
12 from django
.utils
.translation
import ugettext_lazy
as _
13 from django
.conf
import settings
15 from mygpo
.core
.models
import (TwitterModel
, UUIDModel
,
16 GenericManager
, DeleteableModel
, )
17 from mygpo
.usersettings
.models
import UserSettings
18 from mygpo
.podcasts
.models
import Podcast
, Episode
19 from mygpo
.utils
import random_token
22 logger
= logging
.getLogger(__name__
)
25 RE_DEVICE_UID
= re
.compile(r
'^[\w.-]+$')
27 # TODO: derive from ValidationException?
28 class InvalidEpisodeActionAttributes(ValueError):
29 """ raised when the attribues of an episode action fail validation """
32 class SubscriptionException(Exception):
33 """ raised when a subscription can not be modified """
36 GroupedDevices
= collections
.namedtuple('GroupedDevices', 'is_synced devices')
39 class UIDValidator(RegexValidator
):
40 """ Validates that the Device UID conforms to the given regex """
42 message
= 'Invalid Device ID'
46 class UserProxyQuerySet(models
.QuerySet
):
48 def by_username_or_email(self
, username
, email
):
49 """ Queries for a User by username or email """
53 q |
= Q(username
=username
)
64 class UserProxyManager(GenericManager
):
65 """ Manager for the UserProxy model """
67 def get_queryset(self
):
68 return UserProxyQuerySet(self
.model
, using
=self
._db
)
70 def from_user(self
, user
):
71 """ Get the UserProxy corresponding for the given User """
72 return self
.get(pk
=user
.pk
)
75 class UserProxy(DjangoUser
):
77 objects
= UserProxyManager()
87 self
.profile
.activation_key
= None
91 def get_grouped_devices(self
):
92 """ Returns groups of synced devices and a unsynced group """
94 clients
= Client
.objects
.filter(user
=self
, deleted
=False)\
95 .order_by('-sync_group')
100 for client
in clients
:
101 # check if we have just found a new group
102 if last_group
!= client
.sync_group
:
106 group
= GroupedDevices(client
.sync_group
is not None, [])
108 last_group
= client
.sync_group
109 group
.devices
.append(client
)
111 # yield remaining group
116 class UserProfile(TwitterModel
):
117 """ Additional information stored for a User """
119 # the user to which this profile belongs
120 user
= models
.OneToOneField(settings
.AUTH_USER_MODEL
,
121 related_name
='profile')
123 # if False, suggestions should be updated
124 suggestions_up_to_date
= models
.BooleanField(default
=False)
126 # text the user entered about himeself
127 about
= models
.TextField(blank
=True)
129 # Google email address for OAuth login
130 google_email
= models
.CharField(max_length
=100, null
=True)
132 # token for accessing subscriptions of this use
133 subscriptions_token
= models
.CharField(max_length
=32, null
=True,
134 default
=random_token
)
136 # token for accessing the favorite-episodes feed of this user
137 favorite_feeds_token
= models
.CharField(max_length
=32, null
=True,
138 default
=random_token
)
140 # token for automatically updating feeds published by this user
141 publisher_update_token
= models
.CharField(max_length
=32, null
=True,
142 default
=random_token
)
144 # token for accessing the userpage of this user
145 userpage_token
= models
.CharField(max_length
=32, null
=True,
146 default
=random_token
)
148 # key for activating the user
149 activation_key
= models
.CharField(max_length
=40, null
=True)
151 def get_token(self
, token_name
):
152 """ returns a token """
154 if token_name
not in TOKEN_NAMES
:
155 raise TokenException('Invalid token name %s' % token_name
)
157 return getattr(self
, token_name
)
159 def create_new_token(self
, token_name
):
160 """ resets a token """
162 if token_name
not in TOKEN_NAMES
:
163 raise TokenException('Invalid token name %s' % token_name
)
165 setattr(self
, token_name
, random_token())
170 return UserSettings
.objects
.get(user
=self
.user
, content_type
=None)
171 except UserSettings
.DoesNotExist
:
172 return UserSettings(user
=self
.user
, content_type
=None,
176 class SyncGroup(models
.Model
):
177 """ A group of Clients """
179 user
= models
.ForeignKey(settings
.AUTH_USER_MODEL
,
180 on_delete
=models
.CASCADE
)
183 """ Sync the group, ie bring all members up-to-date """
184 from mygpo
.subscriptions
import subscribe
186 # get all subscribed podcasts
187 podcasts
= set(self
.get_subscribed_podcasts())
189 # bring each client up to date, it it is subscribed to all podcasts
190 for client
in self
.client_set
.all():
191 missing_podcasts
= self
.get_missing_podcasts(client
, podcasts
)
192 for podcast
in missing_podcasts
:
193 subscribe(podcast
, self
.user
, client
)
195 def get_subscribed_podcasts(self
):
196 return Podcast
.objects
.filter(subscription__client__sync_group
=self
)
198 def get_missing_podcasts(self
, client
, all_podcasts
):
199 """ the podcasts required to bring the device to the group's state """
200 client_podcasts
= set(client
.get_subscribed_podcasts())
201 return all_podcasts
.difference(client_podcasts
)
204 def display_name(self
):
205 clients
= self
.client_set
.all()
206 return ', '.join(client
.display_name
for client
in clients
)
209 class Client(UUIDModel
, DeleteableModel
):
210 """ A client application """
220 (DESKTOP
, _('Desktop')),
221 (LAPTOP
, _('Laptop')),
222 (MOBILE
, _('Cell phone')),
223 (SERVER
, _('Server')),
224 (TABLET
, _('Tablet')),
228 # User-assigned ID; must be unique for the user
229 uid
= models
.CharField(max_length
=64, validators
=[UIDValidator()])
231 # the user to which the Client belongs
232 user
= models
.ForeignKey(settings
.AUTH_USER_MODEL
,
233 on_delete
=models
.CASCADE
)
236 name
= models
.CharField(max_length
=100, default
='New Device')
238 # one of several predefined types
239 type = models
.CharField(max_length
=max(len(k
) for k
, v
in TYPES
),
240 choices
=TYPES
, default
=OTHER
)
242 # user-agent string from which the Client was last accessed (for writing)
243 user_agent
= models
.CharField(max_length
=300, null
=True, blank
=True)
245 sync_group
= models
.ForeignKey(SyncGroup
, null
=True, blank
=True,
246 on_delete
=models
.PROTECT
)
254 def sync_with(self
, other
):
255 """ Puts two devices in a common sync group"""
257 if self
.user
!= other
.user
:
258 raise ValueError('the devices do not belong to the user')
260 if self
.sync_group
is not None and \
261 other
.sync_group
is not None and \
262 self
.sync_group
!= other
.sync_group
:
264 ogroup
= other
.sync_group
265 Client
.objects
.filter(sync_group
=ogroup
)\
266 .update(sync_group
=self
.sync_group
)
269 elif self
.sync_group
is None and \
270 other
.sync_group
is None:
271 sg
= SyncGroup
.objects
.create(user
=self
.user
)
272 other
.sync_group
= sg
277 elif self
.sync_group
is not None:
278 other
.sync_group
= self
.sync_group
281 elif other
.sync_group
is not None:
282 self
.sync_group
= other
.sync_group
286 """ Stop synchronisation with other clients """
289 logger
.info('Stopping synchronisation of %r', self
)
290 self
.sync_group
= None
293 clients
= Client
.objects
.filter(sync_group
=sg
)
294 logger
.info('%d other clients remaining in sync group', len(clients
))
297 logger
.info('Deleting sync group %r', sg
)
298 for client
in clients
:
299 client
.sync_group
= None
304 def get_sync_targets(self
):
305 """ Returns the devices and groups with which the device can be synced
307 Groups are represented as lists of devices """
311 user
= UserProxy
.objects
.from_user(self
.user
)
312 for group
in user
.get_grouped_devices():
314 if self
in group
.devices
and group
.is_synced
:
315 # the device's group can't be a sync-target
318 elif group
.is_synced
:
322 # every unsynced device is a sync-target
323 for dev
in group
.devices
:
327 def get_subscribed_podcasts(self
):
328 """ Returns all subscribed podcasts for the device
330 The attribute "url" contains the URL that was used when subscribing to
332 return Podcast
.objects
.filter(subscription__client
=self
)
334 def synced_with(self
):
335 if not self
.sync_group
:
338 return Client
.objects
.filter(sync_group
=self
.sync_group
)\
342 def display_name(self
):
343 return self
.name
or self
.uid
346 return '{name} ({uid})'.format(name
=self
.name
, uid
=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 profile
= UserProfile
.objects
.create(user
=user
)
434 user
.profile
= profile