diff --git a/chitatel/settings.py b/chitatel/settings.py index c089e3a..d58556a 100644 --- a/chitatel/settings.py +++ b/chitatel/settings.py @@ -9,6 +9,9 @@ import sys +import djcelery +djcelery.setup_loader() + from chitatel.utils import BOOL, ENV, dict_combine, import_settings, rel @@ -19,7 +22,8 @@ # Database settings DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'ENGINE': 'dbpool.db.backends.postgresql_psycopg2', + 'OPTIONS': {'MAX_CONNS': 1, 'autocommit': True}, 'NAME': 'chitatel', 'USER': 'chitatel', 'PASSWORD': 'chitatel', @@ -28,6 +32,10 @@ } } +SOUTH_DATABASE_ADAPTERS = { + 'default': 'south.db.postgresql_psycopg2', +} + # Date and time settings TIME_ZONE = ENV('TIME_ZONE', 'Europe/Kiev') USE_TZ = BOOL(ENV('USE_TZ', True)) @@ -46,6 +54,8 @@ # 3rd party applications 'debug_toolbar', 'django_extensions', + 'djcelery', + 'djkombu', 'south', # Our applications @@ -164,3 +174,6 @@ # Add local loging settings dict_combine(LOGGING, LOCAL_LOGGING, False) + +# Celery settings +BROKER_BACKEND = 'djkombu.transport.DatabaseTransport' diff --git a/feeds/admin.py b/feeds/admin.py index 3ab2f84..2a75d33 100644 --- a/feeds/admin.py +++ b/feeds/admin.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + """ =========== feeds.admin @@ -9,9 +11,28 @@ from django.contrib import admin -from .models import Entry, Feed, Tag +from .models import Feed, Entry, Tag +from .forms import FeedAdminForm + + +class FeedAdmin(admin.ModelAdmin): + form = FeedAdminForm + list_display = ('_title', 'url', 'reloaded_at') + readonly_fields = ('reloaded_at', '_dict',) + + def save_model(self, request, obj, form, change): + if form.cleaned_data['reload_url']: + obj.reload() + else: + obj.save() +admin.site.register(Feed, FeedAdmin) + + +class EntryAdmin(admin.ModelAdmin): + list_display = ('feed', '_title', '_link') + list_display_links = ('_title',) + readonly_fields = ('feed', '_title', '_dict',) +admin.site.register(Entry, EntryAdmin) -admin.site.register(Entry) -admin.site.register(Feed) admin.site.register(Tag) diff --git a/feeds/fields.py b/feeds/fields.py new file mode 100644 index 0000000..18febd4 --- /dev/null +++ b/feeds/fields.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +import inspect + +from django.db import models + +from south.modelsinspector import add_introspection_rules +from picklefield.fields import PickledObjectField + + +class LazyFieldMixin(object): + ''' + Примесь, позволяющая отложить для классов-полей преобразование БД -> питон + до первого к ним обращения. Это может быть полезно, если обращение к полю + происходит только в редких случаях, а преобразование занимает много + ресурсов (память, время, процессор, соединение с сетью, ...). + ''' + pass # TODO + + +class DenormalizedFieldMixin(object): + ''' + Общие свойства полей для хранения денормализованных данных. Значения таких + полей нельзя редактиовать с помощью форм, а также передавать на сторону + БД при сохранении объекта. Поменять значения таких полей можно только + через метод update queryset'a. Где будет этот update - это уже другой + вопрос. Некоторые поля можно устанавливать при сохранении своей модели, + некоторые - чужой, а еще некоторые - в обработчиках сигналов. Названия + таких полей в модели должно начинаться с символа _, а доступ к ним лучше + делать с помощью соответствующих свойств без символа _. Эта примесь, а + также логика работы с такими полями, не есть что-то универсальное, что + потом можно будет использовать в другом проекте. Для этого нужно сделать + что-то типа DenormalizedModelMixin. Если будут такие пожелания, можно + попробовать это сделать. + ''' + def __init__(self, *args, **kwargs): + kwargs = kwargs.copy() + for option_name, default_value in (('blank', True), + ('null', True), + ('default', None), + ('editable', False)): + if option_name not in kwargs: + kwargs[option_name] = default_value + + super(DenormalizedFieldMixin, self).__init__(*args, **kwargs) + + +class DenormalizedDictField(LazyFieldMixin, + DenormalizedFieldMixin, + PickledObjectField): + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + +class DenormalizedCharField(DenormalizedFieldMixin, models.CharField): + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + +class DenormalizedDateTimeField(DenormalizedFieldMixin, models.DateTimeField): + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + +# учим south работать с нашими полями +local_symbols = locals() +for name in dir(): + value = local_symbols[name] + + if inspect.isclass(value) and issubclass(value, DenormalizedFieldMixin): + regexp = u'^%s.%s$' % (value.__module__, value.__name__) + regexp = regexp.replace('.', '\.') + add_introspection_rules([], [regexp]) diff --git a/feeds/forms.py b/feeds/forms.py new file mode 100644 index 0000000..a63a84f --- /dev/null +++ b/feeds/forms.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class FeedAdminForm(forms.ModelForm): + reload_url = forms.BooleanField(label=_('Reload URL'), required=False) diff --git a/feeds/managers.py b/feeds/managers.py new file mode 100644 index 0000000..54afa0c --- /dev/null +++ b/feeds/managers.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from django.db import models + + +class FeedManager(models.Manager): + def get_query_set(self, *args, **kwargs): + return super( + FeedManager, self + ).get_query_set(*args, **kwargs).defer('_dict') + + +class EntryManager(models.Manager): + def get_query_set(self, *args, **kwargs): + return super( + EntryManager, self + ).get_query_set( + *args, **kwargs + ).defer('_dict').prefetch_related('feed') diff --git a/feeds/models.py b/feeds/models.py index b5e72e7..957898f 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -1,65 +1,294 @@ -""" -============ -feeds.models -============ +# -*- coding: utf-8 -*- -Implement core models for Chitatel project: feed, entry and tag. +import hashlib -""" +import feedparser -from django.db import models +from django.db import transaction +from django.db import models, IntegrityError +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +from .fields import (DenormalizedDictField, DenormalizedFieldMixin, + DenormalizedCharField, DenormalizedDateTimeField) +from .managers import FeedManager, EntryManager -class Feed(models.Model): - """ - Feed model. - Save basic information about feed, like URL, title, short description. - """ - title = models.CharField(_('title'), max_length=128) - href = models.URLField( - _('href'), max_length=255, unique=True, help_text=_('Feed href.') - ) - link = models.URLField( - _('link'), blank=True, max_length=255, - help_text=_('Feed link, in most cases URL of site which produces ' - 'feed.') - ) - summary = models.TextField(_('summary'), blank=True) +class FeedParserDict(models.Model): + ''' + Абстрактная модель для хранения информации об интересующей нас части + rss/atom-ленты. Такую информацию можно получить из результата разбора + ленты функцией feedparser.parse(...) -> dict. Интересующую нас структуру + храним в поле _dict, а отдельные ее элементы (к которым должен быть + доступ со стороны ОРМ или которые будут использоваться в 90% случаях) + добавляем как денормализованные поля в нашу модель. При изменении поля + _dict (с помощью соответствующего свойства) каждое из таких полей + автоматически обновляется. В дикте храним всю информацию о ленте/статье, + которую только можно получить, а вот использовать ее или нет - выбирать + нам. При использовании жесткой модели (создаем для каждого интересующего + нас параметра поле в модели), в случае необходимости добавить новое св-во + ленты, это св-во будет заполняться только для новых объектов, для + существующих же объектов его значения нам неоткуда будет взять. + ''' + _dict = DenormalizedDictField(_('dict'), db_column='_dict') + + class Meta: + abstract = True + + @property + def dict(self, default={}): + if self._dict is not None: + return self._dict + else: + return default + + @dict.setter + def dict(self, value): + ''' + Сохраняет значение нашего словаря в БД, а также обновляет и сохраняет + значения всех денорм. полей. + ''' + update_kwargs = {} + + self._dict = value + update_kwargs['_dict'] = self._dict + + for denormalized_field in self._meta.fields: + if denormalized_field.name in update_kwargs: + continue + + if not isinstance(denormalized_field, DenormalizedFieldMixin): + continue + + if not denormalized_field.name.startswith('_'): + continue + + normalized_field_name = denormalized_field.name[1:] + + # Устанавливаем значение нашего денорм. поля в None. Это заставит + # соответствующее ему свойство обращаться к _dict для получения + # нового значения, а не использовать закешированное в денорм. поле. + setattr(self, denormalized_field.name, None) + + value = getattr(self, normalized_field_name, None) + update_kwargs[denormalized_field.name] = value + setattr(self, denormalized_field.name, value) + + # Если есть такая возможность, то обновляем значения денорм. полей + # в БД. Если объект еще не был сохранен, то наши денорм. поля будут + # сохранениы всместе с другими полями при первом сохранении. + if self.pk: + self.__class__.objects_with_deferred_dict.filter( + pk=self.pk + ).update(**update_kwargs) + + def save(self, *args, **kwargs): + # Если сохраняем объект в первый раз, то сохраняем в БД все поля, а + # если нет, то выбрасываем все денорм. поля (они обновляются только + # при обновления свойства dict). + if self.pk: + update_fields = [] + + for field in self._meta.fields: + if field.name == 'id': + continue + + if isinstance(field, DenormalizedFieldMixin): + continue + + update_fields.append(field.name) + + kwargs = kwargs.copy() + kwargs['update_fields'] = update_fields + + # Если наш словарь не определен (содержимое ленты еще неизвестно), + # то даем возможность денорм. полям получить какие-то нач. значения. + if self._dict is None: + self.dict = None # вызываем процедуру денормализации # + + super(FeedParserDict, self).save(*args, **kwargs) + + def _get_first_not_none_value(self, _dict, keys, default=u''): + for key in keys: + value = _dict.get(key) + + if value is not None: + return value + + return default + + +class Feed(FeedParserDict): + ''' + Модель для хранения информации о ленте. Список свойств соответствует + списку элементов atom-ленты, т.е. с любой лентой работаем как с + atom-лентой. Элементы rss-ленты приводятся к элементам atom-ленты. + + Сравнение RSS и Atom: + http://www.intertwingly.net/wiki/pie/Rss20AndAtom10Compared + ''' - update_date = models.DateTimeField(_('updated at'), blank=True, null=True) + url = models.URLField(_('URL'), max_length=255, unique=True, default=u'') + reloaded_at = models.DateTimeField(_('reloaded at'), null=True, + blank=True, default=None, + editable=False) + # http://pythonhosted.org/feedparser/reference-bozo_exception.html + bozo_exception = models.CharField(_('bozo exception'), max_length=128, + null=True, blank=True, default=None, + editable=False) + _title = DenormalizedCharField(_('title'), max_length=255, + db_column='_title') + + # Для того, чтобы всегда помнили о производительности и выбирали + # тот или иной менеджер в зависимости от ситуации, название + # менеджера по умолчанию изменено. + objects_without_deferred_dict = models.Manager() + objects_with_deferred_dict = FeedManager() class Meta: db_table = 'chitatel_feed' verbose_name = _('feed') verbose_name_plural = _('feeds') + def __init__(self, *args, **kwargs): + super(Feed, self).__init__(*args, **kwargs) -class Entry(models.Model): - """ - Entry model. + # Будем отслеживать изменение поля url. + self._prev_url = self.url - Save all necessary information about feed entry. - """ - feed = models.ForeignKey( - Feed, related_name='entries', verbose_name=_('feed') - ) - uid = models.CharField(_('UID'), max_length=255) + def __unicode__(self): + return self.title + + @property + def title(self): + if self._title is not None: + return self._title + else: + if self.is_loaded: + return self._get_first_not_none_value(self.dict, ('title',)) + else: + return unicode(_('Loading ...')) + + @property + def is_loaded(self): + return self.reloaded_at is not None + + @property + def is_valid(self): + return not bool(self.bozo_exception) + + def save(self, *args, **kwargs): + # Если у нас меняется урл ленты (хотя такого допускать нельзя, + # поскольку на одну и ту же ленту могут быть подписаны разные + # пользователи), то сбрасываем все поля в начальное состояние. + if self._prev_url != self.url: + self.entries.all().delete() + self.bozo_exception = None + self.reloaded_at = None + self.dict = None + + super(Feed, self).save(*args, **kwargs) + + def reload(self): + ''' + Аналог метода save, но кроме всего прочего, загружает и парсит ленту, + а также добавляет и обновляет статьи. Возвращает http-заголовки. + По кеширующим заголовкам можно будет вычислить время следующего + обновления. + ''' + feed = feedparser.parse(self.url) + self.reloaded_at = timezone.now() + + self.bozo_exception = feed.get('bozo_exception') + if self.bozo_exception is None: + self.dict = feed.feed + + super(Feed, self).save() + + # Добавляем новые статьи и обновляем старые + for _dict in feed.entries: + entry = Entry(feed=self, dict=_dict) - title = models.CharField(_('title'), max_length=128) - link = models.URLField(_('link'), max_length=255) - summary = models.TextField(_('summary'), blank=True) - content = models.TextField(_('content'), blank=True) + try: + entry.save() + except IntegrityError: + transaction.rollback() - publish_date = models.DateTimeField(_('published at')) - update_date = models.DateTimeField(_('updated at'), blank=True, null=True) + #try: + # entry = entry.__class__.objects_with_deferred_dict.get( + # feed=self, _uuid=entry._uuid + # ) + # + # entry.dict = _dict + #except entry.__class__.DoesNotExist: + # pass + + return feed.get('headers') + + +class Entry(FeedParserDict): + feed = models.ForeignKey(Feed, verbose_name=_('feed'), + related_name='entries') + _uuid = DenormalizedCharField(_('uuid'), max_length=255, + db_column='_uuid') + _title = DenormalizedCharField(_('title'), max_length=255, + db_column='_title') + _link = DenormalizedCharField(_('link'), max_length=255, + db_column='_link') + _published_at = DenormalizedDateTimeField(_('published at')) + + objects_without_deferred_dict = models.Manager() + objects_with_deferred_dict = EntryManager() + + def __unicode__(self): + return self.title class Meta: db_table = 'chitatel_entry' - unique_together = ('feed', 'uid') verbose_name = _('entry') verbose_name_plural = _('entries') + unique_together = ('feed', '_uuid') + index_together = (('feed', '_published_at'),) + ordering = ('-_published_at',) + + @property + def uuid(self): + if self._uuid is not None: + return self._uuid + else: + return self._get_first_not_none_value(self.dict, ('id', 'link')) + + @property + def title(self): + if self._title is not None: + return self._title + else: + return self._get_first_not_none_value(self.dict, ('title',)) + + @property + def link(self): + if self._link is not None: + return self._link + else: + return self._get_first_not_none_value(self.dict, ('link',)) + + @property + def published_at(self): + if self._published_at is not None: + return self._published_at + else: + value = self._get_first_not_none_value( + self.dict, ('published_parsed',) + ) + + if not value: + value = timezone.now() + else: + value = timezone.make_aware( + timezone.datetime(*value[:-3]), timezone.utc + ) + + return value class Tag(models.Model): @@ -82,3 +311,9 @@ def __unicode__(self): Unicode representation. """ return self.name + + +try: + from .signals import nothing +except ImportError: + pass diff --git a/feeds/signals.py b/feeds/signals.py new file mode 100644 index 0000000..edad819 --- /dev/null +++ b/feeds/signals.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +from django.db.models import signals + +from .models import Feed +from .tasks import reload_feed + + +def on_feed_update(*args, **kwargs): + feed = kwargs['instance'] + + if kwargs['created'] or feed.reloaded_at is None: + reload_feed.apply_async(args=[feed.pk], countdown=4) +signals.post_save.connect(on_feed_update, sender=Feed) diff --git a/feeds/tasks.py b/feeds/tasks.py new file mode 100644 index 0000000..0d03064 --- /dev/null +++ b/feeds/tasks.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +from datetime import timedelta + +from django.utils.timezone import now + +from djcelery import celery + +from .models import Feed + + +def get_eta(http_headers): + # TODO + return now() + timedelta(minutes=32) + + +@celery.task +def reload_feed(pk): + try: + feed = Feed.objects_with_deferred_dict.get(pk=pk) + except Feed.DoesNotExist: + return + + http_headers = feed.reload() + eta = get_eta(http_headers) + reload_feed.apply_async(args=[feed.pk], eta=eta) diff --git a/requirements.txt b/requirements.txt index b8958bb..1943bcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,8 @@ ipython==0.13.2 pep8==1.4.5 psycopg2==2.5.1 raven==3.3.12 +celery==3.0.20 +django-celery==3.0.17 +django-kombu==0.9.4 +django-db-pool==0.0.9 +django-picklefield==0.3.0