From 3470f5d3072ae29f6e65e4888e308bae7d2a0be4 Mon Sep 17 00:00:00 2001 From: Paul Osman Date: Thu, 18 Nov 2010 17:48:08 -0500 Subject: [PATCH] Starting to work on links in projects. Basically a project can add links and a scheduled task will pull in the RSS or Atom feed and add entries to the activity stream of that project. Work in progress --- apps/feeds/__init__.py | 0 apps/feeds/models.py | 8 ++ apps/feeds/tasks.py | 19 ++++ apps/projects/forms.py | 33 ++++--- ...k_feed_url__add_unique_link_project_url.py | 86 +++++++++++++++++++ apps/projects/models.py | 52 +++++++++-- apps/projects/urls.py | 4 +- apps/projects/views.py | 52 +++++++++-- requirements.txt | 4 + settings.py | 9 +- templates/projects/create_link.html | 11 +++ templates/projects/project.html | 11 +++ 12 files changed, 261 insertions(+), 28 deletions(-) create mode 100644 apps/feeds/__init__.py create mode 100644 apps/feeds/models.py create mode 100644 apps/feeds/tasks.py create mode 100644 apps/projects/migrations/0002_auto__add_field_link_feed_url__add_unique_link_project_url.py create mode 100644 templates/projects/create_link.html diff --git a/apps/feeds/__init__.py b/apps/feeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/feeds/models.py b/apps/feeds/models.py new file mode 100644 index 0000000..ef0398c --- /dev/null +++ b/apps/feeds/models.py @@ -0,0 +1,8 @@ +from django.db import models + +from projects.models import Link + + +class Entry(models.Model): + link = models.ForeignKey(Link, related_name='feed_entries') + processed_on = models.DateTimeField(auto_now_add=True) diff --git a/apps/feeds/tasks.py b/apps/feeds/tasks.py new file mode 100644 index 0000000..ac90baa --- /dev/null +++ b/apps/feeds/tasks.py @@ -0,0 +1,19 @@ +import logging +import feedparser + +from celery.task.schedules import crontab +from celery.decorators import periodic_task + +from projects.models import Link + + +log = logging.getLogger(__name__) + + +@periodic_task(run_every=crontab()) +def load_project_feeds(): + links = Link.objects.all() + for link in links: + feed = feedparser.parse(link.url) + log.debug(len(feed.entries)) + log.info("Running test task") diff --git a/apps/projects/forms.py b/apps/projects/forms.py index 1e4b5e4..8616ed4 100644 --- a/apps/projects/forms.py +++ b/apps/projects/forms.py @@ -4,25 +4,35 @@ from django import forms from django.utils.translation import ugettext as _ from messages.models import Message -from projects.models import Project +from projects.models import Project, Link + class ProtectedProjectForm(forms.ModelForm): def __init__(self, *args, **kwargs): - super(ProtectedProjectForm, self).__init__(*args,**kwargs) - + super(ProtectedProjectForm, self).__init__(*args, **kwargs) protected = getattr(self.Meta, 'protected') project = kwargs.get('instance', None) - + if not project or not project.featured: for field in protected: self.fields.pop(field) + class ProjectForm(ProtectedProjectForm): class Meta: model = Project exclude = ('created_by', 'slug', 'featured') protected = ('template', 'css') + +class ProjectLinkForm(forms.ModelForm): + class Meta: + model = Link + widgets = { + 'project': forms.HiddenInput(), + } + + class ProjectContactUsersForm(forms.Form): """ A modified version of ``messages.forms.ComposeForm`` that enables @@ -31,12 +41,12 @@ class ProjectContactUsersForm(forms.Form): """ project = forms.IntegerField( required=True, - widget=forms.HiddenInput() + widget=forms.HiddenInput(), ) subject = forms.CharField(label=_(u'Subject')) body = forms.CharField( label=_(u'Body'), - widget=forms.Textarea(attrs={'rows': '12', 'cols': '55'}) + widget=forms.Textarea(attrs={'rows': '12', 'cols': '55'}), ) def save(self, sender, parent_msg=None): @@ -44,17 +54,18 @@ class ProjectContactUsersForm(forms.Form): try: project = Project.objects.get(id=int(project)) except Project.DoesNotExist: - raise forms.ValidationError(_(u'Hmm, that does not look like a valid project')) + raise forms.ValidationError( + _(u'Hmm, that does not look like a valid project')) recipients = project.followers() subject = self.cleaned_data['subject'] body = self.cleaned_data['body'] message_list = [] for r in recipients: msg = Message( - sender = sender, - recipient = r, - subject = subject, - body = body, + sender=sender, + recipient=r, + subject=subject, + body=body, ) if parent_msg is not None: msg.parent_msg = parent_msg diff --git a/apps/projects/migrations/0002_auto__add_field_link_feed_url__add_unique_link_project_url.py b/apps/projects/migrations/0002_auto__add_field_link_feed_url__add_unique_link_project_url.py new file mode 100644 index 0000000..ff80c44 --- /dev/null +++ b/apps/projects/migrations/0002_auto__add_field_link_feed_url__add_unique_link_project_url.py @@ -0,0 +1,86 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'Link.feed_url' + db.add_column('projects_link', 'feed_url', self.gf('django.db.models.fields.URLField')(default='', max_length=200), keep_default=False) + + # Adding unique constraint on 'Link', fields ['project', 'url'] + db.create_unique('projects_link', ['project_id', 'url']) + + + def backwards(self, orm): + + # Removing unique constraint on 'Link', fields ['project', 'url'] + db.delete_unique('projects_link', ['project_id', 'url']) + + # Deleting field 'Link.feed_url' + db.delete_column('projects_link', 'feed_url') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'projects.link': { + 'Meta': {'unique_together': "(('project', 'url'),)", 'object_name': 'Link'}, + 'feed_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '250'}), + 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + }, + 'projects.project': { + 'Meta': {'object_name': 'Project'}, + 'call_to_action': ('django.db.models.fields.TextField', [], {}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'projects'", 'to': "orm['auth.User']"}), + 'css': ('django.db.models.fields.TextField', [], {}), + 'description': ('django.db.models.fields.TextField', [], {}), + 'featured': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'template': ('django.db.models.fields.TextField', [], {}) + } + } + + complete_apps = ['projects'] diff --git a/apps/projects/models.py b/apps/projects/models.py index 6c8b2af..fb065a3 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1,32 +1,35 @@ +import urllib + from django.contrib.auth.models import User from django.db import models from django.db.models.signals import post_save from django.template.defaultfilters import slugify +from BeautifulSoup import BeautifulSoup + from relationships.models import followers + class Project(models.Model): """Placeholder model for projects.""" object_type = 'http://drumbeat.org/activity/schema/1.0/project' generalized_object_type = 'http://activitystrea.ms/schema/1.0/group' - name = models.CharField(max_length=100, unique=True) slug = models.SlugField(unique=True) description = models.TextField() call_to_action = models.TextField() created_by = models.ForeignKey(User, related_name='projects') - featured = models.BooleanField() template = models.TextField() css = models.TextField() - + def __unicode__(self): return self.name @models.permalink def get_absolute_url(self): return ('projects_show', (), { - 'slug': self.slug + 'slug': self.slug, }) def save(self): @@ -44,18 +47,55 @@ class Project(models.Model): Project.followers = followers + class Link(models.Model): title = models.CharField(max_length=250) url = models.URLField() project = models.ForeignKey(Project) - + feed_url = models.URLField(editable=False, default='') + + class Meta: + unique_together = ('project', 'url',) + + def get_syndication_url(self): + """ + Parse the contents of this link and return the first Atom + or RSS feed URI we find. + @TODO - Account for cases where multiple rel="alternate" + link elements are found in the document. + """ + contents = urllib.urlopen(self.url).read() + soup = BeautifulSoup(contents) + links = soup.head.findAll('link') + + # BeautifulSoup instances are not actually dictionaries, so + # we can't use the more proper 'key in dict' syntax and + # must instead use the deprecated 'has_key()' method. + alternate = [link for link in links + if link.has_key('rel') and link['rel'] == 'alternate'] + atom = [link['href'] for link in alternate + if (link.has_key('href') and link.has_key('type') + and link['type'] == 'application/atom+xml')] + + # we prefer atom to rss + if atom: + return atom[0] + rss = [link['href'] for link in links + if (link.has_key('href') and link.has_key('type') + and link['type'] == 'application/rss+xml')] + if rss: + return rss[0] + + return None + + def project_creation_handler(sender, **kwargs): project = kwargs.get('instance', None) created = kwargs.get('created', False) if not created or not isinstance(project, Project): return - + try: import activity activity.send(project.created_by, 'create', project) diff --git a/apps/projects/urls.py b/apps/projects/urls.py index 1a325f6..30621b8 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -1,7 +1,7 @@ from django.conf.urls.defaults import patterns, url urlpatterns = patterns('', - url(r'^gallery/$', 'projects.views.gallery', + url(r'^list/$', 'projects.views.list', name='projects_gallery'), url(r'^create/$', 'projects.views.create', name='projects_create'), @@ -14,4 +14,6 @@ urlpatterns = patterns('', name='projects_contact_followers'), url(r'^(?P[\w-]+)/style.css$', 'projects.views.featured_css', name='projects_featured_css'), + url(r'^(?P[\w-]+)/link/create/$', 'projects.views.link_create', + name='projects_link_create'), ) diff --git a/apps/projects/views.py b/apps/projects/views.py index 2da5961..99c4a67 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -1,14 +1,21 @@ +import urllib + from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden +from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseForbidden from django.shortcuts import render_to_response, get_object_or_404 from django.template import RequestContext, Context, Template from django.utils.translation import ugettext as _ +from BeautifulSoup import BeautifulSoup + from projects.models import Project from projects.forms import ProjectForm, ProjectContactUsersForm +from projects.forms import ProjectLinkForm + def show(request, slug): project = get_object_or_404(Project, slug=slug) @@ -30,6 +37,7 @@ def show(request, slug): return render_to_response('projects/featured.html', context, context_instance=RequestContext(request)) + @login_required def edit(request, slug): project = get_object_or_404(Project, slug=slug) @@ -44,17 +52,19 @@ def edit(request, slug): reverse('projects_show', kwargs=dict(slug=project.slug))) else: form = ProjectForm(instance=project) - + return render_to_response('projects/edit.html', { 'form': form, 'project': project, }, context_instance=RequestContext(request)) -def gallery(request): + +def list(request): return render_to_response('projects/gallery.html', { - 'projects': Project.objects.all() + 'projects': Project.objects.all(), }, context_instance=RequestContext(request)) + @login_required def create(request): if request.method == 'POST': @@ -64,19 +74,20 @@ def create(request): project.created_by = request.user project.save() return HttpResponseRedirect(reverse('projects_show', kwargs={ - 'slug': project.slug + 'slug': project.slug, })) else: form = ProjectForm() return render_to_response('projects/create.html', { - 'form': form + 'form': form, }, context_instance=RequestContext(request)) + @login_required def contact_followers(request, slug): project = get_object_or_404(Project, slug=slug) if project.created_by != request.user: - return HttpResponseForbidden + return HttpResponseForbidden() if request.method == 'POST': form = ProjectContactUsersForm(request.POST) if form.is_valid(): @@ -84,7 +95,7 @@ def contact_followers(request, slug): messages.add_message(request, messages.INFO, _("Message successfully sent.")) return HttpResponseRedirect(reverse('projects_show', kwargs={ - 'slug': project.slug + 'slug': project.slug, })) else: form = ProjectContactUsersForm() @@ -94,6 +105,31 @@ def contact_followers(request, slug): 'project': project, }, context_instance=RequestContext(request)) + def featured_css(request, slug): project = get_object_or_404(Project, slug=slug) return HttpResponse(project.css, mimetype='text/css') + + +def link_create(request, slug): + project = get_object_or_404(Project, slug=slug) + if project.created_by != request.user: + return HttpResponseForbidden() + form = ProjectLinkForm(initial=dict(project=project.pk)) + if request.method == 'POST': + form = ProjectLinkForm(data=request.POST) + if form.is_valid(): + messages.add_message(request, messages.INFO, + _("Your link has been created")) + link = form.save() + feed_url = link.get_syndication_url() + if feed_url: + link.feed_url = feed_url + link.save() + return HttpResponseRedirect(reverse('projects_show', kwargs={ + 'slug': project.slug, + })) + return render_to_response('projects/create_link.html', { + 'form': form, + 'project': project, + }, context_instance=RequestContext(request)) diff --git a/requirements.txt b/requirements.txt index 91ba9af..d4ff304 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,10 @@ pil django-messages south jogging +feedparser +Celery==2.1.3 +django-celery +BeautifulSoup -e git://github.com/jbalogh/django-nose#egg=django-nose -e git://github.com/clouserw/tower.git#egg=tower diff --git a/settings.py b/settings.py index 09981d3..e0d34a9 100644 --- a/settings.py +++ b/settings.py @@ -3,6 +3,9 @@ import os import logging +import djcelery + +djcelery.setup_loader() # Make filepaths relative to settings. ROOT = os.path.dirname(os.path.abspath(__file__)) @@ -103,6 +106,9 @@ INSTALLED_APPS = ( 'django.contrib.admin', 'django_nose', 'django_openid_auth', + 'south', + 'jogging', + 'djcelery', 'wellknown', 'users', 'profiles', @@ -113,8 +119,7 @@ INSTALLED_APPS = ( 'projects', 'statuses', 'messages', - 'south', - 'jogging', + 'feeds', ) if DEBUG: diff --git a/templates/projects/create_link.html b/templates/projects/create_link.html new file mode 100644 index 0000000..afbf849 --- /dev/null +++ b/templates/projects/create_link.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% load l10n_tags %} +{% block title %}Add Link{% endblock %} +{% block body %} +

{{ _('Add Link') }}

+
+{% csrf_token %} +{{ form.as_p }} + +
+{% endblock %} diff --git a/templates/projects/project.html b/templates/projects/project.html index c7d7409..a0ccd3c 100644 --- a/templates/projects/project.html +++ b/templates/projects/project.html @@ -10,9 +10,20 @@ {% include "projects/_project_admin.html" %} {% include "projects/_followers.html" %} {% include "projects/_message_followers.html" %} +

{{ _('Links') }}

+ {% if user == project.created_by %}

Edit

{% endif %} +{% if user == project.created_by %} +

+ Add Link +

+{% endif %} {% endblock %}