From 6a001328ea8a6a093d1b293ad5f07542a871625c Mon Sep 17 00:00:00 2001 From: Joseph Spiros Date: Wed, 2 Mar 2011 08:04:08 +0000 Subject: [PATCH] Created basic working client UI using ExtJS. The code is a bit hacked together and messy, mostly because I find writing Python and JavaScript concurrently very distracting. --- .gitmodules | 6 + README | 5 + media/reader/extjs | 1 + media/reader/fugue-icons | 1 + media/reader/icons.css | 3 + migrations/0004_auto__add_userentry.py | 103 ++++++++++++ models.py | 18 +- templates/reader/home.html | 27 ++- templates/reader/lib.js | 217 +++++++++++++++++++++++++ urls.py | 7 +- utils.py | 24 ++- views.py | 104 +++++++++++- 12 files changed, 492 insertions(+), 24 deletions(-) create mode 100644 .gitmodules create mode 100644 README create mode 160000 media/reader/extjs create mode 160000 media/reader/fugue-icons create mode 100644 media/reader/icons.css create mode 100644 migrations/0004_auto__add_userentry.py create mode 100644 templates/reader/lib.js diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0713a97 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "media/reader/extjs"] + path = media/reader/extjs + url = git://github.com/probonogeek/extjs.git +[submodule "media/reader/fugue-icons"] + path = media/reader/fugue-icons + url = git://git.ithinksw.org/fugue-icons.git diff --git a/README b/README new file mode 100644 index 0000000..129d111 --- /dev/null +++ b/README @@ -0,0 +1,5 @@ +Prerequisites (only tested on): + * Django 1.2.5+ + * django-staticmedia 0.2.2+ + * south 0.7.3+ + diff --git a/media/reader/extjs b/media/reader/extjs new file mode 160000 index 0000000..530ef4b --- /dev/null +++ b/media/reader/extjs @@ -0,0 +1 @@ +Subproject commit 530ef4b6c5b943cfa68b779d11cf7de29aa878bf diff --git a/media/reader/fugue-icons b/media/reader/fugue-icons new file mode 160000 index 0000000..764abbe --- /dev/null +++ b/media/reader/fugue-icons @@ -0,0 +1 @@ +Subproject commit 764abbef432ef8a7333a58ca21b0ef6ef0a80b1a diff --git a/media/reader/icons.css b/media/reader/icons.css new file mode 100644 index 0000000..6379f80 --- /dev/null +++ b/media/reader/icons.css @@ -0,0 +1,3 @@ +.icon-add-feed { + background: url(fugue-icons/icons/feed--plus.png) 0 no-repeat !important; +} diff --git a/migrations/0004_auto__add_userentry.py b/migrations/0004_auto__add_userentry.py new file mode 100644 index 0000000..0fd20ea --- /dev/null +++ b/migrations/0004_auto__add_userentry.py @@ -0,0 +1,103 @@ +# 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 model 'UserEntry' + db.create_table('reader_userentry', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='reader_userentries', to=orm['auth.User'])), + ('entry', self.gf('django.db.models.fields.related.ForeignKey')(related_name='userentries', to=orm['reader.Entry'])), + ('read', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('reader', ['UserEntry']) + + + def backwards(self, orm): + + # Deleting model 'UserEntry' + db.delete_table('reader_userentry') + + + 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'}) + }, + 'reader.entry': { + 'Meta': {'ordering': "['-published']", 'object_name': 'Entry'}, + 'content': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'feed': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'entries'", 'to': "orm['reader.Feed']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'link': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}), + 'published': ('django.db.models.fields.DateTimeField', [], {}), + 'summary': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {}), + 'uri': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'reader.feed': { + 'Meta': {'object_name': 'Feed'}, + 'alive': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'etag': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'link': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {}), + 'url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + }, + 'reader.subscription': { + 'Meta': {'object_name': 'Subscription'}, + 'custom_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'feed': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'subscriptions'", 'to': "orm['reader.Feed']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reader_subscriptions'", 'to': "orm['auth.User']"}) + }, + 'reader.userentry': { + 'Meta': {'object_name': 'UserEntry'}, + 'entry': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'userentries'", 'to': "orm['reader.Entry']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'read': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reader_userentries'", 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['reader'] diff --git a/models.py b/models.py index 71d559b..1da45f9 100644 --- a/models.py +++ b/models.py @@ -40,4 +40,20 @@ class Subscription(models.Model): @property def title(self): - return self.custom_title if self.custom_title else self.feed.title \ No newline at end of file + return self.custom_title if self.custom_title else self.feed.title + + def __unicode__(self): + return u'%s <%s>' % (self.user, self.feed) + + +class UserEntry(models.Model): + user = models.ForeignKey(User, related_name='reader_userentries') + entry = models.ForeignKey(Entry, related_name='userentries') + read = models.BooleanField(default=False) + + class Meta: + verbose_name = 'user entry relationship' + verbose_name_plural = 'user entry relationships' + + def __unicode__(self): + return u'%s <%s>' % (self.user, self.entry) \ No newline at end of file diff --git a/templates/reader/home.html b/templates/reader/home.html index 6f1ea32..8b1c097 100644 --- a/templates/reader/home.html +++ b/templates/reader/home.html @@ -1,16 +1,15 @@ {% extends 'reader/base.html' %} +{% load staticmedia %} -{% block title %}{{ block.super }} - Home{% endblock %} - -{% block body %} -

Feeds

- {% for feed in feeds %} - {% if forloop.first %}{% endif %} - {% endfor %} -

Entries

- {% for entry in entries %} - {% include 'reader/includes/entry.html' %} - {% endfor %} -{% endblock %} \ No newline at end of file +{% block extrahead %} + + + + + + +{% endblock %}\ \ No newline at end of file diff --git a/templates/reader/lib.js b/templates/reader/lib.js new file mode 100644 index 0000000..94f62d1 --- /dev/null +++ b/templates/reader/lib.js @@ -0,0 +1,217 @@ +Ext.ns('Reader'); + + +Reader.SubscriptionPanel = Ext.extend(Ext.Panel, { + constructor: function(application, config) { + this.application = application; + var self = this; + + var listView = this.listView = new Ext.list.ListView({ + emptyText: 'No Subscriptions', + store: this.application.subscription_store, + columns: [ + { + header: 'Title', + dataIndex: 'title', + width: .75, + }, + { + header: 'Unread', + dataIndex: 'unread', + align: 'right', + }, + ], + hideHeaders: true, + singleSelect: true, + loadingText: 'Loading...', + }); + + Reader.SubscriptionPanel.superclass.constructor.call(this, Ext.applyIf(config||{}, { + title: 'Subscriptions', + items: [ + this.listView, + ], + bbar: [ + { + text: 'Add Feed...', + iconCls: 'icon-add-feed', + handler: function() { + Ext.MessageBox.prompt('Add Feed', 'Enter the URL to the feed:', function(button, text) { + if (button == 'ok') { + Ext.Ajax.request({ + url: '{% url reader_add_subscription %}', + params: { + 'url': text, + 'csrfmiddlewaretoken': '{% with csrf_token as csrf_token_clean %}{{ csrf_token_clean }}{% endwith %}', + }, + success: function() { + self.application.subscription_store.reload(); + }, + failure: function() { + alert('Unable to add feed.'); + }, + }); + } + }); + }, + },'->',{ + text: 'Refresh', + handler: function() { + self.application.subscription_store.reload(); + }, + }, + ], + collapsible: true, + })); + + listView.on('selectionchange', function(listView, selections) { + if (selections.length > 0) { + self.application.selected_subscription(listView.getSelectedRecords()[0]['data']); + } + }); + }, +}); + + +Reader.EntryPanel = Ext.extend(Ext.Panel, { + constructor: function(application, config) { + this.application = application; + var self = this; + + var gridView = this.gridView = new Ext.grid.GridPanel({ + store: this.application.entry_store, + columns: [ + { + header: 'Title', + dataIndex: 'title', + }, + { + header: 'Date', + dataIndex: 'date', + }, + ], + viewConfig: { + forceFit: true, + }, + sm: new Ext.grid.RowSelectionModel({singleSelect: true}), + region: 'north', + split: true, + height: 200, + minSize: 150, + maxSize: 400, + }); + + var entryDetailTpl = this.entryDetailTpl = new Ext.Template( + '

{title}

', + '

{date}

', + '

{content}

' + ); + + var entryDetail = this.entryDetail = { + id: 'entryDetail', + region: 'center', + bodyStyle: { + background: 'white', + padding: '7px', + color: 'black', + }, + preventBodyReset: true, + html: 'Select an entry to view', + autoScroll: true, + }; + + gridView.getSelectionModel().on('rowselect', function(sm, rowIdx, r) { + var detailPanel = Ext.getCmp('entryDetail'); + entryDetailTpl.overwrite(detailPanel.body, r.data); + + Ext.Ajax.request({ + url: '{% url reader_read_entry %}', + params: { + 'entry_id': r.data['id'], + 'csrfmiddlewaretoken': '{% with csrf_token as csrf_token_clean %}{{ csrf_token_clean }}{% endwith %}', + }, + success: function() { + self.application.subscription_store.reload(); + }, + failure: function() { + console.log('Unable to set read entry'); + }, + }); + }); + + Reader.EntryPanel.superclass.constructor.call(this, Ext.applyIf(config||{}, { + title: 'Entries', + header: true, + items: [ this.gridView, this.entryDetail ], + layout: 'border', + })); + }, +}); + + +Reader.Application = Ext.extend(Ext.util.Observable, { + constructor: function(config) { + Ext.apply(this, config, { + renderTo: Ext.getBody(), + }); + Reader.Application.superclass.constructor.call(this); + this.init(); + }, + init: function() { + Ext.QuickTips.init(); + + var subscription_store = this.subscription_store = new Ext.data.JsonStore({ + autoLoad: true, + autoDestroy: true, + url: '{% url reader_get_subscriptions %}', + root: 'root', + totalProperty: 'len', + fields: [ + 'id', + 'title', + 'unread', + ] + }); + + var entry_store = this.entry_store = new Ext.data.JsonStore({ + url: '{% url reader_get_entries %}', + baseParams: { + 'subscription_id': '-2', + 'csrfmiddlewaretoken': '{% with csrf_token as csrf_token_clean %}{{ csrf_token_clean }}{% endwith %}', + }, + root: 'root', + totalProperty: 'len', + fields: [ + 'id', 'title', 'date', 'content', 'link' + ], + }); + + var subscription_panel = this.subscription_panel = new Reader.SubscriptionPanel(this, { + region: 'west', + split: true, + width: 200, + minSize: 150, + maxSize: 400, + }); + var entry_panel = this.entry_panel = new Reader.EntryPanel(this, { + region: 'center', + autoScroll: true, + }); + var viewport = this.viewport = new Ext.Viewport({ + layout: 'border', + renderTo: this.renderTo, + items: [ + this.subscription_panel, + this.entry_panel, + ], + }); + + this.viewport.doLayout(); + }, + selected_subscription: function(subscription) { + this.entry_panel.setTitle(subscription['title']); + this.entry_store.reload({ params: { + 'subscription_id': subscription['id'], + }}); + } +}); \ No newline at end of file diff --git a/urls.py b/urls.py index 449c71d..816f5ec 100644 --- a/urls.py +++ b/urls.py @@ -1,7 +1,12 @@ -from .views import home +from .views import home, lib, get_subscriptions, add_subscription, get_entries, read_entry from django.conf.urls.defaults import patterns, url urlpatterns = patterns('', url(r'^$', home, name='reader_home'), + url(r'^lib.js$', lib, name='reader_lib'), + url(r'^get_subscriptions$', get_subscriptions, name='reader_get_subscriptions'), + url(r'^add_subscription$', add_subscription, name='reader_add_subscription'), + url(r'^get_entries$', get_entries, name='reader_get_entries'), + url(r'^read_entry$', read_entry, name='reader_read_entry'), ) \ No newline at end of file diff --git a/utils.py b/utils.py index 24dcdf4..b5f6850 100644 --- a/utils.py +++ b/utils.py @@ -1,4 +1,4 @@ -from .models import Feed, Entry +from .models import Feed, Entry, Subscription, UserEntry import datetime import feedparser @@ -93,7 +93,23 @@ def refresh_all_feeds(): refresh_feed(feed) -def add_feed(url): - feed = Feed(url=url) +def add_feed(url, user=None): + try: + feed = Feed.objects.get(url=url) + except Feed.DoesNotExist: + feed = Feed(url=url) refresh_feed(feed) - return feed \ No newline at end of file + if user is not None: + sub, _ = Subscription.objects.get_or_create(user=user, feed=feed) + return feed + + +def unread_count(user, feed=None): + if feed: + entries = feed.entries + else: + entries = Entry.objects.filter(feed__subscriptions__user=user) + total_entries = entries.count() + read_entries = entries.filter(userentries__user=user, userentries__read=True).count() + return total_entries - read_entries + \ No newline at end of file diff --git a/views.py b/views.py index 0a1679b..5dd81c9 100644 --- a/views.py +++ b/views.py @@ -1,10 +1,106 @@ -from .models import Feed, Entry +from .models import Feed, Entry, Subscription, UserEntry from django.shortcuts import render_to_response from django.template import RequestContext +from django.http import HttpResponse +from django.contrib.auth.decorators import login_required +from django.utils import simplejson as json +from django.db.models import Q +from .utils import add_feed, unread_count +@login_required +def lib(request): + return render_to_response('reader/lib.js', context_instance=RequestContext(request), mimetype='text/javascript') + + +@login_required def home(request): return render_to_response('reader/home.html', { - 'entries': Entry.objects.all(), - 'feeds': Feed.objects.all(), - }, context_instance=RequestContext(request)) \ No newline at end of file + 'entries': Entry.objects.filter(feed__subscriptions__user=request.user), + 'feeds': Feed.objects.filter(subscriptions__user=request.user), + }, context_instance=RequestContext(request)) + + +@login_required +def get_subscriptions(request): + root = [] + for subscription in Subscription.objects.filter(user=request.user): + root.append({ + 'id': subscription.pk, + 'type': 'feed', + 'title': subscription.title, + 'unread': unread_count(request.user, feed=subscription.feed) + }) + root.append({ + 'id': -2, + 'type': 'all', + 'title': 'All Entries', + 'unread': unread_count(request.user), + }) + root.append({ + 'id': -1, + 'type': 'unread', + 'title': 'Unread Entries', + 'unread': unread_count(request.user), + }) + root.sort(key=lambda sub: sub['id']) + + return HttpResponse(json.dumps({'len': len(root), 'root': root}), mimetype='application/json') + + +@login_required +def add_subscription(request): + try: + url = request.POST['url'] + add_feed(url, user=request.user) + return HttpResponse() + except: + raise Http404 + + +@login_required +def get_entries(request): + if 'subscription_id' in request.POST: + subscription_id = str(request.POST['subscription_id']) + if subscription_id == '-2': + entries = Entry.objects.filter(feed__subscriptions__user=request.user) + elif subscription_id == '-1': + entries = Entry.objects.filter(feed__subscriptions__user=request.user).exclude(userentries__user=request.user, userentries__read=True) + elif subscription_id.startswith('tag:'): + pass + else: + try: + subscription = Subscription.objects.get(user=request.user, id=subscription_id) + entries = subscription.feed.entries.all() + except Subscription.DoesNotExist: + raise Http404 + else: + raise Http404 + + root = [] + for entry in entries: + root.append({ + 'id': entry.pk, + 'title': entry.title, + 'date': entry.published.isoformat(), + 'content': entry.content if entry.content else entry.summary, + 'link': entry.link if entry.link else entry.feed.link, + }) + root.sort(key=lambda entry: entry['date']) + root.reverse() + + return HttpResponse(json.dumps({'len': len(root), 'root': root}), mimetype='application/json') + + +@login_required +def read_entry(request): + if 'entry_id' in request.POST: + entry = Entry.objects.get(id=request.POST['entry_id']) + userentry, created = UserEntry.objects.get_or_create(entry=entry, user=request.user, defaults={ 'read': True }) + if not created: + userentry.read = True + userentry.save() + else: + raise Http404 + + return HttpResponse() \ No newline at end of file -- 2.20.1