[Migration] remove remaining CouchDB related code
[mygpo.git] / mygpo / users / models.py
blob70defe0129aa59c4c7122d6c85554cc0ac3be322
1 from __future__ import unicode_literals
3 import re
4 import uuid
5 import collections
6 import dateutil.parser
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
22 import logging
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 """
42 regex = RE_DEVICE_UID
43 message = 'Invalid Device ID'
44 code='invalid-uid'
47 class UserProxyQuerySet(models.QuerySet):
49 def by_username_or_email(self, username, email):
50 """ Queries for a User by username or email """
51 q = Q()
53 if username:
54 q |= Q(username=username)
56 elif email:
57 q |= Q(email=email)
59 if q:
60 return self.get(q)
61 else:
62 return self.none()
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()
80 class Meta:
81 proxy = True
83 @transaction.atomic
84 def activate(self):
85 self.is_active = True
86 self.save()
88 self.profile.activation_key = None
89 self.profile.save()
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')
98 last_group = object()
99 group = None
101 for client in clients:
102 # check if we have just found a new group
103 if last_group != client.sync_group:
104 if group != None:
105 yield 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
113 if group != None:
114 yield 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)
178 def sync(self):
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)
199 @property
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 """
208 DESKTOP = 'desktop'
209 LAPTOP = 'laptop'
210 MOBILE = 'mobile'
211 SERVER = 'server'
212 TABLET = 'tablet'
213 OTHER = 'other'
215 TYPES = (
216 (DESKTOP, _('Desktop')),
217 (LAPTOP, _('Laptop')),
218 (MOBILE, _('Cell phone')),
219 (SERVER, _('Server')),
220 (TABLET, _('Tablet')),
221 (OTHER, _('Other')),
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)
231 # User-assigned name
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)
244 class Meta:
245 unique_together = (
246 ('user', 'uid'),
249 @transaction.atomic
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:
259 # merge sync_groups
260 ogroup = other.sync_group
261 Client.objects.filter(sync_group=ogroup)\
262 .update(sync_group=self.sync_group)
263 ogroup.delete()
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
269 other.save()
270 self.sync_group = sg
271 self.save()
273 elif self.sync_group is not None:
274 other.sync_group = self.sync_group
275 other.save()
277 elif other.sync_group is not None:
278 self.sync_group = other.sync_group
279 self.save()
281 def stop_sync(self):
282 """ Stop synchronisation with other clients """
283 sg = self.sync_group
285 logger.info('Stopping synchronisation of %r', self)
286 self.sync_group = None
287 self.save()
289 clients = Client.objects.filter(sync_group=sg)
290 logger.info('%d other clients remaining in sync group', len(clients))
292 if len(clients) < 2:
293 logger.info('Deleting sync group %r', sg)
294 for client in clients:
295 client.sync_group = None
296 client.save()
298 sg.delete()
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 """
305 sg = self.sync_group
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
312 continue
314 elif group.is_synced:
315 yield group.devices
317 else:
318 # every unsynced device is a sync-target
319 for dev in group.devices:
320 if not dev == self:
321 yield dev
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
327 the podcast """
328 return Podcast.objects.filter(subscription__client=self)
330 def synced_with(self):
331 if not self.sync_group:
332 return []
334 return Client.objects.filter(sync_group=self.sync_group)\
335 .exclude(pk=self.pk)
337 @property
338 def display_name(self):
339 return self.name or self.uid
341 def __str__(self):
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):
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 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 = 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}
394 if episodes is None:
395 # load episode data
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',
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 = 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'):
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 # TODO: remove uuid column once migration from CouchDB is complete
434 import uuid
435 profile = UserProfile.objects.create(user=user, uuid=uuid.uuid1())
436 user.profile = profile