Initial
1
README
Normal file
|
@ -0,0 +1 @@
|
|||
A News/RSS Reader that controls the amount, relevancy, and interestingness of news subscriptions.
|
0
__init__.py
Normal file
0
apps/__init__.py
Normal file
0
apps/analyzer/__init__.py
Normal file
6
apps/analyzer/models.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
import datetime
|
||||
from apps.rss_feeds.models import Feed, Story
|
||||
from apps.reader.models import UserSubscription, ReadStories
|
||||
from utils import feedparser, object_manager
|
1
apps/analyzer/views.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Create your views here.
|
0
apps/opml_import/__init__.py
Normal file
5
apps/opml_import/models.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from apps.rss_feeds.models import Feed, Story
|
||||
import datetime
|
||||
|
6
apps/opml_import/urls.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.conf.urls.defaults import *
|
||||
|
||||
urlpatterns = patterns('apps.opml_import.views',
|
||||
(r'^$', 'opml_import'),
|
||||
(r'^process', 'process'),
|
||||
)
|
44
apps/opml_import/views.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from django.shortcuts import render_to_response, get_list_or_404, get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.template import RequestContext
|
||||
from apps.rss_feeds.models import Feed, Story
|
||||
from apps.reader.models import UserSubscription, ReadStories, UserSubscriptionFolders
|
||||
from utils.json import json_encode
|
||||
import utils.opml as opml
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpResponse, HttpRequest
|
||||
from django.core import serializers
|
||||
from pprint import pprint
|
||||
import datetime
|
||||
|
||||
|
||||
def opml_import(request):
|
||||
context = None
|
||||
return render_to_response('opml_import/import.xhtml', context,
|
||||
context_instance=RequestContext(request))
|
||||
|
||||
def process(request):
|
||||
context = None
|
||||
outline = opml.from_string(request.POST['opml'])
|
||||
feeds = []
|
||||
for folder in outline:
|
||||
for feed in folder:
|
||||
feed_data = dict(feed_address=feed.xmlUrl, feed_link=feed.htmlUrl, feed_title=feed.title)
|
||||
feeds.append(feed_data)
|
||||
new_feed = Feed(**feed_data)
|
||||
try:
|
||||
new_feed.save()
|
||||
except:
|
||||
new_feed = Feed.objects.get(**feed_data)
|
||||
us = UserSubscription(feed=new_feed, user=request.user)
|
||||
try:
|
||||
us.save()
|
||||
except:
|
||||
us = UserSubscription.objects.get(feed=new_feed, user=request.user)
|
||||
user_sub_folder = UserSubscriptionFolders(user=request.user, feed=new_feed, user_sub=us, folder=folder.text)
|
||||
try:
|
||||
user_sub_folder.save()
|
||||
except:
|
||||
pass
|
||||
data = json_encode(feeds)
|
||||
return HttpResponse(data, mimetype='application/json')
|
0
apps/profile/__init__.py
Normal file
5
apps/profile/models.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
class UserProfile(models.Model):
|
||||
user = models.ForeignKey(User, unique=True)
|
1
apps/profile/views.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Create your views here.
|
0
apps/reader/__init__.py
Normal file
6
apps/reader/admin.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from apps.reader.models import UserSubscription, ReadStories, UserSubscriptionFolders
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(UserSubscription)
|
||||
admin.site.register(ReadStories)
|
||||
admin.site.register(UserSubscriptionFolders)
|
113
apps/reader/models.py
Normal file
|
@ -0,0 +1,113 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
import datetime
|
||||
from apps.rss_feeds.models import Feed, Story
|
||||
from utils import feedparser, object_manager
|
||||
|
||||
class UserSubscription(models.Model):
|
||||
user = models.ForeignKey(User)
|
||||
feed = models.ForeignKey(Feed)
|
||||
last_read_date = models.DateTimeField(default=datetime.datetime(2000,1,1))
|
||||
mark_read_date = models.DateTimeField(default=datetime.datetime(2000,1,1))
|
||||
unread_count = models.IntegerField(default=0)
|
||||
unread_count_updated = models.DateTimeField(
|
||||
default=datetime.datetime(2000,1,1)
|
||||
)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.user.username + ': [' + self.feed.feed_title + '] '
|
||||
|
||||
def save(self, force_insert=False, force_update=False):
|
||||
self.unread_count_updated = datetime.datetime.now()
|
||||
super(UserSubscription, self).save(force_insert, force_update)
|
||||
|
||||
def get_user_feeds(self):
|
||||
return Feed.objects.get(user=self.user, feed=feeds)
|
||||
|
||||
def count_unread(self):
|
||||
if self.unread_count_updated > self.feed.last_update:
|
||||
return self.unread_count
|
||||
|
||||
count = (self.stories_newer_lastread()
|
||||
+ self.stories_between_lastread_allread())
|
||||
if count == 0:
|
||||
self.mark_read_date = datetime.datetime.now()
|
||||
self.last_read_date = datetime.datetime.now()
|
||||
self.unread_count_updated = datetime.datetime.now()
|
||||
self.unread_count = 0
|
||||
self.save()
|
||||
else:
|
||||
self.unread_count = count
|
||||
self.unread_count_updated = datetime.datetime.now()
|
||||
self.save()
|
||||
return count
|
||||
|
||||
def mark_read(self):
|
||||
self.last_read_date = datetime.datetime.now()
|
||||
self.unread_count -= 1
|
||||
self.unread_count_updated = datetime.datetime.now()
|
||||
self.save()
|
||||
|
||||
def mark_feed_read(self):
|
||||
self.last_read_date = datetime.datetime.now()
|
||||
self.mark_read_date = datetime.datetime.now()
|
||||
self.unread_count = 0
|
||||
self.unread_count_updated = datetime.datetime.now()
|
||||
self.save()
|
||||
readstories = ReadStories.objects.filter(user=self.user, feed=self.feed)
|
||||
readstories.delete()
|
||||
|
||||
def stories_newer_lastread(self):
|
||||
return self.feed.new_stories_since_date(self.last_read_date)
|
||||
|
||||
def stories_between_lastread_allread(self):
|
||||
story_count = Story.objects.filter(
|
||||
story_date__gte=self.mark_read_date,
|
||||
story_date__lte=self.last_read_date,
|
||||
story_feed=self.feed
|
||||
).count()
|
||||
read_count = ReadStories.objects.filter(
|
||||
feed=self.feed,
|
||||
read_date__gte=self.mark_read_date,
|
||||
read_date__lte=self.last_read_date
|
||||
).count()
|
||||
return story_count - read_count
|
||||
|
||||
def subscribe_to_feed(self, feed_id):
|
||||
feed = Feed.objects.get(id=feed_id)
|
||||
new_subscription = UserSubscription(user=self.user, feed=feed)
|
||||
new_subscription.save()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "feed")
|
||||
|
||||
|
||||
class ReadStories(models.Model):
|
||||
user = models.ForeignKey(User)
|
||||
feed = models.ForeignKey(Feed)
|
||||
story = models.ForeignKey(Story)
|
||||
read_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return (self.user.username + ': [' + self.feed.feed_title + '] '
|
||||
+ self.story.story_title)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "read stories"
|
||||
verbose_name = "read story"
|
||||
unique_together = ("user", "story")
|
||||
|
||||
class UserSubscriptionFolders(models.Model):
|
||||
user = models.ForeignKey(User)
|
||||
user_sub = models.ForeignKey(UserSubscription)
|
||||
feed = models.ForeignKey(Feed)
|
||||
folder = models.CharField(max_length=255)
|
||||
|
||||
def __unicode__(self):
|
||||
return (self.user.username + ': [' + self.feed.feed_title + '] '
|
||||
+ self.folder)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "folders"
|
||||
verbose_name = "folder"
|
||||
unique_together = ("user", "user_sub")
|
12
apps/reader/urls.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from django.conf.urls.defaults import *
|
||||
|
||||
urlpatterns = patterns('apps.reader.views',
|
||||
(r'^$', 'index'),
|
||||
(r'^load_single_feed', 'load_single_feed'),
|
||||
(r'^load_feeds', 'load_feeds'),
|
||||
(r'^refresh_all_feeds', 'refresh_all_feeds'),
|
||||
(r'^refresh_feed', 'refresh_feed'),
|
||||
(r'^mark_story_as_read', 'mark_story_as_read'),
|
||||
(r'^mark_feed_as_read', 'mark_feed_as_read'),
|
||||
(r'^get_read_feed_items', 'get_read_feed_items'),
|
||||
)
|
200
apps/reader/views.py
Normal file
|
@ -0,0 +1,200 @@
|
|||
from django.shortcuts import render_to_response, get_list_or_404, get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.template import RequestContext
|
||||
from apps.rss_feeds.models import Feed, Story
|
||||
from apps.reader.models import UserSubscription, ReadStories, UserSubscriptionFolders
|
||||
from utils.json import json_encode
|
||||
from utils.story_functions import format_story_link_date__short, format_story_link_date__long
|
||||
from utils.user_functions import get_user
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpResponse, HttpRequest
|
||||
from django.core import serializers
|
||||
from pprint import pprint
|
||||
from django.utils.safestring import mark_safe
|
||||
from utils.feedcache.threading_model import fetch_feeds
|
||||
import datetime
|
||||
import threading
|
||||
|
||||
def index(request):
|
||||
# feeds = Feed.objects.filter(usersubscription__user=request.user)
|
||||
# for f in feeds:
|
||||
# f.update()
|
||||
|
||||
# context = feeds
|
||||
context = {}
|
||||
|
||||
user = request.user
|
||||
user_info = _parse_user_info(user)
|
||||
context.update(user_info)
|
||||
return render_to_response('reader/feeds.xhtml', context,
|
||||
context_instance=RequestContext(request))
|
||||
|
||||
def refresh_all_feeds(request):
|
||||
force_update = False # request.GET.get('force', False)
|
||||
feeds = Feed.objects.all()
|
||||
|
||||
t = threading.Thread(target=refresh_feeds,
|
||||
args=[feeds])
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
# feeds = fetch_feeds(force_update, feeds)
|
||||
|
||||
context = {}
|
||||
|
||||
user = request.user
|
||||
user_info = _parse_user_info(user)
|
||||
context.update(user_info)
|
||||
|
||||
return render_to_response('reader/feeds.xhtml', context,
|
||||
context_instance=RequestContext(request))
|
||||
|
||||
def refresh_feed(request):
|
||||
feed_id = request.REQUEST['feed_id']
|
||||
force_update = request.GET.get('force', False)
|
||||
feeds = Feed.objects.filter(id=feed_id)
|
||||
|
||||
feeds = fetch_feeds(force_update, feeds)
|
||||
|
||||
context = {}
|
||||
|
||||
user = request.user
|
||||
user_info = _parse_user_info(user)
|
||||
context.update(user_info)
|
||||
|
||||
return render_to_response('reader/feeds.xhtml', context,
|
||||
context_instance=RequestContext(request))
|
||||
|
||||
def refresh_feeds(feeds):
|
||||
for f in feeds:
|
||||
f.update()
|
||||
return
|
||||
|
||||
def load_feeds(request):
|
||||
user = get_user(request)
|
||||
|
||||
us = UserSubscriptionFolders.objects.select_related().filter(
|
||||
user=user
|
||||
)
|
||||
|
||||
feeds = []
|
||||
folders = []
|
||||
for sub in us:
|
||||
sub.feed.unread_count = sub.user_sub.count_unread()
|
||||
if sub.folder not in folders:
|
||||
folders.append(sub.folder)
|
||||
feeds.append({'folder': sub.folder, 'feeds': []})
|
||||
for folder in feeds:
|
||||
if folder['folder'] == sub.folder:
|
||||
folder['feeds'].append(sub.feed)
|
||||
|
||||
# Alphabetize folders, then feeds inside folders
|
||||
feeds.sort(lambda x, y: cmp(x['folder'].lower(), y['folder'].lower()))
|
||||
for feed in feeds:
|
||||
feed['feeds'].sort(lambda x, y: cmp(x.feed_title.lower(), y.feed_title.lower()))
|
||||
for f in feed['feeds']:
|
||||
f.feed_address = mark_safe(f.feed_address)
|
||||
|
||||
context = feeds
|
||||
|
||||
data = json_encode(context)
|
||||
return HttpResponse(data, mimetype='application/json')
|
||||
|
||||
def load_single_feed(request):
|
||||
user = get_user(request)
|
||||
|
||||
offset = int(request.REQUEST.get('offset', 0))
|
||||
limit = int(request.REQUEST.get('limit', 25))
|
||||
feed_id = request.REQUEST['feed_id']
|
||||
stories=Story.objects.filter(story_feed=feed_id)[offset:offset+limit]
|
||||
feed = Feed.objects.get(id=feed_id)
|
||||
force_update = request.GET.get('force', False)
|
||||
|
||||
if force_update:
|
||||
fetch_feeds(force_update, [feed])
|
||||
|
||||
us = UserSubscription.objects.select_related("feed").filter(user=user)
|
||||
for sub in us:
|
||||
if sub.feed_id == feed.id:
|
||||
|
||||
print "Feed: " + feed.feed_title
|
||||
user_readstories = ReadStories.objects.filter(
|
||||
user=user,
|
||||
feed=feed
|
||||
)
|
||||
for story in stories:
|
||||
story.short_parsed_date = format_story_link_date__short(story.story_date)
|
||||
story.long_parsed_date = format_story_link_date__long(story.story_date)
|
||||
story.story_feed_title = feed.feed_title
|
||||
story.story_feed_link = mark_safe(feed.feed_link)
|
||||
story.story_permalink = mark_safe(story.story_permalink)
|
||||
if story.story_date < sub.mark_read_date:
|
||||
story.read_status = 1
|
||||
elif story.story_date > sub.last_read_date:
|
||||
story.read_status = 0
|
||||
else:
|
||||
if story.id in [u_rs.story_id for u_rs in user_readstories]:
|
||||
print "READ: "
|
||||
story.read_status = 1
|
||||
else:
|
||||
print "unread: "
|
||||
story.read_status = 0
|
||||
|
||||
context = stories
|
||||
data = json_encode(context)
|
||||
return HttpResponse(data, mimetype='text/plain')
|
||||
|
||||
|
||||
@login_required
|
||||
def mark_story_as_read(request):
|
||||
story_id = request.REQUEST['story_id']
|
||||
story = Story.objects.select_related("story_feed").get(id=story_id)
|
||||
|
||||
read_story = ReadStories.objects.filter(story=story_id, user=request.user, feed=story.story_feed).count()
|
||||
|
||||
print read_story
|
||||
if read_story:
|
||||
data = json_encode(dict(code=1))
|
||||
else:
|
||||
us = UserSubscription.objects.get(
|
||||
feed=story.story_feed,
|
||||
user=request.user
|
||||
)
|
||||
us.mark_read()
|
||||
print "Marked Read: " + str(story_id) + ' ' + str(story.id)
|
||||
m = ReadStories(story=story, user=request.user, feed=story.story_feed)
|
||||
data = json_encode(dict(code=0))
|
||||
try:
|
||||
m.save()
|
||||
except:
|
||||
data = json_encode(dict(code=2))
|
||||
return HttpResponse(data)
|
||||
|
||||
@login_required
|
||||
def mark_feed_as_read(request):
|
||||
feed_id = int(request.REQUEST['feed_id'])
|
||||
feed = Feed.objects.get(id=feed_id)
|
||||
|
||||
us = UserSubscription.objects.get(feed=feed, user=request.user)
|
||||
us.mark_feed_read()
|
||||
|
||||
ReadStories.objects.filter(user=request.user, feed=feed_id).delete()
|
||||
data = json_encode(dict(code=0))
|
||||
try:
|
||||
m.save()
|
||||
except:
|
||||
data = json_encode(dict(code=1))
|
||||
return HttpResponse(data)
|
||||
|
||||
@login_required
|
||||
def get_read_feed_items(request, username):
|
||||
feeds = get_list_or_404(Feed)
|
||||
|
||||
def _parse_user_info(user):
|
||||
return {
|
||||
'user_info': {
|
||||
'is_anonymous': json_encode(user.is_anonymous()),
|
||||
'is_authenticated': json_encode(user.is_authenticated()),
|
||||
'username': user.username if user.is_authenticated() else 'Anonymous'
|
||||
}
|
||||
}
|
0
apps/registration/__init__.py
Normal file
11
apps/registration/admin.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from apps.registration.models import RegistrationProfile
|
||||
|
||||
|
||||
class RegistrationAdmin(admin.ModelAdmin):
|
||||
list_display = ('__unicode__', 'activation_key_expired')
|
||||
search_fields = ('user__username', 'user__first_name')
|
||||
|
||||
|
||||
admin.site.register(RegistrationProfile, RegistrationAdmin)
|
82
apps/registration/docs/forms.txt
Normal file
|
@ -0,0 +1,82 @@
|
|||
=====
|
||||
Forms
|
||||
=====
|
||||
|
||||
|
||||
To ease and automate the process of validating user information during
|
||||
registration, several form classes (built using Django's `newforms
|
||||
library`_) are provided: a base ``RegistrationForm`` and subclasses
|
||||
which provide specific customized functionality. All of the forms
|
||||
described below are found in ``registration.forms``.
|
||||
|
||||
.. _newforms library: http://www.djangoproject.com/documentation/newforms/
|
||||
|
||||
|
||||
``RegistrationForm``
|
||||
====================
|
||||
|
||||
Form for registering a new user account.
|
||||
|
||||
Validates that the requested username is not already in use, and
|
||||
requires the password to be entered twice to catch typos.
|
||||
|
||||
Subclasses should feel free to add any additional validation they
|
||||
need, but should either preserve the base ``save()`` or implement a
|
||||
``save()`` which accepts the ``profile_callback`` keyword argument and
|
||||
passes it through to
|
||||
``RegistrationProfile.objects.create_inactive_user()``.
|
||||
|
||||
Fields:
|
||||
|
||||
``username``
|
||||
The new user's requested username. Will be validated according to
|
||||
the same regular expression Django's authentication system uses to
|
||||
validate usernames.
|
||||
|
||||
``email``
|
||||
The new user's email address. Must be a well-formed email address.
|
||||
|
||||
``password1``
|
||||
The new user's password.
|
||||
|
||||
``password2``
|
||||
The password, again, to catch typos.
|
||||
|
||||
|
||||
Non-validation methods:
|
||||
|
||||
``save()``
|
||||
Creates the new ``User`` and ``RegistrationProfile``, and returns
|
||||
the ``User``.
|
||||
|
||||
This is essentially a light wrapper around
|
||||
``RegistrationProfile.objects.create_inactive_user()``, feeding it
|
||||
the form data and a profile callback (see the documentation on
|
||||
``create_inactive_user()`` for details) if supplied.
|
||||
|
||||
|
||||
Subclasses of ``RegistrationForm``
|
||||
==================================
|
||||
|
||||
As explained above, subclasses may add any additional validation they
|
||||
like, but must either preserve the ``save()`` method or implement a
|
||||
``save()`` method with an identical signature.
|
||||
|
||||
Three subclasses are included as examples, and as ready-made
|
||||
implementations of useful customizations:
|
||||
|
||||
``RegistrationFormTermsOfService``
|
||||
Subclass of ``RegistrationForm`` which adds a required checkbox
|
||||
for agreeing to a site's Terms of Service.
|
||||
|
||||
``RegistrationFormUniqueEmail``
|
||||
Subclass of ``RegistrationForm`` which enforces uniqueness of
|
||||
email addresses.
|
||||
|
||||
``RegistrationFormNoFreeEmail``
|
||||
Subclass of ``RegistrationForm`` which disallows registration with
|
||||
email addresses from popular free webmail services; moderately
|
||||
useful for preventing automated spam registrations.
|
||||
|
||||
To change the list of banned domains, subclass this form and
|
||||
override the attribute ``bad_domains``.
|
174
apps/registration/docs/models.txt
Normal file
|
@ -0,0 +1,174 @@
|
|||
===================
|
||||
Models and managers
|
||||
===================
|
||||
|
||||
|
||||
Because the two-step process of registration and activation requires
|
||||
some means of temporarily storing activation key and retrieving it for
|
||||
verification, a simple model --
|
||||
``registration.models.RegistrationProfile`` -- is provided in this
|
||||
application, and a custom manager --
|
||||
``registration.models.RegistrationManager`` -- is included and defines
|
||||
several useful methods for interacting with ``RegistrationProfile``.
|
||||
|
||||
Both the ``RegistrationProfile`` model and the ``RegistrationManager``
|
||||
are found in ``registration.models``.
|
||||
|
||||
|
||||
The ``RegistrationProfile`` model
|
||||
=================================
|
||||
|
||||
A simple profile which stores an activation key for use during user
|
||||
account registration.
|
||||
|
||||
Generally, you will not want to interact directly with instances of
|
||||
this model; the provided manager includes methods for creating and
|
||||
activating new accounts, as well as for cleaning out accounts which
|
||||
have never been activated.
|
||||
|
||||
While it is possible to use this model as the value of the
|
||||
``AUTH_PROFILE_MODULE`` setting, it's not recommended that you do
|
||||
so. This model's sole purpose is to store data temporarily during
|
||||
account registration and activation, and a mechanism for automatically
|
||||
creating an instance of a site-specific profile model is provided via
|
||||
the ``create_inactive_user`` on ``RegistrationManager``.
|
||||
|
||||
``RegistrationProfile`` objects have the following fields:
|
||||
|
||||
``activation_key``
|
||||
A SHA1 hash used as an account's activation key.
|
||||
|
||||
``user``
|
||||
The ``User`` object for which activation information is being
|
||||
stored.
|
||||
|
||||
``RegistrationProfile`` also has one custom method defined:
|
||||
|
||||
``activation_key_expired()``
|
||||
Determines whether this ``RegistrationProfile``'s activation key
|
||||
has expired.
|
||||
|
||||
Returns ``True`` if the key has expired, ``False`` otherwise.
|
||||
|
||||
Key expiration is determined by a two-step process:
|
||||
|
||||
1. If the user has already activated, the key will have been reset
|
||||
to the string ``ALREADY_ACTIVATED``. Re-activating is not
|
||||
permitted, and so this method returns ``True`` in this case.
|
||||
|
||||
2. Otherwise, the date the user signed up is incremented by the
|
||||
number of days specified in the setting
|
||||
``ACCOUNT_ACTIVATION_DAYS`` (which should be the number of days
|
||||
after signup during which a user is allowed to activate their
|
||||
account); if the result is less than or equal to the current
|
||||
date, the key has expired and this method returns ``True``.
|
||||
|
||||
|
||||
The ``RegistrationManager``
|
||||
===========================
|
||||
|
||||
Custom manager for the ``RegistrationProfile`` model.
|
||||
|
||||
The methods defined here provide shortcuts for account creation and
|
||||
activation (including generation and emailing of activation keys), and
|
||||
for cleaning out expired inactive accounts.
|
||||
|
||||
Methods:
|
||||
|
||||
``activate_user(activation_key)``
|
||||
Validates an activation key and activates the corresponding
|
||||
``User`` if valid.
|
||||
|
||||
If the key is valid and has not expired, returns the ``User``
|
||||
after activating.
|
||||
|
||||
If the key is not valid or has expired, returns ``False``.
|
||||
|
||||
If the key is valid but the ``User`` is already active, returns
|
||||
``False``.
|
||||
|
||||
To prevent reactivation of an account which has been deactivated
|
||||
by site administrators, the activation key is reset to the string
|
||||
``ALREADY_ACTIVATED`` after successful activation.
|
||||
|
||||
``create_inactive_user(username, password, email, send_email=True, profile_callback=None)``
|
||||
Creates a new, inactive ``User``, generates a
|
||||
``RegistrationProfile`` and emails its activation key to the
|
||||
``User``. Returns the new ``User``.
|
||||
|
||||
To disable the email, call with ``send_email=False``.
|
||||
|
||||
The activation email will make use of two templates:
|
||||
|
||||
``registration/activation_email_subject.txt``
|
||||
This template will be used for the subject line of the
|
||||
email. It receives one context variable, ``site``, which is
|
||||
the currently-active ``django.contrib.sites.models.Site``
|
||||
instance. Because it is used as the subject line of an email,
|
||||
this template's output **must** be only a single line of text;
|
||||
output longer than one line will be forcibly joined into only
|
||||
a single line.
|
||||
|
||||
``registration/activation_email.txt``
|
||||
This template will be used for the body of the email. It will
|
||||
receive three context variables: ``activation_key`` will be
|
||||
the user's activation key (for use in constructing a URL to
|
||||
activate the account), ``expiration_days`` will be the number
|
||||
of days for which the key will be valid and ``site`` will be
|
||||
the currently-active ``django.contrib.sites.models.Site``
|
||||
instance.
|
||||
|
||||
To enable creation of a custom user profile along with the
|
||||
``User`` (e.g., the model specified in the ``AUTH_PROFILE_MODULE``
|
||||
setting), define a function which knows how to create and save an
|
||||
instance of that model with appropriate default values, and pass
|
||||
it as the keyword argument ``profile_callback``. This function
|
||||
should accept one keyword argument:
|
||||
|
||||
``user``
|
||||
The ``User`` to relate the profile to.
|
||||
|
||||
``create_profile(user)``
|
||||
Creates a ``RegistrationProfile`` for a given ``User``. Returns
|
||||
the ``RegistrationProfile``.
|
||||
|
||||
The activation key for the ``RegistrationProfile`` will be a SHA1
|
||||
hash, generated from a combination of the ``User``'s username and
|
||||
a random salt.
|
||||
|
||||
``deleted_expired_users()``
|
||||
Removes expired instances of ``RegistrationProfile`` and their
|
||||
associated ``User`` objects.
|
||||
|
||||
Accounts to be deleted are identified by searching for instances
|
||||
of ``RegistrationProfile`` with expired activation keys, and then
|
||||
checking to see if their associated ``User`` instances have the
|
||||
field ``is_active`` set to ``False``; any ``User`` who is both
|
||||
inactive and has an expired activation key will be deleted.
|
||||
|
||||
It is recommended that this method be executed regularly as part
|
||||
of your routine site maintenance; this application provides a
|
||||
custom management command which will call this method, accessible
|
||||
as ``manage.py cleanupregistration``.
|
||||
|
||||
Regularly clearing out accounts which have never been activated
|
||||
serves two useful purposes:
|
||||
|
||||
1. It alleviates the ocasional need to reset a
|
||||
``RegistrationProfile`` and/or re-send an activation email when
|
||||
a user does not receive or does not act upon the initial
|
||||
activation email; since the account will be deleted, the user
|
||||
will be able to simply re-register and receive a new activation
|
||||
key.
|
||||
|
||||
2. It prevents the possibility of a malicious user registering one
|
||||
or more accounts and never activating them (thus denying the
|
||||
use of those usernames to anyone else); since those accounts
|
||||
will be deleted, the usernames will become available for use
|
||||
again.
|
||||
|
||||
If you have a troublesome ``User`` and wish to disable their
|
||||
account while keeping it in the database, simply delete the
|
||||
associated ``RegistrationProfile``; an inactive ``User`` which
|
||||
does not have an associated ``RegistrationProfile`` will not be
|
||||
deleted.
|
337
apps/registration/docs/overview.txt
Normal file
|
@ -0,0 +1,337 @@
|
|||
===================
|
||||
Django registration
|
||||
===================
|
||||
|
||||
|
||||
This is a fairly simple user-registration application for Django_,
|
||||
designed to make allowing user signups as painless as possible.
|
||||
|
||||
.. _Django: http://www.djangoproject.com/
|
||||
|
||||
|
||||
Overview
|
||||
========
|
||||
|
||||
This application enables a common user-registration workflow:
|
||||
|
||||
1. User fills out a registration form, selecting a username and
|
||||
password and entering an email address.
|
||||
|
||||
2. An inactive account is created, and an activation link is sent to
|
||||
the user's email address.
|
||||
|
||||
3. User clicks the activation link, the account becomes active and the
|
||||
user is able to log in and begin contributing to your site.
|
||||
|
||||
Various methods of extending and customizing the registration process
|
||||
are also provided.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
In order to use django-registration, you will need to have a
|
||||
functioning installation of Django 1.0 or newer; due to changes needed
|
||||
to stabilize Django's APIs prior to the 1.0 release,
|
||||
django-registration will not work with older releases of Django.
|
||||
|
||||
There are three basic ways to install django-registration:
|
||||
automatically installing a package using Python's package-management
|
||||
tools, manually installing a package, and installing from a Mercurial
|
||||
|
||||
|
||||
Using a package-management tool
|
||||
-------------------------------
|
||||
|
||||
The easiest way by far to install django-registration and most other
|
||||
interesting Python software is by using an automated
|
||||
package-management tool, so if you're not already familiar with the
|
||||
available tools for Python, now's as good a time as any to get
|
||||
started.
|
||||
|
||||
The most popular option currently is `easy_install`_; refer to its
|
||||
documentation to see how to get it set up. Once you've got it, you'll
|
||||
be able to simply type::
|
||||
|
||||
easy_install django-registration
|
||||
|
||||
And it will handle the rest.
|
||||
|
||||
Another option that's currently gaining steam (and which I personally
|
||||
prefer for Python package management) is `pip`_. Once again, you'll
|
||||
want to refer to its documentation to get up and running, but once you
|
||||
have you'll be able to type::
|
||||
|
||||
pip install django-registration
|
||||
|
||||
And you'll be done.
|
||||
|
||||
|
||||
Manually installing the 0.7 package
|
||||
-----------------------------------
|
||||
|
||||
If you'd prefer to do things the old-fashioned way, you can manually
|
||||
download the `django-registration 0.7 package`_ from the Python
|
||||
Package Index. This will get you a file named
|
||||
"django-registration-0.7.tar.gz" which you can unpack (double-click on
|
||||
the file on most operating systems) to create a directory named
|
||||
"django-registration-0.7". Inside will be a script named "setup.py";
|
||||
running::
|
||||
|
||||
python setup.py install
|
||||
|
||||
will install django-registration (though keep in mind that this
|
||||
defaults to a system-wide installation, and so may require
|
||||
administrative privileges on your computer).
|
||||
|
||||
|
||||
Installing from a Mercurial checkout
|
||||
------------------------------------
|
||||
|
||||
If you have `Mercurial`_ installed on your computer, you can also
|
||||
obtain a complete copy of django-registration by typing::
|
||||
|
||||
hg clone http://bitbucket.org/ubernostrum/django-registration/
|
||||
|
||||
Inside the resulting "django-registration" directory will be a
|
||||
directory named "registration", which is the actual Python module for
|
||||
this application; you can symlink it from somewhere on your Python
|
||||
path. If you prefer, you can use the setup.py script in the
|
||||
"django-registration" directory to perform a normal installation, but
|
||||
using a symlink offers easy upgrades: simply running ``hg pull -u``
|
||||
inside the django-registration directory will fetch updates from the
|
||||
main repository and apply them to your local copy.
|
||||
|
||||
|
||||
.. _easy_install: http://peak.telecommunity.com/DevCenter/EasyInstall
|
||||
.. _pip: http://pypi.python.org/pypi/pip/
|
||||
.. _django-registration 0.7 package: http://pypi.python.org/pypi/django-registration/0.7
|
||||
.. _Mercurial: http://www.selenic.com/mercurial/wiki/
|
||||
|
||||
|
||||
Basic use
|
||||
=========
|
||||
|
||||
To use the registration system with all its default settings, you'll
|
||||
need to do the following:
|
||||
|
||||
1. Add ``registration`` to the ``INSTALLED_APPS`` setting of your
|
||||
Django project.
|
||||
|
||||
2. Add the setting ``ACCOUNT_ACTIVATION_DAYS`` to your settings file;
|
||||
this should be the number of days activation keys will remain valid
|
||||
after an account is registered.
|
||||
|
||||
3. Create the necessary templates (see the section on templates below
|
||||
for details).
|
||||
|
||||
4. Add this line to your site's root URLConf::
|
||||
|
||||
(r'^accounts/', include('registration.urls')),
|
||||
|
||||
5. Link people to ``/accounts/register/`` so they can start signing
|
||||
up. Using the default URLConf will also automatically set up the
|
||||
authentication-oriented views in ``django.contrib.auth`` for you,
|
||||
so if you use it you can point people to, e.g.,
|
||||
``/accounts/login/`` to log in.
|
||||
|
||||
|
||||
Templates used by django-registration
|
||||
=====================================
|
||||
|
||||
The views included in django-registration make use of five templates:
|
||||
|
||||
* ``registration/registration_form.html`` displays the registration
|
||||
form for users to sign up.
|
||||
|
||||
* ``registration/registration_complete.html`` is displayed after the
|
||||
activation email has been sent, to tell the new user to check
|
||||
his/her email.
|
||||
|
||||
* ``registration/activation_email_subject.txt`` is used for the
|
||||
subject of the activation email.
|
||||
|
||||
* ``registration/activation_email.txt`` is used for the body of the
|
||||
activation email.
|
||||
|
||||
* ``registration/activate.html`` is displayed when a user attempts to
|
||||
activate his/her account.
|
||||
|
||||
Examples of all of these templates are not provided; you will need to
|
||||
create them yourself. For views defined in this application, see the
|
||||
included `views documentation`_ for details on available context
|
||||
variables, and for details on the templates used by the activation
|
||||
email see the included `models documentation`_.
|
||||
|
||||
Additionally, the URLConf provided with django-registration includes
|
||||
URL patterns for useful views in Django's built-in authentication
|
||||
application -- this means that a single ``include`` in your root
|
||||
URLConf can wire up registration and the auth application's login,
|
||||
logout, and password change/reset views. If you choose to use these
|
||||
views you will need to provide your own templates for them; consult
|
||||
`the Django authentication documentation`_ for details on the
|
||||
templates and contexts used by these views.
|
||||
|
||||
.. _views documentation: views.txt
|
||||
.. _models documentation: models.txt
|
||||
.. _the Django authentication documentation: http://www.djangoproject.com/documentation/authentication/
|
||||
|
||||
|
||||
How it works
|
||||
============
|
||||
|
||||
Using the recommended default configuration, the URL
|
||||
``/accounts/register/`` will map to the view
|
||||
``registration.views.register``, which displays a registration form
|
||||
(an instance of ``registration.forms.RegistrationForm``); this form
|
||||
asks for a username, email address and password, and verifies that the
|
||||
username is available and requires the password to be entered twice
|
||||
(to catch typos). It then does three things:
|
||||
|
||||
1. Creates an instance of ``django.contrib.models.auth.User``, using
|
||||
the supplied username, email address and password; the
|
||||
``is_active`` field on the new ``User`` will be set to ``False``,
|
||||
meaning that the account is inactive and the user will not be able
|
||||
to log in yet.
|
||||
|
||||
2. Creates an instance of ``registration.models.RegistrationProfile``,
|
||||
stores an activation key (a SHA1 hash generated from the new user's
|
||||
username plus a randomly-generated "salt"), and relates that
|
||||
``RegistrationProfile`` to the ``User`` it just created.
|
||||
|
||||
3. Sends an email to the user (at the address they supplied)
|
||||
containing a link which can be clicked to activate the account.
|
||||
|
||||
For details on customizing this process, including use of alternate
|
||||
registration form classes and automatic creation of a site-specific
|
||||
profile, see the sections on customization below.
|
||||
|
||||
After the activation email has been sent,
|
||||
``registration.views.register`` issues a redirect to the URL
|
||||
``/accounts/register/complete/``. By default, this is mapped to the
|
||||
``direct_to_template`` generic view, and displays the template
|
||||
``registration/registration_complete.html``; this is intended to show
|
||||
a short message telling the user to check his/her email for the
|
||||
activation link.
|
||||
|
||||
The activation link will map to the view
|
||||
``registration.views.activate``, which will attempt to activate the
|
||||
account by setting the ``is_active`` field on the ``User`` to
|
||||
``True``. If the activation key for the ``User`` has expired (this is
|
||||
controlled by the setting ``ACCOUNT_ACTIVATION_DAYS``, as described
|
||||
above), the account will not be activated (see the section on
|
||||
maintenance below for instructions on cleaning out expired accounts
|
||||
which have not been activated).
|
||||
|
||||
|
||||
Maintenance
|
||||
===========
|
||||
|
||||
Inevitably, a site which uses a two-step process for user signup --
|
||||
registration followed by activation -- will accumulate a certain
|
||||
number of accounts which were registered but never activated. These
|
||||
accounts clutter up the database and tie up usernames which might
|
||||
otherwise be actively used, so it's desirable to clean them out
|
||||
periodically. For this purpose, a script,
|
||||
``registration/bin/deleted_expired_users.py``, is provided, which is
|
||||
suitable for use as a regular cron job. See that file for notes on how
|
||||
to add it to your crontab, and the included models documentation (see
|
||||
below) for discussion of how it works and some caveats.
|
||||
|
||||
|
||||
Where to go from here
|
||||
=====================
|
||||
|
||||
Full documentation for all included components is bundled in the
|
||||
packaged release; see the following files for details:
|
||||
|
||||
* `Forms documentation`_ for details on ``RegistrationForm``,
|
||||
pre-packaged subclasses and available customizations.
|
||||
|
||||
* `Models documentation`_ for details on ``RegistrationProfile`` and
|
||||
its custom manager.
|
||||
|
||||
* `Views documentation`_ for details on the ``register`` and
|
||||
``activate`` views, and methods for customizing them.
|
||||
|
||||
.. _Forms documentation: forms.txt
|
||||
.. _Models documentation: models.txt
|
||||
.. _Views documentation: views.txt
|
||||
|
||||
|
||||
Development
|
||||
===========
|
||||
|
||||
The `latest released version`_ of this application is 0.7, and is
|
||||
quite stable; it's already been deployed on a number of sites,
|
||||
including djangoproject.com. You can also obtain the absolute freshest
|
||||
code from `the development repository_`, but be warned that the
|
||||
development code may not always be backwards-compatible, and may well
|
||||
contain bugs that haven't yet been fixed.
|
||||
|
||||
This document covers the 0.7 release of django-registration; new
|
||||
features introduced in the development trunk will be added to the
|
||||
documentation at the time of the next packaged release.
|
||||
|
||||
.. _latest released version: http://pypi.python.org/pypi/django-registration/0.7
|
||||
.. _the development repository: http://www.bitbucket.org/ubernostrum/django-registration/src/
|
||||
|
||||
|
||||
Changes from previous versions
|
||||
==============================
|
||||
|
||||
Several new features were added between version 0.2 and version 0.3;
|
||||
for details, see the CHANGELOG.txt file distributed with the packaged
|
||||
0.3 release.
|
||||
|
||||
One important change to note before upgrading an installation of
|
||||
version 0.1 is a change to the ``RegistrationProfile`` model; the
|
||||
field ``key_generated`` has been removed, since it was redundant with
|
||||
the field ``date_joined`` on Django's bundled ``User`` model. Since
|
||||
this field became a ``NOT NULL`` column in the database, you will need
|
||||
to either drop the ``NOT NULL`` constraint or, preferably, simply drop
|
||||
the column. Consult your database's documentation for the correct way
|
||||
to handle this.
|
||||
|
||||
Between version 0.3 and version 0.4, validation of the password fields
|
||||
was moved from ``clean_password2()`` to ``clean_password()``; this
|
||||
means that errors from mismatched passwords will now show up in
|
||||
``non_field_errors()`` instead of ``errors["password2"]``.
|
||||
|
||||
Between version 0.6 and version 0.7, the script
|
||||
``registration/bin/delete_expired_users.py`` was removed, and replaced
|
||||
with a custom management command; you can now simply run ``manage.py
|
||||
cleanupregistration`` from any project which has django-registration
|
||||
installed.
|
||||
|
||||
|
||||
Dependencies
|
||||
============
|
||||
|
||||
The only dependencies for this application are a functioning install
|
||||
of Django 1.0 or newer and, of course, a Django project in which you'd
|
||||
like to use it.
|
||||
|
||||
Your Django project should have ``django.contrib.admin``,
|
||||
``django.contrib.auth`` and ``django.contrib.sites`` in its
|
||||
``INSTALLED_APPS`` setting.
|
||||
|
||||
|
||||
What this application does not do
|
||||
=================================
|
||||
|
||||
This application does not integrate in any way with OpenID, nor should
|
||||
it; one of the key selling points of OpenID is that users **don't**
|
||||
have to walk through an explicit registration step for every site or
|
||||
service they want to use :)
|
||||
|
||||
|
||||
If you spot a bug
|
||||
=================
|
||||
|
||||
Head over to this application's `project page on Bitbucket`_ and
|
||||
check `the issues list`_ to see if it's already been reported. If not,
|
||||
open a new issue and I'll do my best to respond quickly.
|
||||
|
||||
.. _project page on Bitbucket: http://www.bitbucket.org/ubernostrum/django-registration/overview/
|
||||
.. _the issues list: http://www.bitbucket.org/ubernostrum/django-registration/issues/
|
120
apps/registration/docs/views.txt
Normal file
|
@ -0,0 +1,120 @@
|
|||
=====
|
||||
Views
|
||||
=====
|
||||
|
||||
|
||||
Two views are included which, between them, handle the process of
|
||||
first registering and then activating new user accounts; both views
|
||||
are found in ``registration.views``.
|
||||
|
||||
|
||||
``activate``
|
||||
============
|
||||
|
||||
Activate a ``User``'s account from an activation key, if their key is
|
||||
valid and hasn't expired.
|
||||
|
||||
By default, use the template ``registration/activate.html``; to
|
||||
change this, pass the name of a template as the keyword argument
|
||||
``template_name``.
|
||||
|
||||
**Required arguments**
|
||||
|
||||
``activation_key``
|
||||
The activation key to validate and use for activating the
|
||||
``User``.
|
||||
|
||||
**Optional arguments**
|
||||
|
||||
``extra_context``
|
||||
A dictionary of variables to add to the template context. Any
|
||||
callable object in this dictionary will be called to produce the
|
||||
end result which appears in the context.
|
||||
|
||||
``template_name``
|
||||
A custom template to use.
|
||||
|
||||
**Context:**
|
||||
|
||||
``account``
|
||||
The ``User`` object corresponding to the account, if the
|
||||
activation was successful. ``False`` if the activation was not
|
||||
successful.
|
||||
|
||||
``expiration_days``
|
||||
The number of days for which activation keys stay valid after
|
||||
registration.
|
||||
|
||||
Any extra variables supplied in the ``extra_context`` argument (see
|
||||
above).
|
||||
|
||||
**Template:**
|
||||
|
||||
registration/activate.html or ``template_name`` keyword argument.
|
||||
|
||||
|
||||
``register``
|
||||
============
|
||||
|
||||
Allow a new user to register an account.
|
||||
|
||||
Following successful registration, issue a redirect; by default, this
|
||||
will be whatever URL corresponds to the named URL pattern
|
||||
``registration_complete``, which will be
|
||||
``/accounts/register/complete/`` if using the included URLConf. To
|
||||
change this, point that named pattern at another URL, or pass your
|
||||
preferred URL as the keyword argument ``success_url``.
|
||||
|
||||
By default, ``registration.forms.RegistrationForm`` will be used as
|
||||
the registration form; to change this, pass a different form class as
|
||||
the ``form_class`` keyword argument. The form class you specify must
|
||||
have a method ``save`` which will create and return the new ``User``,
|
||||
and that method must accept the keyword argument ``profile_callback``
|
||||
(see below).
|
||||
|
||||
To enable creation of a site-specific user profile object for the new
|
||||
user, pass a function which will create the profile object as the
|
||||
keyword argument ``profile_callback``. See
|
||||
``RegistrationManager.create_inactive_user`` in the file ``models.py``
|
||||
for details on how to write this function.
|
||||
|
||||
By default, use the template ``registration/registration_form.html``;
|
||||
to change this, pass the name of a template as the keyword argument
|
||||
``template_name``.
|
||||
|
||||
**Required arguments**
|
||||
|
||||
None.
|
||||
|
||||
**Optional arguments**
|
||||
|
||||
``form_class``
|
||||
The form class to use for registration.
|
||||
|
||||
``extra_context``
|
||||
A dictionary of variables to add to the template context. Any
|
||||
callable object in this dictionary will be called to produce the
|
||||
end result which appears in the context.
|
||||
|
||||
``profile_callback``
|
||||
A function which will be used to create a site-specific profile
|
||||
instance for the new ``User``.
|
||||
|
||||
``success_url``
|
||||
The URL to redirect to on successful registration.
|
||||
|
||||
``template_name``
|
||||
A custom template to use.
|
||||
|
||||
**Context:**
|
||||
|
||||
``form``
|
||||
The registration form.
|
||||
|
||||
Any extra variables supplied in the ``extra_context`` argument (see
|
||||
above).
|
||||
|
||||
**Template:**
|
||||
|
||||
registration/registration_form.html or ``template_name`` keyword
|
||||
argument.
|
142
apps/registration/forms.py
Normal file
|
@ -0,0 +1,142 @@
|
|||
"""
|
||||
Forms and validation code for user registration.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from apps.registration.models import RegistrationProfile
|
||||
|
||||
|
||||
# I put this on all required fields, because it's easier to pick up
|
||||
# on them with CSS or JavaScript if they have a class of "required"
|
||||
# in the HTML. Your mileage may vary. If/when Django ticket #3515
|
||||
# lands in trunk, this will no longer be necessary.
|
||||
attrs_dict = { 'class': 'required' }
|
||||
|
||||
|
||||
class RegistrationForm(forms.Form):
|
||||
"""
|
||||
Form for registering a new user account.
|
||||
|
||||
Validates that the requested username is not already in use, and
|
||||
requires the password to be entered twice to catch typos.
|
||||
|
||||
Subclasses should feel free to add any additional validation they
|
||||
need, but should either preserve the base ``save()`` or implement
|
||||
a ``save()`` which accepts the ``profile_callback`` keyword
|
||||
argument and passes it through to
|
||||
``RegistrationProfile.objects.create_inactive_user()``.
|
||||
|
||||
"""
|
||||
username = forms.RegexField(regex=r'^\w+$',
|
||||
max_length=30,
|
||||
widget=forms.TextInput(attrs=attrs_dict),
|
||||
label=_(u'username'))
|
||||
email = forms.EmailField(widget=forms.TextInput(attrs=dict(attrs_dict,
|
||||
maxlength=75)),
|
||||
label=_(u'email address'))
|
||||
password1 = forms.CharField(widget=forms.PasswordInput(attrs=attrs_dict, render_value=False),
|
||||
label=_(u'password'))
|
||||
password2 = forms.CharField(widget=forms.PasswordInput(attrs=attrs_dict, render_value=False),
|
||||
label=_(u'password (again)'))
|
||||
|
||||
def clean_username(self):
|
||||
"""
|
||||
Validate that the username is alphanumeric and is not already
|
||||
in use.
|
||||
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(username__iexact=self.cleaned_data['username'])
|
||||
except User.DoesNotExist:
|
||||
return self.cleaned_data['username']
|
||||
raise forms.ValidationError(_(u'This username is already taken. Please choose another.'))
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Verifiy that the values entered into the two password fields
|
||||
match. Note that an error here will end up in
|
||||
``non_field_errors()`` because it doesn't apply to a single
|
||||
field.
|
||||
|
||||
"""
|
||||
if 'password1' in self.cleaned_data and 'password2' in self.cleaned_data:
|
||||
if self.cleaned_data['password1'] != self.cleaned_data['password2']:
|
||||
raise forms.ValidationError(_(u'You must type the same password each time'))
|
||||
return self.cleaned_data
|
||||
|
||||
def save(self, profile_callback=None):
|
||||
"""
|
||||
Create the new ``User`` and ``RegistrationProfile``, and
|
||||
returns the ``User``.
|
||||
|
||||
This is essentially a light wrapper around
|
||||
``RegistrationProfile.objects.create_inactive_user()``,
|
||||
feeding it the form data and a profile callback (see the
|
||||
documentation on ``create_inactive_user()`` for details) if
|
||||
supplied.
|
||||
|
||||
"""
|
||||
new_user = RegistrationProfile.objects.create_inactive_user(username=self.cleaned_data['username'],
|
||||
password=self.cleaned_data['password1'],
|
||||
email=self.cleaned_data['email'],
|
||||
profile_callback=profile_callback)
|
||||
return new_user
|
||||
|
||||
|
||||
class RegistrationFormTermsOfService(RegistrationForm):
|
||||
"""
|
||||
Subclass of ``RegistrationForm`` which adds a required checkbox
|
||||
for agreeing to a site's Terms of Service.
|
||||
|
||||
"""
|
||||
tos = forms.BooleanField(widget=forms.CheckboxInput(attrs=attrs_dict),
|
||||
label=_(u'I have read and agree to the Terms of Service'),
|
||||
error_messages={ 'required': u"You must agree to the terms to register" })
|
||||
|
||||
|
||||
class RegistrationFormUniqueEmail(RegistrationForm):
|
||||
"""
|
||||
Subclass of ``RegistrationForm`` which enforces uniqueness of
|
||||
email addresses.
|
||||
|
||||
"""
|
||||
def clean_email(self):
|
||||
"""
|
||||
Validate that the supplied email address is unique for the
|
||||
site.
|
||||
|
||||
"""
|
||||
if User.objects.filter(email__iexact=self.cleaned_data['email']):
|
||||
raise forms.ValidationError(_(u'This email address is already in use. Please supply a different email address.'))
|
||||
return self.cleaned_data['email']
|
||||
|
||||
|
||||
class RegistrationFormNoFreeEmail(RegistrationForm):
|
||||
"""
|
||||
Subclass of ``RegistrationForm`` which disallows registration with
|
||||
email addresses from popular free webmail services; moderately
|
||||
useful for preventing automated spam registrations.
|
||||
|
||||
To change the list of banned domains, subclass this form and
|
||||
override the attribute ``bad_domains``.
|
||||
|
||||
"""
|
||||
bad_domains = ['aim.com', 'aol.com', 'email.com', 'gmail.com',
|
||||
'googlemail.com', 'hotmail.com', 'hushmail.com',
|
||||
'msn.com', 'mail.ru', 'mailinator.com', 'live.com']
|
||||
|
||||
def clean_email(self):
|
||||
"""
|
||||
Check the supplied email address against a list of known free
|
||||
webmail domains.
|
||||
|
||||
"""
|
||||
email_domain = self.cleaned_data['email'].split('@')[1]
|
||||
if email_domain in self.bad_domains:
|
||||
raise forms.ValidationError(_(u'Registration using free email addresses is prohibited. Please supply a different email address.'))
|
||||
return self.cleaned_data['email']
|
0
apps/registration/management/__init__.py
Normal file
0
apps/registration/management/commands/__init__.py
Normal file
19
apps/registration/management/commands/cleanupregistration.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
"""
|
||||
A management command which deletes expired accounts (e.g.,
|
||||
accounts which signed up but never activated) from the database.
|
||||
|
||||
Calls ``RegistrationProfile.objects.delete_expired_users()``, which
|
||||
contains the actual logic for determining which accounts are deleted.
|
||||
|
||||
"""
|
||||
|
||||
from django.core.management.base import NoArgsCommand
|
||||
|
||||
from apps.registration.models import RegistrationProfile
|
||||
|
||||
|
||||
class Command(NoArgsCommand):
|
||||
help = "Delete expired user registrations from the database"
|
||||
|
||||
def handle_noargs(self, **options):
|
||||
RegistrationProfile.objects.delete_expired_users()
|
250
apps/registration/models.py
Normal file
|
@ -0,0 +1,250 @@
|
|||
import datetime
|
||||
import random
|
||||
import re
|
||||
import sha
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
SHA1_RE = re.compile('^[a-f0-9]{40}$')
|
||||
|
||||
|
||||
class RegistrationManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the ``RegistrationProfile`` model.
|
||||
|
||||
The methods defined here provide shortcuts for account creation
|
||||
and activation (including generation and emailing of activation
|
||||
keys), and for cleaning out expired inactive accounts.
|
||||
|
||||
"""
|
||||
def activate_user(self, activation_key):
|
||||
"""
|
||||
Validate an activation key and activate the corresponding
|
||||
``User`` if valid.
|
||||
|
||||
If the key is valid and has not expired, return the ``User``
|
||||
after activating.
|
||||
|
||||
If the key is not valid or has expired, return ``False``.
|
||||
|
||||
If the key is valid but the ``User`` is already active,
|
||||
return ``False``.
|
||||
|
||||
To prevent reactivation of an account which has been
|
||||
deactivated by site administrators, the activation key is
|
||||
reset to the string ``ALREADY_ACTIVATED`` after successful
|
||||
activation.
|
||||
|
||||
"""
|
||||
# Make sure the key we're trying conforms to the pattern of a
|
||||
# SHA1 hash; if it doesn't, no point trying to look it up in
|
||||
# the database.
|
||||
if SHA1_RE.search(activation_key):
|
||||
try:
|
||||
profile = self.get(activation_key=activation_key)
|
||||
except self.model.DoesNotExist:
|
||||
return False
|
||||
if not profile.activation_key_expired():
|
||||
user = profile.user
|
||||
user.is_active = True
|
||||
user.save()
|
||||
profile.activation_key = self.model.ACTIVATED
|
||||
profile.save()
|
||||
return user
|
||||
return False
|
||||
|
||||
def create_inactive_user(self, username, password, email,
|
||||
send_email=True, profile_callback=None):
|
||||
"""
|
||||
Create a new, inactive ``User``, generates a
|
||||
``RegistrationProfile`` and email its activation key to the
|
||||
``User``, returning the new ``User``.
|
||||
|
||||
To disable the email, call with ``send_email=False``.
|
||||
|
||||
The activation email will make use of two templates:
|
||||
|
||||
``registration/activation_email_subject.txt``
|
||||
This template will be used for the subject line of the
|
||||
email. It receives one context variable, ``site``, which
|
||||
is the currently-active
|
||||
``django.contrib.sites.models.Site`` instance. Because it
|
||||
is used as the subject line of an email, this template's
|
||||
output **must** be only a single line of text; output
|
||||
longer than one line will be forcibly joined into only a
|
||||
single line.
|
||||
|
||||
``registration/activation_email.txt``
|
||||
This template will be used for the body of the email. It
|
||||
will receive three context variables: ``activation_key``
|
||||
will be the user's activation key (for use in constructing
|
||||
a URL to activate the account), ``expiration_days`` will
|
||||
be the number of days for which the key will be valid and
|
||||
``site`` will be the currently-active
|
||||
``django.contrib.sites.models.Site`` instance.
|
||||
|
||||
To enable creation of a custom user profile along with the
|
||||
``User`` (e.g., the model specified in the
|
||||
``AUTH_PROFILE_MODULE`` setting), define a function which
|
||||
knows how to create and save an instance of that model with
|
||||
appropriate default values, and pass it as the keyword
|
||||
argument ``profile_callback``. This function should accept one
|
||||
keyword argument:
|
||||
|
||||
``user``
|
||||
The ``User`` to relate the profile to.
|
||||
|
||||
"""
|
||||
new_user = User.objects.create_user(username, email, password)
|
||||
new_user.is_active = False
|
||||
new_user.save()
|
||||
|
||||
registration_profile = self.create_profile(new_user)
|
||||
|
||||
if profile_callback is not None:
|
||||
profile_callback(user=new_user)
|
||||
|
||||
if send_email:
|
||||
from django.core.mail import send_mail
|
||||
current_site = Site.objects.get_current()
|
||||
|
||||
subject = render_to_string('registration/activation_email_subject.txt',
|
||||
{ 'site': current_site })
|
||||
# Email subject *must not* contain newlines
|
||||
subject = ''.join(subject.splitlines())
|
||||
|
||||
message = render_to_string('registration/activation_email.txt',
|
||||
{ 'activation_key': registration_profile.activation_key,
|
||||
'expiration_days': settings.ACCOUNT_ACTIVATION_DAYS,
|
||||
'site': current_site })
|
||||
|
||||
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [new_user.email])
|
||||
return new_user
|
||||
|
||||
def create_profile(self, user):
|
||||
"""
|
||||
Create a ``RegistrationProfile`` for a given
|
||||
``User``, and return the ``RegistrationProfile``.
|
||||
|
||||
The activation key for the ``RegistrationProfile`` will be a
|
||||
SHA1 hash, generated from a combination of the ``User``'s
|
||||
username and a random salt.
|
||||
|
||||
"""
|
||||
salt = sha.new(str(random.random())).hexdigest()[:5]
|
||||
activation_key = sha.new(salt+user.username).hexdigest()
|
||||
return self.create(user=user,
|
||||
activation_key=activation_key)
|
||||
|
||||
def delete_expired_users(self):
|
||||
"""
|
||||
Remove expired instances of ``RegistrationProfile`` and their
|
||||
associated ``User``s.
|
||||
|
||||
Accounts to be deleted are identified by searching for
|
||||
instances of ``RegistrationProfile`` with expired activation
|
||||
keys, and then checking to see if their associated ``User``
|
||||
instances have the field ``is_active`` set to ``False``; any
|
||||
``User`` who is both inactive and has an expired activation
|
||||
key will be deleted.
|
||||
|
||||
It is recommended that this method be executed regularly as
|
||||
part of your routine site maintenance; this application
|
||||
provides a custom management command which will call this
|
||||
method, accessible as ``manage.py cleanupregistration``.
|
||||
|
||||
Regularly clearing out accounts which have never been
|
||||
activated serves two useful purposes:
|
||||
|
||||
1. It alleviates the ocasional need to reset a
|
||||
``RegistrationProfile`` and/or re-send an activation email
|
||||
when a user does not receive or does not act upon the
|
||||
initial activation email; since the account will be
|
||||
deleted, the user will be able to simply re-register and
|
||||
receive a new activation key.
|
||||
|
||||
2. It prevents the possibility of a malicious user registering
|
||||
one or more accounts and never activating them (thus
|
||||
denying the use of those usernames to anyone else); since
|
||||
those accounts will be deleted, the usernames will become
|
||||
available for use again.
|
||||
|
||||
If you have a troublesome ``User`` and wish to disable their
|
||||
account while keeping it in the database, simply delete the
|
||||
associated ``RegistrationProfile``; an inactive ``User`` which
|
||||
does not have an associated ``RegistrationProfile`` will not
|
||||
be deleted.
|
||||
|
||||
"""
|
||||
for profile in self.all():
|
||||
if profile.activation_key_expired():
|
||||
user = profile.user
|
||||
if not user.is_active:
|
||||
user.delete()
|
||||
|
||||
|
||||
class RegistrationProfile(models.Model):
|
||||
"""
|
||||
A simple profile which stores an activation key for use during
|
||||
user account registration.
|
||||
|
||||
Generally, you will not want to interact directly with instances
|
||||
of this model; the provided manager includes methods
|
||||
for creating and activating new accounts, as well as for cleaning
|
||||
out accounts which have never been activated.
|
||||
|
||||
While it is possible to use this model as the value of the
|
||||
``AUTH_PROFILE_MODULE`` setting, it's not recommended that you do
|
||||
so. This model's sole purpose is to store data temporarily during
|
||||
account registration and activation, and a mechanism for
|
||||
automatically creating an instance of a site-specific profile
|
||||
model is provided via the ``create_inactive_user`` on
|
||||
``RegistrationManager``.
|
||||
|
||||
"""
|
||||
ACTIVATED = u"ALREADY_ACTIVATED"
|
||||
|
||||
user = models.ForeignKey(User, unique=True, verbose_name=_('user'))
|
||||
activation_key = models.CharField(_('activation key'), max_length=40)
|
||||
|
||||
objects = RegistrationManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('registration profile')
|
||||
verbose_name_plural = _('registration profiles')
|
||||
|
||||
def __unicode__(self):
|
||||
return u"Registration information for %s" % self.user
|
||||
|
||||
def activation_key_expired(self):
|
||||
"""
|
||||
Determine whether this ``RegistrationProfile``'s activation
|
||||
key has expired, returning a boolean -- ``True`` if the key
|
||||
has expired.
|
||||
|
||||
Key expiration is determined by a two-step process:
|
||||
|
||||
1. If the user has already activated, the key will have been
|
||||
reset to the string ``ALREADY_ACTIVATED``. Re-activating is
|
||||
not permitted, and so this method returns ``True`` in this
|
||||
case.
|
||||
|
||||
2. Otherwise, the date the user signed up is incremented by
|
||||
the number of days specified in the setting
|
||||
``ACCOUNT_ACTIVATION_DAYS`` (which should be the number of
|
||||
days after signup during which a user is allowed to
|
||||
activate their account); if the result is less than or
|
||||
equal to the current date, the key has expired and this
|
||||
method returns ``True``.
|
||||
|
||||
"""
|
||||
expiration_date = datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
||||
return self.activation_key == self.ACTIVATED or \
|
||||
(self.user.date_joined + expiration_date <= datetime.datetime.now())
|
||||
activation_key_expired.boolean = True
|
318
apps/registration/tests.py
Normal file
|
@ -0,0 +1,318 @@
|
|||
"""
|
||||
Unit tests for django-registration.
|
||||
|
||||
These tests assume that you've completed all the prerequisites for
|
||||
getting django-registration running in the default setup, to wit:
|
||||
|
||||
1. You have ``registration`` in your ``INSTALLED_APPS`` setting.
|
||||
|
||||
2. You have created all of the templates mentioned in this
|
||||
application's documentation.
|
||||
|
||||
3. You have added the setting ``ACCOUNT_ACTIVATION_DAYS`` to your
|
||||
settings file.
|
||||
|
||||
4. You have URL patterns pointing to the registration and activation
|
||||
views, with the names ``registration_register`` and
|
||||
``registration_activate``, respectively, and a URL pattern named
|
||||
'registration_complete'.
|
||||
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import sha
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.core import management
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.registration import forms
|
||||
from apps.registration.models import RegistrationProfile
|
||||
|
||||
|
||||
class RegistrationTestCase(TestCase):
|
||||
"""
|
||||
Base class for the test cases; this sets up two users -- one
|
||||
expired, one not -- which are used to exercise various parts of
|
||||
the application.
|
||||
|
||||
"""
|
||||
def setUp(self):
|
||||
self.sample_user = RegistrationProfile.objects.create_inactive_user(username='alice',
|
||||
password='secret',
|
||||
email='alice@example.com')
|
||||
self.expired_user = RegistrationProfile.objects.create_inactive_user(username='bob',
|
||||
password='swordfish',
|
||||
email='bob@example.com')
|
||||
self.expired_user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS + 1)
|
||||
self.expired_user.save()
|
||||
|
||||
|
||||
class RegistrationModelTests(RegistrationTestCase):
|
||||
"""
|
||||
Tests for the model-oriented functionality of django-registration,
|
||||
including ``RegistrationProfile`` and its custom manager.
|
||||
|
||||
"""
|
||||
def test_new_user_is_inactive(self):
|
||||
"""
|
||||
Test that a newly-created user is inactive.
|
||||
|
||||
"""
|
||||
self.failIf(self.sample_user.is_active)
|
||||
|
||||
def test_registration_profile_created(self):
|
||||
"""
|
||||
Test that a ``RegistrationProfile`` is created for a new user.
|
||||
|
||||
"""
|
||||
self.assertEqual(RegistrationProfile.objects.count(), 2)
|
||||
|
||||
def test_activation_email(self):
|
||||
"""
|
||||
Test that user signup sends an activation email.
|
||||
|
||||
"""
|
||||
self.assertEqual(len(mail.outbox), 2)
|
||||
|
||||
def test_activation(self):
|
||||
"""
|
||||
Test that user activation actually activates the user and
|
||||
properly resets the activation key, and fails for an
|
||||
already-active or expired user, or an invalid key.
|
||||
|
||||
"""
|
||||
# Activating a valid user returns the user.
|
||||
self.failUnlessEqual(RegistrationProfile.objects.activate_user(RegistrationProfile.objects.get(user=self.sample_user).activation_key).pk,
|
||||
self.sample_user.pk)
|
||||
|
||||
# The activated user must now be active.
|
||||
self.failUnless(User.objects.get(pk=self.sample_user.pk).is_active)
|
||||
|
||||
# The activation key must now be reset to the "already activated" constant.
|
||||
self.failUnlessEqual(RegistrationProfile.objects.get(user=self.sample_user).activation_key,
|
||||
RegistrationProfile.ACTIVATED)
|
||||
|
||||
# Activating an expired user returns False.
|
||||
self.failIf(RegistrationProfile.objects.activate_user(RegistrationProfile.objects.get(user=self.expired_user).activation_key))
|
||||
|
||||
# Activating from a key that isn't a SHA1 hash returns False.
|
||||
self.failIf(RegistrationProfile.objects.activate_user('foo'))
|
||||
|
||||
# Activating from a key that doesn't exist returns False.
|
||||
self.failIf(RegistrationProfile.objects.activate_user(sha.new('foo').hexdigest()))
|
||||
|
||||
def test_account_expiration_condition(self):
|
||||
"""
|
||||
Test that ``RegistrationProfile.activation_key_expired()``
|
||||
returns ``True`` for expired users and for active users, and
|
||||
``False`` otherwise.
|
||||
|
||||
"""
|
||||
# Unexpired user returns False.
|
||||
self.failIf(RegistrationProfile.objects.get(user=self.sample_user).activation_key_expired())
|
||||
|
||||
# Expired user returns True.
|
||||
self.failUnless(RegistrationProfile.objects.get(user=self.expired_user).activation_key_expired())
|
||||
|
||||
# Activated user returns True.
|
||||
RegistrationProfile.objects.activate_user(RegistrationProfile.objects.get(user=self.sample_user).activation_key)
|
||||
self.failUnless(RegistrationProfile.objects.get(user=self.sample_user).activation_key_expired())
|
||||
|
||||
def test_expired_user_deletion(self):
|
||||
"""
|
||||
Test that
|
||||
``RegistrationProfile.objects.delete_expired_users()`` deletes
|
||||
only inactive users whose activation window has expired.
|
||||
|
||||
"""
|
||||
RegistrationProfile.objects.delete_expired_users()
|
||||
self.assertEqual(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
def test_management_command(self):
|
||||
"""
|
||||
Test that ``manage.py cleanupregistration`` functions
|
||||
correctly.
|
||||
|
||||
"""
|
||||
management.call_command('cleanupregistration')
|
||||
self.assertEqual(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
|
||||
class RegistrationFormTests(RegistrationTestCase):
|
||||
"""
|
||||
Tests for the forms and custom validation logic included in
|
||||
django-registration.
|
||||
|
||||
"""
|
||||
def test_registration_form(self):
|
||||
"""
|
||||
Test that ``RegistrationForm`` enforces username constraints
|
||||
and matching passwords.
|
||||
|
||||
"""
|
||||
invalid_data_dicts = [
|
||||
# Non-alphanumeric username.
|
||||
{
|
||||
'data':
|
||||
{ 'username': 'foo/bar',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' },
|
||||
'error':
|
||||
('username', [u"Enter a valid value."])
|
||||
},
|
||||
# Already-existing username.
|
||||
{
|
||||
'data':
|
||||
{ 'username': 'alice',
|
||||
'email': 'alice@example.com',
|
||||
'password1': 'secret',
|
||||
'password2': 'secret' },
|
||||
'error':
|
||||
('username', [u"This username is already taken. Please choose another."])
|
||||
},
|
||||
# Mismatched passwords.
|
||||
{
|
||||
'data':
|
||||
{ 'username': 'foo',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'bar' },
|
||||
'error':
|
||||
('__all__', [u"You must type the same password each time"])
|
||||
},
|
||||
]
|
||||
|
||||
for invalid_dict in invalid_data_dicts:
|
||||
form = forms.RegistrationForm(data=invalid_dict['data'])
|
||||
self.failIf(form.is_valid())
|
||||
self.assertEqual(form.errors[invalid_dict['error'][0]], invalid_dict['error'][1])
|
||||
|
||||
form = forms.RegistrationForm(data={ 'username': 'foo',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' })
|
||||
self.failUnless(form.is_valid())
|
||||
|
||||
def test_registration_form_tos(self):
|
||||
"""
|
||||
Test that ``RegistrationFormTermsOfService`` requires
|
||||
agreement to the terms of service.
|
||||
|
||||
"""
|
||||
form = forms.RegistrationFormTermsOfService(data={ 'username': 'foo',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' })
|
||||
self.failIf(form.is_valid())
|
||||
self.assertEqual(form.errors['tos'], [u"You must agree to the terms to register"])
|
||||
|
||||
form = forms.RegistrationFormTermsOfService(data={ 'username': 'foo',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo',
|
||||
'tos': 'on' })
|
||||
self.failUnless(form.is_valid())
|
||||
|
||||
def test_registration_form_unique_email(self):
|
||||
"""
|
||||
Test that ``RegistrationFormUniqueEmail`` validates uniqueness
|
||||
of email addresses.
|
||||
|
||||
"""
|
||||
form = forms.RegistrationFormUniqueEmail(data={ 'username': 'foo',
|
||||
'email': 'alice@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' })
|
||||
self.failIf(form.is_valid())
|
||||
self.assertEqual(form.errors['email'], [u"This email address is already in use. Please supply a different email address."])
|
||||
|
||||
form = forms.RegistrationFormUniqueEmail(data={ 'username': 'foo',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' })
|
||||
self.failUnless(form.is_valid())
|
||||
|
||||
def test_registration_form_no_free_email(self):
|
||||
"""
|
||||
Test that ``RegistrationFormNoFreeEmail`` disallows
|
||||
registration with free email addresses.
|
||||
|
||||
"""
|
||||
base_data = { 'username': 'foo',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' }
|
||||
for domain in ('aim.com', 'aol.com', 'email.com', 'gmail.com',
|
||||
'googlemail.com', 'hotmail.com', 'hushmail.com',
|
||||
'msn.com', 'mail.ru', 'mailinator.com', 'live.com'):
|
||||
invalid_data = base_data.copy()
|
||||
invalid_data['email'] = u"foo@%s" % domain
|
||||
form = forms.RegistrationFormNoFreeEmail(data=invalid_data)
|
||||
self.failIf(form.is_valid())
|
||||
self.assertEqual(form.errors['email'], [u"Registration using free email addresses is prohibited. Please supply a different email address."])
|
||||
|
||||
base_data['email'] = 'foo@example.com'
|
||||
form = forms.RegistrationFormNoFreeEmail(data=base_data)
|
||||
self.failUnless(form.is_valid())
|
||||
|
||||
|
||||
class RegistrationViewTests(RegistrationTestCase):
|
||||
"""
|
||||
Tests for the views included in django-registration.
|
||||
|
||||
"""
|
||||
def test_registration_view(self):
|
||||
"""
|
||||
Test that the registration view rejects invalid submissions,
|
||||
and creates a new user and redirects after a valid submission.
|
||||
|
||||
"""
|
||||
# Invalid data fails.
|
||||
response = self.client.post(reverse('registration_register'),
|
||||
data={ 'username': 'alice', # Will fail on username uniqueness.
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' })
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.failUnless(response.context['form'])
|
||||
self.failUnless(response.context['form'].errors)
|
||||
|
||||
response = self.client.post(reverse('registration_register'),
|
||||
data={ 'username': 'foo',
|
||||
'email': 'foo@example.com',
|
||||
'password1': 'foo',
|
||||
'password2': 'foo' })
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['Location'], 'http://testserver%s' % reverse('registration_complete'))
|
||||
self.assertEqual(RegistrationProfile.objects.count(), 3)
|
||||
|
||||
def test_activation_view(self):
|
||||
"""
|
||||
Test that the activation view activates the user from a valid
|
||||
key and fails if the key is invalid or has expired.
|
||||
|
||||
"""
|
||||
# Valid user puts the user account into the context.
|
||||
response = self.client.get(reverse('registration_activate',
|
||||
kwargs={ 'activation_key': RegistrationProfile.objects.get(user=self.sample_user).activation_key }))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['account'].pk, self.sample_user.pk)
|
||||
|
||||
# Expired user sets the account to False.
|
||||
response = self.client.get(reverse('registration_activate',
|
||||
kwargs={ 'activation_key': RegistrationProfile.objects.get(user=self.expired_user).activation_key }))
|
||||
self.failIf(response.context['account'])
|
||||
|
||||
# Invalid key gets to the view, but sets account to False.
|
||||
response = self.client.get(reverse('registration_activate',
|
||||
kwargs={ 'activation_key': 'foo' }))
|
||||
self.failIf(response.context['account'])
|
||||
|
||||
# Nonexistent key sets the account to False.
|
||||
response = self.client.get(reverse('registration_activate',
|
||||
kwargs={ 'activation_key': sha.new('foo').hexdigest() }))
|
||||
self.failIf(response.context['account'])
|
72
apps/registration/urls.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
"""
|
||||
URLConf for Django user registration and authentication.
|
||||
|
||||
If the default behavior of the registration views is acceptable to
|
||||
you, simply use a line like this in your root URLConf to set up the
|
||||
default URLs for registration::
|
||||
|
||||
(r'^accounts/', include('registration.urls')),
|
||||
|
||||
This will also automatically set up the views in
|
||||
``django.contrib.auth`` at sensible default locations.
|
||||
|
||||
But if you'd like to customize the behavior (e.g., by passing extra
|
||||
arguments to the various views) or split up the URLs, feel free to set
|
||||
up your own URL patterns for these views instead. If you do, it's a
|
||||
good idea to use the names ``registration_activate``,
|
||||
``registration_complete`` and ``registration_register`` for the
|
||||
various steps of the user-signup process.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from django.conf.urls.defaults import *
|
||||
from django.views.generic.simple import direct_to_template
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
from apps.registration.views import activate
|
||||
from apps.registration.views import register
|
||||
|
||||
|
||||
urlpatterns = patterns('apps.registration.views',
|
||||
# Activation keys get matched by \w+ instead of the more specific
|
||||
# [a-fA-F0-9]{40} because a bad activation key should still get to the view;
|
||||
# that way it can return a sensible "invalid key" message instead of a
|
||||
# confusing 404.
|
||||
url(r'^activate/(?P<activation_key>\w+)/$',
|
||||
'activate',
|
||||
name='registration_activate'),
|
||||
url(r'^login/$',
|
||||
auth_views.login,
|
||||
{'template_name': 'registration/login.html'},
|
||||
name='auth_login'),
|
||||
url(r'^logout/$',
|
||||
auth_views.logout,
|
||||
{'template_name': 'registration/login.html'},
|
||||
name='auth_logout'),
|
||||
url(r'^password/change/$',
|
||||
auth_views.password_change,
|
||||
name='auth_password_change'),
|
||||
url(r'^password/change/done/$',
|
||||
auth_views.password_change_done,
|
||||
name='auth_password_change_done'),
|
||||
url(r'^password/reset/$',
|
||||
auth_views.password_reset,
|
||||
name='auth_password_reset'),
|
||||
url(r'^password/reset/confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
|
||||
auth_views.password_reset_confirm,
|
||||
name='auth_password_reset_confirm'),
|
||||
url(r'^password/reset/complete/$',
|
||||
auth_views.password_reset_complete,
|
||||
name='auth_password_reset_complete'),
|
||||
url(r'^password/reset/done/$',
|
||||
auth_views.password_reset_done,
|
||||
name='auth_password_reset_done'),
|
||||
url(r'^register/$',
|
||||
'register',
|
||||
name='registration_register'),
|
||||
url(r'^register/complete/$',
|
||||
direct_to_template,
|
||||
{'template': 'registration/registration_complete.html'},
|
||||
name='registration_complete'),
|
||||
)
|
164
apps/registration/views.py
Normal file
|
@ -0,0 +1,164 @@
|
|||
"""
|
||||
Views which allow users to create and activate accounts.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template import RequestContext
|
||||
|
||||
from apps.registration.forms import RegistrationForm
|
||||
from apps.registration.models import RegistrationProfile
|
||||
|
||||
|
||||
def activate(request, activation_key,
|
||||
template_name='registration/activate.html',
|
||||
extra_context=None):
|
||||
"""
|
||||
Activate a ``User``'s account from an activation key, if their key
|
||||
is valid and hasn't expired.
|
||||
|
||||
By default, use the template ``registration/activate.html``; to
|
||||
change this, pass the name of a template as the keyword argument
|
||||
``template_name``.
|
||||
|
||||
**Required arguments**
|
||||
|
||||
``activation_key``
|
||||
The activation key to validate and use for activating the
|
||||
``User``.
|
||||
|
||||
**Optional arguments**
|
||||
|
||||
``extra_context``
|
||||
A dictionary of variables to add to the template context. Any
|
||||
callable object in this dictionary will be called to produce
|
||||
the end result which appears in the context.
|
||||
|
||||
``template_name``
|
||||
A custom template to use.
|
||||
|
||||
**Context:**
|
||||
|
||||
``account``
|
||||
The ``User`` object corresponding to the account, if the
|
||||
activation was successful. ``False`` if the activation was not
|
||||
successful.
|
||||
|
||||
``expiration_days``
|
||||
The number of days for which activation keys stay valid after
|
||||
registration.
|
||||
|
||||
Any extra variables supplied in the ``extra_context`` argument
|
||||
(see above).
|
||||
|
||||
**Template:**
|
||||
|
||||
registration/activate.html or ``template_name`` keyword argument.
|
||||
|
||||
"""
|
||||
activation_key = activation_key.lower() # Normalize before trying anything with it.
|
||||
account = RegistrationProfile.objects.activate_user(activation_key)
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
context = RequestContext(request)
|
||||
for key, value in extra_context.items():
|
||||
context[key] = callable(value) and value() or value
|
||||
return render_to_response(template_name,
|
||||
{ 'account': account,
|
||||
'expiration_days': settings.ACCOUNT_ACTIVATION_DAYS },
|
||||
context_instance=context)
|
||||
|
||||
|
||||
def register(request, success_url=None,
|
||||
form_class=RegistrationForm, profile_callback=None,
|
||||
template_name='registration/registration_form.html',
|
||||
extra_context=None):
|
||||
"""
|
||||
Allow a new user to register an account.
|
||||
|
||||
Following successful registration, issue a redirect; by default,
|
||||
this will be whatever URL corresponds to the named URL pattern
|
||||
``registration_complete``, which will be
|
||||
``/accounts/register/complete/`` if using the included URLConf. To
|
||||
change this, point that named pattern at another URL, or pass your
|
||||
preferred URL as the keyword argument ``success_url``.
|
||||
|
||||
By default, ``registration.forms.RegistrationForm`` will be used
|
||||
as the registration form; to change this, pass a different form
|
||||
class as the ``form_class`` keyword argument. The form class you
|
||||
specify must have a method ``save`` which will create and return
|
||||
the new ``User``, and that method must accept the keyword argument
|
||||
``profile_callback`` (see below).
|
||||
|
||||
To enable creation of a site-specific user profile object for the
|
||||
new user, pass a function which will create the profile object as
|
||||
the keyword argument ``profile_callback``. See
|
||||
``RegistrationManager.create_inactive_user`` in the file
|
||||
``models.py`` for details on how to write this function.
|
||||
|
||||
By default, use the template
|
||||
``registration/registration_form.html``; to change this, pass the
|
||||
name of a template as the keyword argument ``template_name``.
|
||||
|
||||
**Required arguments**
|
||||
|
||||
None.
|
||||
|
||||
**Optional arguments**
|
||||
|
||||
``form_class``
|
||||
The form class to use for registration.
|
||||
|
||||
``extra_context``
|
||||
A dictionary of variables to add to the template context. Any
|
||||
callable object in this dictionary will be called to produce
|
||||
the end result which appears in the context.
|
||||
|
||||
``profile_callback``
|
||||
A function which will be used to create a site-specific
|
||||
profile instance for the new ``User``.
|
||||
|
||||
``success_url``
|
||||
The URL to redirect to on successful registration.
|
||||
|
||||
``template_name``
|
||||
A custom template to use.
|
||||
|
||||
**Context:**
|
||||
|
||||
``form``
|
||||
The registration form.
|
||||
|
||||
Any extra variables supplied in the ``extra_context`` argument
|
||||
(see above).
|
||||
|
||||
**Template:**
|
||||
|
||||
registration/registration_form.html or ``template_name`` keyword
|
||||
argument.
|
||||
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
form = form_class(data=request.POST, files=request.FILES)
|
||||
if form.is_valid():
|
||||
new_user = form.save(profile_callback=profile_callback)
|
||||
# success_url needs to be dynamically generated here; setting a
|
||||
# a default value using reverse() will cause circular-import
|
||||
# problems with the default URLConf for this application, which
|
||||
# imports this file.
|
||||
return HttpResponseRedirect(success_url or reverse('registration_complete'))
|
||||
else:
|
||||
form = form_class()
|
||||
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
context = RequestContext(request)
|
||||
for key, value in extra_context.items():
|
||||
context[key] = callable(value) and value() or value
|
||||
return render_to_response(template_name,
|
||||
{ 'form': form },
|
||||
context_instance=context)
|
0
apps/rss_feeds/__init__.py
Normal file
6
apps/rss_feeds/admin.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from apps.rss_feeds.models import Feed, Story, Tag
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(Feed)
|
||||
admin.site.register(Story)
|
||||
admin.site.register(Tag)
|
244
apps/rss_feeds/models.py
Normal file
|
@ -0,0 +1,244 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from utils import feedparser, object_manager
|
||||
from utils.dateutil.parser import parse as dateutil_parse
|
||||
from utils.feed_functions import encode, prints, mtime
|
||||
import time, datetime, random
|
||||
from pprint import pprint
|
||||
from django.utils.http import urlquote
|
||||
from django.db.models import Q
|
||||
from utils.diff import HTMLDiff
|
||||
|
||||
USER_AGENT = 'NewsBlur v1.0 - newsblur.com'
|
||||
|
||||
class Feed(models.Model):
|
||||
feed_address = models.URLField(max_length=255, verify_exists=True, unique=True)
|
||||
feed_link = models.URLField(max_length=200, blank=True)
|
||||
feed_title = models.CharField(max_length=255, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
num_subscribers = models.IntegerField(default=0)
|
||||
last_update = models.DateTimeField(auto_now=True, default=0)
|
||||
min_to_decay = models.IntegerField(default=15)
|
||||
days_to_trim = models.IntegerField(default=90)
|
||||
creation = models.DateField(auto_now_add=True)
|
||||
etag = models.CharField(max_length=50, blank=True)
|
||||
last_modified = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
def __unicode__(self):
|
||||
return self.feed_title
|
||||
|
||||
def last_updated(self):
|
||||
return time.time() - time.mktime(self.last_update.timetuple())
|
||||
|
||||
def new_stories_since_date(self, date):
|
||||
story_count = Story.objects.filter(story_date__gte=date,
|
||||
story_feed=self).count()
|
||||
return story_count
|
||||
|
||||
def add_feed(self, feed_address, feed_link, feed_title):
|
||||
print locals()
|
||||
|
||||
def update(self, force=False, feed=None):
|
||||
if (self.last_updated() / 60) < (self.min_to_decay + (random.random()*self.min_to_decay)) and not force:
|
||||
print 'Feed unchanged: ' + self.feed_title
|
||||
return
|
||||
|
||||
feed_updated, feed = cache.get("feed:" + self.feed_address, (None, None,))
|
||||
if feed and not force:
|
||||
print 'Feed Cached: ' + self.feed_title
|
||||
if not feed or force:
|
||||
last_modified = None
|
||||
now = datetime.datetime.now()
|
||||
if self.last_modified:
|
||||
last_modified = datetime.datetime.timetuple(self.last_modified)
|
||||
if not feed:
|
||||
print '[%d] Retrieving Feed: %s %s' % (self.id, self.feed_title, last_modified)
|
||||
feed = feedparser.parse(self.feed_address,
|
||||
etag=self.etag,
|
||||
modified=last_modified,
|
||||
agent=USER_AGENT)
|
||||
cache.set("feed:" + self.feed_address, (now, feed),
|
||||
self.min_to_decay * 60 * 5)
|
||||
|
||||
self.last_update = datetime.datetime.now()
|
||||
|
||||
# check for movement or disappearance
|
||||
if hasattr(feed, 'status'):
|
||||
if feed.status == 301:
|
||||
self.feed_url = feed.href
|
||||
if feed.status == 410:
|
||||
self.active = False
|
||||
if feed.status >= 400:
|
||||
return
|
||||
|
||||
# Fill in optional fields
|
||||
if not self.feed_title:
|
||||
self.feed_title = feed.feed.get('title',
|
||||
feed.feed.get('link', 'No Title'))
|
||||
if not self.feed_link:
|
||||
self.feed_link = feed.feed.get('link', 'null:')
|
||||
|
||||
self.etag = feed.get('etag', '')
|
||||
if not self.etag:
|
||||
self.etag = ''
|
||||
|
||||
self.last_modified = mtime(feed.get('modified', datetime.datetime.timetuple(datetime.datetime.now())))
|
||||
|
||||
self.save()
|
||||
|
||||
for story in feed['entries']:
|
||||
self.save_story(story)
|
||||
|
||||
self.trim_feed();
|
||||
|
||||
return
|
||||
|
||||
def trim_feed(self):
|
||||
date_diff = datetime.datetime.now() - datetime.timedelta(self.days_to_trim)
|
||||
stories = Story.objects.filter(story_feed=self, story_date__lte=date_diff)
|
||||
for story in stories:
|
||||
story.story_past_trim_date = True
|
||||
story.save()
|
||||
|
||||
def save_story(self, story):
|
||||
story = self._pre_process_story(story)
|
||||
|
||||
if story.get('title'):
|
||||
story_contents = story.get('content')
|
||||
if story_contents is not None:
|
||||
story_content = story_contents[0]['value']
|
||||
else:
|
||||
story_content = story.get('summary')
|
||||
existing_story = self._exists_story(story)
|
||||
if not existing_story:
|
||||
pub_date = datetime.datetime.timetuple(story.get('published'))
|
||||
print '- New story: %s %s' % (pub_date, story.get('title'))
|
||||
|
||||
s = Story(story_feed = self,
|
||||
story_date = story.get('published'),
|
||||
story_title = story.get('title'),
|
||||
story_content = story_content,
|
||||
story_author = story.get('author'),
|
||||
story_permalink = story.get('link')
|
||||
)
|
||||
try:
|
||||
s.save()
|
||||
except:
|
||||
pass
|
||||
elif existing_story.story_title != story.get('title') \
|
||||
or existing_story.story_content != story_content:
|
||||
# update story
|
||||
print '- Updated story in feed (%s): %s / %s' % (self.feed_title, len(existing_story.story_content), len(story_content))
|
||||
|
||||
original_content = None
|
||||
if existing_story.story_original_content:
|
||||
original_content = existing_story.story_original_content
|
||||
else:
|
||||
original_content = existing_story.story_content
|
||||
diff = HTMLDiff(original_content, story_content)
|
||||
print "\t\tDiff: %s %s %s" % diff.getStats()
|
||||
print '\tExisting title / New: : \n\t\t- %s\n\t\t- %s' % (existing_story.story_title, story.get('title'))
|
||||
|
||||
s = Story(id = existing_story.id,
|
||||
story_feed = self,
|
||||
story_date = story.get('published'),
|
||||
story_title = story.get('title'),
|
||||
story_content = diff.getDiff(),
|
||||
story_original_content = original_content,
|
||||
story_author = story.get('author'),
|
||||
story_permalink = story.get('link')
|
||||
)
|
||||
try:
|
||||
s.save()
|
||||
except:
|
||||
pass
|
||||
# else:
|
||||
# print "Unchanged story: %s " % story.get('title')
|
||||
|
||||
return
|
||||
|
||||
def _exists_story(self, entry):
|
||||
pub_date = entry['published']
|
||||
start_date = pub_date - datetime.timedelta(hours=12)
|
||||
end_date = pub_date + datetime.timedelta(hours=12)
|
||||
# print "Dates: %s %s %s" % (pub_date, start_date, end_date)
|
||||
existing_story = Story.objects.filter(
|
||||
(
|
||||
Q(story_title__iexact=entry['title'])
|
||||
& Q(story_date__range=(start_date, end_date))
|
||||
)
|
||||
| (
|
||||
Q(story_permalink__iexact=entry['link'])
|
||||
& Q(story_date__range=(start_date, end_date))
|
||||
),
|
||||
story_feed = self
|
||||
)
|
||||
if len(existing_story):
|
||||
return existing_story[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
def _pre_process_story(self, entry):
|
||||
date_published = entry.get('published', entry.get('updated'))
|
||||
if not date_published:
|
||||
date_published = str(datetime.datetime.now())
|
||||
date_published = dateutil_parse(date_published)
|
||||
# Change the date to UTC and remove timezone info since
|
||||
# MySQL doesn't support it.
|
||||
timezone_diff = datetime.datetime.utcnow() - datetime.datetime.now()
|
||||
date_published_offset = date_published.utcoffset()
|
||||
if date_published_offset:
|
||||
date_published = (date_published - date_published_offset
|
||||
- timezone_diff).replace(tzinfo=None)
|
||||
else:
|
||||
date_published = date_published.replace(tzinfo=None)
|
||||
|
||||
entry['published'] = date_published
|
||||
|
||||
protocol_index = entry['link'].find("://")
|
||||
if protocol_index != -1:
|
||||
entry['link'] = (entry['link'][:protocol_index+3]
|
||||
+ urlquote(entry['link'][protocol_index+3:]))
|
||||
else:
|
||||
entry['link'] = urlquote(entry['link'])
|
||||
return entry
|
||||
|
||||
class Meta:
|
||||
db_table="feeds"
|
||||
ordering=["feed_title"]
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def save(self):
|
||||
super(Tag, self).save()
|
||||
|
||||
class Story(models.Model):
|
||||
'''A feed item'''
|
||||
story_feed = models.ForeignKey(Feed)
|
||||
story_date = models.DateTimeField()
|
||||
story_title = models.CharField(max_length=255)
|
||||
story_content = models.TextField(null=True, blank=True)
|
||||
story_original_content = models.TextField(null=True, blank=True)
|
||||
story_content_type = models.CharField(max_length=255, null=True,
|
||||
blank=True)
|
||||
story_author = models.CharField(max_length=255, null=True, blank=True)
|
||||
story_permalink = models.CharField(max_length=1000)
|
||||
story_past_trim_date = models.BooleanField(default=False)
|
||||
tags = models.ManyToManyField(Tag)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.story_title
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "stories"
|
||||
verbose_name = "story"
|
||||
db_table="stories"
|
||||
ordering=["-story_date", "story_feed"]
|
||||
|
0
apps/rss_feeds/tests.py
Normal file
1
apps/rss_feeds/views.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Create your views here.
|
11
manage.py
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env python
|
||||
from django.core.management import execute_manager
|
||||
try:
|
||||
import settings # Assumed to be in the same directory.
|
||||
except ImportError:
|
||||
import sys
|
||||
sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
execute_manager(settings)
|
509
media/css/reader.css
Normal file
|
@ -0,0 +1,509 @@
|
|||
/* ========== */
|
||||
/* = Global = */
|
||||
/* ========== */
|
||||
|
||||
body {
|
||||
/*resets*/margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none;
|
||||
font-family: 'Lucida Grande',Helvetica, Arial;
|
||||
font-size:62%;
|
||||
}
|
||||
|
||||
a, a:active, a:hover, a:visited, button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* =================== */
|
||||
/* = Resize Controls = */
|
||||
/* =================== */
|
||||
|
||||
.ui-layout-resizer-west {
|
||||
background: #e0e0e0 url(../img/reader/resize_west_small.png) repeat-y 50% 50%;
|
||||
}
|
||||
|
||||
.ui-layout-resizer-north {
|
||||
background: #e0e0e0 url(../img/reader/resize_north.png) repeat-x 50% 50%;
|
||||
}
|
||||
|
||||
/* ============= */
|
||||
/* = Feed List = */
|
||||
/* ============= */
|
||||
|
||||
#feed_list {
|
||||
position: fixed;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
width: 220px;
|
||||
background-color: #D7DDE6;
|
||||
border-right: 1px solid #808080;
|
||||
z-index: 20;
|
||||
font-size: 1.05em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#feed_list .folder {
|
||||
margin: 6px 0px 4px;
|
||||
background: transparent url(../img/icons/silk/folder.png) no-repeat 3px 1px;
|
||||
}
|
||||
|
||||
#feed_list .folder .folder_title {
|
||||
padding: 3px 0px 4px 22px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
color: #404040;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#feed_list .feed {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid #D7DDE6;
|
||||
border-bottom: 1px solid #D7DDE6;
|
||||
}
|
||||
|
||||
#feed_list .feed_id {
|
||||
display: none;
|
||||
}
|
||||
#feed_list img.feed_favicon {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 24px;
|
||||
}
|
||||
#feed_list .feed_title {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
padding: 4px 42px 2px 45px;
|
||||
text-decoration: none;
|
||||
color: #272727;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
|
||||
#feed_list .feed.no_unread_items .feed_title {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#feed_list .feed.selected {
|
||||
background: #f6a828 url(../theme/images/ui-bg_highlight-hard_35_f6a828_1x100.png) 0 50% repeat-x;
|
||||
border-top: 1px solid #A8A8A8;
|
||||
border-bottom: 1px solid #A8A8A8;
|
||||
}
|
||||
#feed_list .unread_count {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 7px;
|
||||
font-weight: bold;
|
||||
color: #FFF;
|
||||
padding: 0 6px;
|
||||
background-color: #8eb6e8;
|
||||
}
|
||||
|
||||
|
||||
/* ================ */
|
||||
/* = Story Titles = */
|
||||
/* ================ */
|
||||
|
||||
#story_titles {
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
#story_titles .wrapper {
|
||||
margin-left: 220px;
|
||||
}
|
||||
|
||||
#story_titles .feed_bar {
|
||||
font-weight: bold;
|
||||
font-size: 1.3em;
|
||||
padding: 2px 140px 2px 28px;
|
||||
background: #dadada url(../theme/images/dadada_40x100_textures_03_highlight_soft_75.png) 0 50% repeat-x;
|
||||
border-bottom: 2px solid #404040;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#story_titles .feed_bar .feed_heading {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#story_titles .feed_bar .feed_heading .feed_favicon {
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#story_titles .feed_bar .unread_count {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
#story_titles .feed_bar .feed_id {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#story_titles .story {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
padding: 0px 206px 0px 28px;
|
||||
border-top: 1px solid #E7EDF6;
|
||||
text-decoration: none;
|
||||
color: #272727;
|
||||
line-height: 1em;
|
||||
background: transparent url(../img/icons/silk/bullet_orange.png) no-repeat 6px 50%;
|
||||
}
|
||||
|
||||
#story_titles .story.NB-story-hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
#story_titles .story a.story_title {
|
||||
text-decoration: none;
|
||||
color: #272727;
|
||||
display: block;
|
||||
padding: 4px 0px;
|
||||
}
|
||||
#story_titles .story .story_id {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#story_titles .story.read {
|
||||
font-weight: normal;
|
||||
background: none;
|
||||
}
|
||||
|
||||
#story_titles .story .story_date {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
width: 200px;
|
||||
top: 4px;
|
||||
}
|
||||
#story_titles .story.selected {
|
||||
color: #304080;
|
||||
border-top: 1px solid #D7DDE6;
|
||||
background: #dadada url(../theme/images/dadada_40x100_textures_03_highlight_soft_75.png) 0 50% repeat-x;
|
||||
}
|
||||
#story_titles .story.after_selected {
|
||||
border-top: 1px solid #D7DDE6;
|
||||
}
|
||||
|
||||
/* =================== */
|
||||
/* = Story Navigator = */
|
||||
/* =================== */
|
||||
|
||||
#story_pane .story_navigator {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
#story_pane a.button {
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
display: -moz-inline-box;
|
||||
display: inline-block;
|
||||
overflow: visible;
|
||||
color: #000;
|
||||
background-color: #acc;
|
||||
padding: 5px 7px;
|
||||
margin: 0px 2px;
|
||||
}
|
||||
|
||||
#story_pane a.button:hover {
|
||||
background-color: #cee;
|
||||
}
|
||||
|
||||
#story_pane a.button.next_unread {
|
||||
float: right;
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
background: #acc url(../img/icons/silk/arrow_down.png) no-repeat 50% 50%;
|
||||
}
|
||||
|
||||
#story_pane a.button.next_unread:hover {
|
||||
background: #cee url(../img/icons/silk/arrow_down.png) no-repeat 50% 50%;
|
||||
}
|
||||
|
||||
#story_pane a.button.previous_unread {
|
||||
float: right;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #acc url(../img/icons/silk/arrow_up.png) no-repeat 50% 50%;
|
||||
}
|
||||
|
||||
#story_pane a.button.previous_unread:hover {
|
||||
background: #cee url(../img/icons/silk/arrow_up.png) no-repeat 50% 50%;
|
||||
}
|
||||
|
||||
/* ================= */
|
||||
/* = Story Content = */
|
||||
/* ================= */
|
||||
|
||||
#story_pane {
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
top: 200px;
|
||||
left: 0px;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.5em;
|
||||
height: 315px;
|
||||
}
|
||||
|
||||
#story_pane .wrapper {
|
||||
margin-left: 220px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
#story_pane .story_title {
|
||||
font-weight: bold;
|
||||
font-size: 1.3em;
|
||||
padding: 12px 140px 12px 28px;
|
||||
background: #dadada url(../theme/images/dadada_40x100_textures_03_highlight_soft_75.png) 0 50% repeat-x;
|
||||
border-top: 4px solid #404040;
|
||||
}
|
||||
|
||||
#story_pane .story_title a {
|
||||
text-decoration: none;
|
||||
color: #101050;
|
||||
}
|
||||
|
||||
#story_pane .story_title a:hover {
|
||||
color: #1010A0;
|
||||
}
|
||||
|
||||
#story_pane .story_meta {
|
||||
color: #606060;
|
||||
font-weight: bold;
|
||||
font-size: .8em;
|
||||
width: 6em;
|
||||
line-height: 1.9em;
|
||||
clear: both;
|
||||
float: left;
|
||||
text-transform: uppercase;
|
||||
padding: 0px 4px 0px 0px;
|
||||
}
|
||||
|
||||
#story_pane .story_feed {
|
||||
padding: 4px 140px 0px 28px;
|
||||
}
|
||||
|
||||
#story_pane .story_feed .feed_favicon {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
#story_pane .story_feed .data {
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#story_pane .story_author {
|
||||
padding: 0px 140px 0px 28px;
|
||||
}
|
||||
|
||||
#story_pane .story_date {
|
||||
padding: 0px 140px 0px 28px;
|
||||
}
|
||||
|
||||
#story_pane .story_content {
|
||||
border-top: 1px solid #909090;
|
||||
margin: 12px 140px 24px 28px;
|
||||
padding: 12px 0px;
|
||||
}
|
||||
|
||||
#story_pane .story_endbar {
|
||||
height: 8px;
|
||||
border-top: 1px solid #404040;
|
||||
background: #dadada url(../theme/images/dadada_40x100_textures_03_highlight_soft_75.png) 0 50% repeat-x;
|
||||
}
|
||||
/* ======================= */
|
||||
/* = Story Modifications = */
|
||||
/* ======================= */
|
||||
|
||||
#story_pane {
|
||||
color: #2b2b2b;
|
||||
}
|
||||
|
||||
#story_pane p {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#story_pane blockquote {
|
||||
background-color: #F0F0F0;
|
||||
border-left: 1px solid #9B9B9B;
|
||||
padding: .5em 2em;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
/* ============ */
|
||||
/* = Task Bar = */
|
||||
/* ============ */
|
||||
|
||||
#task_bar {
|
||||
height: 29px;
|
||||
background: #e0e0e0 url(../img/reader/taskbar_background.png) repeat-x top left;
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* = OPML Import Form = */
|
||||
/* ==================== */
|
||||
|
||||
form.opml_import_form {
|
||||
|
||||
}
|
||||
|
||||
form.opml_import_form textarea {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
form.opml_import_form .section {
|
||||
clear: both;
|
||||
margin: 2px 0px;
|
||||
}
|
||||
|
||||
form.opml_import_form label {
|
||||
display: block;
|
||||
}
|
||||
form.opml_import_form input {
|
||||
display: block;
|
||||
clear: both;
|
||||
float: left;
|
||||
margin: 0px 4px;
|
||||
}
|
||||
|
||||
/* ============== */
|
||||
/* = Bottom Bar = */
|
||||
/* ============== */
|
||||
|
||||
/*************************/
|
||||
/* Recommended for menus */
|
||||
/*************************/
|
||||
|
||||
button.menu {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.menu {
|
||||
display: none;
|
||||
z-index: 99;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
div.menu.active {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/***********************/
|
||||
/* Completely optional */
|
||||
/***********************/
|
||||
|
||||
.menu_button {
|
||||
line-height: 1.8;
|
||||
text-shadow: 1px 1px #ddd;
|
||||
color: #222;
|
||||
background: #a8acae;
|
||||
margin: 3px 2em 0 0.5em;
|
||||
padding: 0 0.8em;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #686c6e;
|
||||
-webkit-border-radius: 0.5em;
|
||||
-moz-border-radius: 0.5em;
|
||||
border-radius: 0.5em;
|
||||
float: right;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.menu_button img {
|
||||
vertical-align: -3px;
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
.menu_button .menu {
|
||||
display: none;
|
||||
right: 0px;
|
||||
bottom: 20px;
|
||||
cursor: normal;
|
||||
}
|
||||
|
||||
.menu_button.hover .menu {
|
||||
display: block;
|
||||
}
|
||||
.menu_button.hover {
|
||||
-webkit-border-top-left-radius: 0;
|
||||
-webkit-border-top-right-radius: 0;
|
||||
-moz-border-radius-topleft: 0;
|
||||
-moz-border-radius-topright: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-color: #cacece;
|
||||
}
|
||||
|
||||
button.menu:hover,
|
||||
button.menu.active {
|
||||
background-color: #cacece;
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
|
||||
div.menu {
|
||||
font-size: 88%;
|
||||
color: #444;
|
||||
background: #cacece;
|
||||
padding: 1em;
|
||||
border: 1px solid #cacece;
|
||||
border-top-width: 3px;
|
||||
-moz-border-radius: 0.5em;
|
||||
-moz-border-radius-topleft: 0;
|
||||
-webkit-border-radius: 0.5em;
|
||||
-webkit-border-top-left-radius: 0;
|
||||
border-radius: 0.5em;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
div.menu.active {
|
||||
background-color: #eaeeee;
|
||||
}
|
||||
|
||||
div.menu h3 {
|
||||
font-size: 108%;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div.menu h4 {
|
||||
font-size: 100%;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
color: #999;
|
||||
margin: 0 0 0.5em 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
div.menu hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
|
||||
div.menu ul {
|
||||
padding-left: 1.5em;
|
||||
}
|
BIN
media/img/icons/actions/add-user.gif
Executable file
After Width: | Height: | Size: 907 B |
BIN
media/img/icons/actions/add-user.png
Executable file
After Width: | Height: | Size: 318 B |
BIN
media/img/icons/actions/add.gif
Executable file
After Width: | Height: | Size: 864 B |
BIN
media/img/icons/actions/add.png
Executable file
After Width: | Height: | Size: 228 B |
BIN
media/img/icons/actions/arrow-down.gif
Executable file
After Width: | Height: | Size: 888 B |
BIN
media/img/icons/actions/arrow-down.png
Executable file
After Width: | Height: | Size: 273 B |
BIN
media/img/icons/actions/arrow-left.gif
Executable file
After Width: | Height: | Size: 887 B |
BIN
media/img/icons/actions/arrow-left.png
Executable file
After Width: | Height: | Size: 279 B |
BIN
media/img/icons/actions/arrow-right.gif
Executable file
After Width: | Height: | Size: 886 B |
BIN
media/img/icons/actions/arrow-right.png
Executable file
After Width: | Height: | Size: 255 B |
BIN
media/img/icons/actions/arrow-up.gif
Executable file
After Width: | Height: | Size: 891 B |
BIN
media/img/icons/actions/arrow-up.png
Executable file
After Width: | Height: | Size: 281 B |
BIN
media/img/icons/actions/button-bold.gif
Executable file
After Width: | Height: | Size: 934 B |
BIN
media/img/icons/actions/button-bold.png
Executable file
After Width: | Height: | Size: 316 B |
BIN
media/img/icons/actions/button-italic.gif
Executable file
After Width: | Height: | Size: 933 B |
BIN
media/img/icons/actions/button-italic.png
Executable file
After Width: | Height: | Size: 294 B |
BIN
media/img/icons/actions/button-underline.gif
Executable file
After Width: | Height: | Size: 936 B |
BIN
media/img/icons/actions/button-underline.png
Executable file
After Width: | Height: | Size: 298 B |
BIN
media/img/icons/actions/button.gif
Executable file
After Width: | Height: | Size: 925 B |
BIN
media/img/icons/actions/button.png
Executable file
After Width: | Height: | Size: 225 B |
BIN
media/img/icons/actions/cancel.gif
Executable file
After Width: | Height: | Size: 934 B |
BIN
media/img/icons/actions/cancel.png
Executable file
After Width: | Height: | Size: 322 B |
BIN
media/img/icons/actions/clipboard.gif
Executable file
After Width: | Height: | Size: 960 B |
BIN
media/img/icons/actions/clipboard.png
Executable file
After Width: | Height: | Size: 386 B |
BIN
media/img/icons/actions/copy.gif
Executable file
After Width: | Height: | Size: 915 B |
BIN
media/img/icons/actions/copy.png
Executable file
After Width: | Height: | Size: 346 B |
BIN
media/img/icons/actions/cut.gif
Executable file
After Width: | Height: | Size: 879 B |
BIN
media/img/icons/actions/cut.png
Executable file
After Width: | Height: | Size: 281 B |
BIN
media/img/icons/actions/delete-user.gif
Executable file
After Width: | Height: | Size: 896 B |
BIN
media/img/icons/actions/delete-user.png
Executable file
After Width: | Height: | Size: 282 B |
BIN
media/img/icons/actions/forbidden-alt.gif
Executable file
After Width: | Height: | Size: 943 B |
BIN
media/img/icons/actions/forbidden-alt.png
Executable file
After Width: | Height: | Size: 358 B |
BIN
media/img/icons/actions/forbidden.gif
Executable file
After Width: | Height: | Size: 944 B |
BIN
media/img/icons/actions/forbidden.png
Executable file
After Width: | Height: | Size: 303 B |
BIN
media/img/icons/actions/info.gif
Executable file
After Width: | Height: | Size: 944 B |
BIN
media/img/icons/actions/info.png
Executable file
After Width: | Height: | Size: 362 B |
BIN
media/img/icons/actions/lock.gif
Executable file
After Width: | Height: | Size: 916 B |
BIN
media/img/icons/actions/lock.png
Executable file
After Width: | Height: | Size: 304 B |
BIN
media/img/icons/actions/mail-new.gif
Executable file
After Width: | Height: | Size: 943 B |
BIN
media/img/icons/actions/mail-new.png
Executable file
After Width: | Height: | Size: 367 B |
BIN
media/img/icons/actions/ok.gif
Executable file
After Width: | Height: | Size: 896 B |
BIN
media/img/icons/actions/ok.png
Executable file
After Width: | Height: | Size: 324 B |
BIN
media/img/icons/actions/paste.gif
Executable file
After Width: | Height: | Size: 971 B |
BIN
media/img/icons/actions/paste.png
Executable file
After Width: | Height: | Size: 388 B |
BIN
media/img/icons/actions/refresh.gif
Executable file
After Width: | Height: | Size: 912 B |
BIN
media/img/icons/actions/refresh.png
Executable file
After Width: | Height: | Size: 362 B |
BIN
media/img/icons/actions/search.gif
Executable file
After Width: | Height: | Size: 915 B |
BIN
media/img/icons/actions/search.png
Executable file
After Width: | Height: | Size: 361 B |
BIN
media/img/icons/actions/splitwindow.gif
Executable file
After Width: | Height: | Size: 917 B |
BIN
media/img/icons/actions/splitwindow.png
Executable file
After Width: | Height: | Size: 236 B |
BIN
media/img/icons/actions/stop.gif
Executable file
After Width: | Height: | Size: 930 B |
BIN
media/img/icons/actions/stop.png
Executable file
After Width: | Height: | Size: 342 B |
BIN
media/img/icons/actions/subtract.gif
Executable file
After Width: | Height: | Size: 850 B |
BIN
media/img/icons/actions/subtract.png
Executable file
After Width: | Height: | Size: 175 B |
BIN
media/img/icons/actions/tab-new.gif
Executable file
After Width: | Height: | Size: 905 B |
BIN
media/img/icons/actions/tab-new.png
Executable file
After Width: | Height: | Size: 283 B |
BIN
media/img/icons/actions/tab.gif
Executable file
After Width: | Height: | Size: 887 B |
BIN
media/img/icons/actions/tab.png
Executable file
After Width: | Height: | Size: 233 B |
BIN
media/img/icons/actions/tabs-new.gif
Executable file
After Width: | Height: | Size: 922 B |
BIN
media/img/icons/actions/tabs-new.png
Executable file
After Width: | Height: | Size: 308 B |
BIN
media/img/icons/actions/tabs.gif
Executable file
After Width: | Height: | Size: 906 B |