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
26 from mygpo
.api
.constants
import EPISODE_ACTION_TYPES
, DEVICE_TYPES
, 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
)
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)
61 def subscriptions(self
):
63 returns all public subscriptions to this podcast
65 return Subscription
.objects
.public_subscriptions([self
])
68 def subscription_count(self
):
69 return self
.subscriptions().count()
71 def subscriber_count(self
):
73 Returns the number of public subscriptions to this podcast
75 subscriptions
= self
.subscriptions()
76 return subscriptions
.values('user').distinct().count()
79 def listener_count(self
):
80 from mygpo
.data
.models
import Listener
81 return Listener
.objects
.filter(podcast
=self
).values('user').distinct().count()
83 def listener_count_timespan(self
, start
, end
):
84 return EpisodeAction
.objects
.filter(episode__podcast
=self
,
85 timestamp__range
=(start
, end
),
86 action
='play').values('user_id').distinct().count()
88 def logo_shortname(self
):
89 return hashlib
.sha1(self
.logo_url
).hexdigest()
91 def subscribe_targets(self
, user
):
93 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
94 devices/syncgroups on which the podcast is already subscribed
98 devices
= Device
.objects
.filter(user
=user
, deleted
=False)
100 subscriptions
= [x
.podcast
for x
in d
.get_subscriptions()]
101 if self
in subscriptions
: continue
104 if not d
.sync_group
in targets
: targets
.append(d
.sync_group
)
111 def group_with(self
, other
, grouptitle
, myname
, othername
):
112 if self
.group
== other
.group
and self
.group
!= None:
115 if self
.group
!= None:
116 if other
.group
== None:
117 self
.group
.add(other
, othername
)
120 raise ValueError('the podcasts are already in different groups')
122 if other
.group
== None:
123 g
= PodcastGroup
.objects
.create(title
=grouptitle
)
125 g
.add(other
, othername
)
131 if self
.group
== None:
132 raise ValueError('the podcast currently isn\'t in any group')
138 podcasts
= Podcast
.objects
.filter(group
=g
)
139 if podcasts
.count() == 1:
144 def get_similar(self
):
145 from mygpo
.data
.models
import RelatedPodcast
146 return [r
.rel_podcast
for r
in RelatedPodcast
.objects
.filter(ref_podcast
=self
)]
148 def get_episodes(self
):
149 return Episode
.objects
.filter(podcast
=self
)
151 def __unicode__(self
):
152 return self
.title
if self
.title
!= '' else self
.url
158 class PodcastGroup(models
.Model
):
159 title
= models
.CharField(max_length
=100, blank
=False)
161 def add(self
, podcast
, membername
):
162 if podcast
.group
== self
:
163 podcast
.group_member_name
= membername
165 elif podcast
.group
!= None:
169 podcast
.group_member_name
= membername
173 return Podcast
.objects
.filter(group
=self
)
175 def subscriptions(self
):
177 returns the public subscriptions to podcasts in the group
179 return Subscription
.objects
.public_subscriptions(self
.podcasts())
181 def subscription_count(self
):
182 return self
.subscriptions().count()
184 def subscriber_count(self
):
186 Returns the number of public subscriptions to podcasts of this group
188 subscriptions
= self
.subscriptions()
189 return subscriptions
.values('user').distinct().count()
192 def __unicode__(self
):
196 db_table
= 'podcast_groups'
199 class ToplistEntryManager(models
.Manager
):
201 def get_query_set(self
):
202 return super(ToplistEntryManager
, self
).get_query_set().order_by('-subscriptions')
205 class ToplistEntry(models
.Model
):
206 podcast
= models
.ForeignKey(Podcast
, null
=True)
207 podcast_group
= models
.ForeignKey(PodcastGroup
, null
=True)
208 oldplace
= models
.IntegerField(db_column
='old_place')
209 subscriptions
= models
.IntegerField(db_column
='subscription_count')
211 objects
= ToplistEntryManager()
218 return self
.podcast_group
220 def get_podcast(self
):
222 Returns a podcast which is representative for this toplist-entry
223 If the entry is a non-grouped podcast, it is returned
224 If the entry is a podcast group, one of its podcasts is returned
229 return self
.podcast_group
.podcasts()[0]
231 def __unicode__(self
):
232 return '%s (%s)' % (self
.podcast
, self
.subscriptions
)
238 class EpisodeToplistEntryManager(models
.Manager
):
240 def get_query_set(self
):
241 return super(EpisodeToplistEntryManager
, self
).get_query_set().order_by('-listeners')
244 class EpisodeToplistEntry(models
.Model
):
245 episode
= models
.ForeignKey('Episode')
246 listeners
= models
.PositiveIntegerField()
248 objects
= EpisodeToplistEntryManager()
250 def __unicode__(self
):
251 return '%s (%s)' % (self
.episode
, self
.listeners
)
254 db_table
= 'episode_toplist'
257 class SuggestionEntryManager(models
.Manager
):
259 def for_user(self
, user
):
260 from mygpo
.data
.models
import SuggestionBlacklist
262 suggestions
= SuggestionEntry
.objects
.filter(user
=user
).order_by('-priority')
264 subscriptions
= [x
.podcast
for x
in Subscription
.objects
.filter(user
=user
)]
265 suggestions
= filter(lambda x
: x
.podcast
not in subscriptions
, suggestions
)
267 blacklist
= [x
.podcast
for x
in SuggestionBlacklist
.objects
.filter(user
=user
)]
268 suggestions
= filter(lambda x
: x
.podcast
not in blacklist
, suggestions
)
273 class SuggestionEntry(models
.Model
):
274 podcast
= models
.ForeignKey(Podcast
)
275 user
= models
.ForeignKey(User
)
276 priority
= models
.IntegerField()
278 objects
= SuggestionEntryManager()
280 def __unicode__(self
):
281 return '%s (%s)' % (self
.podcast
, self
.priority
)
284 db_table
= 'suggestion'
287 class Episode(models
.Model
):
288 podcast
= models
.ForeignKey(Podcast
)
289 url
= models
.URLField(verify_exists
=False)
290 title
= models
.CharField(max_length
=100, blank
=True)
291 description
= models
.TextField(null
=True, blank
=True)
292 link
= models
.URLField(null
=True, blank
=True, verify_exists
=False)
293 timestamp
= models
.DateTimeField(null
=True, blank
=True)
294 author
= models
.CharField(max_length
=100, null
=True, blank
=True)
295 duration
= models
.PositiveIntegerField(null
=True, blank
=True)
296 filesize
= models
.PositiveIntegerField(null
=True, blank
=True)
297 language
= models
.CharField(max_length
=10, null
=True, blank
=True)
298 last_update
= models
.DateTimeField(auto_now
=True)
299 outdated
= models
.BooleanField(default
=False) #set to true after episode hasn't been found in feed
300 mimetype
= models
.CharField(max_length
=30, blank
=True, null
=True)
303 m
= re
.search('\D*(\d+)\D+', self
.title
)
304 return m
.group(1) if m
else ''
308 s
= s
.replace(self
.podcast
.title
, '')
309 s
= s
.replace(self
.number(), '')
310 m
= re
.search('\W*(.+)', s
)
311 s
= m
.group(1) if m
else s
315 def listener_count(self
):
316 from mygpo
.data
.models
import Listener
317 return Listener
.objects
.filter(episode
=self
).values('user').distinct().count()
319 def listener_count_timespan(self
, start
, end
):
320 return EpisodeAction
.objects
.filter(episode
=self
,
321 timestamp__range
=(start
, end
),
322 action
='play').values('user_id').distinct().count()
324 def __unicode__(self
):
325 return '%s (%s)' % (self
.shortname(), self
.podcast
)
329 unique_together
= ('podcast', 'url')
331 class SyncGroup(models
.Model
):
333 Devices that should be synced with each other need to be grouped
336 SyncGroups are automatically created by calling
337 device.sync_with(other_device), but can also be created manually.
339 device.sync() synchronizes the device for which the method is called
340 with the other devices in its SyncGroup.
342 user
= models
.ForeignKey(User
)
344 def __unicode__(self
):
345 devices
= [d
.name
for d
in Device
.objects
.filter(sync_group
=self
)]
346 return ', '.join(devices
)
349 return Device
.objects
.filter(sync_group
=self
)
351 def add(self
, device
):
352 if device
.sync_group
== self
: return
353 if device
.sync_group
!= None:
356 device
.sync_group
= self
360 db_table
= 'sync_group'
363 class Device(models
.Model
):
364 user
= models
.ForeignKey(User
)
365 uid
= models
.SlugField(max_length
=50)
366 name
= models
.CharField(max_length
=100, blank
=True)
367 type = models
.CharField(max_length
=10, choices
=DEVICE_TYPES
)
368 sync_group
= models
.ForeignKey(SyncGroup
, blank
=True, null
=True)
369 deleted
= models
.BooleanField(default
=False)
370 settings
= JSONField(default
={})
372 def __unicode__(self
):
373 return self
.name
if self
.name
else _('Unnamed Device (%s)' % self
.uid
)
375 def get_subscriptions(self
):
377 return Subscription
.objects
.filter(device
=self
)
380 for s
in self
.get_sync_actions():
382 SubscriptionAction
.objects
.create(device
=self
, podcast
=s
.podcast
, action
=s
.action
)
384 log('Error adding subscription action: %s (device %s, podcast %s, action %s)' % (str(e
), repr(self
), repr(s
.podcast
), repr(s
.action
)))
386 def sync_targets(self
):
388 returns all Devices and SyncGroups that can be used as a parameter for self.sync_with()
390 sync_targets
= list(Device
.objects
.filter(user
=self
.user
, sync_group
=None, deleted
=False).exclude(pk
=self
.id))
392 sync_groups
= SyncGroup
.objects
.filter(user
=self
.user
)
393 if self
.sync_group
!= None: sync_groups
= sync_groups
.exclude(pk
=self
.sync_group
.id)
395 sync_targets
.extend( list(sync_groups
) )
399 def get_sync_actions(self
):
401 returns the SyncGroupSubscriptionActions correspond to the
402 SubscriptionActions that need to be saved for the current device
403 to synchronize it with its SyncGroup
405 if self
.sync_group
== None:
408 devices
= self
.sync_group
.devices().exclude(pk
=self
.id)
410 sync_actions
= self
.latest_actions()
413 a
= d
.latest_actions()
415 if not sync_actions
.has_key(s
):
416 if a
[s
].action
== SUBSCRIBE_ACTION
:
417 sync_actions
[s
] = a
[s
]
418 elif a
[s
].newer_than(sync_actions
[s
]) and (sync_actions
[s
].action
!= a
[s
].action
):
419 sync_actions
[s
] = a
[s
]
421 #remove actions that did not change
422 current_state
= self
.latest_actions()
423 for podcast
in current_state
.keys():
424 if podcast
in current_state
and podcast
in sync_actions
and sync_actions
[podcast
] == current_state
[podcast
]:
425 del sync_actions
[podcast
]
427 return sync_actions
.values()
429 def latest_actions(self
):
431 returns the latest action for each podcast
432 that has an action on this device
434 #all podcasts that have an action on this device
435 podcasts
= [sa
.podcast
for sa
in SubscriptionAction
.objects
.filter(device
=self
)]
436 podcasts
= list(set(podcasts
)) #remove duplicates
440 actions
[p
] = self
.latest_action(p
)
444 def latest_action(self
, podcast
):
446 returns the latest action for the given podcast on this device
448 actions
= SubscriptionAction
.objects
.filter(podcast
=podcast
,device
=self
).order_by('-timestamp', '-id')
449 if actions
.count() == 0:
454 def sync_with(self
, other
):
456 set the device to be synchronized with other, which can either be a Device or a SyncGroup.
457 this method places them in the same SyncGroup. get_sync_actions() can
458 then return the SyncGroupSubscriptionActions for brining the device
459 in sync with its group
461 if self
.user
!= other
.user
:
462 raise ValueError('the devices belong to different users')
464 if isinstance(other
, SyncGroup
):
469 if self
.sync_group
== other
.sync_group
and self
.sync_group
!= None:
472 if self
.sync_group
!= None:
473 if other
.sync_group
== None:
474 self
.sync_group
.add(other
)
477 raise ValueError('the devices are in different sync groups')
480 if other
.sync_group
== None:
481 g
= SyncGroup
.objects
.create(user
=self
.user
)
486 oter
.sync_group
.add(self
)
490 stops synchronizing the device
491 this method removes the device from its SyncGroup. If only one
492 device remains in the SyncGroup, it is removed so the device can
493 be used in other groups.
495 if self
.sync_group
== None:
496 raise ValueError('the device is not synced')
499 self
.sync_group
= None
502 devices
= Device
.objects
.filter(sync_group
=g
)
503 if devices
.count() == 1:
512 class EpisodeAction(models
.Model
):
513 user
= models
.ForeignKey(User
)
514 episode
= models
.ForeignKey(Episode
)
515 device
= models
.ForeignKey(Device
,null
=True)
516 action
= models
.CharField(max_length
=10, choices
=EPISODE_ACTION_TYPES
)
517 timestamp
= models
.DateTimeField(default
=datetime
.now
)
518 started
= models
.IntegerField(null
=True, blank
=True)
519 playmark
= models
.IntegerField(null
=True, blank
=True)
520 total
= models
.IntegerField(null
=True, blank
=True)
522 def __unicode__(self
):
523 return '%s %s %s' % (self
.user
, self
.action
, self
.episode
)
525 def playmark_time(self
):
526 return datetime
.fromtimestamp(float(self
.playmark
))
528 def started_time(self
):
529 return datetime
.fromtimestamp(float(self
.started
))
532 db_table
= 'episode_log'
535 class SubscriptionManager(models
.Manager
):
537 def public_subscriptions(self
, podcasts
=None):
539 Returns either all public subscriptions or those for the given podcasts
542 subscriptions
= self
.filter(podcast__in
=podcasts
) if podcasts
else self
.all()
544 # remove users with private profiles
545 subscriptions
= subscriptions
.exclude(user__userprofile__public_profile
=False)
547 # remove inactive (eg deleted) users
548 subscriptions
= subscriptions
.exclude(user__is_active
=False)
551 # remove uers that have marked their subscription to this podcast as private
552 private_users
= SubscriptionMeta
.objects
.filter(podcast__in
=podcasts
, public
=False).values('user')
553 subscriptions
= subscriptions
.exclude(user__in
=private_users
)
558 class Subscription(models
.Model
):
559 device
= models
.ForeignKey(Device
, primary_key
=True)
560 podcast
= models
.ForeignKey(Podcast
)
561 user
= models
.ForeignKey(User
)
562 subscribed_since
= models
.DateTimeField()
564 objects
= SubscriptionManager()
566 def __unicode__(self
):
567 return '%s - %s on %s' % (self
.device
.user
, self
.podcast
, self
.device
)
570 #this is different than get_or_create because it does not necessarily create a new meta-object
571 qs
= SubscriptionMeta
.objects
.filter(user
=self
.user
, podcast
=self
.podcast
)
574 return SubscriptionMeta(user
=self
.user
, podcast
=self
.podcast
)
578 #this method has to be overwritten, if not it tries to delete a view
583 db_table
= 'current_subscription'
584 #not available in Django 1.0 (Debian stable)
588 class SubscriptionMeta(models
.Model
):
589 user
= models
.ForeignKey(User
)
590 podcast
= models
.ForeignKey(Podcast
)
591 public
= models
.BooleanField(default
=True)
592 settings
= JSONField(default
={})
594 def __unicode__(self
):
595 return '%s - %s - %s' % (self
.user
, self
.podcast
, self
.public
)
597 def save(self
, *args
, **kwargs
):
598 self
.public
= self
.settings
.get('public_subscription', True)
599 super(SubscriptionMeta
, self
).save(*args
, **kwargs
)
603 db_table
= 'subscription'
604 unique_together
= ('user', 'podcast')
607 class EpisodeSettings(models
.Model
):
608 user
= models
.ForeignKey(User
)
609 episode
= models
.ForeignKey(Episode
)
610 settings
= JSONField(default
={})
612 def save(self
, *args
, **kwargs
):
613 super(EpisodeSettings
, self
).save(*args
, **kwargs
)
615 from mygpo
.api
.models
.users
import EpisodeFavorite
616 fav
= self
.settings
.get('is_favorite', False)
618 EpisodeFavorite
.objects
.get_or_create(user
=self
.user
, episode
=self
.episode
)
620 EpisodeFavorite
.objects
.filter(user
=self
.user
, episode
=self
.episode
).delete()
624 db_table
= 'episode_settings'
625 unique_together
= ('user', 'episode')
628 class SubscriptionAction(models
.Model
):
629 device
= models
.ForeignKey(Device
)
630 podcast
= models
.ForeignKey(Podcast
)
631 action
= models
.IntegerField(choices
=SUBSCRIPTION_ACTION_TYPES
)
632 timestamp
= models
.DateTimeField(blank
=True, default
=datetime
.now
)
634 def action_string(self
):
635 return 'subscribe' if self
.action
== SUBSCRIBE_ACTION
else 'unsubscribe'
637 def newer_than(self
, action
):
638 return self
.timestamp
> action
.timestamp
640 def __unicode__(self
):
641 return '%s %s %s %s' % (self
.device
.user
, self
.device
, self
.action_string(), self
.podcast
)
644 db_table
= 'subscription_log'
645 unique_together
= ('device', 'podcast', 'timestamp')
648 class URLSanitizingRule(models
.Model
):
649 use_podcast
= models
.BooleanField()
650 use_episode
= models
.BooleanField()
651 search
= models
.CharField(max_length
=100)
652 search_precompiled
= None
653 replace
= models
.CharField(max_length
=100, null
=False, blank
=True)
654 priority
= models
.PositiveIntegerField()
655 description
= models
.TextField(null
=False, blank
=True)
658 db_table
= 'sanitizing_rules'
660 def __unicode__(self
):
661 return '%s -> %s' % (self
.search
, self
.replace
)
664 from mygpo
.search
.signals
import update_podcast_entry
, update_podcast_group_entry
, remove_podcast_entry
, remove_podcast_group_entry
665 from django
.db
.models
.signals
import post_save
, pre_delete
667 post_save
.connect(update_podcast_entry
, sender
=Podcast
)
668 pre_delete
.connect(remove_podcast_entry
, sender
=Podcast
)
670 post_save
.connect(update_podcast_group_entry
, sender
=PodcastGroup
)
671 pre_delete
.connect(remove_podcast_group_entry
, sender
=PodcastGroup
)