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
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"""
42 message
= "Invalid Device ID"
46 class UserProxyQuerySet(models
.QuerySet
):
47 def by_username_or_email(self
, username
, email
):
48 """Queries for a User by username or email"""
52 q |
= Q(username
=username
)
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()
90 self
.profile
.activation_key
= None
93 def get_grouped_devices(self
):
94 """Returns groups of synced devices and a unsynced group"""
97 Client
.objects
.filter(user
=self
, deleted
=False)
98 .order_by("-sync_group")
99 .prefetch_related("sync_group")
102 last_group
= object()
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:
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:
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
):
170 if token_name
not in TOKEN_NAMES
:
171 raise TokenException("Invalid token name %s" % token_name
)
173 setattr(self
, token_name
, random_token())
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
)
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
)
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"""
226 (DESKTOP
, _("Desktop")),
227 (LAPTOP
, _("Laptop")),
228 (MOBILE
, _("Cell phone")),
229 (SERVER
, _("Server")),
230 (TABLET
, _("Tablet")),
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
)
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
256 unique_together
= (("user", "uid"),)
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")
266 self
.sync_group
is not None
267 and other
.sync_group
is not None
268 and self
.sync_group
!= other
.sync_group
271 ogroup
= other
.sync_group
272 Client
.objects
.filter(sync_group
=ogroup
).update(sync_group
=self
.sync_group
)
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
282 elif self
.sync_group
is not None:
283 other
.sync_group
= self
.sync_group
286 elif other
.sync_group
is not None:
287 self
.sync_group
= other
.sync_group
291 """Stop synchronisation with other clients"""
294 logger
.info("Stopping synchronisation of %r", self
)
295 self
.sync_group
= None
298 clients
= Client
.objects
.filter(sync_group
=sg
)
299 logger
.info("%d other clients remaining in sync group", len(clients
))
302 logger
.info("Deleting sync group %r", sg
)
303 for client
in clients
:
304 client
.sync_group
= None
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
321 elif group
.is_synced
:
325 # every unsynced device is a sync-target
326 for dev
in group
.devices
:
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
335 return Podcast
.objects
.filter(subscription__client
=self
)
337 def synced_with(self
):
338 if not self
.sync_group
:
341 return Client
.objects
.filter(sync_group
=self
.sync_group
).exclude(pk
=self
.pk
)
344 def display_name(self
):
345 return self
.name
or self
.uid
348 return "{name} ({uid})".format(name
=self
.name
, uid
=self
.uid
)
352 "subscriptions_token",
353 "favorite_feeds_token",
354 "publisher_update_token",
359 class TokenException(Exception):
363 class HistoryEntry(object):
364 """A class that can represent subscription and episode actions"""
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
)
382 return getattr(self
, "position", None)
385 def fetch_data(cls
, user
, entries
, podcasts
=None, episodes
=None):
386 """Efficiently loads additional data for a number of entries"""
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(
395 podcasts
= {podcast
.id.hex: podcast
for podcast
in podcasts
}
399 episode_ids
= [getattr(x
, "episode_id", None) for x
in entries
]
400 episode_ids
= filter(None, episode_ids
)
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
}
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"):
424 device
= devices
.get(getattr(entry
, "device_id", None), None)
425 entry
.device
= device