Commits

Dan Chudnov committed 7d937b2 Merge

merging lots

Comments (0)

Files changed (14)

 unalog.com.
 
 Python 2.5.2
-Django 1.1.1
+Django 1.2.4
 PostgreSQL 8.3
 psycopg2 2.0.13
 Java 6
 
 Install non-.deb python dependencies:
 
-    from tarball:   Django 1.1.1
+    from tarball:   Django 1.2.1
     w/easy_install: solrpy 0.9 (and iso8601, if import from zodb install)
 
 Set up solr config.  Move the original /etc/solr/conf/schema.xml

base/fixtures/test_account.json

+[{"pk": 5, "model": "contenttypes.contenttype", "fields": {"model": "contenttype", "name": "content type", "app_label": "contenttypes"}}, {"pk": 15, "model": "contenttypes.contenttype", "fields": {"model": "entry", "name": "entry", "app_label": "base"}}, {"pk": 13, "model": "contenttypes.contenttype", "fields": {"model": "entrytag", "name": "entry tag", "app_label": "base"}}, {"pk": 11, "model": "contenttypes.contenttype", "fields": {"model": "filter", "name": "filter", "app_label": "base"}}, {"pk": 2, "model": "contenttypes.contenttype", "fields": {"model": "group", "name": "group", "app_label": "auth"}}, {"pk": 9, "model": "contenttypes.contenttype", "fields": {"model": "groupprofile", "name": "group profile", "app_label": "base"}}, {"pk": 8, "model": "contenttypes.contenttype", "fields": {"model": "logentry", "name": "log entry", "app_label": "admin"}}, {"pk": 4, "model": "contenttypes.contenttype", "fields": {"model": "message", "name": "message", "app_label": "auth"}}, {"pk": 1, "model": "contenttypes.contenttype", "fields": {"model": "permission", "name": "permission", "app_label": "auth"}}, {"pk": 6, "model": "contenttypes.contenttype", "fields": {"model": "session", "name": "session", "app_label": "sessions"}}, {"pk": 7, "model": "contenttypes.contenttype", "fields": {"model": "site", "name": "site", "app_label": "sites"}}, {"pk": 12, "model": "contenttypes.contenttype", "fields": {"model": "tag", "name": "tag", "app_label": "base"}}, {"pk": 14, "model": "contenttypes.contenttype", "fields": {"model": "url", "name": "url", "app_label": "base"}}, {"pk": 3, "model": "contenttypes.contenttype", "fields": {"model": "user", "name": "user", "app_label": "auth"}}, {"pk": 10, "model": "contenttypes.contenttype", "fields": {"model": "userprofile", "name": "user profile", "app_label": "base"}}, {"pk": 1, "model": "sites.site", "fields": {"domain": "example.com", "name": "example.com"}}, {"pk": 1, "model": "base.userprofile", "fields": {"tz": "", "date_modified": "2010-12-17 09:42:14", "url": "", "default_to_private_entry": false, "token": "", "group_invites": [], "user": 1, "is_private": false}}, {"pk": 22, "model": "auth.permission", "fields": {"codename": "add_logentry", "name": "Can add log entry", "content_type": 8}}, {"pk": 23, "model": "auth.permission", "fields": {"codename": "change_logentry", "name": "Can change log entry", "content_type": 8}}, {"pk": 24, "model": "auth.permission", "fields": {"codename": "delete_logentry", "name": "Can delete log entry", "content_type": 8}}, {"pk": 4, "model": "auth.permission", "fields": {"codename": "add_group", "name": "Can add group", "content_type": 2}}, {"pk": 5, "model": "auth.permission", "fields": {"codename": "change_group", "name": "Can change group", "content_type": 2}}, {"pk": 6, "model": "auth.permission", "fields": {"codename": "delete_group", "name": "Can delete group", "content_type": 2}}, {"pk": 10, "model": "auth.permission", "fields": {"codename": "add_message", "name": "Can add message", "content_type": 4}}, {"pk": 11, "model": "auth.permission", "fields": {"codename": "change_message", "name": "Can change message", "content_type": 4}}, {"pk": 12, "model": "auth.permission", "fields": {"codename": "delete_message", "name": "Can delete message", "content_type": 4}}, {"pk": 1, "model": "auth.permission", "fields": {"codename": "add_permission", "name": "Can add permission", "content_type": 1}}, {"pk": 2, "model": "auth.permission", "fields": {"codename": "change_permission", "name": "Can change permission", "content_type": 1}}, {"pk": 3, "model": "auth.permission", "fields": {"codename": "delete_permission", "name": "Can delete permission", "content_type": 1}}, {"pk": 7, "model": "auth.permission", "fields": {"codename": "add_user", "name": "Can add user", "content_type": 3}}, {"pk": 8, "model": "auth.permission", "fields": {"codename": "change_user", "name": "Can change user", "content_type": 3}}, {"pk": 9, "model": "auth.permission", "fields": {"codename": "delete_user", "name": "Can delete user", "content_type": 3}}, {"pk": 43, "model": "auth.permission", "fields": {"codename": "add_entry", "name": "Can add entry", "content_type": 15}}, {"pk": 44, "model": "auth.permission", "fields": {"codename": "change_entry", "name": "Can change entry", "content_type": 15}}, {"pk": 45, "model": "auth.permission", "fields": {"codename": "delete_entry", "name": "Can delete entry", "content_type": 15}}, {"pk": 37, "model": "auth.permission", "fields": {"codename": "add_entrytag", "name": "Can add entry tag", "content_type": 13}}, {"pk": 38, "model": "auth.permission", "fields": {"codename": "change_entrytag", "name": "Can change entry tag", "content_type": 13}}, {"pk": 39, "model": "auth.permission", "fields": {"codename": "delete_entrytag", "name": "Can delete entry tag", "content_type": 13}}, {"pk": 31, "model": "auth.permission", "fields": {"codename": "add_filter", "name": "Can add filter", "content_type": 11}}, {"pk": 32, "model": "auth.permission", "fields": {"codename": "change_filter", "name": "Can change filter", "content_type": 11}}, {"pk": 33, "model": "auth.permission", "fields": {"codename": "delete_filter", "name": "Can delete filter", "content_type": 11}}, {"pk": 25, "model": "auth.permission", "fields": {"codename": "add_groupprofile", "name": "Can add group profile", "content_type": 9}}, {"pk": 26, "model": "auth.permission", "fields": {"codename": "change_groupprofile", "name": "Can change group profile", "content_type": 9}}, {"pk": 27, "model": "auth.permission", "fields": {"codename": "delete_groupprofile", "name": "Can delete group profile", "content_type": 9}}, {"pk": 34, "model": "auth.permission", "fields": {"codename": "add_tag", "name": "Can add tag", "content_type": 12}}, {"pk": 35, "model": "auth.permission", "fields": {"codename": "change_tag", "name": "Can change tag", "content_type": 12}}, {"pk": 36, "model": "auth.permission", "fields": {"codename": "delete_tag", "name": "Can delete tag", "content_type": 12}}, {"pk": 40, "model": "auth.permission", "fields": {"codename": "add_url", "name": "Can add url", "content_type": 14}}, {"pk": 41, "model": "auth.permission", "fields": {"codename": "change_url", "name": "Can change url", "content_type": 14}}, {"pk": 42, "model": "auth.permission", "fields": {"codename": "delete_url", "name": "Can delete url", "content_type": 14}}, {"pk": 28, "model": "auth.permission", "fields": {"codename": "add_userprofile", "name": "Can add user profile", "content_type": 10}}, {"pk": 29, "model": "auth.permission", "fields": {"codename": "change_userprofile", "name": "Can change user profile", "content_type": 10}}, {"pk": 30, "model": "auth.permission", "fields": {"codename": "delete_userprofile", "name": "Can delete user profile", "content_type": 10}}, {"pk": 13, "model": "auth.permission", "fields": {"codename": "add_contenttype", "name": "Can add content type", "content_type": 5}}, {"pk": 14, "model": "auth.permission", "fields": {"codename": "change_contenttype", "name": "Can change content type", "content_type": 5}}, {"pk": 15, "model": "auth.permission", "fields": {"codename": "delete_contenttype", "name": "Can delete content type", "content_type": 5}}, {"pk": 16, "model": "auth.permission", "fields": {"codename": "add_session", "name": "Can add session", "content_type": 6}}, {"pk": 17, "model": "auth.permission", "fields": {"codename": "change_session", "name": "Can change session", "content_type": 6}}, {"pk": 18, "model": "auth.permission", "fields": {"codename": "delete_session", "name": "Can delete session", "content_type": 6}}, {"pk": 19, "model": "auth.permission", "fields": {"codename": "add_site", "name": "Can add site", "content_type": 7}}, {"pk": 20, "model": "auth.permission", "fields": {"codename": "change_site", "name": "Can change site", "content_type": 7}}, {"pk": 21, "model": "auth.permission", "fields": {"codename": "delete_site", "name": "Can delete site", "content_type": 7}}, {"pk": 1, "model": "auth.user", "fields": {"username": "unalog", "first_name": "", "last_name": "", "is_active": true, "is_superuser": true, "is_staff": true, "last_login": "2010-12-17 09:42:14", "groups": [], "user_permissions": [], "password": "sha1$09dce$08819bd241680d01bc650c74e7a5c8526a2235ea", "email": "unalog@example.com", "date_joined": "2010-12-17 09:42:14"}}]
 # users don't automatically get one, but everyone has to have one.
 def user_post_save_create_profile (sender, **kwargs):
     if kwargs['created']:
-        up = UserProfile(user=kwargs['instance'])
-        up.save()
+        UserProfile.objects.get_or_create(user=kwargs['instance'])
     
 post_save.connect(user_post_save_create_profile, User)
 
     token = m.CharField(blank=True, max_length=32)
     tz = m.CharField(blank=True, max_length=6)
     group_invites = m.ManyToManyField(Group, related_name='invitees', blank=True)
+    webhook_url = m.URLField(blank=True, verify_exists=True)
     date_modified = m.DateTimeField(auto_now=True)
     
     def solr_reindex (self):
 class UserProfileForm (ModelForm):
     class Meta:
         model = UserProfile
-        fields = ['url', 'is_private', 'default_to_private_entry']
+        fields = ['url', 'is_private', 'default_to_private_entry',
+            'webhook_url']
 
 
 class Filter (m.Model):
         """
         return hashlib.md5(self.value).hexdigest()
 
-    def save(self, force_insert=False, force_update=False):
+    def save(self, force_insert=False, force_update=False, **kwargs):
         """
         Set the md5sum automatically.
         """
         self.md5sum = self.md5
-        super(Url, self).save(force_insert, force_update)
+        super(Url, self).save(force_insert, force_update, **kwargs)
         
         
-        
+
 class Entry (m.Model):
     user = m.ForeignKey(User, related_name='entries')
     title = m.TextField()
     is_private = m.BooleanField(default=False, db_index=True)
     content = m.TextField(blank=True)
     groups = m.ManyToManyField(Group, related_name='entries')
-    date_created = m.DateTimeField(auto_now_add=True, db_index=True)
+    date_created = m.DateTimeField(db_index=True)
     date_modified = m.DateTimeField(auto_now=True)
     
     def __unicode__ (self):
             self.solr_delete()
         super(Entry, self).delete()
         
+
+#class EntryWebhook (m.Model):
+#    entry = m.ForeignKey(Entry, unique=True)
+#    url = m.URLField()
+#    num_attempts = m.SmallIntegerField(default=0)
+#    date_first_attempt = m.DateTimeField(auto_now_add=True)
+#    date_latest_attempt = m.DateTimeField(auto_now=True, db_index=True)
+#    latest_attempt_status = m.CharField(max_length=3, blank=True)
+#
+#    class Meta:
+#        verbose_name = "Webhook POST URL"
+
+        
+## This handler is wired up to Entry's post_save to create a new
+## EntryWebhook.  Done here with a signal instead of on Entry.save()
+## because we only want to do it when the Entry is first created.
+#def create_entry_webhook_handler(sender, **kwargs):
+#    if kwargs['created']:
+#        instance = kwargs['instance']
+#        webhook_url = instance.user.get_profile().webhook_url
+#        if webhook_url:
+#            entry_webhook = EntryWebhook(entry=kwargs['instance'],
+#                url=webhook_url)
+#            entry_webhook.save()
+#post_save.connect(create_entry_webhook_handler, sender=Entry)

base/templates/about.html

 I started this project in 2003 so I could track what my friends were reading. Some other people started similar projects at the same time who were smarter or more savvy than I was.  Their friends aren't more interesting than mine, though. So here we still are.
 </p>
 
-{% endblock %}
+<h2>API</h2>
+
+<p>
+If you find yourself wanting to add bookmarks programatically you can use 
+the minimal API by POSTing some JSON to <em>{{ site_url }}/entry/new</em>. 
+You'll need to pass along your username and password with 
+<em>HTTP Basic Authentication</em>, and also
+need to supply a <em>Content-type: application/json</em> request header.
+
+Here's a minimal example with cURL:
+
+<pre>
+    curl --user user:pass \
+         --header "Content-type: application/json" \
+         --data '{"url": "http://example.com", "title": "Example"}' \
+         {{ site_url }}/entry/new
+</pre>
+
+Here's a fuller example of the JSON that you can supply:
+
+<pre>
+    {
+      "url": "http://zombo.com",
+      "title": "ZOMBO",
+      "comment": "found this awesome website today",
+      "tags": "website awesome example",
+      "content": "You can do anything at Zombo.com. The only limit is yourself.",
+      "is_private": true
+    }
+</pre>
+
+{% endblock %}

base/templates/base.html

 	                        [groups<!--[<a href='/my/group/'>groups</a>]-->]
                         
 						{% else %}
-							<form id='loginform' action='{% url login %}' method='post'>
+                                                    <form id='loginform' action='{% url login %}' method='post'>
+                                                    {% csrf_token %}
 		                     name <input name='username' type='text' size='8' />
 		                     pass <input name='password' type='password' size='8' />
 		                     <input type='submit' name='login' value='login' />
 		</div>
     </body>
 </html>
-    
+    

base/templates/entry_delete.html

 
 
 <p>
-	From {{ entry.date_created|date:"l, Y-m-d" }}:
+    From {{ entry.date_created|date:"l, Y-m-d" }}:
 </p>
 
 {% include "entry.html" %}
 
 <p>
 <form action='.' method='post'>
-	Are you sure you want to delete this entry?
-	<input type='submit' name='submit' value='yes'>
+    {% csrf_token %}
+    Are you sure you want to delete this entry?
+    <input type='submit' name='submit' value='yes'>
 </form>
 </p>
 
 <pre>
-	{{ data }}
+    {{ data }}
 </pre>
 
 
 
-{% endblock %}
+{% endblock %}

base/templates/entry_edit.html

 
 
 <form action='{% url entry_edit entry.id %}' method='POST'>
+    {% csrf_token %}
     <table class='gentable'>
         <tbody>
             {{ form.as_table }}
-			<tr>
-				<td></td>
-				<td><input type='submit' value='save' /></td>
-			</tr>
+            <tr>
+                <td></td>
+                <td><input type='submit' value='save' /></td>
+            </tr>
         </tbody>
     </table>
 </form>
 
-{% endblock %}
+{% endblock %}

base/templates/entry_new.html

 {% if old_entries %}
     <div id='entryset'>
         <h2>Hey!  You've saved this before.</h2>
-        	{% regroup old_entries by date_created.date as date_groups %}
-        	{% for date_group in date_groups %}
-        	   	<h2>{{ date_group.grouper|date:"l, Y-m-d" }}</h2>
-        		{% for entry in date_group.list %}
-        			{% include "entry.html" %}
-        		{% endfor %}       
-        	{% endfor %}
+            {% regroup old_entries by date_created.date as date_groups %}
+            {% for date_group in date_groups %}
+                   <h2>{{ date_group.grouper|date:"l, Y-m-d" }}</h2>
+                {% for entry in date_group.list %}
+                    {% include "entry.html" %}
+                {% endfor %}       
+            {% endfor %}
         <h2>Save it again if you like.</h2>
     </div>
 {% else %}
 </p>
 
 <form action='{% url entry_new %}' method='POST' accept-charset='UTF-8'>
-	<table>
-		<tbody>
-			<tr>
-				<th valign='top'><label for='id_url'>URL:</label></th>
-				<td><input type='text' name='url' size='80' value='{{ form.data.url }}' id='id_url' /></td>
-			</tr>
-			<tr>
-				<th valign='top'><label for='id_title'>Title:</label></th>
-				<td><input type='text' name='title' size='80' value='{{ form.data.title }}' id='id_title' /></td>
-			</tr>
-			<tr>
-				<th valign='top'><label for='id_tags'>Tags:</label></th>
-				<td><input type='text' name='tags' size='80' id='id_tags' /><br />Separate with spaces</td>
-			</tr>
-			<tr>
-				<th valign='top'><label for='id_is_private'>Is private:</label></th>
-				<td><input type='checkbox' name='is_private' id='id_is_private' 
-				    {% if user.get_profile.default_to_private_entry %}
-				        checked='checked'
-				    {% endif %}
-				    /></td>
-			</tr>
-			<tr>
-				<th valign='top'><label for='id_comment'>Comment:</label></th>
-				<td><textarea id='id_comment' rows='4' cols='60' name='comment'></textarea></td>
-			</tr>
-			<tr>
-				<th valign='top'><label for='id_content'>Content:</label></th>
-				<td><textarea id='id_content' rows='10' cols='60' name='content'>{{ content }}</textarea></td>
-			</tr>
-			<tr>
-				<td></td>
-				<td><input name='submit' type='submit' value='Save anyway' /></td>
-			</tr>
-		</tbody>
-	</table>
+    {% csrf_token %}
+    <table>
+        <tbody>
+            <tr>
+                <th valign='top'><label for='id_url'>URL:</label></th>
+                <td><input type='text' name='url' size='80' value='{{ form.data.url }}' id='id_url' /></td>
+            </tr>
+            <tr>
+                <th valign='top'><label for='id_title'>Title:</label></th>
+                <td><input type='text' name='title' size='80' value='{{ form.data.title }}' id='id_title' /></td>
+            </tr>
+            <tr>
+                <th valign='top'><label for='id_tags'>Tags:</label></th>
+                <td><input type='text' name='tags' size='80' id='id_tags' /><br />Separate with spaces</td>
+            </tr>
+            <tr>
+                <th valign='top'><label for='id_is_private'>Is private:</label></th>
+                <td><input type='checkbox' name='is_private' id='id_is_private' 
+                    {% if user.get_profile.default_to_private_entry %}
+                        checked='checked'
+                    {% endif %}
+                    /></td>
+            </tr>
+            <tr>
+                <th valign='top'><label for='id_comment'>Comment:</label></th>
+                <td><textarea id='id_comment' rows='4' cols='60' name='comment'></textarea></td>
+            </tr>
+            <tr>
+                <th valign='top'><label for='id_content'>Content:</label></th>
+                <td><textarea id='id_content' rows='10' cols='60' name='content'>{{ content }}</textarea></td>
+            </tr>
+            <tr>
+                <td></td>
+                <td><input name='submit' type='submit' value='Save anyway' /></td>
+            </tr>
+        </tbody>
+    </table>
 </form>
 
-{% endblock %}
+{% endblock %}

base/templates/filters.html

             <th width='10%'>remove?</th>
         </tr>
         <form method='post' action=''>
+            {% csrf_token %}
             {{ formset.management_form }}
 
             {% for form in formset.forms %}
         </tr>
         <tr>
             <form method='post' action='{% url filter_new %}'>
+                {% csrf_token %}
                 <input type='hidden' name='user' id='id_user' value='{{ user.id }}'>
                 <td>{{ new_form.attr_name }}</td>
                 <td>{{ new_form.value }}</td>
 </table>
 
 
-{% endblock %}
+{% endblock %}

base/templates/prefs.html

     </thead>
     <tbody>
         <form method='POST' action='{% url prefs %}'>
+            {% csrf_token %}
             <input id='id_user_id' name='user_id' type='hidden' value='{{ user.id }}' />
             <tr>
                 <th>Your URL</th>
 
 
 
-{% endblock content %}
+{% endblock content %}
+"""
+IMPORTANT!
+
+For these tests to run you'll need to run a test solr instance on a port 9999 
+so that you don't stomp on your real production solr.
+    
+    java -Djetty.port=9999 -DSTOP.PORT=9998 -jar start.jar
+
+"""
+
+import datetime
+
+from django.conf import settings
+from django.test import TestCase, Client
+from django.utils import simplejson as json
+
+from unalog2.base import models as m
+
+class UnalogTests(TestCase):
+    fixtures = ['test_account.json']
+    settings.SOLR_URL = "http://localhost:9999/solr"
+
+    test_entry = {
+        'url':          'http://example.com/',
+        'title':        'hey example.com!',
+        'is_private':   False,
+        'tags':         'unalog yeah',
+        'comment':      'this is just an example see?',
+        'content':      'example yeah! example yeah!',
+        'submit':       None
+    }
+
+    def test_add_entry(self):
+        client = Client()
+        self.assertTrue(client.login(username='unalog', password='unalog'))
+        response = client.post('/entry/new', self.test_entry)
+        self.assertEqual(response['location'], 
+                'http://testserver/entry/1/edit/')
+        entry = m.Entry.objects.get(id=1)
+        self.assertEqual(entry.url.value, 'http://example.com/')
+        self.assertEqual(entry.title, 'hey example.com!')
+        self.assertEqual(entry.is_private, False)
+        self.assertEqual(entry.comment, 'this is just an example see?')
+        self.assertEqual(entry.content, 'example yeah! example yeah!')
+        entry_tags = entry.tags.all()
+        self.assertEqual(len(entry_tags), 2)
+        self.assertEqual(entry_tags[0].tag.name, 'unalog')
+        self.assertEqual(entry_tags[1].tag.name, 'yeah')
+
+
+    def test_add_entry_json(self):
+        client = Client()
+        self.assertTrue(client.login(username='unalog', password='unalog'))
+        entry_json = json.dumps(self.test_entry)
+        response = client.post('/entry/new', entry_json, 
+                content_type='application/json')
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response['location'], 'http://testserver/entry/1/edit/')
+        entry = m.Entry.objects.get(id=1)
+        self.assertEqual(entry.url.value, 'http://example.com/')
+        self.assertEqual(entry.title, 'hey example.com!')
+        self.assertEqual(entry.is_private, False)
+        self.assertEqual(entry.comment, 'this is just an example see?')
+        self.assertEqual(entry.content, 'example yeah! example yeah!')
+        entry_tags = entry.tags.all()
+        self.assertEqual(len(entry_tags), 2)
+        self.assertEqual(entry_tags[0].tag.name, 'unalog')
+        self.assertEqual(entry_tags[1].tag.name, 'yeah')
+
+    def test_add_entry_json_with_created(self):
+        client = Client()
+        self.assertTrue(client.login(username='unalog', password='unalog'))
+        test_entry = self.test_entry.copy()
+        test_entry['date_created'] = '1985-04-12T23:20:50.52Z'
+        entry_json = json.dumps(test_entry)
+        response = client.post('/entry/new', entry_json, 
+                content_type='application/json')
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response['location'], 'http://testserver/entry/1/edit/')
+        entry = m.Entry.objects.get(id=1)
+        self.assertEqual(entry.date_created, 
+            datetime.datetime(1985, 4, 12, 23, 20, 50))
+        
+    def test_add_entry_json_with_bad_created(self):
+        client = Client()
+        self.assertTrue(client.login(username='unalog', password='unalog'))
+        test_entry = self.test_entry.copy()
+        test_entry['date_created'] = 'blah'
+        entry_json = json.dumps(test_entry)
+        response = client.post('/entry/new', entry_json, 
+                content_type='application/json')
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(m.Entry.objects.all().count(), 0)
+
+    def test_add_entry_invalid_json(self):
+        client = Client()
+        self.assertTrue(client.login(username='unalog', password='unalog'))
+        response = client.post('/entry/new', '{bad json har har;]', 
+                content_type='application/json')
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response['content-type'], 'text/plain')
+        self.assertEqual(response.content, 'invalid json: Expecting property name: line 1 column 1 (char 1)')
+        self.assertEqual(m.Entry.objects.all().count(), 0)
+
+    def test_add_entry_bad_json(self):
+        # remove the required url field
+        bad_json = self.test_entry.copy()
+        del bad_json['url']
+        entry_json = json.dumps(bad_json)
+        client = Client()
+        self.assertTrue(client.login(username='unalog', password='unalog'))
+        response = client.post('/entry/new', entry_json, 
+                content_type='application/json')
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response['content-type'], 'text/plain')
+        self.assertEqual(response.content, 'There was a problem with your JSON:\n\n  url: This field is required.')
+        self.assertEqual(m.Entry.objects.all().count(), 0)
+
+import datetime
 import math
 import re
+import time
 
 from django.conf import settings
 from django.contrib.auth import authenticate, logout
 from django.db.models import Count
 from django import forms
 from django.forms.models import modelformset_factory
-from django.http import Http404, HttpResponse, HttpResponseRedirect
+from django.http import Http404, HttpResponse, HttpResponseRedirect, \
+    HttpResponseBadRequest
 from django.http import HttpResponsePermanentRedirect
 from django.shortcuts import render_to_response, get_object_or_404
 from django.template import RequestContext, loader
 from django.utils import feedgenerator
+from django.utils import simplejson as json
 from django.views.decorators.cache import cache_control
+from django.views.decorators.csrf import csrf_exempt
+
 
 import solr
+import feedparser
 
 from basicauth import logged_in_or_basicauth
 
     comment = forms.CharField(required=False, widget=forms.Textarea)
     content = forms.CharField(required=False, widget=forms.Textarea)
 
-
 def apply_user_filters_to_entries (request, qs):
     # Assume the user's already been authenticated.
     for f in request.user.filters.filter(is_active=True):
     return paginator, page
 
 
+@csrf_exempt # to allow javascript bookmarklet to post
 @logged_in_or_basicauth(REALM)
 def entry_new (request):
     """
-    Save a new URL entry.
+    Save a new URL entry. Can either come from an html form, or via some json.
     """
     request.encoding = 'utf-8'
+    payload = request.META['CONTENT_TYPE']
     context = RequestContext(request)
     d = {}
+
     if request.method == 'POST':
-        form = EntryForm(request.POST)
+        date_created = datetime.datetime.now()
+
+        # if application/json was posted try to construct a form based on it
+        if payload == 'application/json':
+            # try to parse the json
+            try:
+                entry_json = json.loads(request.raw_post_data)
+                form = EntryForm(entry_json)
+                # if the json isn't right respond with an text error message
+                # that explains the problem
+                if not form.is_valid():
+                    error_msg = ["There was a problem with your JSON:\n"]
+                    for k, v in form.errors.items():
+                        for e in v:
+                            error_msg.append("  %s: %s" % (k, e))
+                    return HttpResponseBadRequest("\n".join(error_msg),
+                            mimetype="text/plain")
+
+                # json allows you to post date_created
+                if entry_json.has_key('date_created'):
+                    try:
+                        # parse the rfc3339 datetime
+                        t = entry_json['date_created']
+                        t = time.mktime(feedparser._parse_date(t))
+                        date_created = datetime.datetime.fromtimestamp(t)
+                    except TypeError, e:
+                        return HttpResponseBadRequest("invalid date_created, should be RFC3339 compatible, e.g.  1985-04-12T23:20:50.52Z", mimetype="text/plain")
+            except ValueError, e:
+
+                return HttpResponseBadRequest("invalid json: %s" % e, 
+                        mimetype="text/plain")
+
+        # otherwise it's a standard POSTed form
+        else:
+            form = EntryForm(request.POST)
+
         if form.is_valid():
             url_str = form.cleaned_data['url']
             title = form.cleaned_data['title']
                         
             # It must be either new, or a duplicate url by choice, so go ahead
             new_entry = m.Entry(user=request.user, title=title, 
-                is_private=is_private, comment=comment, content=content)
+                is_private=is_private, comment=comment, content=content,
+                date_created=date_created)
             
             url, was_created = m.Url.objects.get_or_create(value=url_str)
             new_entry.url = url
 def about (request):
     context = RequestContext(request)
     return render_to_response('about.html', 
-        {'title': 'About'},
+        {'title': 'About', 'site_url': settings.UNALOG_URL},
         context)
 
 def contact (request):
+#!/usr/bin/env python
+
+"""
+This is a delcious to unalog import tool. 
+
+  ./d2u.py --username me --password secret delicious-20101216.htm
+
+Use --unalog if you want to target another unalog instance.
+
+You will need lxml, html5lib and httplib2 installed:
+
+    easy_install lxml
+    easy_install html5lib
+    easy_install httplib2
+
+"""
+
+import httplib2
+import json
+import optparse
+import os
+import time
+import urllib
+
+from html5lib import HTMLParser, treebuilders
+from lxml import etree
+
+opt_parser = optparse.OptionParser()
+opt_parser.add_option('-u', '--username', dest='username')
+opt_parser.add_option('-p', '--password', dest='password')
+opt_parser.add_option('-n', '--unalog', dest='unalog', 
+                      default="http://unalog.com")
+
+opts, args = opt_parser.parse_args()
+
+if len(args) != 1:
+    opt_parser.error("must supply delicious bookmarks html file")
+elif not os.path.isfile(args[0]):
+    opt_parser.error("no such file: %s" % args[0])
+else:
+    delicious = args[0]
+
+if not opts.username or not opts.password:
+    opt_parser.error("must supply --username or --password")
+
+# parse the delicious html bookmarks export 
+xhtml = "http://www.w3.org/1999/xhtml"
+parser = HTMLParser(tree=treebuilders.getTreeBuilder("lxml"))
+doc = parser.parse(open(delicious))
+
+# create http client
+h = httplib2.Http()
+h.add_credentials(opts.username, opts.password)
+unalog = opts.unalog.rstrip("/") + "/entry/new"
+
+status = {}
+count = 0
+
+for dt in doc.findall(".//{%s}dt" % xhtml):
+    # get the bookmark from the dt
+    a = dt.find('{%s}a' % xhtml)
+    b  = a.attrib
+
+    # see if there's a comment in the next element
+    e = dt.getnext()
+    if e is not None and e.tag == "{%s}dd" % xhtml:
+        comment = e.text
+    else:
+        comment = None
+
+    # convert the epoch time into rfc 3339 time
+    t = time.localtime(int(b["add_date"]))
+    t = time.strftime('%Y-%m-%dT%H:%M:%S%z', t)
+
+    # get the content at the url
+    url = b["href"]
+    resp, content = h.request(url, "GET")
+    status[resp.status] = status.get(resp.status, 0) + 1
+
+    # build the bookmark entry
+    entry = {
+        "url": b["href"],
+        "title": a.text,
+        "tags": b["tags"].replace(',', ' '),
+        "private": b["private"] == 1,
+        "date_created": t,
+        "comment": comment,
+        #"content": content
+    }
+
+    # send the bookmark to unalog as json
+    print url
+    resp, content = h.request(unalog, "POST", body=json.dumps(entry),
+            headers={"content-type": "application/json"})
+
+    count += 1
+    if count % 30 == 0:
+        break
+
+print status
 
 MANAGERS = ADMINS
 
-DATABASE_ENGINE = 'postgresql_psycopg2' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
-DATABASE_NAME = ''             # Or path to database file if using sqlite3.
-DATABASE_USER = ''             # Not used with sqlite3.
-DATABASE_PASSWORD = ''         # Not used with sqlite3.
-DATABASE_HOST = ''             # Set to empty string for localhost. Not used with sqlite3.
-DATABASE_PORT = ''             # Set to empty string for default. Not used with sqlite3.
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.postgresql_psycopg2', 
+        'NAME': '',
+        'USER': '',
+        'PASSWORD': '',
+        'HOST': '',
+        'PORT': '',
+    }
+}
 
 # Local time zone for this installation. Choices can be found here:
 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.middleware.doc.XViewMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
 )
 
 TEMPLATE_CONTEXT_PROCESSORS = (
-	'django.core.context_processors.auth',
+    'django.core.context_processors.auth',
 )
 
 ROOT_URLCONF = 'urls'