simplify, refactor APIs
[mygpo.git] / mygpo / api / models / __init__.py
blob61564682eaf52c79e2ed99d3a2bd07c0178830da
2 # This file is part of my.gpodder.org.
4 # my.gpodder.org is free software: you can redistribute it and/or modify it
5 # under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or (at your
7 # option) any later version.
9 # my.gpodder.org is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11 # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
12 # License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with my.gpodder.org. If not, see <http://www.gnu.org/licenses/>.
18 from django.db import models
19 from django.contrib.auth.models import User
20 from datetime import datetime
21 from django.utils.translation import ugettext as _
22 from mygpo.api.fields import SeparatedValuesField, JSONField
23 import hashlib
24 import re
26 from mygpo.api.constants import EPISODE_ACTION_TYPES, DEVICE_TYPES, UNSUBSCRIBE_ACTION, SUBSCRIBE_ACTION, SUBSCRIPTION_ACTION_TYPES
27 from mygpo.log import log
29 class UserProfile(models.Model):
30 user = models.ForeignKey(User, unique=True, db_column='user_ptr_id')
32 public_profile = models.BooleanField(default=True)
33 generated_id = models.BooleanField(default=False)
34 deleted = models.BooleanField(default=False)
35 suggestion_up_to_date = models.BooleanField(default=False)
36 settings = JSONField(default={})
38 def __unicode__(self):
39 return '%s (%s, %s)' % (self.user.username, self.public_profile, self.generated_id)
41 def save(self, *args, **kwargs):
42 self.public_profile = self.settings.get('public_profile', True)
43 super(UserProfile, self).save(*args, **kwargs)
45 class Meta:
46 db_table = 'user'
48 class Podcast(models.Model):
49 url = models.URLField(unique=True, verify_exists=False)
50 title = models.CharField(max_length=100, blank=True)
51 description = models.TextField(blank=True, null=True)
52 link = models.URLField(blank=True, null=True, verify_exists=False)
53 last_update = models.DateTimeField(null=True,blank=True)
54 logo_url = models.CharField(max_length=1000,null=True,blank=True)
55 author = models.CharField(max_length=100, null=True, blank=True)
56 language = models.CharField(max_length=10, null=True, blank=True)
57 group = models.ForeignKey('PodcastGroup', null=True)
58 group_member_name = models.CharField(max_length=20, default=None, null=True, blank=False)
59 content_types = SeparatedValuesField(null=True, blank=True)
62 def subscribe(self, device):
63 """
64 Subscribe to the current Podcast on the given Device
65 """
66 SubscriptionAction.objects.create(podcast=self, action=SUBSCRIBE_ACTION, device=device)
69 def unsubscribe(self, device):
70 """
71 Unsubscribe the current Podcast from the given Device
72 """
73 SubscriptionAction.objects.create(podcast=self, action=UNSUBSCRIBE_ACTION, device=device)
76 def subscriptions(self):
77 """
78 returns all public subscriptions to this podcast
79 """
80 return Subscription.objects.public_subscriptions([self])
83 def subscription_count(self):
84 return self.subscriptions().count()
86 def subscriber_count(self):
87 """
88 Returns the number of public subscriptions to this podcast
89 """
90 subscriptions = self.subscriptions()
91 return subscriptions.values('user').distinct().count()
94 def listener_count(self):
95 from mygpo.data.models import Listener
96 return Listener.objects.filter(podcast=self).values('user').distinct().count()
98 def listener_count_timespan(self, start, end):
99 return EpisodeAction.objects.filter(episode__podcast=self,
100 timestamp__range=(start, end),
101 action='play').values('user_id').distinct().count()
103 def logo_shortname(self):
104 return hashlib.sha1(self.logo_url).hexdigest()
106 def subscribe_targets(self, user):
108 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
109 devices/syncgroups on which the podcast is already subscribed
111 targets = []
113 devices = Device.objects.filter(user=user, deleted=False)
114 for d in devices:
115 subscriptions = [x.podcast for x in d.get_subscriptions()]
116 if self in subscriptions: continue
118 if d.sync_group:
119 if not d.sync_group in targets: targets.append(d.sync_group)
120 else:
121 targets.append(d)
123 return targets
126 def group_with(self, other, grouptitle, myname, othername):
127 if self.group == other.group and self.group != None:
128 return
130 if self.group != None:
131 if other.group == None:
132 self.group.add(other, othername)
134 else:
135 raise ValueError('the podcasts are already in different groups')
136 else:
137 if other.group == None:
138 g = PodcastGroup.objects.create(title=grouptitle)
139 g.add(self, myname)
140 g.add(other, othername)
142 else:
143 oter.group.add(self)
145 def ungroup(self):
146 if self.group == None:
147 raise ValueError('the podcast currently isn\'t in any group')
149 g = self.group
150 self.group = None
151 self.save()
153 podcasts = Podcast.objects.filter(group=g)
154 if podcasts.count() == 1:
155 p = podcasts[0]
156 p.group = None
157 p.save()
159 def get_similar(self):
160 from mygpo.data.models import RelatedPodcast
161 return [r.rel_podcast for r in RelatedPodcast.objects.filter(ref_podcast=self)]
163 def get_episodes(self):
164 return Episode.objects.filter(podcast=self)
166 def __unicode__(self):
167 return self.title if self.title != '' else self.url
169 class Meta:
170 db_table = 'podcast'
173 class PodcastGroup(models.Model):
174 title = models.CharField(max_length=100, blank=False)
176 def add(self, podcast, membername):
177 if podcast.group == self:
178 podcast.group_member_name = membername
180 elif podcast.group != None:
181 podcast.ungroup()
183 podcast.group = self
184 podcast.group_member_name = membername
185 podcast.save()
187 def podcasts(self):
188 return Podcast.objects.filter(group=self)
190 def subscriptions(self):
192 returns the public subscriptions to podcasts in the group
194 return Subscription.objects.public_subscriptions(self.podcasts())
196 def subscription_count(self):
197 return self.subscriptions().count()
199 def subscriber_count(self):
201 Returns the number of public subscriptions to podcasts of this group
203 subscriptions = self.subscriptions()
204 return subscriptions.values('user').distinct().count()
207 def __unicode__(self):
208 return self.title
210 class Meta:
211 db_table = 'podcast_groups'
214 class ToplistEntryManager(models.Manager):
216 def get_query_set(self):
217 return super(ToplistEntryManager, self).get_query_set().order_by('-subscriptions')
220 class ToplistEntry(models.Model):
221 podcast = models.ForeignKey(Podcast, null=True)
222 podcast_group = models.ForeignKey(PodcastGroup, null=True)
223 oldplace = models.IntegerField(db_column='old_place')
224 subscriptions = models.IntegerField(db_column='subscription_count')
226 objects = ToplistEntryManager()
229 def get_item(self):
230 if self.podcast:
231 return self.podcast
232 else:
233 return self.podcast_group
235 def get_podcast(self):
237 Returns a podcast which is representative for this toplist-entry
238 If the entry is a non-grouped podcast, it is returned
239 If the entry is a podcast group, one of its podcasts is returned
241 if self.podcast:
242 return self.podcast
243 else:
244 return self.podcast_group.podcasts()[0]
246 def __unicode__(self):
247 return '%s (%s)' % (self.podcast, self.subscriptions)
249 class Meta:
250 db_table = 'toplist'
253 class EpisodeToplistEntryManager(models.Manager):
255 def get_query_set(self):
256 return super(EpisodeToplistEntryManager, self).get_query_set().order_by('-listeners')
259 class EpisodeToplistEntry(models.Model):
260 episode = models.ForeignKey('Episode')
261 listeners = models.PositiveIntegerField()
263 objects = EpisodeToplistEntryManager()
265 def __unicode__(self):
266 return '%s (%s)' % (self.episode, self.listeners)
268 class Meta:
269 db_table = 'episode_toplist'
272 class SuggestionEntryManager(models.Manager):
274 def for_user(self, user):
275 from mygpo.data.models import SuggestionBlacklist
277 suggestions = SuggestionEntry.objects.filter(user=user).order_by('-priority')
279 subscriptions = [x.podcast for x in Subscription.objects.filter(user=user)]
280 suggestions = filter(lambda x: x.podcast not in subscriptions, suggestions)
282 blacklist = [x.podcast for x in SuggestionBlacklist.objects.filter(user=user)]
283 suggestions = filter(lambda x: x.podcast not in blacklist, suggestions)
285 return suggestions
288 class SuggestionEntry(models.Model):
289 podcast = models.ForeignKey(Podcast)
290 user = models.ForeignKey(User)
291 priority = models.IntegerField()
293 objects = SuggestionEntryManager()
295 def __unicode__(self):
296 return '%s (%s)' % (self.podcast, self.priority)
298 class Meta:
299 db_table = 'suggestion'
302 class Episode(models.Model):
303 podcast = models.ForeignKey(Podcast)
304 url = models.URLField(verify_exists=False)
305 title = models.CharField(max_length=100, blank=True)
306 description = models.TextField(null=True, blank=True)
307 link = models.URLField(null=True, blank=True, verify_exists=False)
308 timestamp = models.DateTimeField(null=True, blank=True)
309 author = models.CharField(max_length=100, null=True, blank=True)
310 duration = models.PositiveIntegerField(null=True, blank=True)
311 filesize = models.PositiveIntegerField(null=True, blank=True)
312 language = models.CharField(max_length=10, null=True, blank=True)
313 last_update = models.DateTimeField(auto_now=True)
314 outdated = models.BooleanField(default=False) #set to true after episode hasn't been found in feed
315 mimetype = models.CharField(max_length=30, blank=True, null=True)
317 def number(self):
318 m = re.search('\D*(\d+)\D+', self.title)
319 return m.group(1) if m else ''
321 def shortname(self):
322 s = self.title
323 s = s.replace(self.podcast.title, '')
324 s = s.replace(self.number(), '')
325 m = re.search('\W*(.+)', s)
326 s = m.group(1) if m else s
327 s = s.strip()
328 return s
330 def listener_count(self):
331 from mygpo.data.models import Listener
332 return Listener.objects.filter(episode=self).values('user').distinct().count()
334 def listener_count_timespan(self, start, end):
335 return EpisodeAction.objects.filter(episode=self,
336 timestamp__range=(start, end),
337 action='play').values('user_id').distinct().count()
339 def __unicode__(self):
340 return '%s (%s)' % (self.shortname(), self.podcast)
342 class Meta:
343 db_table = 'episode'
344 unique_together = ('podcast', 'url')
346 class SyncGroup(models.Model):
348 Devices that should be synced with each other need to be grouped
349 in a SyncGroup.
351 SyncGroups are automatically created by calling
352 device.sync_with(other_device), but can also be created manually.
354 device.sync() synchronizes the device for which the method is called
355 with the other devices in its SyncGroup.
357 user = models.ForeignKey(User)
359 def __unicode__(self):
360 devices = [d.name for d in Device.objects.filter(sync_group=self)]
361 return ', '.join(devices)
363 def devices(self):
364 return Device.objects.filter(sync_group=self)
366 def add(self, device):
367 if device.sync_group == self: return
368 if device.sync_group != None:
369 device.unsync()
371 device.sync_group = self
372 device.save()
374 class Meta:
375 db_table = 'sync_group'
378 class Device(models.Model):
379 user = models.ForeignKey(User)
380 uid = models.SlugField(max_length=50)
381 name = models.CharField(max_length=100, blank=True, default='Default Device')
382 type = models.CharField(max_length=10, choices=DEVICE_TYPES, default='other')
383 sync_group = models.ForeignKey(SyncGroup, blank=True, null=True)
384 deleted = models.BooleanField(default=False)
385 settings = JSONField(default={})
387 def __unicode__(self):
388 return self.name if self.name else _('Unnamed Device (%s)' % self.uid)
390 def get_subscriptions(self):
391 self.sync()
392 return Subscription.objects.filter(device=self)
394 def sync(self):
395 for s in self.get_sync_actions():
396 try:
397 SubscriptionAction.objects.create(device=self, podcast=s.podcast, action=s.action)
398 except Exception, e:
399 log('Error adding subscription action: %s (device %s, podcast %s, action %s)' % (str(e), repr(self), repr(s.podcast), repr(s.action)))
401 def sync_targets(self):
403 returns all Devices and SyncGroups that can be used as a parameter for self.sync_with()
405 sync_targets = list(Device.objects.filter(user=self.user, sync_group=None, deleted=False).exclude(pk=self.id))
407 sync_groups = SyncGroup.objects.filter(user=self.user)
408 if self.sync_group != None: sync_groups = sync_groups.exclude(pk=self.sync_group.id)
410 sync_targets.extend( list(sync_groups) )
411 return sync_targets
414 def get_sync_actions(self):
416 returns the SyncGroupSubscriptionActions correspond to the
417 SubscriptionActions that need to be saved for the current device
418 to synchronize it with its SyncGroup
420 if self.sync_group == None:
421 return []
423 devices = self.sync_group.devices().exclude(pk=self.id)
425 sync_actions = self.latest_actions()
427 for d in devices:
428 a = d.latest_actions()
429 for s in a.keys():
430 if not sync_actions.has_key(s):
431 if a[s].action == SUBSCRIBE_ACTION:
432 sync_actions[s] = a[s]
433 elif a[s].newer_than(sync_actions[s]) and (sync_actions[s].action != a[s].action):
434 sync_actions[s] = a[s]
436 #remove actions that did not change
437 current_state = self.latest_actions()
438 for podcast in current_state.keys():
439 if podcast in current_state and podcast in sync_actions and sync_actions[podcast] == current_state[podcast]:
440 del sync_actions[podcast]
442 return sync_actions.values()
444 def latest_actions(self):
446 returns the latest action for each podcast
447 that has an action on this device
449 #all podcasts that have an action on this device
450 podcasts = [sa.podcast for sa in SubscriptionAction.objects.filter(device=self)]
451 podcasts = list(set(podcasts)) #remove duplicates
453 actions = {}
454 for p in podcasts:
455 actions[p] = self.latest_action(p)
457 return actions
459 def latest_action(self, podcast):
461 returns the latest action for the given podcast on this device
463 actions = SubscriptionAction.objects.filter(podcast=podcast,device=self).order_by('-timestamp', '-id')
464 if actions.count() == 0:
465 return None
466 else:
467 return actions[0]
469 def sync_with(self, other):
471 set the device to be synchronized with other, which can either be a Device or a SyncGroup.
472 this method places them in the same SyncGroup. get_sync_actions() can
473 then return the SyncGroupSubscriptionActions for brining the device
474 in sync with its group
476 if self.user != other.user:
477 raise ValueError('the devices belong to different users')
479 if isinstance(other, SyncGroup):
480 other.add(self)
481 self.save()
482 return
484 if self.sync_group == other.sync_group and self.sync_group != None:
485 return
487 if self.sync_group != None:
488 if other.sync_group == None:
489 self.sync_group.add(other)
491 else:
492 raise ValueError('the devices are in different sync groups')
494 else:
495 if other.sync_group == None:
496 g = SyncGroup.objects.create(user=self.user)
497 g.add(self)
498 g.add(other)
500 else:
501 oter.sync_group.add(self)
503 def unsync(self):
505 stops synchronizing the device
506 this method removes the device from its SyncGroup. If only one
507 device remains in the SyncGroup, it is removed so the device can
508 be used in other groups.
510 if self.sync_group == None:
511 raise ValueError('the device is not synced')
513 g = self.sync_group
514 self.sync_group = None
515 self.save()
517 devices = Device.objects.filter(sync_group=g)
518 if devices.count() == 1:
519 d = devices[0]
520 d.sync_group = None
521 d.save()
522 g.delete()
524 class Meta:
525 db_table = 'device'
527 class EpisodeAction(models.Model):
528 user = models.ForeignKey(User)
529 episode = models.ForeignKey(Episode)
530 device = models.ForeignKey(Device,null=True)
531 action = models.CharField(max_length=10, choices=EPISODE_ACTION_TYPES)
532 timestamp = models.DateTimeField(default=datetime.now)
533 started = models.IntegerField(null=True, blank=True)
534 playmark = models.IntegerField(null=True, blank=True)
535 total = models.IntegerField(null=True, blank=True)
537 def __unicode__(self):
538 return '%s %s %s' % (self.user, self.action, self.episode)
540 def playmark_time(self):
541 return datetime.fromtimestamp(float(self.playmark))
543 def started_time(self):
544 return datetime.fromtimestamp(float(self.started))
546 class Meta:
547 db_table = 'episode_log'
550 class SubscriptionManager(models.Manager):
552 def public_subscriptions(self, podcasts=None, users=None):
554 Returns either all public subscriptions or filtered for the given podcasts and/or users
557 subscriptions = self.filter(podcast__in=podcasts) if podcasts else self.all()
559 # remove users with private profiles
560 subscriptions = subscriptions.exclude(user__userprofile__public_profile=False)
562 # remove inactive (eg deleted) users
563 subscriptions = subscriptions.exclude(user__is_active=False)
565 if podcasts:
566 # remove uers that have marked their subscription to this podcast as private
567 private_users = SubscriptionMeta.objects.filter(podcast__in=podcasts, public=False).values('user')
568 subscriptions = subscriptions.exclude(user__in=private_users)
570 if users:
571 subscriptions = subscriptions.filter(user__in=users)
573 return subscriptions
576 class Subscription(models.Model):
577 device = models.ForeignKey(Device, primary_key=True)
578 podcast = models.ForeignKey(Podcast)
579 user = models.ForeignKey(User)
580 subscribed_since = models.DateTimeField()
582 objects = SubscriptionManager()
584 def __unicode__(self):
585 return '%s - %s on %s' % (self.device.user, self.podcast, self.device)
587 def get_meta(self):
588 #this is different than get_or_create because it does not necessarily create a new meta-object
589 qs = SubscriptionMeta.objects.filter(user=self.user, podcast=self.podcast)
591 if qs.count() == 0:
592 return SubscriptionMeta(user=self.user, podcast=self.podcast)
593 else:
594 return qs[0]
596 #this method has to be overwritten, if not it tries to delete a view
597 def delete(self):
598 pass
600 class Meta:
601 db_table = 'current_subscription'
602 #not available in Django 1.0 (Debian stable)
603 managed = False
606 class SubscriptionMeta(models.Model):
607 user = models.ForeignKey(User)
608 podcast = models.ForeignKey(Podcast)
609 public = models.BooleanField(default=True)
610 settings = JSONField(default={})
612 def __unicode__(self):
613 return '%s - %s - %s' % (self.user, self.podcast, self.public)
615 def save(self, *args, **kwargs):
616 self.public = self.settings.get('public_subscription', True)
617 super(SubscriptionMeta, self).save(*args, **kwargs)
620 class Meta:
621 db_table = 'subscription'
622 unique_together = ('user', 'podcast')
625 class EpisodeSettings(models.Model):
626 user = models.ForeignKey(User)
627 episode = models.ForeignKey(Episode)
628 settings = JSONField(default={})
630 def save(self, *args, **kwargs):
631 super(EpisodeSettings, self).save(*args, **kwargs)
633 from mygpo.api.models.users import EpisodeFavorite
634 fav = self.settings.get('is_favorite', False)
635 if fav:
636 EpisodeFavorite.objects.get_or_create(user=self.user, episode=self.episode)
637 else:
638 EpisodeFavorite.objects.filter(user=self.user, episode=self.episode).delete()
641 class Meta:
642 db_table = 'episode_settings'
643 unique_together = ('user', 'episode')
646 class SubscriptionAction(models.Model):
647 device = models.ForeignKey(Device)
648 podcast = models.ForeignKey(Podcast)
649 action = models.IntegerField(choices=SUBSCRIPTION_ACTION_TYPES)
650 timestamp = models.DateTimeField(blank=True, default=datetime.now)
652 def action_string(self):
653 return 'subscribe' if self.action == SUBSCRIBE_ACTION else 'unsubscribe'
655 def newer_than(self, action):
656 return self.timestamp > action.timestamp
658 def __unicode__(self):
659 return '%s %s %s %s' % (self.device.user, self.device, self.action_string(), self.podcast)
661 class Meta:
662 db_table = 'subscription_log'
663 unique_together = ('device', 'podcast', 'timestamp')
666 class URLSanitizingRule(models.Model):
667 use_podcast = models.BooleanField()
668 use_episode = models.BooleanField()
669 search = models.CharField(max_length=100)
670 search_precompiled = None
671 replace = models.CharField(max_length=100, null=False, blank=True)
672 priority = models.PositiveIntegerField()
673 description = models.TextField(null=False, blank=True)
675 class Meta:
676 db_table = 'sanitizing_rules'
678 def __unicode__(self):
679 return '%s -> %s' % (self.search, self.replace)
682 from mygpo.search.signals import update_podcast_entry, update_podcast_group_entry, remove_podcast_entry, remove_podcast_group_entry
683 from django.db.models.signals import post_save, pre_delete
685 post_save.connect(update_podcast_entry, sender=Podcast)
686 pre_delete.connect(remove_podcast_entry, sender=Podcast)
688 post_save.connect(update_podcast_group_entry, sender=PodcastGroup)
689 pre_delete.connect(remove_podcast_group_entry, sender=PodcastGroup)