Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

linkdrop

  • Loading branch information...
commit ce578f69c518c038b523b3aacd3227a6e9415b92 0 parents
@mhammond mhammond authored
Showing with 6,696 additions and 0 deletions.
  1. +10 −0 .hgignore
  2. +3 −0  MANIFEST.in
  3. +19 −0 README.txt
  4. +104 −0 development.ini
  5. +19 −0 docs/index.txt
  6. +276 −0 ez_setup.py
  7. 0  linkdrop/__init__.py
  8. 0  linkdrop/config/__init__.py
  9. +63 −0 linkdrop/config/deployment.ini_tmpl
  10. +43 −0 linkdrop/config/environment.py
  11. +67 −0 linkdrop/config/middleware.py
  12. +28 −0 linkdrop/config/routing.py
  13. 0  linkdrop/controllers/__init__.py
  14. +246 −0 linkdrop/controllers/account.py
  15. +44 −0 linkdrop/controllers/error.py
  16. 0  linkdrop/lib/__init__.py
  17. +18 −0 linkdrop/lib/app_globals.py
  18. +20 −0 linkdrop/lib/base.py
  19. +163 −0 linkdrop/lib/helpers.py
  20. +9 −0 linkdrop/model/__init__.py
  21. +17 −0 linkdrop/model/account.py
  22. +81 −0 linkdrop/model/expando_mixin.py
  23. +22 −0 linkdrop/model/meta.py
  24. +40 −0 linkdrop/model/serializer_mixin.py
  25. +94 −0 linkdrop/model/types.py
  26. BIN  linkdrop/public/bg.png
  27. BIN  linkdrop/public/favicon.ico
  28. +137 −0 linkdrop/public/index.html
  29. BIN  linkdrop/public/pylons-logo.gif
  30. +401 −0 linkdrop/simple_oauth.py
  31. +34 −0 linkdrop/tests/__init__.py
  32. 0  linkdrop/tests/functional/__init__.py
  33. 0  linkdrop/tests/test_models.py
  34. +18 −0 linkdrop/websetup.py
  35. +31 −0 setup.cfg
  36. +37 −0 setup.py
  37. +21 −0 test.ini
  38. +6 −0 web/scratch/README.txt
  39. +168 −0 web/scratch/oauth/index.html
  40. +121 −0 web/scratch/oauth/index.js
  41. +99 −0 web/scripts/blade/defer.js
  42. +227 −0 web/scripts/blade/dispatch.js
  43. +58 −0 web/scripts/blade/fn.js
  44. +863 −0 web/scripts/blade/jig.js
  45. +128 −0 web/scripts/blade/object.js
  46. +59 −0 web/scripts/blade/url.js
  47. +149 −0 web/scripts/cards.js
  48. +174 −0 web/scripts/fancyzoom.js
  49. +129 −0 web/scripts/friendly.js
  50. +42 −0 web/scripts/hashDispatch.js
  51. +1 −0  web/scripts/iscroll-min.js
  52. +149 −0 web/scripts/isoDate.js
  53. +205 −0 web/scripts/jquery.easing.1.3.js
  54. +308 −0 web/scripts/jquery.masonry.js
  55. +486 −0 web/scripts/jquery.tmpl.js
  56. +334 −0 web/scripts/jquery.vgrid.0.1.5.js
  57. +7 −0 web/scripts/json2.js
  58. +381 −0 web/scripts/md5.js
  59. +104 −0 web/scripts/placeholder.js
  60. +221 −0 web/scripts/rdapi.js
  61. +204 −0 web/scripts/requireplugins-jquery-1.4.2.js
  62. +8 −0 web/scripts/templates/cardsHeader.html
10 .hgignore
@@ -0,0 +1,10 @@
+syntax:regexp
+\.egg
+\.egg-info
+\.pyc
+\.pyo
+\.swp
+linkdrop.kpf
+renv
+\.DS_Store
+development.db
3  MANIFEST.in
@@ -0,0 +1,3 @@
+include linkdrop/config/deployment.ini_tmpl
+recursive-include linkdrop/public *
+recursive-include linkdrop/templates *
19 README.txt
@@ -0,0 +1,19 @@
+This file is for you to describe the linkdrop application. Typically
+you would include information such as the information below:
+
+Installation and Setup
+======================
+
+Install ``linkdrop`` using easy_install::
+
+ easy_install linkdrop
+
+Make a config file as follows::
+
+ paster make-config linkdrop config.ini
+
+Tweak the config file as appropriate and then setup the application::
+
+ paster setup-app config.ini
+
+Then you are ready to go.
104 development.ini
@@ -0,0 +1,104 @@
+#
+# linkdrop - Pylons development environment configuration
+#
+# The %(here)s variable will be replaced with the parent directory of this file
+#
+[DEFAULT]
+debug = true
+# Uncomment and replace with the address which should receive any error reports
+#email_to = you@yourdomain.com
+smtp_server = localhost
+error_email_from = paste@localhost
+
+oauth.twitter.com.consumer_key = 2r1qbed58DAaNMe142msTg
+oauth.twitter.com.consumer_secret = prh6A961516mJ3XEjd7eERsGxuVZqycrBB6lV7LQ
+# This is a 'raindrop' app currently owned by markh. By necessity it is
+# configured to use a redirect URL back to the default host and port specified
+# below for this server.
+oauth.facebook.com.app_id = 158102624846
+oauth.facebook.com.app_secret = 4203f7f23803f405e06509ec4d4b9729
+
+
+[composite:main]
+use = egg:Paste#urlmap
+/ = home
+/api = api
+
+[server:main]
+use = egg:Paste#http
+host = 127.0.0.1
+port = 5000
+
+
+[app:home]
+use = egg:Paste#static
+document_root = %(here)s/web
+
+[app:api]
+#use: config:api.ini
+
+use = egg:linkdrop
+full_stack = true
+static_files = true
+
+cache_dir = %(here)s/data
+beaker.session.key = linkdrop
+beaker.session.secret = somesecret
+
+# If you'd like to fine-tune the individual locations of the cache data dirs
+# for the Cache data, or the Session saves, un-comment the desired settings
+# here:
+#beaker.cache.data_dir = %(here)s/data/cache
+#beaker.session.data_dir = %(here)s/data/sessions
+
+# SQLAlchemy database URL
+sqlalchemy.url = sqlite:///%(here)s/development.db
+
+# WARNING: *THE LINE BELOW MUST BE UNCOMMENTED ON A PRODUCTION ENVIRONMENT*
+# Debug mode will enable the interactive debugging tool, allowing ANYONE to
+# execute malicious code after an exception is raised.
+#set debug = false
+
+
+# Logging configuration
+[loggers]
+keys = root, routes, linkdrop, sqlalchemy
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = INFO
+handlers = console
+
+[logger_routes]
+level = INFO
+handlers =
+qualname = routes.middleware
+# "level = DEBUG" logs the route matched and routing variables.
+
+[logger_linkdrop]
+level = DEBUG
+handlers =
+qualname = linkdrop
+
+[logger_sqlalchemy]
+level = INFO
+handlers =
+qualname = sqlalchemy.engine
+# "level = INFO" logs SQL queries.
+# "level = DEBUG" logs SQL queries and results.
+# "level = WARN" logs neither. (Recommended for production systems.)
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s,%(msecs)03d %(levelname)-5.5s [%(name)s] [%(threadName)s] %(message)s
+datefmt = %H:%M:%S
19 docs/index.txt
@@ -0,0 +1,19 @@
+linkdrop
+++++++++
+
+This is the main index page of your documentation. It should be written in
+`reStructuredText format <http://docutils.sourceforge.net/rst.html>`_.
+
+You can generate your documentation in HTML format by running this command::
+
+ setup.py pudge
+
+For this to work you will need to download and install `buildutils`_,
+`pudge`_, and `pygments`_. The ``pudge`` command is disabled by
+default; to ativate it in your project, run::
+
+ setup.py addcommand -p buildutils.pudge_command
+
+.. _buildutils: http://pypi.python.org/pypi/buildutils
+.. _pudge: http://pudge.lesscode.org/
+.. _pygments: http://pygments.org/
276 ez_setup.py
@@ -0,0 +1,276 @@
+#!python
+"""Bootstrap setuptools installation
+
+If you want to use setuptools in your package's setup.py, just include this
+file in the same directory with it, and add this to the top of your setup.py::
+
+ from ez_setup import use_setuptools
+ use_setuptools()
+
+If you want to require a specific version of setuptools, set a download
+mirror, or use an alternate download directory, you can do so by supplying
+the appropriate options to ``use_setuptools()``.
+
+This file can also be run as a script to install or upgrade setuptools.
+"""
+import sys
+DEFAULT_VERSION = "0.6c9"
+DEFAULT_URL = "http://pypi.python.org/packages/%s/s/setuptools/" % sys.version[:3]
+
+md5_data = {
+ 'setuptools-0.6b1-py2.3.egg': '8822caf901250d848b996b7f25c6e6ca',
+ 'setuptools-0.6b1-py2.4.egg': 'b79a8a403e4502fbb85ee3f1941735cb',
+ 'setuptools-0.6b2-py2.3.egg': '5657759d8a6d8fc44070a9d07272d99b',
+ 'setuptools-0.6b2-py2.4.egg': '4996a8d169d2be661fa32a6e52e4f82a',
+ 'setuptools-0.6b3-py2.3.egg': 'bb31c0fc7399a63579975cad9f5a0618',
+ 'setuptools-0.6b3-py2.4.egg': '38a8c6b3d6ecd22247f179f7da669fac',
+ 'setuptools-0.6b4-py2.3.egg': '62045a24ed4e1ebc77fe039aa4e6f7e5',
+ 'setuptools-0.6b4-py2.4.egg': '4cb2a185d228dacffb2d17f103b3b1c4',
+ 'setuptools-0.6c1-py2.3.egg': 'b3f2b5539d65cb7f74ad79127f1a908c',
+ 'setuptools-0.6c1-py2.4.egg': 'b45adeda0667d2d2ffe14009364f2a4b',
+ 'setuptools-0.6c2-py2.3.egg': 'f0064bf6aa2b7d0f3ba0b43f20817c27',
+ 'setuptools-0.6c2-py2.4.egg': '616192eec35f47e8ea16cd6a122b7277',
+ 'setuptools-0.6c3-py2.3.egg': 'f181fa125dfe85a259c9cd6f1d7b78fa',
+ 'setuptools-0.6c3-py2.4.egg': 'e0ed74682c998bfb73bf803a50e7b71e',
+ 'setuptools-0.6c3-py2.5.egg': 'abef16fdd61955514841c7c6bd98965e',
+ 'setuptools-0.6c4-py2.3.egg': 'b0b9131acab32022bfac7f44c5d7971f',
+ 'setuptools-0.6c4-py2.4.egg': '2a1f9656d4fbf3c97bf946c0a124e6e2',
+ 'setuptools-0.6c4-py2.5.egg': '8f5a052e32cdb9c72bcf4b5526f28afc',
+ 'setuptools-0.6c5-py2.3.egg': 'ee9fd80965da04f2f3e6b3576e9d8167',
+ 'setuptools-0.6c5-py2.4.egg': 'afe2adf1c01701ee841761f5bcd8aa64',
+ 'setuptools-0.6c5-py2.5.egg': 'a8d3f61494ccaa8714dfed37bccd3d5d',
+ 'setuptools-0.6c6-py2.3.egg': '35686b78116a668847237b69d549ec20',
+ 'setuptools-0.6c6-py2.4.egg': '3c56af57be3225019260a644430065ab',
+ 'setuptools-0.6c6-py2.5.egg': 'b2f8a7520709a5b34f80946de5f02f53',
+ 'setuptools-0.6c7-py2.3.egg': '209fdf9adc3a615e5115b725658e13e2',
+ 'setuptools-0.6c7-py2.4.egg': '5a8f954807d46a0fb67cf1f26c55a82e',
+ 'setuptools-0.6c7-py2.5.egg': '45d2ad28f9750e7434111fde831e8372',
+ 'setuptools-0.6c8-py2.3.egg': '50759d29b349db8cfd807ba8303f1902',
+ 'setuptools-0.6c8-py2.4.egg': 'cba38d74f7d483c06e9daa6070cce6de',
+ 'setuptools-0.6c8-py2.5.egg': '1721747ee329dc150590a58b3e1ac95b',
+ 'setuptools-0.6c9-py2.3.egg': 'a83c4020414807b496e4cfbe08507c03',
+ 'setuptools-0.6c9-py2.4.egg': '260a2be2e5388d66bdaee06abec6342a',
+ 'setuptools-0.6c9-py2.5.egg': 'fe67c3e5a17b12c0e7c541b7ea43a8e6',
+ 'setuptools-0.6c9-py2.6.egg': 'ca37b1ff16fa2ede6e19383e7b59245a',
+}
+
+import sys, os
+try: from hashlib import md5
+except ImportError: from md5 import md5
+
+def _validate_md5(egg_name, data):
+ if egg_name in md5_data:
+ digest = md5(data).hexdigest()
+ if digest != md5_data[egg_name]:
+ print >>sys.stderr, (
+ "md5 validation of %s failed! (Possible download problem?)"
+ % egg_name
+ )
+ sys.exit(2)
+ return data
+
+def use_setuptools(
+ version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
+ download_delay=15
+):
+ """Automatically find/download setuptools and make it available on sys.path
+
+ `version` should be a valid setuptools version number that is available
+ as an egg for download under the `download_base` URL (which should end with
+ a '/'). `to_dir` is the directory where setuptools will be downloaded, if
+ it is not already available. If `download_delay` is specified, it should
+ be the number of seconds that will be paused before initiating a download,
+ should one be required. If an older version of setuptools is installed,
+ this routine will print a message to ``sys.stderr`` and raise SystemExit in
+ an attempt to abort the calling script.
+ """
+ was_imported = 'pkg_resources' in sys.modules or 'setuptools' in sys.modules
+ def do_download():
+ egg = download_setuptools(version, download_base, to_dir, download_delay)
+ sys.path.insert(0, egg)
+ import setuptools; setuptools.bootstrap_install_from = egg
+ try:
+ import pkg_resources
+ except ImportError:
+ return do_download()
+ try:
+ pkg_resources.require("setuptools>="+version); return
+ except pkg_resources.VersionConflict, e:
+ if was_imported:
+ print >>sys.stderr, (
+ "The required version of setuptools (>=%s) is not available, and\n"
+ "can't be installed while this script is running. Please install\n"
+ " a more recent version first, using 'easy_install -U setuptools'."
+ "\n\n(Currently using %r)"
+ ) % (version, e.args[0])
+ sys.exit(2)
+ else:
+ del pkg_resources, sys.modules['pkg_resources'] # reload ok
+ return do_download()
+ except pkg_resources.DistributionNotFound:
+ return do_download()
+
+def download_setuptools(
+ version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
+ delay = 15
+):
+ """Download setuptools from a specified location and return its filename
+
+ `version` should be a valid setuptools version number that is available
+ as an egg for download under the `download_base` URL (which should end
+ with a '/'). `to_dir` is the directory where the egg will be downloaded.
+ `delay` is the number of seconds to pause before an actual download attempt.
+ """
+ import urllib2, shutil
+ egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3])
+ url = download_base + egg_name
+ saveto = os.path.join(to_dir, egg_name)
+ src = dst = None
+ if not os.path.exists(saveto): # Avoid repeated downloads
+ try:
+ from distutils import log
+ if delay:
+ log.warn("""
+---------------------------------------------------------------------------
+This script requires setuptools version %s to run (even to display
+help). I will attempt to download it for you (from
+%s), but
+you may need to enable firewall access for this script first.
+I will start the download in %d seconds.
+
+(Note: if this machine does not have network access, please obtain the file
+
+ %s
+
+and place it in this directory before rerunning this script.)
+---------------------------------------------------------------------------""",
+ version, download_base, delay, url
+ ); from time import sleep; sleep(delay)
+ log.warn("Downloading %s", url)
+ src = urllib2.urlopen(url)
+ # Read/write all in one block, so we don't create a corrupt file
+ # if the download is interrupted.
+ data = _validate_md5(egg_name, src.read())
+ dst = open(saveto,"wb"); dst.write(data)
+ finally:
+ if src: src.close()
+ if dst: dst.close()
+ return os.path.realpath(saveto)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+def main(argv, version=DEFAULT_VERSION):
+ """Install or upgrade setuptools and EasyInstall"""
+ try:
+ import setuptools
+ except ImportError:
+ egg = None
+ try:
+ egg = download_setuptools(version, delay=0)
+ sys.path.insert(0,egg)
+ from setuptools.command.easy_install import main
+ return main(list(argv)+[egg]) # we're done here
+ finally:
+ if egg and os.path.exists(egg):
+ os.unlink(egg)
+ else:
+ if setuptools.__version__ == '0.0.1':
+ print >>sys.stderr, (
+ "You have an obsolete version of setuptools installed. Please\n"
+ "remove it from your system entirely before rerunning this script."
+ )
+ sys.exit(2)
+
+ req = "setuptools>="+version
+ import pkg_resources
+ try:
+ pkg_resources.require(req)
+ except pkg_resources.VersionConflict:
+ try:
+ from setuptools.command.easy_install import main
+ except ImportError:
+ from easy_install import main
+ main(list(argv)+[download_setuptools(delay=0)])
+ sys.exit(0) # try to force an exit
+ else:
+ if argv:
+ from setuptools.command.easy_install import main
+ main(argv)
+ else:
+ print "Setuptools version",version,"or greater has been installed."
+ print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)'
+
+def update_md5(filenames):
+ """Update our built-in md5 registry"""
+
+ import re
+
+ for name in filenames:
+ base = os.path.basename(name)
+ f = open(name,'rb')
+ md5_data[base] = md5(f.read()).hexdigest()
+ f.close()
+
+ data = [" %r: %r,\n" % it for it in md5_data.items()]
+ data.sort()
+ repl = "".join(data)
+
+ import inspect
+ srcfile = inspect.getsourcefile(sys.modules[__name__])
+ f = open(srcfile, 'rb'); src = f.read(); f.close()
+
+ match = re.search("\nmd5_data = {\n([^}]+)}", src)
+ if not match:
+ print >>sys.stderr, "Internal error!"
+ sys.exit(2)
+
+ src = src[:match.start(1)] + repl + src[match.end(1):]
+ f = open(srcfile,'w')
+ f.write(src)
+ f.close()
+
+
+if __name__=='__main__':
+ if len(sys.argv)>2 and sys.argv[1]=='--md5update':
+ update_md5(sys.argv[2:])
+ else:
+ main(sys.argv[1:])
+
+
+
+
+
+
0  linkdrop/__init__.py
No changes.
0  linkdrop/config/__init__.py
No changes.
63 linkdrop/config/deployment.ini_tmpl
@@ -0,0 +1,63 @@
+#
+# linkdrop - Pylons configuration
+#
+# The %(here)s variable will be replaced with the parent directory of this file
+#
+[DEFAULT]
+debug = true
+email_to = you@yourdomain.com
+smtp_server = localhost
+error_email_from = paste@localhost
+
+[server:main]
+use = egg:Paste#http
+host = 0.0.0.0
+port = 5000
+
+[app:main]
+use = egg:linkdrop
+full_stack = true
+static_files = true
+
+cache_dir = %(here)s/data
+beaker.session.key = linkdrop
+beaker.session.secret = ${app_instance_secret}
+app_instance_uuid = ${app_instance_uuid}
+
+# If you'd like to fine-tune the individual locations of the cache data dirs
+# for the Cache data, or the Session saves, un-comment the desired settings
+# here:
+#beaker.cache.data_dir = %(here)s/data/cache
+#beaker.session.data_dir = %(here)s/data/sessions
+
+# SQLAlchemy database URL
+sqlalchemy.url = sqlite:///production.db
+
+# WARNING: *THE LINE BELOW MUST BE UNCOMMENTED ON A PRODUCTION ENVIRONMENT*
+# Debug mode will enable the interactive debugging tool, allowing ANYONE to
+# execute malicious code after an exception is raised.
+set debug = false
+
+
+# Logging configuration
+[loggers]
+keys = root
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = INFO
+handlers = console
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s] [%(threadName)s] %(message)s
43 linkdrop/config/environment.py
@@ -0,0 +1,43 @@
+"""Pylons environment configuration"""
+import os
+
+from pylons.configuration import PylonsConfig
+from sqlalchemy import engine_from_config
+
+import linkdrop.lib.app_globals as app_globals
+import linkdrop.lib.helpers
+from linkdrop.config.routing import make_map
+from linkdrop.model import init_model
+
+def load_environment(global_conf, app_conf):
+ """Configure the Pylons environment via the ``pylons.config``
+ object
+ """
+ config = PylonsConfig()
+
+ # Pylons paths
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ paths = dict(root=root,
+ controllers=os.path.join(root, 'controllers'),
+ static_files=os.path.join(root, 'public'),
+ templates=[os.path.join(root, 'templates')])
+
+ # Initialize config with the basic options
+ config.init_app(global_conf, app_conf, package='linkdrop', paths=paths)
+
+ config['routes.map'] = make_map(config)
+ config['pylons.app_globals'] = app_globals.Globals(config)
+ config['pylons.h'] = linkdrop.lib.helpers
+
+ # Setup cache object as early as possible
+ import pylons
+ pylons.cache._push_object(config['pylons.app_globals'].cache)
+
+ # Setup the SQLAlchemy database engine
+ engine = engine_from_config(config, 'sqlalchemy.')
+ init_model(engine)
+
+ # CONFIGURATION OPTIONS HERE (note: all config options will override
+ # any Pylons config options)
+
+ return config
67 linkdrop/config/middleware.py
@@ -0,0 +1,67 @@
+"""Pylons middleware initialization"""
+from beaker.middleware import SessionMiddleware
+from paste.cascade import Cascade
+from paste.registry import RegistryManager
+from paste.urlparser import StaticURLParser
+from paste.deploy.converters import asbool
+from pylons.middleware import ErrorHandler, StatusCodeRedirect
+from pylons.wsgiapp import PylonsApp
+from routes.middleware import RoutesMiddleware
+
+from linkdrop.config.environment import load_environment
+
+def make_app(global_conf, full_stack=True, static_files=True, **app_conf):
+ """Create a Pylons WSGI application and return it
+
+ ``global_conf``
+ The inherited configuration for this application. Normally from
+ the [DEFAULT] section of the Paste ini file.
+
+ ``full_stack``
+ Whether this application provides a full WSGI stack (by default,
+ meaning it handles its own exceptions and errors). Disable
+ full_stack when this application is "managed" by another WSGI
+ middleware.
+
+ ``static_files``
+ Whether this application serves its own static files; disable
+ when another web server is responsible for serving them.
+
+ ``app_conf``
+ The application's local configuration. Normally specified in
+ the [app:<name>] section of the Paste ini file (where <name>
+ defaults to main).
+
+ """
+ # Configure the Pylons environment
+ config = load_environment(global_conf, app_conf)
+
+ # The Pylons WSGI app
+ app = PylonsApp(config=config)
+
+ # Routing/Session/Cache Middleware
+ app = RoutesMiddleware(app, config['routes.map'], singleton=False)
+ app = SessionMiddleware(app, config)
+
+ # CUSTOM MIDDLEWARE HERE (filtered by error handling middlewares)
+
+ if asbool(full_stack):
+ # Handle Python exceptions
+ app = ErrorHandler(app, global_conf, **config['pylons.errorware'])
+
+ # Display error documents for 401, 403, 404 status codes (and
+ # 500 when debug is disabled)
+ if asbool(config['debug']):
+ app = StatusCodeRedirect(app)
+ else:
+ app = StatusCodeRedirect(app, [400, 401, 403, 404, 500])
+
+ # Establish the Registry for this application
+ app = RegistryManager(app)
+
+ if asbool(static_files):
+ # Serve static files
+ static_app = StaticURLParser(config['pylons.paths']['static_files'])
+ app = Cascade([static_app, app])
+ app.config = config
+ return app
28 linkdrop/config/routing.py
@@ -0,0 +1,28 @@
+"""Routes configuration
+
+The more specific and detailed routes should be defined first so they
+may take precedent over the more generic routes. For more information
+refer to the routes manual at http://routes.groovie.org/docs/
+"""
+from routes import Mapper
+
+def make_map(config):
+ """Create, configure and return the routes Mapper"""
+ map = Mapper(directory=config['pylons.paths']['controllers'],
+ always_scan=config['debug'])
+ map.minimization = False
+ map.explicit = False
+
+ # The ErrorController route (handles 404/500 error pages); it should
+ # likely stay at the top, ensuring it can always be resolved
+ map.connect('/error/{action}', controller='error')
+ map.connect('/error/{action}/{id}', controller='error')
+
+ # CUSTOM ROUTES HERE
+ map.connect('/account/oauth_facebook/{redirect_info}', controller='account', action="oauth_facebook")
+
+
+ map.connect('/{controller}/{action}')
+ map.connect('/{controller}/{action}/{id}')
+
+ return map
0  linkdrop/controllers/__init__.py
No changes.
246 linkdrop/controllers/account.py
@@ -0,0 +1,246 @@
+import logging
+import urllib, cgi, json
+
+from pylons import config, request, response, session, tmpl_context as c, url
+from pylons.controllers.util import abort, redirect
+from pylons.decorators import jsonify
+from pylons.decorators.util import get_pylons
+
+from linkdrop import simple_oauth
+from linkdrop.lib.base import BaseController, render
+from linkdrop.lib.helpers import json_exception_response, api_response, api_entry, api_arg
+
+from linkdrop.model.meta import Session
+from linkdrop.model.account import Account
+from sqlalchemy.orm.exc import NoResultFound
+from sqlalchemy import and_
+
+log = logging.getLogger(__name__)
+
+
+def get_oauth_config(provider):
+ key = 'oauth.'+provider+'.'
+ keylen = len(key)
+ d = {}
+ for k,v in config.items():
+ if k.startswith(key):
+ d[k[keylen:]] = v
+ return d
+
+def get_oauth_consumer(oauth_config):
+ return simple_oauth.OAuthEntity(oauth_config['consumer_key'], oauth_config['consumer_secret'])
+
+
+class AccountController(BaseController):
+ """
+Accounts
+========
+
+The 'account' namespace is used to access information regarding the current
+user's account. This does not retrieve the users contact, for that see
+the contacts API that uses @me/@self.
+
+"""
+ __api_controller__ = True # for docs
+
+ # for testing...
+ @api_response
+ @json_exception_response
+ def get(self, id=None):
+ if id is None:
+ accts = Session.query(Account).all()
+ else:
+ accts = [Session.query(Account).get(id)]
+ return [a.to_dict() for a in accts]
+
+ @json_exception_response
+ def oauth_start(self, *args, **kw):
+ pylons = get_pylons(args)
+ try:
+ domain = request.params.get('domain')
+ return_path = request.params['return_to']
+ except KeyError, what:
+ raise ValueError("'%s' request param is not optional" % (what,))
+
+ scope = request.params.get('scope', domain)
+
+ oauth_config = get_oauth_config(domain)
+ url_gen = simple_oauth.getOAuthUrlGenerator(domain, '')
+
+ consumer = get_oauth_consumer(oauth_config)
+ callback_url = url(controller='account', action="oauth_done",
+ qualified=True, domain=domain,
+ return_to=return_path)
+
+ csrf_token = request.environ.get('CSRF_TOKEN')
+ if csrf_token:
+ callback_url += '&rd-token=%s' % (csrf_token)
+
+ # Note the xoauth module automatically generates nonces and timestamps to prevent replays.)
+ request_entity = simple_oauth.GenerateRequestToken(consumer, scope, None, None,
+ callback_url, url_gen)
+
+ # Save the request secret into the session.
+ session = request.environ['beaker.session']
+ session["oauth_request_key"] = request_entity.key
+ session["oauth_request_secret"] = request_entity.secret
+ session.save()
+
+ # And arrange for the client to redirect to the service to continue
+ # the process...
+ loc = '%s?oauth_token=%s' % (url_gen.GetAuthorizeTokenUrl(),
+ simple_oauth.UrlEscape(request_entity.key))
+ log.info("redirecting to %r and requesting to land back on %r",
+ loc, callback_url)
+ pylons.response.headers['Location'] = loc
+ pylons.response.status_int = 302
+
+ @json_exception_response
+ def oauth_done(self, *args, **kw):
+ pylons = get_pylons(args)
+ try:
+ domain = request.params['domain']
+ return_path = request.params['return_to']
+ except KeyError, what:
+ raise ValueError("'%s' request param is not optional" % (what,))
+
+ oauth_config = get_oauth_config(domain)
+ url_gen = simple_oauth.getOAuthUrlGenerator(domain, '')
+
+ oauth_token = request.params['oauth_token']
+ oauth_verifier = request.params['oauth_verifier']
+ # Get the request secret from the session.
+ # Save the request secret into the session.
+ session = request.environ['beaker.session']
+ request_key = session.pop("oauth_request_key")
+ request_secret = session.pop("oauth_request_secret")
+ session.save()
+
+ if request_secret and request_key == oauth_token:
+ request_token = simple_oauth.OAuthEntity(oauth_token, request_secret)
+ consumer = get_oauth_consumer(oauth_config)
+ # Make the oauth call to get the final verified token
+ verified_token = simple_oauth.GetAccessToken(consumer, request_token,
+ oauth_verifier, url_gen)
+
+ if domain == "twitter.com":
+ userid = verified_token.user_id
+ username = verified_token.screen_name
+ else:
+ raise ValueError(domain) # can't obtain user information for this provider...
+ # Find or create an account
+ try:
+ acct = Session.query(Account).filter(and_(Account.domain==domain, Account.userid==userid)).one()
+ except NoResultFound:
+ acct = Account()
+ acct.domain = domain
+ acct.userid = userid
+ acct.username = username
+ Session.add(acct)
+
+ # Update the account with the final tokens and delete the transient ones.
+ acct.oauth_token = verified_token.key
+ acct.oauth_token_secret = verified_token.secret
+
+ Session.commit()
+ fragment = "oauth_success_" + domain
+ else:
+ fragment = "oauth_failure_" + domain
+
+ # and finally redirect back to the signup page.
+ loc = request.host_url + return_path + "#" + fragment.replace(".", "_")
+ log.info("Final redirect back to %r", loc)
+ pylons.response.headers['Location'] = loc
+ pylons.response.status_int = 302
+
+ @json_exception_response
+ def oauth_facebook(self, redirect_info=None, *args, **kw):
+ pylons = get_pylons(args)
+ # NOTE: facebook insists the redirect URLS are identical for each
+ # leg of the auth (ie, no 'oauth_start/oauth_done') and that the
+ # redirect URL contains no request params (ie, no '?return_to=xxx' in
+ # the URL.) We worm around the second problem by encoding the params
+ # as base64 and appending it to the URL itself (in which case it comes
+ # to us via redirect_info)
+ if redirect_info is None:
+ # this is the initial request from linkdrop.
+ return_to = request.params.get('return_to', None)
+ else:
+ # this is a redirected leg.
+ return_to = redirect_info.decode("base64")
+
+ domain = "facebook.com"
+ # experimentation shows callback_url can not have request params!
+ callback_url = url(controller='account', action="oauth_facebook",
+ redirect_info=return_to.encode("base64")[:-1],
+ qualified=True)
+ csrf_token = request.environ.get('CSRF_TOKEN')
+ if csrf_token:
+ if '?' in callback_url:
+ callback_url += '&rd-token=%s' % (csrf_token)
+ else:
+ callback_url += '?rd-token=%s' % (csrf_token)
+
+ oauth_config = get_oauth_config(domain)
+
+ args = dict(client_id=oauth_config['app_id'], redirect_uri=callback_url)
+ verification_code = request.params.get("code")
+ if not verification_code:
+ # make the auth request to get the code.
+ args['scope'] = request.params.get('scope', '')
+ loc = "https://graph.facebook.com/oauth/authorize?" + urllib.urlencode(args)
+ log.info("facebook auth redirecting to %r and requesting to land back on %r",
+ loc, callback_url)
+ else:
+ args["client_secret"] = oauth_config['app_secret']
+ args["code"] = verification_code
+ # must we really use urlopen here, or can we do it via redirects?
+ resp = urllib.urlopen(
+ "https://graph.facebook.com/oauth/access_token?" +
+ urllib.urlencode(args))
+ redirect_query = ""
+ if resp.headers.get("content-type", "").startswith("text/javascript"):
+ # almost certainly an error response.
+ resp = json.load(resp)
+ log.error("facebook auth request failed with %r", resp)
+ fragment = "oauth_failure_" + domain
+ else:
+ response = cgi.parse_qs(resp.read())
+ access_token = response["access_token"][-1]
+
+ # Download the user profile and until we know what to do with
+ # it, just log it!
+ profile = json.load(urllib.urlopen(
+ "https://graph.facebook.com/me?" +
+ urllib.urlencode(dict(access_token=access_token))))
+
+ from pprint import pformat
+ log.info("facebook profile: %s", pformat(profile))
+
+ if 'error' in profile:
+ log.error("facebook profile request failed with %r", profile)
+ fragment = "oauth_failure_" + domain
+ else:
+ # Setup the linkdrop account.
+ facebookid = profile['id']
+ acct_proto = "facebook"
+ # Try and find an account to use or create a new one.
+ try:
+ acct = Session.query(Account).filter(and_(Account.domain=="facebook.com", Account.userid==facebookid)).one()
+ except NoResultFound:
+ acct = Account()
+ acct.domain = "facebook.com"
+ acct.userid = facebookid
+ acct.username = ""
+ Session.add(acct)
+
+ acct.oauth_token = access_token
+ Session.commit()
+ fragment = "oauth_success_" + domain
+ redirect_query = "?" + urllib.urlencode(dict(id=acct.id, name=profile['name']))
+
+ loc = request.host_url + return_to + redirect_query + "#" + fragment.replace(".", "_")
+ log.info("Final redirect back to %r", loc)
+
+ pylons.response.headers['Location'] = loc
+ pylons.response.status_int = 302
44 linkdrop/controllers/error.py
@@ -0,0 +1,44 @@
+import cgi
+
+from paste.urlparser import PkgResourcesParser
+from pylons.middleware import error_document_template
+from webhelpers.html.builder import literal
+
+from linkdrop.lib.base import BaseController
+
+class ErrorController(BaseController):
+ """Generates error documents as and when they are required.
+
+ The ErrorDocuments middleware forwards to ErrorController when error
+ related status codes are returned from the application.
+
+ This behaviour can be altered by changing the parameters to the
+ ErrorDocuments middleware in your config/middleware.py file.
+
+ """
+ def document(self):
+ """Render the error document"""
+ request = self._py_object.request
+ resp = request.environ.get('pylons.original_response')
+ content = literal(resp.body) or cgi.escape(request.GET.get('message', ''))
+ page = error_document_template % \
+ dict(prefix=request.environ.get('SCRIPT_NAME', ''),
+ code=cgi.escape(request.GET.get('code', str(resp.status_int))),
+ message=content)
+ return page
+
+ def img(self, id):
+ """Serve Pylons' stock images"""
+ return self._serve_file('/'.join(['media/img', id]))
+
+ def style(self, id):
+ """Serve Pylons' stock stylesheets"""
+ return self._serve_file('/'.join(['media/style', id]))
+
+ def _serve_file(self, path):
+ """Call Paste's FileApp (a WSGI application) to serve the file
+ at the specified path
+ """
+ request = self._py_object.request
+ request.environ['PATH_INFO'] = '/%s' % path
+ return PkgResourcesParser('pylons', 'pylons')(request.environ, self.start_response)
0  linkdrop/lib/__init__.py
No changes.
18 linkdrop/lib/app_globals.py
@@ -0,0 +1,18 @@
+"""The application's Globals object"""
+
+from beaker.cache import CacheManager
+from beaker.util import parse_cache_config_options
+
+class Globals(object):
+ """Globals acts as a container for objects available throughout the
+ life of the application
+
+ """
+
+ def __init__(self, config):
+ """One instance of Globals is created during application
+ initialization and is available during requests via the
+ 'app_globals' variable
+
+ """
+ self.cache = CacheManager(**parse_cache_config_options(config))
20 linkdrop/lib/base.py
@@ -0,0 +1,20 @@
+"""The base Controller API
+
+Provides the BaseController class for subclassing.
+"""
+from pylons.controllers import WSGIController
+from pylons.templating import render_mako as render
+
+from linkdrop.model.meta import Session
+
+class BaseController(WSGIController):
+
+ def __call__(self, environ, start_response):
+ """Invoke the Controller"""
+ # WSGIController.__call__ dispatches to the Controller method
+ # the request is routed to. This routing information is
+ # available in environ['pylons.routes_dict']
+ try:
+ return WSGIController.__call__(self, environ, start_response)
+ finally:
+ Session.remove()
163 linkdrop/lib/helpers.py
@@ -0,0 +1,163 @@
+"""Helper functions
+
+Consists of functions to typically be used within templates, but also
+available to Controllers. This module is available to templates as 'h'.
+"""
+from pylons.decorators.util import get_pylons
+from pylons.controllers.core import HTTPException
+from decorator import decorator
+import pprint
+from xml.sax.saxutils import escape
+import json
+
+import logging
+
+
+from raindrop.model.meta import Session
+
+logger=logging.getLogger(__name__)
+
+@decorator
+def exception_rollback(func, *args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except Exception, e:
+ Session.rollback()
+ raise
+
+@decorator
+def json_exception_response(func, *args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except HTTPException:
+ raise
+ except Exception, e:
+ logger.exception("%s(%s, %s) failed", func, args, kwargs)
+ #pylons = get_pylons(args)
+ #pylons.response.status_int = 500
+ return {
+ 'result': None,
+ 'error': {
+ 'name': e.__class__.__name__,
+ 'message': str(e)
+ }
+ }
+
+@decorator
+def api_response(func, *args, **kwargs):
+ pylons = get_pylons(args)
+ data = func(*args, **kwargs)
+ format = pylons.request.params.get('format', 'json')
+
+ if format == 'test':
+ pylons.response.headers['Content-Type'] = 'text/plain'
+ return pprint.pformat(data)
+ elif format == 'xml':
+ # a quick-dirty dict serializer
+ def ser(d):
+ r = ""
+ for k,v in d.items():
+ if isinstance(v, dict):
+ r += "<%s>%s</%s>" % (k, ser(v), k)
+ elif isinstance(v, list):
+ for i in v:
+ #print k,i
+ r += ser({k:i})
+ else:
+ r += "<%s>%s</%s>" % (k, escape("%s"%v), k)
+ return r
+ pylons.response.headers['Content-Type'] = 'text/xml'
+ return '<?xml version="1.0" encoding="UTF-8"?>' + ser({'response': data}).encode('utf-8')
+ pylons.response.headers['Content-Type'] = 'application/json'
+ return json.dumps(data)
+
+def api_entry(**kw):
+ """Decorator to add tags to functions.
+ """
+ def decorate(f):
+ if not hasattr(f, "__api"):
+ f.__api = kw
+ if not getattr(f, "__doc__") and 'doc' in kw:
+ doc = kw['doc']
+ if 'name' in kw:
+ doc = kw['name'] + "\n" + "="*len(kw['name']) +"\n\n" + doc
+ args = []
+ for m in kw.get('queryargs', []):
+ line = " %(name)-20s %(type)-10s %(doc)s" % m
+ opts = []
+ if m['required']: opts.append("required")
+ if m['default']: opts.append("default=%s" % m['default'])
+ if m['allowed']: opts.append("options=%r" % m['allowed'])
+ if opts:
+ line = "%s (%s)" % (line, ','.join(opts),)
+ args.append(line)
+ d = "Request Arguments\n-----------------\n\n%s\n\n" % '\n'.join(args)
+ if 'bodyargs' in kw:
+ assert 'body' not in kw, "can't specify body and bodyargs"
+ for m in kw['bodyargs']:
+ line = " %(name)-20s %(type)-10s %(doc)s" % m
+ opts = []
+ if m['required']: opts.append("required")
+ if m['default']: opts.append("default=%s" % m['default'])
+ if m['allowed']: opts.append("options=%r" % m['allowed'])
+ if opts:
+ line = "%s (%s)" % (line, ','.join(opts),)
+ args.append(line)
+ d = d+ "**Request Body**: A JSON object with the following fields:"
+ d = d+ "\n".join(args)
+ elif 'body' in kw:
+ d = d+ "**Request Body**: %(type)-10s %(doc)s\n\n" % kw['body']
+ if 'response' in kw:
+ d = d+ "**Response Body**: %(type)-10s %(doc)s\n\n" % kw['response']
+ f.__doc__ = doc + d
+ return f
+ return decorate
+
+def api_arg(name, type=None, required=False, default=None, allowed=None, doc=None):
+ return {
+ 'name': name,
+ 'type': type,
+ 'required': required,
+ 'default': default,
+ 'allowed': allowed,
+ 'doc': doc or ''
+ }
+
+
+if __name__ == '__main__':
+ @api_entry(
+ name="contacts",
+ body=("json", "A json object"),
+ doc="""
+See Portable Contacts for api for detailed documentation.
+
+http://portablecontacts.net/draft-spec.html
+
+**Examples**::
+
+ /contacts returns all contacts
+ /contacts/@{user}/@{group} returns all contacts (user=me, group=all)
+ /contacts/@{user}/@{group}/{id} returns a specific contact
+
+""",
+ urlargs=[
+ api_arg('user', 'string', True, None, ['me'], 'User to query'),
+ api_arg('group', 'string', True, None, ['all', 'self'], 'Group to query'),
+ api_arg('id', 'integer', False, None, None, 'Contact ID to return'),
+ ],
+ queryargs=[
+ # name, type, required, default, allowed, doc
+ api_arg('filterBy', 'string', False, None, None, 'Field name to query'),
+ api_arg('filterOp', 'string', False, None, ['equals', 'contains', 'startswith', 'present'], 'Filter operation'),
+ api_arg('filterValue', 'string', False, None, None, 'A value to compare using filterOp (not used with present)'),
+ api_arg('startIndex', 'int', False, 0, None, 'The start index of the query, used for paging'),
+ api_arg('count', 'int', False, 20, None, 'The number of results to return, used with paging'),
+ api_arg('sortBy', 'string', False, 'ascending', ['ascending','descending'], 'A list of conversation ids'),
+ api_arg('sortOrder', 'string', False, 'ascending', ['ascending','descending'], 'A list of conversation ids'),
+ api_arg('fields', 'list', False, None, None, 'A list of fields to return'),
+ ],
+ response=('object', 'A POCO result object')
+ )
+ def foo():
+ pass
+ print foo.__doc__
9 linkdrop/model/__init__.py
@@ -0,0 +1,9 @@
+"""The application's model objects"""
+from linkdrop.model.meta import Session, Base
+from linkdrop.model.account import Account
+
+
+def init_model(engine):
+ """Call me before using any of the tables or classes in the model"""
+ Session.configure(bind=engine)
+ Base.metadata.create_all(bind=Session.bind)
17 linkdrop/model/account.py
@@ -0,0 +1,17 @@
+# Account definitions
+from sqlalchemy import Column, Integer, String, Boolean, UniqueConstraint
+from linkdrop.model.meta import Base, Session, make_table_args
+from linkdrop.model.types import RDUnicode
+from linkdrop.model.expando_mixin import JsonExpandoMixin
+from linkdrop.model.serializer_mixin import SerializerMixin
+
+class Account(JsonExpandoMixin, SerializerMixin, Base):
+ __tablename__ = 'accounts'
+ __table_args__ = make_table_args(UniqueConstraint('domain', 'username', 'userid'))
+
+ id = Column(Integer, primary_key=True)
+
+ # The external account identity information, modelled from poco
+ domain = Column(RDUnicode(128), nullable=False)
+ username = Column(RDUnicode(128), nullable=False)
+ userid = Column(RDUnicode(128), nullable=False)
81 linkdrop/model/expando_mixin.py
@@ -0,0 +1,81 @@
+# A mixin for all objects which want 'expando' functionality;
+# ie, the ability to have arbitrary content stored in a json column, but
+# have the object seamlessly provide access to the items in the json as though
+# they were real properties.
+
+import json
+from sqlalchemy.orm.interfaces import MapperExtension, EXT_CONTINUE
+from sqlalchemy import Column, Text
+
+# A mapper extension to help us with 'expandos' magic - ensures that expando
+# attributes set via normal 'object.expando=value' syntax is reflected
+# back into the json_attributes column.
+class _ExpandoFlushingExtension(MapperExtension):
+ def before_insert(self, mapper, connection, instance):
+ instance._flush_expandos()
+ return EXT_CONTINUE
+
+ before_update = before_insert
+
+
+# The actual mixin class
+class JsonExpandoMixin(object):
+ __mapper_args__ = {'extension': _ExpandoFlushingExtension()}
+ json_attributes = Column(Text)
+
+ # Methods for providing 'expandos' via the json_attributes field.
+ def _get_expando_namespace(self):
+ if '_expando_namespace' not in self.__dict__:
+ assert '_orig_json' not in self.__dict__
+ attrs = self.json_attributes
+ self.__dict__['_orig_json'] = attrs
+ if not attrs:
+ _expando_namespace = {}
+ else:
+ _expando_namespace = json.loads(attrs)
+ self.__dict__['_expando_namespace'] = _expando_namespace
+ return self.__dict__['_expando_namespace']
+
+ def __getattr__(self, name):
+ if name.startswith('_'):
+ raise AttributeError(name)
+ # is it in the namespace?
+ try:
+ return self._get_expando_namespace()[name]
+ except KeyError:
+ raise AttributeError(name)
+
+ def __setattr__(self, name, value):
+ if name.startswith("_") or name in self.__dict__ or hasattr(self.__class__, name):
+ object.__setattr__(self, name, value)
+ return
+ # assume it is an 'expando' object
+ # Set json attributes to itself simply so the object is marked as
+ # 'dirty' for subsequent updates.
+ self.json_attributes = self.json_attributes
+ self._get_expando_namespace()[name] = value
+
+ def __delattr__(self, name):
+ try:
+ del self._get_expando_namespace()[name]
+ self.json_attributes = self.json_attributes # to mark as dirty
+ except KeyError:
+ raise AttributeError("'%s' is not an 'expando' property" % (name,))
+
+ # Note that you should never need to call this function manually - a
+ # mapper extension is defined above which calls this function before
+ # the object is saved.
+ def _flush_expandos(self):
+ try:
+ en = self.__dict__['_expando_namespace']
+ except KeyError:
+ # no property accesses at all
+ return
+ if self._orig_json != self.json_attributes:
+ # This means someone used 'expandos' *and* explicitly set
+ # json_attributes on the same object.
+ raise ValueError("object's json_attributes have changed externally")
+ self.json_attributes = None if not en else json.dumps(en)
+ # and reset the world back to as if expandos have never been set.
+ del self.__dict__['_orig_json']
+ del self.__dict__['_expando_namespace']
22 linkdrop/model/meta.py
@@ -0,0 +1,22 @@
+"""SQLAlchemy Metadata and Session object"""
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import scoped_session, sessionmaker
+
+__all__ = ['Base', 'Session']
+
+# SQLAlchemy session manager. Updated by model.init_model()
+Session = scoped_session(sessionmaker())
+
+# The declarative Base
+Base = declarative_base()
+
+
+# Return a value suitable for __table_args__ which includes common table
+# arguments which should be used by all tables.
+def make_table_args(*args, **kw):
+ kwuse = kw.copy()
+ if 'mysql_charset' not in kwuse:
+ kwuse['mysql_charset'] = 'utf8'
+ if not args:
+ return kwuse
+ return args + (kwuse,)
40 linkdrop/model/serializer_mixin.py
@@ -0,0 +1,40 @@
+# A serializer for items.
+from datetime import datetime
+import json
+from sqlalchemy.orm.util import object_mapper
+from sqlalchemy.orm.properties import ColumnProperty, RelationProperty
+
+class SerializerMixin(object):
+ # deal with the special plural fields
+ def _rd_collection_to_dict(self, name, fields):
+ if fields and name not in fields:
+ return
+ if hasattr(self, name):
+ for entry in getattr(self, name, []):
+ val = entry.to_dict()
+ if val:
+ #import sys;print >> sys.stderr, val
+ yield val
+ #propdict.setdefault(name, []).append(val)
+
+ def to_dict(self, fields=None):
+ propdict = {}
+ for prop in object_mapper(self).iterate_properties:
+ if isinstance(prop, (ColumnProperty)):# or isinstance(prop, RelationProperty) and prop.secondary:
+ if fields and prop.key not in fields: continue
+ val = getattr(self, prop.key)
+ if val:
+ if isinstance(val, datetime):
+ val = val.isoformat().split('.')[0].replace('+00:00','Z')
+
+ if prop.key == 'json_attributes':
+ propdict.update(json.loads(val))
+ else:
+ propdict[prop.key] = val
+ elif prop.key != 'json_attributes':
+ propdict[prop.key] = val
+
+ for val in self._rd_collection_to_dict('tags', fields):
+ propdict.setdefault('tags', []).append(val)
+
+ return propdict
94 linkdrop/model/types.py
@@ -0,0 +1,94 @@
+import time
+import datetime
+import dateutil.parser
+from dateutil.tz import tzutc, tzlocal
+from email.utils import formatdate as email_format_date
+from email.utils import mktime_tz, parsedate_tz
+from sqlalchemy.types import TypeDecorator, DateTime, Unicode
+import codecs
+
+# SqlAlchemy takes a very anal approach to Unicode - if a column is unicode,
+# then the Python object must also be unicode and not a string. This is very
+# painful in py2k, so we loosen this a little - string objects are fine so long
+# as they don't include extended chars.
+class RDUnicode(Unicode):
+ def __init__(self, length=None, **kwargs):
+ kwargs.setdefault('_warn_on_bytestring', False)
+ super(RDUnicode, self).__init__(length=length, **kwargs)
+
+ def bind_processor(self, dialect):
+ encoder = codecs.getencoder(dialect.encoding)
+ def process(value):
+ if isinstance(value, unicode):
+ return encoder.encode(value)
+ elif isinstance(value, str):
+ # Force an error should someone pass a non-ascii string
+ assert value.decode('ascii')==value
+ return value
+ elif value is None:
+ return None
+ raise ValueError("invalid value for unicode column: %r" % (value,))
+
+
+# from http://stackoverflow.com/questions/2528189/can-sqlalchemy-datetime-objects-only-be-naive
+# to force all dates going to and coming back from the DB to be in UTC.
+# All raindrop DateTime fields should be declared using this type.
+class UTCDateTime(TypeDecorator):
+ impl = DateTime
+
+ def process_bind_param(self, value, engine):
+ if value is not None:
+ try:
+ return value.astimezone(tzutc())
+ except ValueError:
+ print "FAILED", value
+
+ def process_result_value(self, value, engine):
+ if value is not None:
+ return datetime.datetime(value.year, value.month, value.day,
+ value.hour, value.minute, value.second,
+ value.microsecond, tzinfo=tzutc())
+
+ # Helpers for creating, formatting and parsing datetime values.
+ @classmethod
+ def from_string(cls, strval, deftz=None):
+ try:
+ ret = dateutil.parser.parse(strval)
+ except ValueError:
+ # Sadly, some (but not many) dates which appear in emails can't be
+ # parsed by dateutil, but can by the email package. I've no idea
+ # if such dates are rfc compliant, but they do exist in the wild -
+ # eg:
+ # "Sat, 11 Oct 2008 13:29:43 -0400 (Eastern Daylight Time)"
+ try:
+ utctimestamp = mktime_tz(parsedate_tz(strval))
+ ret = datetime.datetime.fromtimestamp(utctimestamp, tzutc())
+ except TypeError, exc:
+ raise ValueError(exc.args[0])
+ else:
+ # dateutil parsed it - now turn it into a UTC value.
+ # If there is no tzinfo in the string we assume utc.
+ if ret.tzinfo is None:
+ if deftz is None: deftz = tzutc()
+ ret = ret.replace(tzinfo=deftz)
+ return ret
+
+ @classmethod
+ def as_string(cls, datetimeval):
+ return datetimeval.isoformat().split('.')[0].replace('+00:00','Z')
+
+ @classmethod
+ def as_rfc2822_string(cls, datetimeval):
+ # Need to pass localtime as that is what the email package expects.
+ lt = datetimeval.astimezone(tzlocal())
+ timestamp = time.mktime(lt.timetuple())
+ return email_format_date(timestamp)
+
+ @classmethod
+ def from_timestamp(cls, tsval, tz=None):
+ if tz is None: tz = tzutc()
+ return datetime.datetime.fromtimestamp(tsval, tz)
+
+ @classmethod
+ def now(cls):
+ return datetime.datetime.now(tzutc())
BIN  linkdrop/public/bg.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
BIN  linkdrop/public/favicon.ico
Binary file not shown
137 linkdrop/public/index.html
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html
+ PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <head>
+ <title>Welcome to Pylons!</title>
+ <style type="text/css">
+ body {
+ font-family: arial, helvetica, sans-serif;
+ background-color: #ffc900;
+ background-image: url(bg.png);
+ background-repeat: repeat-x;
+ width:100%;
+ height:100%;
+ margin:0;
+ max-height: 100%;
+ padding:0;
+ border:none;
+ line-height:1.4;
+ }
+ #container {
+ color:white;
+ background-color:#111;
+ position: absolute;
+ left: 50%;
+ width: 500px;
+ margin-left: -300px;
+ padding:50px;
+ height:100%;
+ }
+ #footer {
+ margin: 120px 0 0 0;
+ padding: 3px;
+ text-align:center;
+ font-size:small;
+ background-color:#222;
+ letter-spacing: 1px;
+ }
+ h1 {
+ text-align:center;
+ font-size:xx-large;
+ font-weight:normal;
+ margin: 0 0 20px 0;
+ border:none;
+ padding:0;
+ letter-spacing: 5px;
+ }
+ h2 {
+ font-size:xx-large;
+ font-weight:normal;
+ margin: 0 0 20px 0;
+ border:none;
+ padding:0;
+ }
+ hr {
+ margin-bottom:30px;
+ border: 1px solid #222;
+ background-color: #222;
+ padding: 2px;
+ }
+ #logo {
+ background-image: url(signum8b_spk.png);
+ background-repeat: no-repeat;
+ height: 0;
+ overflow: hidden;
+ padding-top: 99px;
+ width: 239px;
+ }
+ #left {
+ float:left;
+ width:250px;
+ margin:0 50px 0 0;
+ border:none;
+ padding:0 0 0 10px;
+ }
+ #right {
+ margin:0 0 0 330px;
+ border:none;
+ padding:0;
+ }
+ ul {
+ list-style:none;
+ margin:0;
+ border:none;
+ padding:0;
+ }
+ a:visited {
+ color:white;
+ text-decoration:none;
+ }
+ a:link {
+ color:white;
+ text-decoration:none;
+ }</style>
+ </head>
+ <body>
+ <div id="container">
+ <h1>Welcome to <img src="pylons-logo.gif" alt="Logo displaying the word Pylons"
+ style="vertical-align:-15px; width: 250px;"/>
+ </h1>
+ <hr/>
+ <div id="left">
+ <h2>Let's begin!</h2>
+ <p>If you haven't used Pylons before, start with the <a href="http://pylonshq.com/docs/en/0.9.7/gettingstarted/"
+ style="text-decoration:underline;">beginners' tutorial</a>.</p>
+ </div>
+ <div id="right">
+ <h2>Help</h2>
+ <ul>
+ <li>
+ <a href="http://pylonshq.com/docs/en/0.9.7/">Official documentation</a>
+ </li>
+ <li>
+ <a href="http://wiki.pylonshq.com/display/pylonsfaq/Home">FAQ</a>
+ </li>
+ <li>
+ <a href="http://wiki.pylonshq.com/dashboard.action">Wiki</a>
+ </li>
+ <li>
+ <a href="http://wiki.pylonshq.com/display/pylonscommunity/Home#Home-JointheMailingLists">Mailing list</a>
+ </li>
+ <li>
+ <a href="http://wiki.pylonshq.com/display/pylonscommunity/Home#Home-IRC">IRC</a>
+ </li>
+ <li>
+ <a href="http://pylonshq.com/project/pylonshq/roadmap">Bug tracker</a>
+ </li>
+ </ul>
+ </div>
+ <div id="footer">
+ <a href="http://www.pylonshq.com" style="color: #ccc; text-decoration:none;"
+ >www.pylonshq.com</a>
+ </div>
+ </div>
+ </body>
+</html>
BIN  linkdrop/public/pylons-logo.gif
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
401 linkdrop/simple_oauth.py
@@ -0,0 +1,401 @@
+# Copyright 2010 Google Inc.
+# Portions copyright Mozilla Messaging 2010.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+ # http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Code lifted from google's XOAUTH sample; almost identical to that sample
+# but with print statements removed or replaced with logging.
+# Also added some raindrop-specific helper functions...
+
+"""Utilities for XOAUTH authentication.
+
+This script has the following modes of operation:
+ --generate_oauth_token
+ --generate_xoauth_string
+ --test_imap_authentication
+ --test_smtp_authentication
+
+The --generate_oauth_token mode will generate and authorize an OAuth token for
+testing.
+
+ xoauth --generate_oauth_token --user=xxx@googlemail.com
+
+The script will converse with Google Accounts and generate an oauth request
+token, then present you with a URL you should visit in your browser to authorize
+the token. Once you get the verification code from the website, enter it into
+the script to get your OAuth access token. The output from this command will be
+two values: an OAuth token and an OAuth token secret. These values are reusable,
+so if you save them somewhere you won't have to keep repeating this first step.
+
+The --generate_xoauth_string option generates an XOauth auth string that can
+be fed directly to IMAP or SMTP.
+
+(3-legged OAuth)
+ xoauth --generate_xoauth_string --user=xxx@googlemail.com
+ --oauth_token=k99hfs9dh --oauth_token_secret=sd9fhidfskfj
+
+(2-legged OAuth)
+ xoauth --generate_xoauth_string --user=xxx@googlemail.com
+ --consumer_key=foo.com --consumer_secret=sd9fhidfskfj
+ --xoauth_requestor_id=xxx@googlemail.com
+
+The output of this mode will be a base64-encoded string. To use it, connect to
+imap.googlemail.com:993 and pass it as the second argument to the AUTHENTICATE
+command.
+
+ a AUTHENTICATE XOAUTH a9sha9sfs[...]9dfja929dk==
+
+The --test_imap_authentication and --test_smtp_authentication comands generate
+an XOAUTH string and use them to authenticate to a live IMAP or SMTP server.
+You can use the --imap_hostname and --smtp_hostname options to specify the
+server to connect to.
+
+ xoauth --test_imap_authentication --user=xxx@googlemail.com
+ --oauth_token=k99hfs9dh --oauth_token_secret=sd9fhidfskfj
+
+ xoauth --test_smtp_authentication --user=xxx@googlemail.com
+ --oauth_token=k99hfs9dh --oauth_token_secret=sd9fhidfskfj
+
+"""
+
+import base64
+import hmac
+import imaplib
+import random
+import hashlib
+import smtplib
+import sys
+import time
+import urllib
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def UrlEscape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urllib.quote(text, safe='~-._')
+
+
+def UrlUnescape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urllib.unquote(text)
+
+
+def FormatUrlParams(params):
+ """Formats parameters into a URL query string.
+
+ Args:
+ params: A key-value map.
+
+ Returns:
+ A URL query string version of the given parameters.
+ """
+ param_fragments = []
+ for param in sorted(params.iteritems(), key=lambda x: x[0]):
+ param_fragments.append('%s=%s' % (param[0], UrlEscape(param[1])))
+ return '&'.join(param_fragments)
+
+
+def EscapeAndJoin(elems):
+ return '&'.join([UrlEscape(x) for x in elems])
+
+
+def GenerateSignatureBaseString(method, request_url_base, params):
+ """Generates an OAuth signature base string.
+
+ Args:
+ method: The HTTP request method, e.g. "GET".
+ request_url_base: The base of the requested URL. For example, if the
+ requested URL is
+ "https://mail.google.com/mail/b/xxx@googlemail.com/imap/?" +
+ "xoauth_requestor_id=xxx@googlemail.com", the request_url_base would be
+ "https://mail.google.com/mail/b/xxx@googlemail.com/imap/".
+ params: Key-value map of OAuth parameters, plus any parameters from the
+ request URL.
+
+ Returns:
+ A signature base string prepared according to the OAuth Spec.
+ """
+ return EscapeAndJoin([method, request_url_base, FormatUrlParams(params)])
+
+
+def GenerateHmacSha1Signature(text, key):
+ digest = hmac.new(key, text, hashlib.sha1)
+ return base64.b64encode(digest.digest())
+
+
+def GenerateOauthSignature(base_string, consumer_secret, token_secret):
+ key = EscapeAndJoin([consumer_secret, token_secret])
+ return GenerateHmacSha1Signature(base_string, key)
+
+
+def ParseUrlParamString(param_string):
+ """Parses a URL parameter string into a key-value map.
+
+ Args:
+ param_string: A URL parameter string, e.g. "foo=bar&oof=baz".
+
+ Returns:
+ A key-value dict.
+ """
+ kv_pairs = param_string.split('&')
+ params = {}
+ for kv in kv_pairs:
+ k, v = kv.split('=')
+ params[k] = UrlUnescape(v)
+ return params
+
+
+class OAuthEntity(object):
+ """Represents consumers and tokens in OAuth."""
+
+ def __init__(self, key, secret, **rest):
+ self.key = key
+ self.secret = secret
+ for n, v in rest.iteritems():
+ setattr(self, n, v)
+
+ def __repr__(self):
+ return "<OAuthEntity(%r)>" % self.__dict__
+
+
+def FillInCommonOauthParams(params, consumer, nonce=None, timestamp=None):
+ """Fills in parameters that are common to all oauth requests.
+
+ Args:
+ params: Parameter map, which will be added to.
+ consumer: An OAuthEntity representing the OAuth consumer.
+ nonce: optional supplied nonce
+ timestamp: optional supplied timestamp
+ """
+ params['oauth_consumer_key'] = consumer.key
+ if nonce:
+ params['oauth_nonce'] = nonce
+ else:
+ params['oauth_nonce'] = str(random.randrange(2**64 - 1))
+ params['oauth_signature_method'] = 'HMAC-SHA1'
+ params['oauth_version'] = '1.0'
+ if timestamp:
+ params['oauth_timestamp'] = timestamp
+ else:
+ params['oauth_timestamp'] = str(int(time.time()))
+
+
+def GenerateRequestToken(consumer, scope, nonce, timestamp, callback_url,
+ google_accounts_url_generator):
+ """Generates an OAuth request token by talking to Google Accounts.
+
+ Args:
+ consumer: An OAuthEntity representing the OAuth consumer.
+ scope: Scope for the OAuth access token.
+ nonce: The nonce to use in the signature. If None is passed, a random nonce
+ will be generated.
+ timestamp: Timestamp to use in the signature. If None is passed, the current
+ time will be used.
+ google_accounts_url_generator: function that creates a Google Accounts URL
+ for the given URL fragment.
+
+ Returns:
+ An OAuthEntity representing the request token.
+ """
+ params = {}
+ FillInCommonOauthParams(params, consumer, nonce, timestamp)
+ #params['oauth_callback'] = 'oob'
+ params['oauth_callback'] = callback_url
+ params['scope'] = scope
+ request_url = google_accounts_url_generator.GetRequestTokenUrl()
+ token = OAuthEntity(None, '')
+ base_string = GenerateSignatureBaseString('GET', request_url, params)
+ signature = GenerateOauthSignature(base_string, consumer.secret,
+ token.secret)
+ params['oauth_signature'] = signature
+
+ url = '%s?%s' % (request_url, FormatUrlParams(params))
+ response = urllib.urlopen(url).read()
+ response_params = ParseUrlParamString(response)
+
+ # for param in response_params.items():
+ # print '%s: %s' % param
+
+ key = response_params.pop('oauth_token')
+ secret = response_params.pop('oauth_token_secret')
+ token = OAuthEntity(key, secret, **response_params)
+
+ #print ('To authorize token, visit this url and follow the directions '
+ # 'to generate a verification code:')
+
+ #print ' %s?oauth_token=%s' % (
+ # google_accounts_url_generator.GetAuthorizeTokenUrl(),
+ # UrlEscape(response_params['oauth_token']))
+ return token
+
+
+def GetAccessToken(consumer, request_token, oauth_verifier,
+ google_accounts_url_generator):
+ """Obtains an OAuth access token from Google Accounts.
+
+ Args:
+ consumer: An OAuth entity representing the OAuth consumer.
+ request_token: An OAuthEntity representing the request token (e.g. as
+ returned by GenerateRequestToken.
+ oauth_verifier: The verification string displayed to the user after
+ completing Google Accounts authorization.
+ google_accounts_url_generator: function that creates a Google Accounts URL
+ for the given URL fragment.
+
+ Returns:
+ An OAuthEntity representing the OAuth access token.
+ """
+ params = {}
+ FillInCommonOauthParams(params, consumer)
+ params['oauth_token'] = request_token.key
+ params['oauth_verifier'] = oauth_verifier
+ request_url = google_accounts_url_generator.GetAccessTokenUrl()
+ base_string = GenerateSignatureBaseString('GET', request_url, params)
+ signature = GenerateOauthSignature(base_string, consumer.secret,
+ request_token.secret)
+ params['oauth_signature'] = signature
+
+ url = '%s?%s' % (request_url, FormatUrlParams(params))
+ response = urllib.urlopen(url).read()
+ response_params = ParseUrlParamString(response)
+ #for param in ('oauth_token', 'oauth_token_secret'):
+ # print '%s: %s' % (param, response_params[param])
+ key = response_params.pop('oauth_token')
+ secret = response_params.pop('oauth_token_secret')
+ return OAuthEntity(key, secret, **response_params)
+
+
+def GenerateXOauthString(consumer, access_token, user, proto,
+ xoauth_requestor_id, nonce, timestamp):
+ """Generates an IMAP XOAUTH authentication string.
+
+ Args:
+ consumer: An OAuthEntity representing the consumer.
+ access_token: An OAuthEntity representing the access token.
+ user: The Google Mail username (full email address)
+ proto: "imap" or "smtp", for example.
+ xoauth_requestor_id: xoauth_requestor_id URL parameter for 2-legged OAuth
+ nonce: optional supplied nonce
+ timestamp: optional supplied timestamp
+
+ Returns:
+ A string that can be passed as the argument to an IMAP
+ "AUTHENTICATE XOAUTH" command after being base64-encoded.
+ """
+ method = 'GET'
+ url_params = {}
+ if xoauth_requestor_id:
+ url_params['xoauth_requestor_id'] = xoauth_requestor_id
+ oauth_params = {}
+ FillInCommonOauthParams(oauth_params, consumer, nonce, timestamp)
+ if access_token.key:
+ oauth_params['oauth_token'] = access_token.key
+ signed_params = oauth_params.copy()
+ signed_params.update(url_params)
+ request_url_base = (
+ 'https://mail.google.com/mail/b/%s/%s/' % (user, proto))
+ base_string = GenerateSignatureBaseString(
+ method,
+ request_url_base,
+ signed_params)
+ logger.debug('signature base string: %s', base_string)
+ signature = GenerateOauthSignature(base_string, consumer.secret,
+ access_token.secret)
+ oauth_params['oauth_signature'] = signature
+
+ formatted_params = []
+ for k, v in sorted(oauth_params.iteritems()):
+ formatted_params.append('%s="%s"' % (k, UrlEscape(v)))
+ param_list = ','.join(formatted_params)
+ if url_params:
+ request_url = '%s?%s' % (request_url_base,
+ FormatUrlParams(url_params))
+ else:
+ request_url = request_url_base
+ preencoded = '%s %s %s' % (method, request_url, param_list)
+ logger.debug('xoauth string: %s' + preencoded)
+ return preencoded
+
+class GoogleAccountsUrlGenerator:
+ def __init__(self, user):
+ self.__apps_domain = None
+ at_index = user.find('@')
+ if at_index != -1 and (at_index + 1) < len(user):
+ domain = user[(at_index + 1):].lower()
+ if domain != 'gmail.com' and domain != 'googlemail.com':
+ self.__apps_domain = domain
+
+ def GetRequestTokenUrl(self):
+ return 'https://www.google.com/accounts/OAuthGetRequestToken'
+
+ def GetAuthorizeTokenUrl(self):
+ if self.__apps_domain:
+ return ('https://www.google.com/a/%s/OAuthAuthorizeToken' %
+ self.__apps_domain)
+ else:
+ return 'https://www.google.com/accounts/OAuthAuthorizeToken'
+
+ def GetAccessTokenUrl(self):
+ return 'https://www.google.com/accounts/OAuthGetAccessToken'
+
+# Some raindrop specific stuff...
+
+class TwitterAccountsUrlGenerator:
+
+ def GetRequestTokenUrl(self):
+ return 'http://twitter.com/oauth/request_token'
+
+ def GetAuthorizeTokenUrl(self):
+ return 'http://twitter.com/oauth/authorize'
+
+ def GetAccessTokenUrl(self):
+ return 'http://twitter.com/oauth/access_token'
+
+def getOAuthUrlGenerator(provider, user):
+ if provider == 'twitter.com':
+ return TwitterAccountsUrlGenerator()
+ elif provider in ['gmail.com', 'google.com']:
+ return GoogleAccountsUrlGenerator(user)
+ else:
+ raise Exception('OAuth provider %s not supported' % provider)
+
+def GenerateXOauthStringFromAcctInfo(protocol, acct_info):
+ """Generates an IMAP XOAUTH authentication string from a raindrop
+ 'account info' dictionary.
+ """
+ username = acct_info.username
+ oauth_token = getattr(acct_info, 'oauth_token', None)
+ oauth_token_secret = getattr(acct_info, 'oauth_token_secret', None)
+ consumer_key = getattr(acct_info, 'oauth_consumer_key', 'anonymous')
+ consumer_secret = getattr(acct_info, 'oauth_consumer_secret', 'anonymous')
+ consumer = OAuthEntity(consumer_key, consumer_secret)
+ google_accounts_url_generator = GoogleAccountsUrlGenerator(username)
+ access_token = OAuthEntity(oauth_token, oauth_token_secret)
+ xoauth_requestor_id = None
+ # the utility functions above will generate nonce and timestamps for us
+ nonce = None
+ timestamp = None
+ xoauth_string = GenerateXOauthString(
+ consumer, access_token, username, protocol,
+ xoauth_requestor_id, nonce, timestamp)
+ return xoauth_string
+
+
+def AcctInfoSupportsOAuth(acct_info):
+ # A reflection on the need to have a URL generator per provider is that
+ # we only support gmail for now...
+ is_google = getattr(acct_info, 'kind', None) == 'gmail' or \
+ getattr(acct_info, 'host', '').endswith('gmail.com')
+ return is_google and getattr(acct_info, 'oauth_token', None) and getattr(acct_info, 'oauth_token_secret', None)
34 linkdrop/tests/__init__.py
@@ -0,0 +1,34 @@
+"""Pylons application test package
+
+This package assumes the Pylons environment is already loaded, such as
+when this script is imported from the `nosetests --with-pylons=test.ini`
+command.
+
+This module initializes the application via ``websetup`` (`paster
+setup-app`) and provides the base testing objects.
+"""
+from unittest import TestCase
+
+from paste.deploy import loadapp
+from paste.script.appinstall import SetupCommand
+from pylons import url
+from routes.util import URLGenerator
+from webtest import TestApp
+
+import pylons.test
+
+__all__ = ['environ', 'url', 'TestController']
+
+# Invoke websetup with the current config file
+SetupCommand('setup-app').run([pylons.test.pylonsapp.config['__file__']])
+
+environ = {}
+
+class TestController(TestCase):
+
+ def __init__(self, *args, **kwargs):
+ wsgiapp = pylons.test.pylonsapp
+ config = wsgiapp.config
+ self.app = TestApp(wsgiapp)
+ url._push_object(URLGenerator(config['routes.map'], environ))
+ TestCase.__init__(self, *args, **kwargs)
0  linkdrop/tests/functional/__init__.py
No changes.
0  linkdrop/tests/test_models.py
No changes.
18 linkdrop/websetup.py
@@ -0,0 +1,18 @@
+"""Setup the linkdrop application"""
+import logging
+
+import pylons.test
+
+from linkdrop.config.environment import load_environment
+from linkdrop.model.meta import Session, Base
+
+log = logging.getLogger(__name__)
+
+def setup_app(command, conf, vars):
+ """Place any commands to setup linkdrop here"""
+ # Don't reload the app if it was loaded under the testing environment
+ if not pylons.test.pylonsapp:
+ load_environment(conf.global_conf, conf.local_conf)
+
+ # Create the tables if they don't already exist
+ Base.metadata.create_all(bind=Session.bind)
31 setup.cfg
@@ -0,0 +1,31 @@
+[egg_info]
+tag_build = dev
+tag_svn_revision = true
+
+[easy_install]
+find_links = http://www.pylonshq.com/download/
+
+[nosetests]
+with-pylons = test.ini
+
+# Babel configuration
+[compile_catalog]
+domain = linkdrop
+directory = linkdrop/i18n
+statistics = true
+
+[extract_messages]
+add_comments = TRANSLATORS:
+output_file = linkdrop/i18n/linkdrop.pot
+width = 80
+
+[init_catalog]
+domain = linkdrop
+input_file = linkdrop/i18n/linkdrop.pot
+output_dir = linkdrop/i18n
+
+[update_catalog]
+domain = linkdrop
+input_file = linkdrop/i18n/linkdrop.pot
+output_dir = linkdrop/i18n
+previous = true
37 setup.py
@@ -0,0 +1,37 @@
+try:
+ from setuptools import setup, find_packages
+except ImportError:
+ from ez_setup import use_setuptools
+ use_setuptools()
+ from setuptools import setup, find_packages
+
+setup(
+ name='linkdrop',
+ version='0.1',
+ description='',
+ author='',
+ author_email='',
+ url='',
+ install_requires=[
+ "Pylons>=1.0",
+ "SQLAlchemy>=0.5",
+ ],
+ setup_requires=["PasteScript>=1.6.3"],
+ packages=find_packages(exclude=['ez_setup']),
+ include_package_data=True,
+ test_suite='nose.collector',
+ package_data={'linkdrop': ['i18n/*/LC_MESSAGES/*.mo']},
+ #message_extractors={'linkdrop': [
+ # ('**.py', 'python', None),
+ # ('templates/**.mako', 'mako', {'input_encoding': 'utf-8'}),
+ # ('public/**', 'ignore', None)]},
+ zip_safe=False,
+ paster_plugins=['PasteScript', 'Pylons'],
+ entry_points="""
+ [paste.app_factory]
+ main = linkdrop.config.middleware:make_app
+
+ [paste.app_install]
+ main = pylons.util:PylonsInstaller
+ """,
+)
21 test.ini
@@ -0,0 +1,21 @@
+#
+# linkdrop - Pylons testing environment configuration
+#
+# The %(here)s variable will be replaced with the parent directory of this file
+#
+[DEFAULT]
+debug = true
+# Uncomment and replace with the address which should receive any error reports
+#email_to = you@yourdomain.com
+smtp_server = localhost
+error_email_from = paste@localhost
+
+[server:main]
+use = egg:Paste#http
+host = 127.0.0.1
+port = 5000
+
+[app:main]
+use = config:development.ini
+
+# Add additional test specific configuration options as necessary.
6 web/scratch/README.txt
@@ -0,0 +1,6 @@
+This is a "scratch pad" folder - it is used for items to aid with development
+before the "real" application has matured. As a result, they tend to be very
+light in terms of styling and design elements - they are just bare-bones.
+
+Items here should be short-lived and removed from here once they are replaced
+with real UI.
168 web/scratch/oauth/index.html
@@ -0,0 +1,168 @@
+<!-- ***** BEGIN LICENSE BLOCK *****
+ - Version: MPL 1.1
+ -
+ - The contents of this file are subject to the Mozilla Public License Version
+ - 1.1 (the "License"); you may not use this file except in compliance with
+ - the License. You may obtain a copy of the License at
+ - http://www.mozilla.org/MPL/
+ -
+ - Software distributed under the License is distributed on an "AS IS" basis,
+ - WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ - for the specific language governing rights and limitations under the
+ - License.
+ -
+ - The Original Code is Raindrop.
+ -
+ - The Initial Developer of the Original Code is
+ - Mozilla Messaging, Inc..
+ - Portions created by the Initial Developer are Copyright (C) 2009
+ - the Initial Developer. All Rights Reserved.
+ -
+ - Contributor(s):
+ - -->
+
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Links keep dropping on my head</title>
+ <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
+
+ <script src="../../scripts/requireplugins-jquery-1.4.2.js" charset="utf-8"></script>
+ <script>require(["index.js"]);</script>
+ <style>
+ .hidden {
+ display: none;
+ }
+
+ .invisible {
+ visibility: hidden;
+ }
+ </style>
+</head>
+<body class="settings">
+
+ <div class="row">
+ <div class="c4 logo">
+ </div>
+ </div>
+
+ <!-- default section -->
+ <div class="section welcome hidden">
+ <div class="row">
+ <div class="c1">
+ <p>Welcome.</p>
+ <p><a href="#twitter">Create an account</a></p>
+ </div>
+ </div>
+ </div>
+
+ <!-- twitter section -->
+ <div class="section twitter hidden">
+ <form id="oauthForm" action="../../api/account/oauth_start" method="GET">
+ <div class="row">
+ <div class="c1 twitterHeader">
+ <strong>step 4:</strong> Add twitter account *
+ </div>
+ <div class="c1">
+ <input type="hidden" name="domain" value="twitter.com">
+ <input type="hidden" name="return_to" value="/scratch/oauth/">
+ <div class="twitterActions">
+ <a class="skip" href="#facebook">skip this step</a>
+ <button>submit</button>
+ </div>
+ <div class="finePrint grey">
+ *You may delete your account at any time
+ </div>
+
+ </div>
+ </div>
+ <div class="c1">
+ <div class="usernameError error invisible">Please enter your Twitter name</div>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <!-- oauth_success_twitter section -->
+ <div class="section oauth_success_twitter_com hidden">
+ <div class="row">
+ <div class="c1">