diff --git a/www/assets/devnet.png b/www/assets/devnet.png
new file mode 100644
index 0000000000..936d411492
Binary files /dev/null and b/www/assets/devnet.png differ
diff --git a/www/assets/icons/devnet.12.png b/www/assets/icons/devnet.12.png
new file mode 100644
index 0000000000..f90393eee3
Binary files /dev/null and b/www/assets/icons/devnet.12.png differ
diff --git a/www/assets/icons/devnet.16.png b/www/assets/icons/devnet.16.png
new file mode 100644
index 0000000000..7b53b00c34
Binary files /dev/null and b/www/assets/icons/devnet.16.png differ
diff --git a/www/index.html b/www/index.html
index c093ad52be..6c6e3dc533 100644
--- a/www/index.html
+++ b/www/index.html
@@ -120,6 +120,7 @@
Tip someone!
@@ -225,13 +246,19 @@
After Reconnect
{% if user_github_account is not None %}
- {{ user_github_account.user_info['login'] }}
+ {{ user_github_account.get_user_info()['login'] }}
+
+ {% end %}
+ {% if user_devnet_account is not None %}
+
+
+ {{ user_devnet_account.get_user_info()['screen_name'] }}
{% end %}
{% if user_twitter_account is not None %}
- {{ user_twitter_account.user_info['screen_name'] }}
+ {{ user_twitter_account.get_user_info()['screen_name'] }}
{% end %}
@@ -276,6 +303,12 @@
After Reconnect
{{ other_github_account.user_info['login'] }}
{% end %}
+ {% if other_devnet_account is not None and account.platform != 'devnet' %}
+
+
+ {{ other_devnet_account.user_info['screen_name'] }}
+
+ {% end %}
{% if other_twitter_account is not None and account.platform != 'twitter' %}
diff --git a/www/on/devnet/%screen_name/index.html b/www/on/devnet/%screen_name/index.html
new file mode 100644
index 0000000000..de004a743b
--- /dev/null
+++ b/www/on/devnet/%screen_name/index.html
@@ -0,0 +1,129 @@
+"""DevNet user page on Gittip.
+"""
+import datetime
+import decimal
+
+import requests
+from aspen import json, Response, log
+from aspen.utils import to_age, utc
+from gittip import AMOUNTS, CARDINALS, db
+from gittip.elsewhere import devnet
+from gittip.models import Participant
+
+
+# ========================================================================== ^L
+
+# Try to load from DevNet.
+# =========================
+
+user_info = devnet.get_user_info(path['screen_name'])
+
+
+# Try to load from Gittip.
+# ========================
+
+username = user_info['screen_name']
+name = user_info.get('name')
+if not name:
+ name = username
+user_info['html_url'] = "/on/devnet/%s" % username
+
+account = devnet.DevNetAccount(user_info['id'], user_info)
+participant = Participant.query.get(account.participant_id)
+can_tip = not account.is_locked
+lock_action = "unlock" if account.is_locked else "lock"
+if account.is_claimed:
+ request.redirect('/%s/' % participant.id)
+
+if not user.ANON:
+ my_tip = user.get_tip_to(participant.id)
+
+tip_or_pledge = "pledge"
+nbackers = participant.get_number_of_backers()
+
+# ========================================================================== ^L
+{% extends templates/participant.html %}
+
+{% block their_voice %}
+ {% if account.is_locked %}
+
+
{{ username }} has opted out of Gittip.
+
+
If you are {{ username }}
+ on DevNet, you can unlock your account to allow people to
+ pledge tips to you on Gittip. We never collect any money on your behalf
+ until you explicitly opt in.
+
+
+
+ {% else %}
+
+
+
{{ name }} has not joined Gittip.
+
+
Is this you?
+ {% if user.ANON %}
+ Click here to opt in to Gittip.
+ {% else %}
+ You’ll have to sign out and sign back in to claim this account.
+ {% end %}
There are {{ CARDINALS[nbackers] }} people ready to give.
+ {% else %}
+
There are {{ nbackers }} people ready to give.
+ {% end %}
+
+
+ {% if not user.ANON %}
+
+
{{ 'But we' if nbackers > 0 else 'We' }} will never collect money on
+ behalf of {{ username }} until they opt in.
+
+ {% else %}
+
+
{{ 'But we' if nbackers > 0 else 'We' }} will never collect money on
+ your behalf until you opt in.
+
+
What is Gittip?
+
+
Gittip is a way to thank and support your favorite artists, musicians,
+ writers, programmers, etc. by setting up a small weekly gift to them. Read more ...
+
+
Don’t like what you see?
+
+
If you are {{ username }} you can explicitly opt out of Gittip by
+ locking this account. We don’t allow new pledges to locked
+ accounts.
+
+
+
+ {% end %}
+ {% end %}
+{% end %}
diff --git a/www/on/devnet/%screen_name/lock-fail.html b/www/on/devnet/%screen_name/lock-fail.html
new file mode 100644
index 0000000000..daa7a53363
--- /dev/null
+++ b/www/on/devnet/%screen_name/lock-fail.html
@@ -0,0 +1,16 @@
+username = path['screen_name']
+^L
+{% extends templates/base.html %}
+
+{% block body %}
+
+
+
Are you really {{ username }}?
+
+
Your attempt to lock or unlock this account failed because you’re
+logged into GitHub as someone else. Please log out of GitHub and try again.
+
+
+{% end %}
diff --git a/www/on/devnet/associate b/www/on/devnet/associate
new file mode 100644
index 0000000000..79d5c5855c
--- /dev/null
+++ b/www/on/devnet/associate
@@ -0,0 +1,100 @@
+"""Associate a DevNet account with a Gittip account.
+
+First we do the OAuth dance with DevNet. Once we've authenticated the user
+against DevNet, we record them in our elsewhere table. This table contains
+information for DevNet users whether or not they are explicit participants in
+the Gittip community.
+
+"""
+from urlparse import parse_qs
+
+import requests
+from oauth_hook import OAuthHook
+from aspen import log, Response, json
+from aspen import resources
+from gittip.elsewhere import ACTIONS, devnet, github, twitter
+from gittip.participant import NeedConfirmation
+
+from gittip import participant
+from gittip.authentication import User
+# ========================== ^L
+
+if 'denied' in qs:
+ request.redirect('/')
+
+screen_name = qs.get('screen_name') # GitTip ID
+
+if screen_name is None:
+ log(u"We got a user_info from DevNet with no screen_name [%s, %s]" % (action, then))
+ raise Response(400)
+
+then = screen_name
+
+account = participant.Participant(screen_name)
+log(u"Looked up participant: [%s]" % (account.get_details()))
+
+if account.get_details() is not None:
+ user = User.from_id(screen_name) # give them a session
+ # look up the elsewhere accounts, specifically we're just looking to see if they already have a DevNet account
+ (github_account, twitter_account, devnet_account) = account.get_accounts_elsewhere()
+ #log(u"Looked up elsewhere:\nGitHub: %s\nTwitter: %s\nDevNet: %s" % (github_account, twitter_account, devnet_account))
+ if devnet_account is not None:
+ action = 'opt-in'
+ else:
+ action = 'connect'
+else:
+ action = 'opt-in'
+
+# Populate DevNet user info. These lines simulate the remote server populating its user_info response.
+user_id = screen_name + '@DevNet'
+user_info = {"id": user_id, "screen_name": screen_name}
+user_info['html_url'] = "/on/devnet/" + screen_name
+
+# This line then updates the database with the returned values for this DevNet entry
+# if they have an account this will be associated with their account
+# if they don't this will become the basis for their account
+account = devnet.DevNetAccount(user_info['id'], user_info)
+
+
+# Do something.
+log(u"%s wants to %s using account %s" % (screen_name, action, account))
+
+if action == 'opt-in': # opt in
+ user = account.opt_in(screen_name) # setting 'user' gives them a session
+elif action == 'connect': # connect
+ if user.ANON:
+ raise Response(404)
+ try:
+ user.take_over(account, True)
+ except NeedConfirmation, obstacles:
+
+ # XXX Eep! Internal redirect! Really?!
+ request.internally_redirected_from = request.fs
+ request.fs = website.www_root + '/on/confirm.html'
+ request.resource = resources.get(request)
+
+ raise request.resource.respond(request)
+else: # lock or unlock
+ if then != screen_name:
+
+ # The user could spoof `then' to match their screen_name, but the most they
+ # can do is lock/unlock their own DevNet account in a convoluted way.
+
+ then = u'/on/devnet/%s/lock-fail.html' % then
+
+ else:
+
+ # Associate the DevNet screen_name with a randomly-named, unclaimed Gittip
+ # participant.
+
+ assert account.participant_id != screen_name, screen_name # sanity check
+
+
+if then == u'':
+ then = u'/%s/' % account.participant_id
+if not then.startswith(u'/'):
+ # Interpret it as a DevNet screen_name.
+ then = u'/on/devnet/%s/' % then
+request.redirect(then)
+
+# ========================== ^L text/plain
diff --git a/www/on/devnet/authenticate b/www/on/devnet/authenticate
new file mode 100644
index 0000000000..f5d0424d9c
--- /dev/null
+++ b/www/on/devnet/authenticate
@@ -0,0 +1,42 @@
+from aspen import Response
+from gittip.elsewhere.github import GitHubAccount
+from gittip.elsewhere.twitter import TwitterAccount
+from gittip.elsewhere.devnet import DevNetAccount
+
+# ====== ^L
+if user.ANON or not POST:
+ raise Response(404)
+
+platform = body['platform']
+if platform not in ('github', 'twitter', 'devnet'):
+ raise Response(400, "bad platform: %s" % platform)
+
+user_id = body['user_id']
+if not user_id:
+ raise Response(400, "no user_id")
+
+
+# Look for a connect_token.
+# =========================
+# CSRF isn't enough to protect against unauthorized take_overs. Someone need
+# only find their own CSRF header and use that. We need a token specific to the
+# connection request.
+
+connect_key = (user.id, platform, user_id)
+expected = website.connect_tokens.pop(connect_key, None)
+actual = body.get('connect_token')
+if expected is None or actual != expected:
+ msg = str("Is %s gaming us? %s:%s" % (user.id, expected, actual))
+ raise Response(400, msg)
+
+
+if platform == 'github':
+ account = GitHubAccount(user_id)
+elif platform == 'devnet':
+ account = DevNetAccount(user_id)
+else:
+ account = TwitterAccount(user_id)
+user.take_over(account, have_confirmation=True)
+request.redirect('/about/me.html')
+
+# ====== ^L
diff --git a/www/on/devnet/login.html b/www/on/devnet/login.html
new file mode 100644
index 0000000000..8836b85759
--- /dev/null
+++ b/www/on/devnet/login.html
@@ -0,0 +1,120 @@
+from gittip import db, AMOUNTS
+from gittip.elsewhere import github
+^L
+
+try:
+ limit = min(int(qs.get('limit', 10)), 100)
+ offset = int(qs.get('offset', 0))
+except ValueError:
+ raise Response(400)
+
+participants = db.fetchall("""
+
+ SELECT p.id id, SUM(case e.platform when 'devnet' then 1 else 0 end) > 0 has_devnet, bool_or(is_suspicious) is_suspicious
+ FROM participants p
+ INNER JOIN elsewhere e ON p.id = e.participant_id
+ --WHERE is_suspicious IS NOT true
+ GROUP BY p.id
+ ORDER BY p.id
+ LIMIT %s
+ OFFSET %s
+
+""", (limit, offset))
+
+^L
+{% extends templates/base.html %}
+
+{% block heading %}
+
+
+ Set up recurring gift tips to people who
+ make the world better. Learn more ...
+
+
+
+
+
+{% end %}
+{% block body %}
+
+
+
+
Log In As:
+
+
+
+
+ {% for i, participant in enumerate(participants, start=1) %}
+