Merge pull request #793 from gpodder/remove-advertise
[mygpo.git] / mygpo / users / models.py
blob8c473fdefef03559fd03aed679190054ef110647
1 import re
2 import collections
3 import dateutil.parser
5 from django.core.validators import RegexValidator
6 from django.db import transaction, models
7 from django.db.models import Q
8 from django.contrib.auth.models import User as DjangoUser
9 from django.utils.translation import gettext_lazy as _
10 from django.contrib.auth.validators import ASCIIUsernameValidator
11 from django.conf import settings
13 from mygpo.core.models import TwitterModel, UUIDModel, GenericManager, DeleteableModel
14 from mygpo.usersettings.models import UserSettings
15 from mygpo.podcasts.models import Podcast, Episode
16 from mygpo.utils import random_token
18 import logging
20 logger = logging.getLogger(__name__)
23 RE_DEVICE_UID = re.compile(r"^[\w.-]+$")
26 # TODO: derive from ValidationException?
27 class InvalidEpisodeActionAttributes(ValueError):
28 """raised when the attribues of an episode action fail validation"""
31 class SubscriptionException(Exception):
32 """raised when a subscription can not be modified"""
35 GroupedDevices = collections.namedtuple("GroupedDevices", "is_synced devices")
38 class UIDValidator(RegexValidator):
39 """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):
47 def by_username_or_email(self, username, email):
48 """Queries for a User by username or email"""
49 q = Q()
51 if username:
52 q |= Q(username=username)
54 elif email:
55 q |= Q(email=email)
57 if q:
58 return self.get(q)
59 else:
60 raise UserProxy.DoesNotExist
63 class UserProxyManager(GenericManager):
64 """Manager for the UserProxy model"""
66 def get_queryset(self):
67 return UserProxyQuerySet(self.model, using=self._db)
69 def from_user(self, user):
70 """Get the UserProxy corresponding for the given User"""
71 return self.get(pk=user.pk)
74 class UserProxy(DjangoUser):
76 objects = UserProxyManager()
78 # only accept ASCII usernames, see
79 # https://docs.djangoproject.com/en/dev/releases/1.10/#official-support-for-unicode-usernames
80 username_validator = ASCIIUsernameValidator()
82 class Meta:
83 proxy = True
85 @transaction.atomic
86 def activate(self):
87 self.is_active = True
88 self.save()
90 self.profile.activation_key = None
91 self.profile.save()
93 def get_grouped_devices(self):
94 """Returns groups of synced devices and a unsynced group"""
96 clients = (
97 Client.objects.filter(user=self, deleted=False)
98 .order_by("-sync_group")
99 .prefetch_related("sync_group")
102 last_group = object()
103 group = None
105 for client in clients:
106 # check if we have just found a new group
107 if last_group != client.sync_group:
108 if group is not None:
109 yield group
111 group = GroupedDevices(client.sync_group is not None, [])
113 last_group = client.sync_group
114 group.devices.append(client)
116 # yield remaining group
117 if group is not None:
118 yield group
121 class UserProfile(TwitterModel):
122 """Additional information stored for a User"""
124 # the user to which this profile belongs
125 user = models.OneToOneField(
126 settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile"
129 # if False, suggestions should be updated
130 suggestions_up_to_date = models.BooleanField(default=False)
132 # text the user entered about himeself
133 about = models.TextField(blank=True)
135 # Google email address for OAuth login
136 google_email = models.CharField(max_length=100, null=True)
138 # token for accessing subscriptions of this use
139 subscriptions_token = models.CharField(
140 max_length=32, null=True, default=random_token
143 # token for accessing the favorite-episodes feed of this user
144 favorite_feeds_token = models.CharField(
145 max_length=32, null=True, default=random_token
148 # token for automatically updating feeds published by this user
149 publisher_update_token = models.CharField(
150 max_length=32, null=True, default=random_token
153 # token for accessing the userpage of this user
154 userpage_token = models.CharField(max_length=32, null=True, default=random_token)
156 # key for activating the user
157 activation_key = models.CharField(max_length=40, null=True)
159 def get_token(self, token_name):
160 """returns a token"""
162 if token_name not in TOKEN_NAMES:
163 raise TokenException("Invalid token name %s" % token_name)
165 return getattr(self, token_name)
167 def create_new_token(self, token_name):
168 """resets a token"""
170 if token_name not in TOKEN_NAMES:
171 raise TokenException("Invalid token name %s" % token_name)
173 setattr(self, token_name, random_token())
175 @property
176 def settings(self):
177 try:
178 return UserSettings.objects.get(user=self.user, content_type=None)
179 except UserSettings.DoesNotExist:
180 return UserSettings(user=self.user, content_type=None, object_id=None)
183 class SyncGroup(models.Model):
184 """A group of Clients"""
186 user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
188 def sync(self):
189 """Sync the group, ie bring all members up-to-date"""
190 from mygpo.subscriptions.tasks import subscribe
192 # get all subscribed podcasts
193 podcasts = set(self.get_subscribed_podcasts())
195 # bring each client up to date, it it is subscribed to all podcasts
196 for client in self.client_set.all():
197 missing_podcasts = self.get_missing_podcasts(client, podcasts)
198 for podcast in missing_podcasts:
199 subscribe.delay(podcast.pk, self.user.pk, client.uid)
201 def get_subscribed_podcasts(self):
202 return Podcast.objects.filter(subscription__client__sync_group=self)
204 def get_missing_podcasts(self, client, all_podcasts):
205 """the podcasts required to bring the device to the group's state"""
206 client_podcasts = set(client.get_subscribed_podcasts())
207 return all_podcasts.difference(client_podcasts)
209 @property
210 def display_name(self):
211 clients = self.client_set.all()
212 return ", ".join(client.display_name for client in clients)
215 class Client(UUIDModel, DeleteableModel):
216 """A client application"""
218 DESKTOP = "desktop"
219 LAPTOP = "laptop"
220 MOBILE = "mobile"
221 SERVER = "server"
222 TABLET = "tablet"
223 OTHER = "other"
225 TYPES = (
226 (DESKTOP, _("Desktop")),
227 (LAPTOP, _("Laptop")),
228 (MOBILE, _("Cell phone")),
229 (SERVER, _("Server")),
230 (TABLET, _("Tablet")),
231 (OTHER, _("Other")),
234 # User-assigned ID; must be unique for the user
235 uid = models.CharField(max_length=64, validators=[UIDValidator()])
237 # the user to which the Client belongs
238 user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
240 # User-assigned name
241 name = models.CharField(max_length=100, default="New Device")
243 # one of several predefined types
244 type = models.CharField(
245 max_length=max(len(k) for k, v in TYPES), choices=TYPES, default=OTHER
248 # user-agent string from which the Client was last accessed (for writing)
249 user_agent = models.CharField(max_length=300, null=True, blank=True)
251 sync_group = models.ForeignKey(
252 SyncGroup, null=True, blank=True, on_delete=models.PROTECT
255 class Meta:
256 unique_together = (("user", "uid"),)
258 @transaction.atomic
259 def sync_with(self, other):
260 """Puts two devices in a common sync group"""
262 if self.user != other.user:
263 raise ValueError("the devices do not belong to the user")
265 if (
266 self.sync_group is not None
267 and other.sync_group is not None
268 and self.sync_group != other.sync_group
270 # merge sync_groups
271 ogroup = other.sync_group
272 Client.objects.filter(sync_group=ogroup).update(sync_group=self.sync_group)
273 ogroup.delete()
275 elif self.sync_group is None and other.sync_group is None:
276 sg = SyncGroup.objects.create(user=self.user)
277 other.sync_group = sg
278 other.save()
279 self.sync_group = sg
280 self.save()
282 elif self.sync_group is not None:
283 other.sync_group = self.sync_group
284 other.save()
286 elif other.sync_group is not None:
287 self.sync_group = other.sync_group
288 self.save()
290 def stop_sync(self):
291 """Stop synchronisation with other clients"""
292 sg = self.sync_group
294 logger.info("Stopping synchronisation of %r", self)
295 self.sync_group = None
296 self.save()
298 clients = Client.objects.filter(sync_group=sg)
299 logger.info("%d other clients remaining in sync group", len(clients))
301 if len(clients) < 2:
302 logger.info("Deleting sync group %r", sg)
303 for client in clients:
304 client.sync_group = None
305 client.save()
307 sg.delete()
309 def get_sync_targets(self):
310 """Returns the devices and groups with which the device can be synced
312 Groups are represented as lists of devices"""
314 user = UserProxy.objects.from_user(self.user)
315 for group in user.get_grouped_devices():
317 if self in group.devices and group.is_synced:
318 # the device's group can't be a sync-target
319 continue
321 elif group.is_synced:
322 yield group.devices
324 else:
325 # every unsynced device is a sync-target
326 for dev in group.devices:
327 if not dev == self:
328 yield dev
330 def get_subscribed_podcasts(self):
331 """Returns all subscribed podcasts for the device
333 The attribute "url" contains the URL that was used when subscribing to
334 the podcast"""
335 return Podcast.objects.filter(subscription__client=self)
337 def synced_with(self):
338 if not self.sync_group:
339 return []
341 return Client.objects.filter(sync_group=self.sync_group).exclude(pk=self.pk)
343 @property
344 def display_name(self):
345 return self.name or self.uid
347 def __str__(self):
348 return "{name} ({uid})".format(name=self.name, uid=self.uid)
351 TOKEN_NAMES = (
352 "subscriptions_token",
353 "favorite_feeds_token",
354 "publisher_update_token",
355 "userpage_token",
359 class TokenException(Exception):
360 pass
363 class HistoryEntry(object):
364 """A class that can represent subscription and episode actions"""
366 @classmethod
367 def from_action_dict(cls, action):
369 entry = HistoryEntry()
371 if "timestamp" in action:
372 ts = action.pop("timestamp")
373 entry.timestamp = dateutil.parser.parse(ts)
375 for key, value in action.items():
376 setattr(entry, key, value)
378 return entry
380 @property
381 def playmark(self):
382 return getattr(self, "position", None)
384 @classmethod
385 def fetch_data(cls, user, entries, podcasts=None, episodes=None):
386 """Efficiently loads additional data for a number of entries"""
388 if podcasts is None:
389 # load podcast data
390 podcast_ids = [getattr(x, "podcast_id", None) for x in entries]
391 podcast_ids = filter(None, podcast_ids)
392 podcasts = Podcast.objects.filter(id__in=podcast_ids).prefetch_related(
393 "slugs"
395 podcasts = {podcast.id.hex: podcast for podcast in podcasts}
397 if episodes is None:
398 # load episode data
399 episode_ids = [getattr(x, "episode_id", None) for x in entries]
400 episode_ids = filter(None, episode_ids)
401 episodes = (
402 Episode.objects.filter(id__in=episode_ids)
403 .select_related("podcast")
404 .prefetch_related("slugs", "podcast__slugs")
406 episodes = {episode.id.hex: episode for episode in episodes}
408 # load device data
409 # does not need pre-populated data because no db-access is required
410 device_ids = [getattr(x, "device_id", None) for x in entries]
411 device_ids = filter(None, device_ids)
412 devices = {client.id.hex: client for client in user.client_set.all()}
414 for entry in entries:
415 podcast_id = getattr(entry, "podcast_id", None)
416 entry.podcast = podcasts.get(podcast_id, None)
418 episode_id = getattr(entry, "episode_id", None)
419 entry.episode = episodes.get(episode_id, None)
421 if hasattr(entry, "user"):
422 entry.user = user
424 device = devices.get(getattr(entry, "device_id", None), None)
425 entry.device = device
427 return entries