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
, UserManager
20 from datetime
import datetime
21 from django
.utils
.translation
import ugettext
as _
22 from mygpo
.api
.fields
import SeparatedValuesField
26 from mygpo
.api
.constants
import EPISODE_ACTION_TYPES
, DEVICE_TYPES
, SUBSCRIBE_ACTION
, UNSUBSCRIBE_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)
37 def __unicode__(self
):
38 return '%s (%s, %s)' % (self
.user
.username
, self
.public_profile
, self
.generated_id
)
43 class Podcast(models
.Model
):
44 url
= models
.URLField(unique
=True, verify_exists
=False)
45 title
= models
.CharField(max_length
=100, blank
=True)
46 description
= models
.TextField(blank
=True, null
=True)
47 link
= models
.URLField(blank
=True, null
=True, verify_exists
=False)
48 last_update
= models
.DateTimeField(null
=True,blank
=True)
49 logo_url
= models
.CharField(max_length
=1000,null
=True,blank
=True)
50 author
= models
.CharField(max_length
=100, null
=True, blank
=True)
51 language
= models
.CharField(max_length
=10, null
=True, blank
=True)
52 group
= models
.ForeignKey('PodcastGroup', null
=True)
53 group_member_name
= models
.CharField(max_length
=20, default
=None, null
=True, blank
=False)
54 content_types
= SeparatedValuesField(null
=True, blank
=True)
56 def subscriptions(self
):
58 returns all public subscriptions to this podcast
60 subscriptions
= Subscription
.objects
.filter(podcast
=self
)
62 # remove users with private profiles
63 subscriptions
= subscriptions
.exclude(user__userprofile__public_profile
=False)
65 # remove inactive (eg deleted) users
66 subscriptions
= subscriptions
.exclude(user__is_active
=False)
68 # remove uers that have marked their subscription to this podcast as private
69 private_users
= SubscriptionMeta
.objects
.filter(podcast
=self
, public
=False).values('user')
70 subscriptions
= subscriptions
.exclude(user__in
=private_users
)
75 def subscription_count(self
):
76 return self
.subscriptions().count()
78 def subscriber_count(self
):
80 Returns the number of public subscriptions to this podcast
82 subscriptions
= self
.subscriptions()
83 return subscriptions
.values('user').distinct().count()
86 def listener_count(self
):
87 from mygpo
.data
.models
import Listener
88 return Listener
.objects
.filter(podcast
=self
).values('user').distinct().count()
90 def logo_shortname(self
):
91 return hashlib
.sha1(self
.logo_url
).hexdigest()
93 def subscribe_targets(self
, user
):
95 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
96 devices/syncgroups on which the podcast is already subscribed
100 devices
= Device
.objects
.filter(user
=user
, deleted
=False)
102 subscriptions
= [x
.podcast
for x
in d
.get_subscriptions()]
103 if self
in subscriptions
: continue
106 if not d
.sync_group
in targets
: targets
.append(d
.sync_group
)
113 def group_with(self
, other
, grouptitle
, myname
, othername
):
114 if self
.group
== other
.group
and self
.group
!= None:
117 if self
.group
!= None:
118 if other
.group
== None:
119 self
.group
.add(other
, othername
)
122 raise ValueError('the podcasts are already in different groups')
124 if other
.group
== None:
125 g
= PodcastGroup
.objects
.create(title
=grouptitle
)
127 g
.add(other
, othername
)
133 if self
.group
== None:
134 raise ValueError('the podcast currently isn\'t in any group')
140 podcasts
= Podcast
.objects
.filter(group
=g
)
141 if podcasts
.count() == 1:
146 def get_similar(self
):
147 from mygpo
.data
.models
import RelatedPodcast
148 return [r
.rel_podcast
for r
in RelatedPodcast
.objects
.filter(ref_podcast
=self
)]
150 def __unicode__(self
):
151 return self
.title
if self
.title
!= '' else self
.url
157 class PodcastGroup(models
.Model
):
158 title
= models
.CharField(max_length
=100, blank
=False)
160 def add(self
, podcast
, membername
):
161 if podcast
.group
== self
:
162 podcast
.group_member_name
= membername
164 elif podcast
.group
!= None:
168 podcast
.group_member_name
= membername
172 return Podcast
.objects
.filter(group
=self
)
174 def __unicode__(self
):
178 db_table
= 'podcast_groups'
181 class ToplistEntryManager(models
.Manager
):
183 def get_query_set(self
):
184 return super(ToplistEntryManager
, self
).get_query_set().order_by('-subscriptions')
187 class ToplistEntry(models
.Model
):
188 podcast
= models
.ForeignKey(Podcast
, null
=True)
189 podcast_group
= models
.ForeignKey(PodcastGroup
, null
=True)
190 oldplace
= models
.IntegerField(db_column
='old_place')
191 subscriptions
= models
.IntegerField(db_column
='subscription_count')
193 objects
= ToplistEntryManager()
200 return self
.podcast_group
202 def get_podcast(self
):
204 Returns a podcast which is representative for this toplist-entry
205 If the entry is a non-grouped podcast, it is returned
206 If the entry is a podcast group, one of its podcasts is returned
211 return self
.podcast_group
.podcasts()[0]
213 def __unicode__(self
):
214 return '%s (%s)' % (self
.podcast
, self
.subscriptions
)
220 class EpisodeToplistEntryManager(models
.Manager
):
222 def get_query_set(self
):
223 return super(EpisodeToplistEntryManager
, self
).get_query_set().order_by('-listeners')
226 class EpisodeToplistEntry(models
.Model
):
227 episode
= models
.ForeignKey('Episode')
228 listeners
= models
.PositiveIntegerField()
230 objects
= EpisodeToplistEntryManager()
232 def __unicode__(self
):
233 return '%s (%s)' % (self
.episode
, self
.listeners
)
236 db_table
= 'episode_toplist'
239 class SuggestionEntryManager(models
.Manager
):
241 def for_user(self
, user
):
242 from mygpo
.data
.models
import SuggestionBlacklist
244 suggestions
= SuggestionEntry
.objects
.filter(user
=user
).order_by('-priority')
246 subscriptions
= [x
.podcast
for x
in Subscription
.objects
.filter(user
=user
)]
247 suggestions
= filter(lambda x
: x
.podcast
not in subscriptions
, suggestions
)
249 blacklist
= [x
.podcast
for x
in SuggestionBlacklist
.objects
.filter(user
=user
)]
250 suggestions
= filter(lambda x
: x
.podcast
not in blacklist
, suggestions
)
255 class SuggestionEntry(models
.Model
):
256 podcast
= models
.ForeignKey(Podcast
)
257 user
= models
.ForeignKey(User
)
258 priority
= models
.IntegerField()
260 objects
= SuggestionEntryManager()
262 def __unicode__(self
):
263 return '%s (%s)' % (self
.podcast
, self
.priority
)
266 db_table
= 'suggestion'
269 class Episode(models
.Model
):
270 podcast
= models
.ForeignKey(Podcast
)
271 url
= models
.URLField(verify_exists
=False)
272 title
= models
.CharField(max_length
=100, blank
=True)
273 description
= models
.TextField(null
=True, blank
=True)
274 link
= models
.URLField(null
=True, blank
=True, verify_exists
=False)
275 timestamp
= models
.DateTimeField(null
=True, blank
=True)
276 author
= models
.CharField(max_length
=100, null
=True, blank
=True)
277 duration
= models
.PositiveIntegerField(null
=True, blank
=True)
278 filesize
= models
.PositiveIntegerField(null
=True, blank
=True)
279 language
= models
.CharField(max_length
=10, null
=True, blank
=True)
280 last_update
= models
.DateTimeField(auto_now
=True)
281 outdated
= models
.BooleanField(default
=False) #set to true after episode hasn't been found in feed
282 mimetype
= models
.CharField(max_length
=30, blank
=True, null
=True)
285 m
= re
.search('\D*(\d+)\D+', self
.title
)
290 s
= s
.replace(self
.podcast
.title
, '')
291 s
= s
.replace(self
.number(), '')
292 s
= re
.search('\W*(.+)', s
).group(1)
296 def listener_count(self
):
297 from mygpo
.data
.models
import Listener
298 return Listener
.objects
.filter(episode
=self
).values('user').distinct().count()
300 def __unicode__(self
):
301 return '%s (%s)' % (self
.shortname(), self
.podcast
)
305 unique_together
= ('podcast', 'url')
307 class SyncGroup(models
.Model
):
309 Devices that should be synced with each other need to be grouped
312 SyncGroups are automatically created by calling
313 device.sync_with(other_device), but can also be created manually.
315 device.sync() synchronizes the device for which the method is called
316 with the other devices in its SyncGroup.
318 user
= models
.ForeignKey(User
)
320 def __unicode__(self
):
321 devices
= [d
.name
for d
in Device
.objects
.filter(sync_group
=self
)]
322 return ', '.join(devices
)
325 return Device
.objects
.filter(sync_group
=self
)
327 def add(self
, device
):
328 if device
.sync_group
== self
: return
329 if device
.sync_group
!= None:
332 device
.sync_group
= self
336 db_table
= 'sync_group'
339 class Device(models
.Model
):
340 user
= models
.ForeignKey(User
)
341 uid
= models
.SlugField(max_length
=50)
342 name
= models
.CharField(max_length
=100, blank
=True)
343 type = models
.CharField(max_length
=10, choices
=DEVICE_TYPES
)
344 sync_group
= models
.ForeignKey(SyncGroup
, blank
=True, null
=True)
345 deleted
= models
.BooleanField(default
=False)
347 def __unicode__(self
):
348 return self
.name
if self
.name
else _('Unnamed Device (%s)' % self
.uid
)
350 def get_subscriptions(self
):
352 return Subscription
.objects
.filter(device
=self
)
355 for s
in self
.get_sync_actions():
357 SubscriptionAction
.objects
.create(device
=self
, podcast
=s
.podcast
, action
=s
.action
)
359 log('Error adding subscription action: %s (device %s, podcast %s, action %s)' % (str(e
), repr(self
), repr(s
.podcast
), repr(s
.action
)))
361 def sync_targets(self
):
363 returns all Devices and SyncGroups that can be used as a parameter for self.sync_with()
365 sync_targets
= list(Device
.objects
.filter(user
=self
.user
, sync_group
=None, deleted
=False).exclude(pk
=self
.id))
367 sync_groups
= SyncGroup
.objects
.filter(user
=self
.user
)
368 if self
.sync_group
!= None: sync_groups
= sync_groups
.exclude(pk
=self
.sync_group
.id)
370 sync_targets
.extend( list(sync_groups
) )
374 def get_sync_actions(self
):
376 returns the SyncGroupSubscriptionActions correspond to the
377 SubscriptionActions that need to be saved for the current device
378 to synchronize it with its SyncGroup
380 if self
.sync_group
== None:
383 devices
= self
.sync_group
.devices().exclude(pk
=self
.id)
385 sync_actions
= self
.latest_actions()
388 a
= d
.latest_actions()
390 if not sync_actions
.has_key(s
):
391 if a
[s
].action
== SUBSCRIBE_ACTION
:
392 sync_actions
[s
] = a
[s
]
393 elif a
[s
].newer_than(sync_actions
[s
]) and (sync_actions
[s
].action
!= a
[s
].action
):
394 sync_actions
[s
] = a
[s
]
396 #remove actions that did not change
397 current_state
= self
.latest_actions()
398 for podcast
in current_state
.keys():
399 if podcast
in current_state
and podcast
in sync_actions
and sync_actions
[podcast
] == current_state
[podcast
]:
400 del sync_actions
[podcast
]
402 return sync_actions
.values()
404 def latest_actions(self
):
406 returns the latest action for each podcast
407 that has an action on this device
409 #all podcasts that have an action on this device
410 podcasts
= [sa
.podcast
for sa
in SubscriptionAction
.objects
.filter(device
=self
)]
411 podcasts
= list(set(podcasts
)) #remove duplicates
415 actions
[p
] = self
.latest_action(p
)
419 def latest_action(self
, podcast
):
421 returns the latest action for the given podcast on this device
423 actions
= SubscriptionAction
.objects
.filter(podcast
=podcast
,device
=self
).order_by('-timestamp', '-id')
424 if actions
.count() == 0:
429 def sync_with(self
, other
):
431 set the device to be synchronized with other, which can either be a Device or a SyncGroup.
432 this method places them in the same SyncGroup. get_sync_actions() can
433 then return the SyncGroupSubscriptionActions for brining the device
434 in sync with its group
436 if self
.user
!= other
.user
:
437 raise ValueError('the devices belong to different users')
439 if isinstance(other
, SyncGroup
):
444 if self
.sync_group
== other
.sync_group
and self
.sync_group
!= None:
447 if self
.sync_group
!= None:
448 if other
.sync_group
== None:
449 self
.sync_group
.add(other
)
452 raise ValueError('the devices are in different sync groups')
455 if other
.sync_group
== None:
456 g
= SyncGroup
.objects
.create(user
=self
.user
)
461 oter
.sync_group
.add(self
)
465 stops synchronizing the device
466 this method removes the device from its SyncGroup. If only one
467 device remains in the SyncGroup, it is removed so the device can
468 be used in other groups.
470 if self
.sync_group
== None:
471 raise ValueError('the device is not synced')
474 self
.sync_group
= None
477 devices
= Device
.objects
.filter(sync_group
=g
)
478 if devices
.count() == 1:
487 class EpisodeAction(models
.Model
):
488 user
= models
.ForeignKey(User
)
489 episode
= models
.ForeignKey(Episode
)
490 device
= models
.ForeignKey(Device
,null
=True)
491 action
= models
.CharField(max_length
=10, choices
=EPISODE_ACTION_TYPES
)
492 timestamp
= models
.DateTimeField(default
=datetime
.now
)
493 started
= models
.IntegerField(null
=True, blank
=True)
494 playmark
= models
.IntegerField(null
=True, blank
=True)
495 total
= models
.IntegerField(null
=True, blank
=True)
497 def __unicode__(self
):
498 return '%s %s %s' % (self
.user
, self
.action
, self
.episode
)
500 def playmark_time(self
):
501 return datetime
.fromtimestamp(float(self
.playmark
))
504 db_table
= 'episode_log'
507 class Subscription(models
.Model
):
508 device
= models
.ForeignKey(Device
, primary_key
=True)
509 podcast
= models
.ForeignKey(Podcast
)
510 user
= models
.ForeignKey(User
)
511 subscribed_since
= models
.DateTimeField()
513 def __unicode__(self
):
514 return '%s - %s on %s' % (self
.device
.user
, self
.podcast
, self
.device
)
517 #this is different than get_or_create because it does not necessarily create a new meta-object
518 qs
= SubscriptionMeta
.objects
.filter(user
=self
.user
, podcast
=self
.podcast
)
521 return SubscriptionMeta(user
=self
.user
, podcast
=self
.podcast
)
525 #this method has to be overwritten, if not it tries to delete a view
530 db_table
= 'current_subscription'
531 #not available in Django 1.0 (Debian stable)
535 class SubscriptionMeta(models
.Model
):
536 user
= models
.ForeignKey(User
)
537 podcast
= models
.ForeignKey(Podcast
)
538 public
= models
.BooleanField(default
=True)
540 def __unicode__(self
):
541 return '%s - %s - %s' % (self
.user
, self
.podcast
, self
.public
)
544 db_table
= 'subscription'
545 unique_together
= ('user', 'podcast')
548 class SubscriptionAction(models
.Model
):
549 device
= models
.ForeignKey(Device
)
550 podcast
= models
.ForeignKey(Podcast
)
551 action
= models
.IntegerField(choices
=SUBSCRIPTION_ACTION_TYPES
)
552 timestamp
= models
.DateTimeField(blank
=True, default
=datetime
.now
)
554 def action_string(self
):
555 return 'subscribe' if self
.action
== SUBSCRIBE_ACTION
else 'unsubscribe'
557 def newer_than(self
, action
):
558 return self
.timestamp
> action
.timestamp
560 def __unicode__(self
):
561 return '%s %s %s %s' % (self
.device
.user
, self
.device
, self
.action_string(), self
.podcast
)
564 db_table
= 'subscription_log'
565 unique_together
= ('device', 'podcast', 'timestamp')
568 class URLSanitizingRule(models
.Model
):
569 use_podcast
= models
.BooleanField()
570 use_episode
= models
.BooleanField()
571 search
= models
.CharField(max_length
=100)
572 search_precompiled
= None
573 replace
= models
.CharField(max_length
=100, null
=False, blank
=True)
574 priority
= models
.PositiveIntegerField()
575 description
= models
.TextField(null
=False, blank
=True)
578 db_table
= 'sanitizing_rules'
580 def __unicode__(self
):
581 return '%s -> %s' % (self
.search
, self
.replace
)
584 from mygpo
.search
.signals
import update_podcast_entry
, update_podcast_group_entry
585 from django
.db
.models
.signals
import post_save
, pre_delete
587 post_save
.connect(update_podcast_entry
, sender
=Podcast
)
588 pre_delete
.connect(update_podcast_entry
, sender
=Podcast
)
590 post_save
.connect(update_podcast_group_entry
, sender
=PodcastGroup
)
591 pre_delete
.connect(update_podcast_group_entry
, sender
=PodcastGroup
)