Created basic working client UI using ExtJS. master
authorJoseph Spiros <joseph.spiros@ithinksw.com>
Wed, 2 Mar 2011 08:04:08 +0000 (08:04 +0000)
committerJoseph Spiros <joseph.spiros@ithinksw.com>
Wed, 2 Mar 2011 08:04:08 +0000 (08:04 +0000)
The code is a bit hacked together and messy, mostly because I find writing Python and JavaScript concurrently very distracting.

12 files changed:
.gitmodules [new file with mode: 0644]
README [new file with mode: 0644]
media/reader/extjs [new submodule]
media/reader/fugue-icons [new submodule]
media/reader/icons.css [new file with mode: 0644]
migrations/0004_auto__add_userentry.py [new file with mode: 0644]
models.py
templates/reader/home.html
templates/reader/lib.js [new file with mode: 0644]
urls.py
utils.py
views.py

diff --git a/.gitmodules b/.gitmodules
new file mode 100644 (file)
index 0000000..0713a97
--- /dev/null
@@ -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 (file)
index 0000000..129d111
--- /dev/null
+++ b/README
@@ -0,0 +1,5 @@
+Prerequisites (only tested on):
+       * Django 1.2.5+ <http://www.djangoproject.com/>
+       * django-staticmedia 0.2.2+ <http://pypi.python.org/pypi/django-staticmedia/>
+       * south 0.7.3+ <http://south.aeracode.org/>
+
diff --git a/media/reader/extjs b/media/reader/extjs
new file mode 160000 (submodule)
index 0000000..530ef4b
--- /dev/null
@@ -0,0 +1 @@
+Subproject commit 530ef4b6c5b943cfa68b779d11cf7de29aa878bf
diff --git a/media/reader/fugue-icons b/media/reader/fugue-icons
new file mode 160000 (submodule)
index 0000000..764abbe
--- /dev/null
@@ -0,0 +1 @@
+Subproject commit 764abbef432ef8a7333a58ca21b0ef6ef0a80b1a
diff --git a/media/reader/icons.css b/media/reader/icons.css
new file mode 100644 (file)
index 0000000..6379f80
--- /dev/null
@@ -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 (file)
index 0000000..0fd20ea
--- /dev/null
@@ -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']
index 71d559b..1da45f9 100644 (file)
--- 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
index 6f1ea32..8b1c097 100644 (file)
@@ -1,16 +1,15 @@
 {% extends 'reader/base.html' %}
+{% load staticmedia %}
 
-{% block title %}{{ block.super }} - Home{% endblock %}
-
-{% block body %}
-       <h1>Feeds</h1>
-       {% for feed in feeds %}
-               {% if forloop.first %}<ul>{% endif %}
-               <li><a href="{{ feed.link }}">{{ feed.title }}</a></li>
-               {% if forloop.last %}</ul>{% endif %}
-       {% endfor %}
-       <h1>Entries</h1>
-       {% for entry in entries %}
-               {% include 'reader/includes/entry.html' %}
-       {% endfor %}
-{% endblock %}
\ No newline at end of file
+{% block extrahead %}
+<link rel="stylesheet" href="{% mediaurl 'reader/extjs/resources/css/ext-all.css' %}" type="text/css" />
+<link rel="stylesheet" href="{% mediaurl 'reader/icons.css' %}" type="text/css" />
+<script src="{% mediaurl 'reader/extjs/adapter/ext/ext-base.js' %}" type="text/javascript"></script>
+<script src="{% mediaurl 'reader/extjs/ext-all.js' %}" type="text/javascript"></script>
+<script src="{% url reader_lib %}" type="text/javascript"></script>
+<script type="text/javascript">
+       Ext.onReady(function() {
+               READER = new Reader.Application();
+       });
+</script>
+{% endblock %}\
\ No newline at end of file
diff --git a/templates/reader/lib.js b/templates/reader/lib.js
new file mode 100644 (file)
index 0000000..94f62d1
--- /dev/null
@@ -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(
+                       '<h1><a href="{link}" target="_blank">{title}</a></h1>',
+                       '<p style="font-size: smaller;">{date}</p>',
+                       '<p>{content}</p>'
+               );
+               
+               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 (file)
--- 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
index 24dcdf4..b5f6850 100644 (file)
--- 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
index 0a1679b..5dd81c9 100644 (file)
--- a/views.py
+++ b/views.py
-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