Fix Python 2 / 3 incompatabilities
[mygpo.git] / mygpo / users / models.py
blob702e93f35f03990d5a0a3a3f9be05527f46c6028
3 import re
4 import uuid
5 import collections
6 import dateutil.parser
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
21 import logging
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 """
41 regex = RE_DEVICE_UID
42 message = 'Invalid Device ID'
43 code='invalid-uid'
46 class UserProxyQuerySet(models.QuerySet):
48 def by_username_or_email(self, username, email):
49 """ Queries for a User by username or email """
50 q = Q()
52 if username:
53 q |= Q(username=username)
55 elif email:
56 q |= Q(email=email)
58 if q:
59 return self.get(q)
60 else:
61 return self.none()
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()
79 class Meta:
80 proxy = True
82 @transaction.atomic
83 def activate(self):
84 self.is_active = True
85 self.save()
87 self.profile.activation_key = None
88 self.profile.save()
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')
97 last_group = object()
98 group = None
100 for client in clients:
101 # check if we have just found a new group
102 if last_group != client.sync_group:
103 if group != None:
104 yield 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
112 if group != None:
113 yield 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())
167 @property
168 def settings(self):
169 try:
170 return UserSettings.objects.get(user=self.user, content_type=None)
171 except UserSettings.DoesNotExist:
172 return UserSettings(user=self.user, content_type=None,
173 object_id=None)
176 class SyncGroup(models.Model):
177 """ A group of Clients """
179 user = models.ForeignKey(settings.AUTH_USER_MODEL,
180 on_delete=models.CASCADE)
182 def sync(self):
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)
203 @property
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 """
212 DESKTOP = 'desktop'
213 LAPTOP = 'laptop'
214 MOBILE = 'mobile'
215 SERVER = 'server'
216 TABLET = 'tablet'
217 OTHER = 'other'
219 TYPES = (
220 (DESKTOP, _('Desktop')),
221 (LAPTOP, _('Laptop')),
222 (MOBILE, _('Cell phone')),
223 (SERVER, _('Server')),
224 (TABLET, _('Tablet')),
225 (OTHER, _('Other')),
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)
235 # User-assigned name
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)
248 class Meta:
249 unique_together = (
250 ('user', 'uid'),
253 @transaction.atomic
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:
263 # merge sync_groups
264 ogroup = other.sync_group
265 Client.objects.filter(sync_group=ogroup)\
266 .update(sync_group=self.sync_group)
267 ogroup.delete()
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
273 other.save()
274 self.sync_group = sg
275 self.save()
277 elif self.sync_group is not None:
278 other.sync_group = self.sync_group
279 other.save()
281 elif other.sync_group is not None:
282 self.sync_group = other.sync_group
283 self.save()
285 def stop_sync(self):
286 """ Stop synchronisation with other clients """
287 sg = self.sync_group
289 logger.info('Stopping synchronisation of %r', self)
290 self.sync_group = None
291 self.save()
293 clients = Client.objects.filter(sync_group=sg)
294 logger.info('%d other clients remaining in sync group', len(clients))
296 if len(clients) < 2:
297 logger.info('Deleting sync group %r', sg)
298 for client in clients:
299 client.sync_group = None
300 client.save()
302 sg.delete()
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 """
309 sg = self.sync_group
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
316 continue
318 elif group.is_synced:
319 yield group.devices
321 else:
322 # every unsynced device is a sync-target
323 for dev in group.devices:
324 if not dev == self:
325 yield dev
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
331 the podcast """
332 return Podcast.objects.filter(subscription__client=self)
334 def synced_with(self):
335 if not self.sync_group:
336 return []
338 return Client.objects.filter(sync_group=self.sync_group)\
339 .exclude(pk=self.pk)
341 @property
342 def display_name(self):
343 return self.name or self.uid
345 def __str__(self):
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):
354 pass
357 class HistoryEntry(object):
358 """ A class that can represent subscription and episode actions """
361 @classmethod
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 list(action.items()):
371 setattr(entry, key, value)
373 return entry
376 @property
377 def playmark(self):
378 return getattr(self, 'position', None)
381 @classmethod
382 def fetch_data(cls, user, entries,
383 podcasts=None, episodes=None):
384 """ Efficiently loads additional data for a number of entries """
386 if podcasts is None:
387 # load podcast data
388 podcast_ids = [getattr(x, 'podcast_id', None) for x in entries]
389 podcast_ids = [_f for _f in podcast_ids if _f]
390 podcasts = Podcast.objects.filter(id__in=podcast_ids)\
391 .prefetch_related('slugs')
392 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
394 if episodes is None:
395 # load episode data
396 episode_ids = [getattr(x, 'episode_id', None) for x in entries]
397 episode_ids = [_f for _f in episode_ids if _f]
398 episodes = Episode.objects.filter(id__in=episode_ids)\
399 .select_related('podcast')\
400 .prefetch_related('slugs',
401 'podcast__slugs')
402 episodes = {episode.id.hex: episode for episode in episodes}
404 # load device data
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 = [_f for _f in device_ids if _f]
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'):
419 entry.user = user
421 device = devices.get(getattr(entry, 'device_id', None), None)
422 entry.device = device
425 return entries
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