diff --git a/.gitignore b/.gitignore index 7ed4adc855..67b8c79816 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ gittip.css .vagrant node_modules/ .DS_Store +docs/_build +docs/gittip +docs/gittip.rst diff --git a/README.md b/README.md index 42f7b9ba6f..aa2e6151e7 100644 --- a/README.md +++ b/README.md @@ -278,27 +278,22 @@ Now, you need to setup the database. Local Database Setup -------------------- -For advanced development and testing databse changes, you need to configure -authentication and set up a gittip database. - -You need [Postgres](http://www.postgresql.org/download/). We're working -on [porting](https://github.com/gittip/www.gittip.com/issues?milestone=28&state=open) -Gittip from raw SQL to a declarative ORM with SQLAlchemy. After that we may be -able to remove the hard dependency on Postgres so you can use SQLite in -development, but for now you need Postgres. - -The best version of Postgres to use is 9.2, because that's what is being -run in production at Heroku. Version 9.1 is the second-best, because Gittip -uses the [hstore](http://www.postgresql.org/docs/9.2/static/hstore.html) -extension for unstructured data, and that isn't bundled with earlier -versions than 9.1. If you're on a Mac, maybe try out Heroku's +For advanced development and testing database changes, you need a local +installation of [Postgres](http://www.postgresql.org/download/). The best +version of Postgres to use is 9.1.9, because that's what we're using in +production at Heroku. Gittip uses the +[hstore](http://www.postgresql.org/docs/9.1/static/hstore.html) extension for +unstructured data, and that isn't bundled with earlier versions than 9.1. If +you're on a Mac, maybe try out Heroku's [Postgres.app](http://www.postgresql.org/download/). If installing using a package manager, you may need several packages. On Ubuntu and Debian, the -required packages are: `postgresql` (base), `libpq5-dev`/`libpq-dev`, (includes headers needed -to build the `psycopg2` Python library), `postgresql-contrib` (includes -hstore), `python-dev` (includes Python header files for `psycopg2`). +required packages are: `postgresql` (base), `libpq5-dev`/`libpq-dev`, (includes +headers needed to build the `psycopg2` Python library), `postgresql-contrib` +(includes hstore), `python-dev` (includes Python header files for `psycopg2`). + +If you are receiving issues from `psycopg2`, please [ensure their its are +met](http://initd.org/psycopg/docs/faq.html#problems-compiling-and-deploying-psycopg2). -If you are receiving issues from `psycopg2`, please [ensure their dependencies are met](http://initd.org/psycopg/docs/faq.html#problems-compiling-and-deploying-psycopg2). ### Authentication @@ -341,6 +336,7 @@ we've run against the production database. You should never change commands that have already been run. New DDL will be (manually) run against the production database as part of deployment. + ### Example data The gittip database created in the last step is empty. To populate it with @@ -348,6 +344,7 @@ some fake data, so that more of the site is functional, run this command: $ make data + ### Notes for Mac OS X users If when running the tests you see errors of the form: diff --git a/configure-aspen.py b/configure-aspen.py index 6883898d58..d936deaeac 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -4,12 +4,9 @@ import gittip import gittip.wireup -import gittip.authentication -import gittip.orm -import gittip.csrf -import gittip.cache_static -import gittip.models.participant -from aspen import log_dammit +import gittip.security.authentication +import gittip.security.csrf +import gittip.utils.cache_static version_file = os.path.join(website.www_root, 'version.txt') @@ -21,7 +18,7 @@ gittip.wireup.canonical() -gittip.wireup.db() +website.db = gittip.wireup.db() gittip.wireup.billing() gittip.wireup.username_restrictions(website) gittip.wireup.sentry(website) @@ -43,16 +40,19 @@ def up_minthreads(website): website.hooks.inbound_early += [ gittip.canonize , gittip.configure_payments - , gittip.authentication.inbound - , gittip.csrf.inbound + , gittip.security.authentication.inbound + , gittip.security.csrf.inbound ] -website.hooks.inbound_core += [gittip.cache_static.inbound] +website.hooks.inbound_late += [ gittip.utils.cache_static.inbound + #, gittip.security.authentication.check_role + #, participant.typecast + #, community.typecast + ] -website.hooks.outbound += [ gittip.authentication.outbound - , gittip.csrf.outbound - , gittip.orm.rollback - , gittip.cache_static.outbound +website.hooks.outbound += [ gittip.security.authentication.outbound + , gittip.security.csrf.outbound + , gittip.utils.cache_static.outbound ] @@ -97,83 +97,9 @@ def add_stuff(request): UPDATE_HOMEPAGE_EVERY = int(os.environ['UPDATE_HOMEPAGE_EVERY']) def update_homepage_queries(): + from gittip import utils while 1: - with gittip.db.get_transaction() as txn: - log_dammit("updating homepage queries") - start = time.time() - txn.execute(""" - - DROP TABLE IF EXISTS _homepage_new_participants; - CREATE TABLE _homepage_new_participants AS - SELECT username, claimed_time FROM ( - SELECT DISTINCT ON (p.username) - p.username - , claimed_time - FROM participants p - JOIN elsewhere e - ON p.username = participant - WHERE claimed_time IS NOT null - AND is_suspicious IS NOT true - ) AS foo - ORDER BY claimed_time DESC; - - DROP TABLE IF EXISTS _homepage_top_givers; - CREATE TABLE _homepage_top_givers AS - SELECT tipper AS username, anonymous, sum(amount) AS amount - FROM ( SELECT DISTINCT ON (tipper, tippee) - amount - , tipper - FROM tips - JOIN participants p ON p.username = tipper - JOIN participants p2 ON p2.username = tippee - JOIN elsewhere ON elsewhere.participant = tippee - WHERE p.last_bill_result = '' - AND p.is_suspicious IS NOT true - AND p2.claimed_time IS NOT NULL - AND elsewhere.is_locked = false - ORDER BY tipper, tippee, mtime DESC - ) AS foo - JOIN participants p ON p.username = tipper - WHERE is_suspicious IS NOT true - GROUP BY tipper, anonymous - ORDER BY amount DESC; - - DROP TABLE IF EXISTS _homepage_top_receivers; - CREATE TABLE _homepage_top_receivers AS - SELECT tippee AS username, claimed_time, sum(amount) AS amount - FROM ( SELECT DISTINCT ON (tipper, tippee) - amount - , tippee - FROM tips - JOIN participants p ON p.username = tipper - JOIN elsewhere ON elsewhere.participant = tippee - WHERE last_bill_result = '' - AND elsewhere.is_locked = false - AND is_suspicious IS NOT true - AND claimed_time IS NOT null - ORDER BY tipper, tippee, mtime DESC - ) AS foo - JOIN participants p ON p.username = tippee - WHERE is_suspicious IS NOT true - GROUP BY tippee, claimed_time - ORDER BY amount DESC; - - DROP TABLE IF EXISTS homepage_new_participants; - ALTER TABLE _homepage_new_participants - RENAME TO homepage_new_participants; - - DROP TABLE IF EXISTS homepage_top_givers; - ALTER TABLE _homepage_top_givers - RENAME TO homepage_top_givers; - - DROP TABLE IF EXISTS homepage_top_receivers; - ALTER TABLE _homepage_top_receivers - RENAME TO homepage_top_receivers; - - """) - end = time.time() - elapsed = end - start - log_dammit("updated homepage queries in %.2f seconds" % elapsed) + utils.update_homepage_queries_once(website.db) time.sleep(UPDATE_HOMEPAGE_EVERY) homepage_updater = threading.Thread(target=update_homepage_queries) diff --git a/default_local.env b/default_local.env index c38d641e72..e7afef9fd6 100644 --- a/default_local.env +++ b/default_local.env @@ -1,3 +1,4 @@ +PYTHONDONTWRITEBYTECODE=true CANONICAL_HOST= CANONICAL_SCHEME=http MIN_THREADS=10 diff --git a/default_tests.env b/default_tests.env index 5dbad99563..58d6b27d3f 100644 --- a/default_tests.env +++ b/default_tests.env @@ -1,3 +1,4 @@ +PYTHONDONTWRITEBYTECODE=true CANONICAL_HOST= CANONICAL_SCHEME=http MIN_THREADS=10 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000..9ff9ed3bef --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,49 @@ +# Makefile for Sphinx documentation +# Trimmed up from the + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(SPHINXOPTS) . + +.PHONY: help clean html linkcheck doctest + +default: html + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf gittip* $(BUILDDIR)/* + +rst: + AUTOLIB_LIBRARY_ROOT=../gittip ./autolib.py + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/autolib.py b/docs/autolib.py new file mode 100755 index 0000000000..7ecfc13a87 --- /dev/null +++ b/docs/autolib.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +"""Generate *.rst files to mirror *.py files in a Python library. + +This script is conceptually similar to the sphinx-apidoc script bundled with +Sphinx: + + http://sphinx-doc.org/man/sphinx-apidoc.html + +We produce different *.rst output, however. + +""" +from __future__ import print_function, unicode_literals +import os + + +w = lambda f, s, *a, **kw: print(s.format(*a, **kw), file=f) + + +def rst_for_module(toc_path): + """Given a toc_path, write rst and return a file object. + """ + + f = open(toc_path + '.rst', 'w+') + + heading = ":mod:`{}`".format(os.path.basename(toc_path)) + dotted = toc_path.replace('/', '.') + + w(f, heading) + w(f, "=" * len(heading)) + w(f, ".. automodule:: {}", dotted) + + return f + + +def rst_for_package(root, dirs, files): + """Given ../mylib/path/to/package and lists of dir/file names, write rst. + """ + + doc_path = root[3:] + if not os.path.isdir(doc_path): + os.mkdir(doc_path) + + + # Start a rst doc for this package. + # ================================= + + f = rst_for_module(doc_path) + + + # Add a table of contents. + # ======================== + + w(f, ".. toctree::") + + def toc(doc_path, name): + parent = os.path.dirname(doc_path) + toc_path = os.path.join(doc_path[len(parent):].lstrip('/'), name) + if toc_path.endswith('.py'): + toc_path = toc_path[:-len('.py')] + w(f, " {}", toc_path) + return os.path.join(parent, toc_path) + + for name in sorted(dirs + files): + if name in dirs: + toc(doc_path, name) + else: + if not name.endswith('.py'): continue + if name == '__init__.py': continue + + toc_path = toc(doc_path, name) + + + # Write a rst file for each module. + # ================================= + + rst_for_module(toc_path) + + +def main(): + library_root = os.environ['AUTOLIB_LIBRARY_ROOT'] + for root, dirs, files in os.walk(library_root): + rst_for_package(root, dirs, files) + + +if __name__ == '__main__': + main() diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000000..37d0b490ba --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# +# Gittip documentation build configuration file, created by +# sphinx-quickstart on Thu Aug 8 23:20:15 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Gittip' +copyright = u'2013, Gittip, LLC' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '-' +# The full version, including alpha/beta/rc tags. +release = '-' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Generate RST files ------------------------------------------------------- + +# We do this in here instead of in the Makefile so that RTD picks this up. +os.environ['AUTOLIB_LIBRARY_ROOT'] = '../gittip' +os.system("./autolib.py") + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Gittipdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'Gittip.tex', u'Gittip Documentation', + u'Gittip, LLC', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'gittip', u'Gittip Documentation', + [u'Gittip, LLC'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'Gittip', u'Gittip Documentation', + u'Gittip, LLC', 'Gittip', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/': None} + +autodoc_default_flags = ['members', 'member-order: bysource'] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000000..d4ef8ca499 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,17 @@ +www.gittip.com +============== + +Welcome! This is the documentation for programmers working on `www.gittip.com`_ +(not to be confused with programmers working with Gittip's `web API`_). + +.. _www.gittip.com: https://github.com/gittip/www.gittip.com +.. _web API: https://github.com/gittip/www.gittip.com#api + + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + + gittip Python library diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000000..d598997db7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Gittip.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Gittip.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/gittip/__init__.py b/gittip/__init__.py index 5a1a068630..f21a0fa1ed 100644 --- a/gittip/__init__.py +++ b/gittip/__init__.py @@ -1,3 +1,5 @@ +"""This is the Python library behind www.gittip.com. +""" import datetime import locale import os @@ -37,6 +39,14 @@ def age(): return "%s month%s" % (nmonths, plural) +class NotSane(Exception): + """This is used when a sanity check fails. + + A sanity check is when it really seems like the logic shouldn't allow the + condition to arise, but you never know. + + """ + db = None # This global is wired in wireup. It's an instance of # gittip.postgres.PostgresManager. diff --git a/gittip/billing/payday.py b/gittip/billing/payday.py index 77ed39f8bc..22b27446af 100644 --- a/gittip/billing/payday.py +++ b/gittip/billing/payday.py @@ -25,10 +25,8 @@ import aspen.utils from aspen import log from aspen.utils import typecheck -from gittip.participant import Participant -from gittip.models.participant import Participant as ORMParticipant +from gittip.models.participant import Participant from psycopg2 import IntegrityError -from psycopg2.extras import RealDictRow # Set fees and minimums. @@ -73,13 +71,18 @@ def is_whitelisted(participant): initial SELECT, so we should never see one here. """ - assert participant['is_suspicious'] is not True, participant['username'] - if participant['is_suspicious'] is None: - log("UNREVIEWED: %s" % participant['username']) + assert participant.is_suspicious is not True, participant.username + if participant.is_suspicious is None: + log("UNREVIEWED: %s" % participant.username) return False return True +class NoPayday(Exception): + def __str__(self): + return "No payday found where one was expected." + + class Payday(object): """Represent an abstract event during which money is moved. @@ -91,7 +94,7 @@ class Payday(object): """ def __init__(self, db): - """Takes a gittip.postgres.PostgresManager instance. + """Takes a postgres.Postgres instance. """ self.db = db @@ -110,13 +113,10 @@ def genparticipants(self, ts_start, for_payday): That's okay. """ - for deets in self.get_participants(ts_start): - participant = Participant(deets['username']) - tips, total = participant.get_tips_and_total( for_payday=for_payday - , db=self.db - ) + for participant in self.get_participants(ts_start): + tips, total = participant.get_tips_and_total(for_payday=for_payday) typecheck(total, Decimal) - yield(deets, tips, total) + yield(participant, tips, total) def run(self): @@ -161,11 +161,11 @@ def start(self): """ try: - rec = self.db.one("INSERT INTO paydays DEFAULT VALUES " - "RETURNING ts_start") + ts_start = self.db.one("INSERT INTO paydays DEFAULT VALUES " + "RETURNING ts_start") log("Starting a new payday.") except IntegrityError: # Collision, we have a Payday already. - rec = self.db.one(""" + ts_start = self.db.one(""" SELECT ts_start FROM paydays @@ -173,9 +173,7 @@ def start(self): """) log("Picking up with an existing payday.") - assert rec is not None # Must either create or recycle a Payday. - ts_start = rec['ts_start'] log("Payday started at %s." % ts_start) return ts_start @@ -205,12 +203,7 @@ def get_participants(self, ts_start): """Given a timestamp, return a list of participants dicts. """ PARTICIPANTS = """\ - SELECT username - , balance - , balanced_account_uri - , stripe_customer_id - , is_suspicious - , number + SELECT participants.*::participants FROM participants WHERE claimed_time IS NOT NULL AND claimed_time < %s @@ -239,27 +232,28 @@ def pachinko(self, ts_start, participants): for i, (participant, foo, bar) in enumerate(participants, start=1): if i % 100 == 0: log("Pachinko done for %d participants." % i) - if participant['number'] != 'plural': + if participant.number != 'plural': continue - team = ORMParticipant.query.get(participant['username']) - available = team.balance - log("Pachinko out from %s with $%s." % (team.username, available)) + available = participant.balance + log("Pachinko out from %s with $%s." % ( participant.username + , available + )) def tip(member, amount): tip = {} - tip['tipper'] = team.username + tip['tipper'] = participant.username tip['tippee'] = member['username'] tip['amount'] = amount tip['claimed_time'] = ts_start - self.tip( {"username": team.username} + self.tip( {"username": participant.username} , tip , ts_start , pachinko=True ) return tip['amount'] - for member in team.get_members(): + for member in participant.get_members(): amount = min(member['take'], available) available -= amount tip(member, amount) @@ -288,7 +282,7 @@ def charge_and_or_transfer(self, ts_start, participant, tips, total): money between Gittip accounts. """ - short = total - participant['balance'] + short = total - participant.balance if short > 0: # The participant's Gittip account is short the amount needed to @@ -356,15 +350,14 @@ def clear_pending_to_balance(self): def end(self): - rec = self.db.one("""\ + self.db.one("""\ UPDATE paydays SET ts_end=now() WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """) - self.assert_one_payday(rec) + """, default=NoPayday) # Move money between Gittip participants. @@ -382,7 +375,7 @@ def tip(self, participant, tip, ts_start, pachinko=False): """ msg = "$%s from %s to %s%s." msg %= ( tip['amount'] - , participant['username'] + , participant.username , tip['tippee'] , " (pachinko)" if pachinko else "" ) @@ -406,7 +399,7 @@ def tip(self, participant, tip, ts_start, pachinko=False): log("SKIPPED: %s" % msg) return 0 - if not self.transfer(participant['username'], tip['tippee'], \ + if not self.transfer(participant.username, tip['tippee'], \ tip['amount'], pachinko=pachinko): # The transfer failed due to a lack of funds for the participant. @@ -432,8 +425,7 @@ def transfer(self, tipper, tippee, amount, pachinko=False): , amount, Decimal , pachinko, bool ) - with self.db.get_connection() as conn: - cursor = conn.cursor() + with self.db.get_cursor() as cursor: try: self.debit_participant(cursor, tipper, amount) @@ -447,7 +439,6 @@ def transfer(self, tipper, tippee, amount, pachinko=False): else: self.mark_transfer(cursor, amount) - conn.commit() return True @@ -506,11 +497,11 @@ def charge(self, participant, amount): function and add it to amount to end up with charge_amount. """ - typecheck(participant, RealDictRow, amount, Decimal) + typecheck(participant, Participant, amount, Decimal) - username = participant['username'] - balanced_account_uri = participant['balanced_account_uri'] - stripe_customer_id = participant['stripe_customer_id'] + username = participant.username + balanced_account_uri = participant.balanced_account_uri + stripe_customer_id = participant.stripe_customer_id typecheck( username, unicode , balanced_account_uri, (unicode, None) @@ -564,7 +555,7 @@ def ach_credit(self, ts_start, participant, tips, total): # Leave money in Gittip to cover their obligations next week (as these # currently stand). Also reduce the amount by our service fee. - balance = participant['balance'] + balance = participant.balance assert balance is not None, balance # sanity check amount = balance - total @@ -581,7 +572,7 @@ def ach_credit(self, ts_start, participant, tips, total): also_log = " ($%s balance - $%s in obligations)" also_log %= (balance, total) log("Minimum payout is $%s. %s is only due $%s%s." - % (MINIMUM_CREDIT, participant['username'], amount, also_log)) + % (MINIMUM_CREDIT, participant.username, amount, also_log)) return # Participant owed too little. if not is_whitelisted(participant): @@ -600,7 +591,7 @@ def ach_credit(self, ts_start, participant, tips, total): else: also_log = "$%s" % amount msg = "Crediting %s %d cents (%s - $%s fee = $%s) on Balanced ... " - msg %= (participant['username'], cents, also_log, fee, credit_amount) + msg %= (participant.username, cents, also_log, fee, credit_amount) # Try to dance with Balanced. @@ -608,15 +599,15 @@ def ach_credit(self, ts_start, participant, tips, total): try: - balanced_account_uri = participant['balanced_account_uri'] + balanced_account_uri = participant.balanced_account_uri if balanced_account_uri is None: log("%s has no balanced_account_uri." - % participant['username']) + % participant.username) return # not in Balanced account = balanced.Account.find(balanced_account_uri) if 'merchant' not in account.roles: - log("%s is not a merchant." % participant['username']) + log("%s is not a merchant." % participant.username) return # not a merchant account.credit(cents) @@ -627,7 +618,7 @@ def ach_credit(self, ts_start, participant, tips, total): error = err.message log(msg + "failed: %s" % error) - self.record_credit(credit_amount, fee, error, participant['username']) + self.record_credit(credit_amount, fee, error, participant.username) def charge_on_balanced(self, username, balanced_account_uri, amount): @@ -733,8 +724,7 @@ def record_charge(self, amount, charge_amount, fee, error, username): """ - with self.db.get_connection() as connection: - cursor = connection.cursor() + with self.db.get_cursor() as cursor: if error: last_bill_result = error @@ -768,9 +758,6 @@ def record_charge(self, amount, charge_amount, fee, error, username): cursor.execute(RESULT, (last_bill_result, amount, username)) - connection.commit() - - def record_credit(self, amount, fee, error, username): """Given a Bunch of Stuff, return None. @@ -787,8 +774,7 @@ def record_credit(self, amount, fee, error, username): credit = -amount # From Gittip's POV this is money flowing out of the # system. - with self.db.get_connection() as connection: - cursor = connection.cursor() + with self.db.get_cursor() as cursor: if error: last_ach_result = error @@ -823,30 +809,26 @@ def record_credit(self, amount, fee, error, username): , username )) - connection.commit() - def record_transfer(self, cursor, tipper, tippee, amount): - RECORD = """\ + cursor.run("""\ INSERT INTO transfers (tipper, tippee, amount) VALUES (%s, %s, %s) - """ - cursor.execute(RECORD, (tipper, tippee, amount)) + """, (tipper, tippee, amount)) def mark_missing_funding(self): - STATS = """\ + self.db.one("""\ UPDATE paydays SET ncc_missing = ncc_missing + 1 WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """ - self.assert_one_payday(self.db.one(STATS)) + """, default=NoPayday) def mark_charge_failed(self, cursor): @@ -859,7 +841,7 @@ def mark_charge_failed(self, cursor): """ cursor.execute(STATS) - self.assert_one_payday(cursor.fetchone()) + assert cursor.fetchone() is not None def mark_charge_success(self, cursor, amount, fee): STATS = """\ @@ -873,23 +855,21 @@ def mark_charge_success(self, cursor, amount, fee): """ cursor.execute(STATS, (amount, fee)) - self.assert_one_payday(cursor.fetchone()) + assert cursor.fetchone() is not None def mark_ach_failed(self, cursor): - STATS = """\ + cursor.one("""\ UPDATE paydays SET nach_failing = nach_failing + 1 WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """ - cursor.execute(STATS) - self.assert_one_payday(cursor.fetchone()) + """, default=NoPayday) def mark_ach_success(self, cursor, amount, fee): - STATS = """\ + cursor.one("""\ UPDATE paydays SET nachs = nachs + 1 @@ -898,13 +878,11 @@ def mark_ach_success(self, cursor, amount, fee): WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """ - cursor.execute(STATS, (-amount, fee)) - self.assert_one_payday(cursor.fetchone()) + """, (-amount, fee), default=NoPayday) def mark_transfer(self, cursor, amount): - STATS = """\ + cursor.one("""\ UPDATE paydays SET ntransfers = ntransfers + 1 @@ -912,13 +890,11 @@ def mark_transfer(self, cursor, amount): WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """ - cursor.execute(STATS, (amount,)) - self.assert_one_payday(cursor.fetchone()) + """, (amount,), default=NoPayday) def mark_pachinko(self, cursor, amount): - STATS = """\ + cursor.one("""\ UPDATE paydays SET npachinko = npachinko + 1 @@ -926,13 +902,11 @@ def mark_pachinko(self, cursor, amount): WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """ - cursor.execute(STATS, (amount,)) - self.assert_one_payday(cursor.fetchone()) + """, default=NoPayday) def mark_participant(self, nsuccessful_tips): - STATS = """\ + self.db.one("""\ UPDATE paydays SET nparticipants = nparticipants + 1 @@ -941,18 +915,6 @@ def mark_participant(self, nsuccessful_tips): WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz RETURNING id - """ - self.assert_one_payday( self.db.one( STATS - , ( 1 if nsuccessful_tips > 0 else 0 - , nsuccessful_tips # XXX bug? - ) - ) - ) - - - def assert_one_payday(self, payday): - """Given the result of a payday stats update, make sure it's okay. - """ - assert payday is not None - payday = list(payday) - assert len(payday) == 1, payday + """, ( 1 if nsuccessful_tips > 0 else 0 + , nsuccessful_tips # XXX bug? + ), default=NoPayday) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index ed1cfa2fd1..463a100565 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -1,10 +1,14 @@ +"""This subpackage contains functionality for working with accounts elsewhere. +""" +from __future__ import print_function, unicode_literals + from aspen.utils import typecheck from psycopg2 import IntegrityError import gittip -from gittip.authentication import User -from gittip.models.participant import Participant -from gittip.participant import reserve_a_random_username +from gittip.security.user import User +from gittip.models.participant import Participant, reserve_a_random_username +from gittip.models.participant import ProblemChangingUsername ACTIONS = [u'opt-in', u'connect', u'lock', u'unlock'] @@ -14,7 +18,7 @@ def _resolve(platform, username_key, username): """Given three unicodes, return a username. """ typecheck(platform, unicode, username_key, unicode, username, unicode) - rec = gittip.db.one(""" + participant = gittip.db.one(""" SELECT participant FROM elsewhere @@ -24,11 +28,11 @@ def _resolve(platform, username_key, username): """, (platform, username_key, username,)) # XXX Do we want a uniqueness constraint on $username_key? Can we do that? - if rec is None: + if participant is None: raise Exception( "User %s on %s isn't known to us." % (username, platform) ) - return rec['participant'] + return participant class AccountElsewhere(object): @@ -68,17 +72,17 @@ def opt_in(self, desired_username): """Given a desired username, return a User object. """ self.set_is_locked(False) - user = User.from_username(self.participant) # give them a session + user = User.from_username(self.participant) + user.sign_in() assert not user.ANON, self.participant # sanity check if self.is_claimed: newly_claimed = False else: newly_claimed = True - user.set_as_claimed() + user.participant.set_as_claimed() try: - user.change_username(desired_username) - user.username = self.username = desired_username - except user.ProblemChangingUsername: + user.participant.change_username(desired_username) + except ProblemChangingUsername: pass return user, newly_claimed @@ -110,13 +114,13 @@ def upsert(self, user_info): # participant we reserved for them is rolled back as well. try: - with gittip.db.get_transaction() as txn: - _username = reserve_a_random_username(txn) - txn.execute( "INSERT INTO elsewhere " - "(platform, user_id, participant) " - "VALUES (%s, %s, %s)" - , (self.platform, self.user_id, _username) - ) + with gittip.db.get_cursor() as cursor: + _username = reserve_a_random_username(cursor) + cursor.execute( "INSERT INTO elsewhere " + "(platform, user_id, participant) " + "VALUES (%s, %s, %s)" + , (self.platform, self.user_id, _username) + ) except IntegrityError: pass @@ -143,7 +147,7 @@ def upsert(self, user_info): WHERE platform=%s AND user_id=%s RETURNING participant - """, (user_info, self.platform, self.user_id))['participant'] + """, (user_info, self.platform, self.user_id)) # Get a little more info to return. @@ -164,7 +168,7 @@ def upsert(self, user_info): return ( username - , rec['claimed_time'] is not None - , rec['is_locked'] - , rec['balance'] + , rec.claimed_time is not None + , rec.is_locked + , rec.balance ) diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index 3e3b8748e4..5d07231ae3 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -52,7 +52,7 @@ def get_user_info(username): , (username,) ) if rec is not None: - user_info = rec['user_info'] + user_info = rec else: url = "%s/users/%s?pagelen=100" user_info = requests.get(url % (BASE_API_URL, username)) diff --git a/gittip/elsewhere/bountysource.py b/gittip/elsewhere/bountysource.py index d112dd43e2..3fbb893dd4 100644 --- a/gittip/elsewhere/bountysource.py +++ b/gittip/elsewhere/bountysource.py @@ -1,7 +1,7 @@ import os import md5 import time -from gittip.models import Participant +from gittip.models.participant import Participant from gittip.elsewhere import AccountElsewhere, _resolve www_host = os.environ['BOUNTYSOURCE_WWW_HOST'].decode('ASCII') @@ -90,7 +90,7 @@ def get_participant_via_access_token(access_token): if access_token_valid(access_token): parts = access_token.split('.') participant_id = parts[0] - return Participant.query.filter_by(id=participant_id).one() + return Participant.from_id(participant_id) def filter_user_info(user_info): diff --git a/gittip/elsewhere/github.py b/gittip/elsewhere/github.py index 2b25733578..31afcf803c 100644 --- a/gittip/elsewhere/github.py +++ b/gittip/elsewhere/github.py @@ -8,7 +8,6 @@ from aspen.utils import typecheck from gittip import log from gittip.elsewhere import ACTIONS, AccountElsewhere, _resolve -from postgres import TooFew class GitHubAccount(AccountElsewhere): @@ -94,17 +93,14 @@ def get_user_info(login): A dictionary containing github specific information for the user. """ typecheck(login, unicode) - try: - rec = gittip.db.one( "SELECT user_info FROM elsewhere " - "WHERE platform='github' " - "AND user_info->'login' = %s" - , (login,) - ) - except TooFew: - rec = None + rec = gittip.db.one( "SELECT user_info FROM elsewhere " + "WHERE platform='github' " + "AND user_info->'login' = %s" + , (login,) + ) if rec is not None: - user_info = rec['user_info'] + user_info = rec else: url = "https://api.github.com/users/%s" user_info = requests.get(url % login, params={ diff --git a/gittip/elsewhere/take_over.py b/gittip/elsewhere/take_over.py new file mode 100644 index 0000000000..fd8e0cd478 --- /dev/null +++ b/gittip/elsewhere/take_over.py @@ -0,0 +1,2 @@ +"""Provide functionality for merging accounts. +""" diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index 61a8fcff17..3a79d46a60 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -6,7 +6,6 @@ from gittip.elsewhere import AccountElsewhere, _resolve from os import environ from requests_oauthlib import OAuth1 -from postgres import TooFew class TwitterAccount(AccountElsewhere): @@ -38,17 +37,14 @@ def get_user_info(screen_name): """Given a unicode, return a dict. """ typecheck(screen_name, unicode) - try: - rec = gittip.db.one( "SELECT user_info FROM elsewhere " - "WHERE platform='twitter' " - "AND user_info->'screen_name' = %s" - , (screen_name,) - ) - except TooFew: - rec = None + rec = gittip.db.one( "SELECT user_info FROM elsewhere " + "WHERE platform='twitter' " + "AND user_info->'screen_name' = %s" + , (screen_name,) + ) if rec is not None: - user_info = rec['user_info'] + user_info = rec else: # Updated using Twython as a point of reference: # https://github.com/ryanmcgrath/twython/blob/master/twython/twython.py#L76 diff --git a/gittip/fake_data.py b/gittip/fake_data.py deleted file mode 100644 index a3abba0088..0000000000 --- a/gittip/fake_data.py +++ /dev/null @@ -1,169 +0,0 @@ -from faker import Factory -from gittip import orm, wireup, MAX_TIP, MIN_TIP -from gittip.models.tip import Tip -from gittip.models.participant import Participant -from gittip.models.elsewhere import Elsewhere - -import gittip -import decimal -import random -import string - -faker = Factory.create() - -platforms = ['github', 'twitter', 'bitbucket'] - - -def fake_text_id(size=6, chars=string.ascii_lowercase + string.digits): - """ - Create a random text id - """ - return ''.join(random.choice(chars) for x in range(size)) - - -def fake_balance(max_amount=100): - """ - Return a random amount between 0 and max_amount - """ - return random.random() * max_amount - -def fake_int_id(nmax=2 ** 31 -1): - """ - Create a random int id - """ - return random.randint(0, nmax) - - -def fake_participant(is_admin=False, anonymous=False): - """ - Create a fake User - """ - username = faker.firstName() + fake_text_id(3) - return Participant( - id=fake_int_id(), - username=username, - username_lower=username.lower(), - statement=faker.sentence(), - ctime=faker.dateTimeThisYear(), - is_admin=is_admin, - balance=fake_balance(), - anonymous=anonymous, - goal=fake_balance(), - balanced_account_uri=faker.uri(), - last_ach_result='', - is_suspicious=False, - last_bill_result='', # Needed to not be suspicious - claimed_time=faker.dateTimeThisYear(), - number="singular" - ) - -def fake_tip_amount(): - amount = ((decimal.Decimal(random.random()) * (MAX_TIP - MIN_TIP)) - + MIN_TIP) - - decimal_amount = decimal.Decimal(amount).quantize(decimal.Decimal('.01')) - - return decimal_amount - - -def fake_tip(tipper, tippee): - """ - Create a fake tip - """ - return Tip( - id=fake_int_id(), - ctime=faker.dateTimeThisYear(), - mtime=faker.dateTimeThisMonth(), - tipper=tipper.username, - tippee=tippee.username, - amount=fake_tip_amount() - ) - - -def fake_elsewhere(participant, platform=None): - """ - Create a fake elsewhere - """ - if platform is None: - platform = random.choice(platforms) - - info_templates = { - "github": { - "name": participant.username, - "html_url": "https://github.com/" + participant.username, - "type": "User", - "login": participant.username - }, - "twitter": { - "name": participant.username, - "html_url": "https://twitter.com/" + participant.username, - "screen_name": participant.username - }, - "bitbucket": { - "display_name": participant.username, - "username": participant.username, - "is_team": "False", - "html_url": "https://bitbucket.org/" + participant.username, - } - } - - return Elsewhere( - id=fake_int_id(), - platform=platform, - user_id=fake_text_id(), - is_locked=False, - participant=participant.username, - user_info=info_templates[platform] - ) - - -def populate_db(session, num_participants=100, num_tips=50, num_teams=5): - """ - Populate DB with fake data - """ - #Make the participants - participants = [] - for i in xrange(num_participants): - p = fake_participant() - session.add(p) - participants.append(p) - - #Make the "Elsewhere's" - for p in participants: - #All participants get between 1 and 3 elsewheres - num_elsewheres = random.randint(1, 3) - for platform_name in platforms[:num_elsewheres]: - e = fake_elsewhere(p, platform_name) - session.add(e) - - #Make teams - teams = [] - for i in xrange(num_teams): - t = fake_participant() - t.number = "plural" - session.add(t) - session.commit() - #Add 1 to 3 members to the team - members = random.sample(participants, random.randint(1, 3)) - for p in members: - t.add_member(p) - teams.append(t) - - #Make the tips - tips = [] - for i in xrange(num_tips): - tipper, tippee = random.sample(participants, 2) - t = fake_tip(tipper, tippee) - tips.append(t) - session.add(t) - session.commit() - - -def main(): - db = orm.db - dbsession = db.session - gittip.db = wireup.db() - populate_db(dbsession) - -if __name__ == '__main__': - main() diff --git a/gittip/models/__init__.py b/gittip/models/__init__.py index 1e8f4d5dc6..74e614a264 100644 --- a/gittip/models/__init__.py +++ b/gittip/models/__init__.py @@ -1,21 +1,8 @@ -from gittip.models.absorption import Absorption -from gittip.models.elsewhere import Elsewhere -from gittip.models.exchange import Exchange -from gittip.models.participant import Participant -from gittip.models.payday import Payday -from gittip.models.tip import Tip -from gittip.models.transfer import Transfer -from gittip.models.user import User -from gittip.models.goal import Goal -from gittip.models.api_key import APIKey +""" +The most important object in the Gittip object model is Participant, and the +second most important one is Ccommunity. There are a few others, but those are +the most important two. Participant, in particular, is at the center of +everything on Gittip. -# We actually don't want this one in here, because the only reason afaict for -# things to be in here is so that the test infrastructure automatically cleans -# up tables for us, but this is a view, not a table. XXX Even without this we -# still get OperationalErrors in the test suite. - -#from gittip.models.community import Community - - -all = [Elsewhere, Exchange, Participant, Payday, Tip, Transfer, User] +""" diff --git a/gittip/models/_mixin_elsewhere.py b/gittip/models/_mixin_elsewhere.py new file mode 100644 index 0000000000..067b06c2fc --- /dev/null +++ b/gittip/models/_mixin_elsewhere.py @@ -0,0 +1,389 @@ +import os + +from gittip import NotSane +from aspen.utils import typecheck +from psycopg2 import IntegrityError + + +# Exceptions +# ========== + +class UnknownPlatform(Exception): pass + +class NeedConfirmation(Exception): + """Represent the case where we need user confirmation during a merge. + + This is used in the workflow for merging one participant into another. + + """ + + def __init__(self, a, b, c): + self.other_is_a_real_participant = a + self.this_is_others_last_account_elsewhere = b + self.we_already_have_that_kind_of_account = c + self._all = (a, b, c) + + def __repr__(self): + return "" % self._all + __str__ = __repr__ + + def __eq__(self, other): + return self._all == other._all + + def __ne__(self, other): + return not self.__eq__(other) + + def __nonzero__(self): + # bool(need_confirmation) + A, B, C = self._all + return A or C + + +# Mixin +# ===== + +class MixinElsewhere(object): + """We use this as a mixin for Participant, and in a hackish way on the + homepage and community pages. + + """ + + def get_accounts_elsewhere(self): + """Return a four-tuple of elsewhere Records. + """ + github_account = None + twitter_account = None + bitbucket_account = None + bountysource_account = None + + ACCOUNTS = "SELECT * FROM elsewhere WHERE participant=%s" + accounts = self.db.all(ACCOUNTS, (self.username,)) + + for account in accounts: + if account.platform == "github": + github_account = account + elif account.platform == "twitter": + twitter_account = account + elif account.platform == "bitbucket": + bitbucket_account = account + elif account.platform == "bountysource": + bountysource_account = account + else: + raise UnknownPlatform(account.platform) + + return ( github_account + , twitter_account + , bitbucket_account + , bountysource_account + ) + + + def get_img_src(self, size=128): + """Return a value for . + + Until we have our own profile pics, delegate. XXX Is this an attack + vector? Can someone inject this value? Don't think so, but if you make + it happen, let me know, eh? Thanks. :) + + https://www.gittip.com/security.txt + + """ + typecheck(size, int) + + src = '/assets/%s/avatar-default.gif' % os.environ['__VERSION__'] + + github, twitter, bitbucket, bountysource = \ + self.get_accounts_elsewhere() + if github is not None: + # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ + if 'gravatar_id' in github.user_info: + gravatar_hash = github.user_info['gravatar_id'] + src = "https://www.gravatar.com/avatar/%s.jpg?s=%s" + src %= (gravatar_hash, size) + + elif twitter is not None: + # https://dev.twitter.com/docs/api/1.1/get/users/show + if 'profile_image_url_https' in twitter.user_info: + src = twitter.user_info['profile_image_url_https'] + + # For Twitter, we don't have good control over size. The + # biggest option is 73px(?!), but that's too small. Let's go + # with the original: even though it may be huge, that's + # preferrable to guaranteed blurriness. :-/ + + src = src.replace('_normal.', '.') + + return src + + + def take_over(self, account_elsewhere, have_confirmation=False): + """Given two objects and a bool, raise NeedConfirmation or return None. + + This method associates an account on another platform (GitHub, Twitter, + etc.) with the given Gittip participant. Every account elsewhere has an + associated Gittip participant account, even if its only a stub + participant (it allows us to track pledges to that account should they + ever decide to join Gittip). + + In certain circumstances, we want to present the user with a + confirmation before proceeding to reconnect the account elsewhere to + the new Gittip account; NeedConfirmation is the signal to request + confirmation. If it was the last account elsewhere connected to the old + Gittip account, then we absorb the old Gittip account into the new one, + effectively archiving the old account. + + Here's what absorbing means: + + - consolidated tips to and fro are set up for the new participant + + Amounts are summed, so if alice tips bob $1 and carl $1, and + then bob absorbs carl, then alice tips bob $2(!) and carl $0. + + And if bob tips alice $1 and carl tips alice $1, and then bob + absorbs carl, then bob tips alice $2(!) and carl tips alice $0. + + The ctime of each new consolidated tip is the older of the two + tips that are being consolidated. + + If alice tips bob $1, and alice absorbs bob, then alice tips + bob $0. + + If alice tips bob $1, and bob absorbs alice, then alice tips + bob $0. + + - all tips to and from the other participant are set to zero + - the absorbed username is released for reuse + - the absorption is recorded in an absorptions table + + This is done in one transaction. + + """ + # Lazy imports to dodge circular imports. + from gittip.models.participant import reserve_a_random_username + from gittip.models.participant import gen_random_usernames + + platform = account_elsewhere.platform + user_id = account_elsewhere.user_id + + CONSOLIDATE_TIPS_RECEIVING = """ + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT min(ctime), tipper, %s AS tippee, sum(amount) + FROM ( SELECT DISTINCT ON (tipper, tippee) + ctime, tipper, tippee, amount + FROM tips + ORDER BY tipper, tippee, mtime DESC + ) AS unique_tips + WHERE (tippee=%s OR tippee=%s) + AND NOT (tipper=%s AND tippee=%s) + AND NOT (tipper=%s) + GROUP BY tipper + + """ + + CONSOLIDATE_TIPS_GIVING = """ + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT min(ctime), %s AS tipper, tippee, sum(amount) + FROM ( SELECT DISTINCT ON (tipper, tippee) + ctime, tipper, tippee, amount + FROM tips + ORDER BY tipper, tippee, mtime DESC + ) AS unique_tips + WHERE (tipper=%s OR tipper=%s) + AND NOT (tipper=%s AND tippee=%s) + AND NOT (tippee=%s) + GROUP BY tippee + + """ + + ZERO_OUT_OLD_TIPS_RECEIVING = """ + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT DISTINCT ON (tipper) ctime, tipper, tippee, 0 AS amount + FROM tips + WHERE tippee=%s + + """ + + ZERO_OUT_OLD_TIPS_GIVING = """ + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT DISTINCT ON (tippee) ctime, tipper, tippee, 0 AS amount + FROM tips + WHERE tipper=%s + + """ + + with self.db.get_cursor() as cursor: + + # Load the existing connection. + # ============================= + # Every account elsewhere has at least a stub participant account + # on Gittip. + + rec = cursor.one(""" + + SELECT participant + , claimed_time IS NULL AS is_stub + FROM elsewhere + JOIN participants ON participant=participants.username + WHERE elsewhere.platform=%s AND elsewhere.user_id=%s + + """, (platform, user_id), default=NotSane) + + other_username = rec.participant + + + # Make sure we have user confirmation if needed. + # ============================================== + # We need confirmation in whatever combination of the following + # three cases: + # + # - the other participant is not a stub; we are taking the + # account elsewhere away from another viable Gittip + # participant + # + # - the other participant has no other accounts elsewhere; taking + # away the account elsewhere will leave the other Gittip + # participant without any means of logging in, and it will be + # archived and its tips absorbed by us + # + # - we already have an account elsewhere connected from the given + # platform, and it will be handed off to a new stub + # participant + + # other_is_a_real_participant + other_is_a_real_participant = not rec.is_stub + + # this_is_others_last_account_elsewhere + nelsewhere = cursor.one( "SELECT count(*) FROM elsewhere " + "WHERE participant=%s" + , (other_username,) + ) + assert nelsewhere > 0 # sanity check + this_is_others_last_account_elsewhere = (nelsewhere == 1) + + # we_already_have_that_kind_of_account + nparticipants = cursor.one( "SELECT count(*) FROM elsewhere " + "WHERE participant=%s AND platform=%s" + , (self.username, platform) + ) + assert nparticipants in (0, 1) # sanity check + we_already_have_that_kind_of_account = nparticipants == 1 + + need_confirmation = NeedConfirmation( other_is_a_real_participant + , this_is_others_last_account_elsewhere + , we_already_have_that_kind_of_account + ) + if need_confirmation and not have_confirmation: + raise need_confirmation + + + # We have user confirmation. Proceed. + # =================================== + # There is a race condition here. The last person to call this will + # win. XXX: I'm not sure what will happen to the DB and UI for the + # loser. + + + # Move any old account out of the way. + # ==================================== + + if we_already_have_that_kind_of_account: + new_stub_username = reserve_a_random_username(cursor) + cursor.run( "UPDATE elsewhere SET participant=%s " + "WHERE platform=%s AND participant=%s" + , (new_stub_username, platform, self.username) + ) + + + # Do the deal. + # ============ + # If other_is_not_a_stub, then other will have the account + # elsewhere taken away from them with this call. If there are other + # browsing sessions open from that account, they will stay open + # until they expire (XXX Is that okay?) + + cursor.run( "UPDATE elsewhere SET participant=%s " + "WHERE platform=%s AND user_id=%s" + , (self.username, platform, user_id) + ) + + + # Fold the old participant into the new as appropriate. + # ===================================================== + # We want to do this whether or not other is a stub participant. + + if this_is_others_last_account_elsewhere: + + # Take over tips. + # =============== + + x, y = self.username, other_username + cursor.run(CONSOLIDATE_TIPS_RECEIVING, (x, x,y, x,y, x)) + cursor.run(CONSOLIDATE_TIPS_GIVING, (x, x,y, x,y, x)) + cursor.run(ZERO_OUT_OLD_TIPS_RECEIVING, (other_username,)) + cursor.run(ZERO_OUT_OLD_TIPS_GIVING, (other_username,)) + + + # Archive the old participant. + # ============================ + # We always give them a new, random username. We sign out + # the old participant. + + for archive_username in gen_random_usernames(): + try: + username = cursor.one(""" + + UPDATE participants + SET username=%s + , username_lower=%s + , session_token=NULL + , session_expires=now() + WHERE username=%s + RETURNING username + + """, ( archive_username + , archive_username.lower() + , other_username + ), default=NotSane) + except IntegrityError: + continue # archive_username is already taken; + # extremely unlikely, but ... + # XXX But can the UPDATE fail in other ways? + else: + assert username == archive_username + break + + + # Record the absorption. + # ====================== + # This is for preservation of history. + + cursor.run( "INSERT INTO absorptions " + "(absorbed_was, absorbed_by, archived_as) " + "VALUES (%s, %s, %s)" + , ( other_username + , self.username + , archive_username + ) + ) + +# Utter Hack +# ========== + +def utter_hack(records): + for rec in records: + yield UtterHack(rec) + +class UtterHack(MixinElsewhere): + def __init__(self, rec): + import gittip + self.db = gittip.db + for name in rec._fields: + setattr(self, name, getattr(rec, name)) diff --git a/gittip/models/_mixin_team.py b/gittip/models/_mixin_team.py new file mode 100644 index 0000000000..4823cf2ad2 --- /dev/null +++ b/gittip/models/_mixin_team.py @@ -0,0 +1,180 @@ +"""Teams on Gittip are plural participants with members. +""" +from decimal import Decimal + +from aspen.utils import typecheck + + +class MemberLimitReached(Exception): pass + + +class MixinTeam(object): + """This class provides methods for working with a Participant as a Team. + + :param Participant participant: the underlying :py:class:`~gittip.participant.Participant` object for this team + + """ + + # XXX These were all written with the ORM and need to be converted. + + def __init__(self, participant): + self.participant = participant + + def show_as_team(self, user): + """Return a boolean, whether to show this participant as a team. + """ + if not self.IS_PLURAL: + return False + if user.ADMIN: + return True + if not self.get_members(): + if self == user.participant: + return True + return False + return True + + def add_member(self, member): + """Add a member to this team. + """ + assert self.IS_PLURAL + if len(self.get_members()) == 149: + raise MemberLimitReached + self.__set_take_for(member, Decimal('0.01'), self) + + def remove_member(self, member): + """Remove a member from this team. + """ + assert self.IS_PLURAL + self.__set_take_for(member, Decimal('0.00'), self) + + def member_of(self, team): + """Given a Participant object, return a boolean. + """ + assert team.IS_PLURAL + for member in team.get_members(): + if member['username'] == self.username: + return True + return False + + def get_take_last_week_for(self, member): + """What did the user actually take most recently? Used in throttling. + """ + assert self.IS_PLURAL + membername = member.username if hasattr(member, 'username') \ + else member['username'] + return self.db.one(""" + + SELECT amount + FROM transfers + WHERE tipper=%s AND tippee=%s + AND timestamp > + (SELECT ts_start FROM paydays ORDER BY ts_start DESC LIMIT 1) + ORDER BY timestamp DESC LIMIT 1 + + """, (self.username, membername), default=Decimal('0.00')) + + def get_take_for(self, member): + """Return a Decimal representation of the take for this member, or 0. + """ + assert self.IS_PLURAL + return self.db.one( "SELECT take FROM current_memberships " + "WHERE member=%s AND team=%s" + , (member.username, self.username) + , default=Decimal('0.00') + ) + + def compute_max_this_week(self, last_week): + """2x last week's take, but at least a dollar. + """ + return max(last_week * Decimal('2'), Decimal('1.00')) + + def set_take_for(self, member, take, recorder): + """Sets member's take from the team pool. + """ + assert self.IS_PLURAL + + # lazy import to avoid circular import + from gittip.security.user import User + from gittip.models.participant import Participant + + typecheck( member, Participant + , take, Decimal + , recorder, (Participant, User) + ) + + last_week = self.get_take_last_week_for(member) + max_this_week = self.compute_max_this_week(last_week) + if take > max_this_week: + take = max_this_week + + self.__set_take_for(member, take, recorder) + return take + + def __set_take_for(self, member, take, recorder): + assert self.IS_PLURAL + # XXX Factored out for testing purposes only! :O Use .set_take_for. + self.db.run(""" + + INSERT INTO memberships (ctime, member, team, take, recorder) + VALUES ( COALESCE (( SELECT ctime + FROM memberships + WHERE member=%s + AND team=%s + LIMIT 1 + ), CURRENT_TIMESTAMP) + , %s + , %s + , %s + , %s + ) + + """, (member.username, self.username, member.username, self.username, \ + take, recorder.username)) + + def get_members(self): + assert self.IS_PLURAL + return self.db.all(""" + + SELECT member AS username, take, ctime, mtime + FROM current_memberships + WHERE team=%s + ORDER BY ctime DESC + + """, (self.username,), back_as=dict) + + def get_teams_membership(self): + assert self.IS_PLURAL + TAKE = "SELECT sum(take) FROM current_memberships WHERE team=%s" + total_take = self.db.one(TAKE, (self.username,), default=0) + team_take = max(self.get_dollars_receiving() - total_take, 0) + membership = { "ctime": None + , "mtime": None + , "username": self.username + , "take": team_take + } + return membership + + def get_memberships(self, current_participant): + assert self.IS_PLURAL + members = self.get_members() + members.append(self.get_teams_membership()) + budget = balance = self.get_dollars_receiving() + for member in members: + member['removal_allowed'] = current_participant == self + member['editing_allowed'] = False + if current_participant is not None: + if member['username'] == current_participant.username: + member['is_current_user'] = True + if member['ctime'] is not None: + # current user, but not the team itself + member['editing_allowed']= True + take = member['take'] + member['take'] = take + member['last_week'] = last_week = \ + self.get_take_last_week_for(member) + member['max_this_week'] = self.compute_max_this_week(last_week) + amount = min(take, balance) + balance -= amount + member['balance'] = balance + member['percentage'] = (amount / budget) if budget > 0 else 0 + return members diff --git a/gittip/models/absorption.py b/gittip/models/absorption.py deleted file mode 100644 index b3f8b70f39..0000000000 --- a/gittip/models/absorption.py +++ /dev/null @@ -1,18 +0,0 @@ -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import Integer, Text, TIMESTAMP - -from gittip.orm import db - -class Absorption(db.Model): - __tablename__ = 'absorptions' - - id = Column(Integer, nullable=False, primary_key=True) - timestamp = Column(TIMESTAMP(timezone=True), nullable=False, - default="now()") - absorbed_was = Column(Text, nullable=False) - absorbed_by = Column(Text, ForeignKey("participants.id", - onupdate="CASCADE", - ondelete="RESTRICT"), nullable=False) - archived_as = Column(Text, ForeignKey("participants.id", - onupdate="RESTRICT", - ondelete="RESTRICT"), nullable=False) \ No newline at end of file diff --git a/gittip/models/api_key.py b/gittip/models/api_key.py deleted file mode 100644 index ded02f54a7..0000000000 --- a/gittip/models/api_key.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import Integer, Text, TIMESTAMP - -from gittip.orm import db - -class APIKey(db.Model): - __tablename__ = 'api_keys' - - id = Column(Integer, nullable=False, primary_key=True) - ctime = Column(TIMESTAMP(timezone=True), nullable=False) - mtime = Column(TIMESTAMP(timezone=True), nullable=False, default="now()") - participant = Column(Text - , ForeignKey( "participants.username" - , onupdate="CASCADE" - , ondelete="RESTRICT" - ) - , nullable=False - ) - api_key = Column(Text, nullable=True) - diff --git a/gittip/models/community.py b/gittip/models/community.py index 9522e9cc8c..4c85a8ee2b 100644 --- a/gittip/models/community.py +++ b/gittip/models/community.py @@ -1,14 +1,11 @@ import re import gittip -from gittip.orm import db -from sqlalchemy.schema import Column -from sqlalchemy.types import Text, BigInteger +from postgres.orm import Model name_pattern = re.compile(r'^[A-Za-z0-9,._ -]+$') - def slugize(slug): """Convert a string to a string for an URL. """ @@ -23,47 +20,56 @@ def slugize(slug): def slug_to_name(slug): + """Given a slug like ``python``, return a name like ``Python``. + + :database: One SELECT, one row + + """ SQL = "SELECT name FROM community_summary WHERE slug=%s" - rec = gittip.db.one(SQL, (slug,)) - return None if rec is None else rec['name'] + return gittip.db.one(SQL, (slug,)) def get_list_for(user): - if user is None or (hasattr(user, 'ANON') and user.ANON): - return list(gittip.db.all(""" + """Return a listing of communities. - SELECT max(name) AS name - , slug - , count(*) AS nmembers - FROM current_communities - GROUP BY slug - ORDER BY nmembers DESC, slug + :database: One SELECT, multiple rows - """)) + """ + if user is None or user.ANON: + member_test = "false" + sort_order = 'DESC' + params = () else: - return list(gittip.db.all(""" + member_test = "bool_or(participant = %s)" + sort_order = 'ASC' + params = (user.participant.username,) - SELECT max(name) AS name - , slug - , count(*) AS nmembers - , bool_or(participant = %s) AS is_member - FROM current_communities - GROUP BY slug - ORDER BY nmembers ASC, slug + return gittip.db.all(""" - """, (user.username,))) + SELECT max(name) AS name + , slug + , count(*) AS nmembers + , {} AS is_member + FROM current_communities + GROUP BY slug + ORDER BY nmembers {}, slug + """.format(member_test, sort_order), params) -class Community(db.Model): - __tablename__ = 'community_summary' - name = Column(Text) - slug = Column(Text, primary_key=True) - nmembers = Column(BigInteger) +class Community(Model): + """Model a community on Gittip. + """ + + typname = "community_summary" - def check_membership(self, user): - return gittip.db.one(""" + def check_membership(self, participant): + return self.db.one(""" SELECT * FROM current_communities WHERE slug=%s AND participant=%s - """, (self.slug, user.username)) is not None + """, (self.slug, participant.username)) is not None + + +def typecast(request): + pass diff --git a/gittip/models/elsewhere.py b/gittip/models/elsewhere.py deleted file mode 100644 index d4d2a1156b..0000000000 --- a/gittip/models/elsewhere.py +++ /dev/null @@ -1,31 +0,0 @@ -from sqlalchemy.dialects.postgresql.hstore import HSTORE -from sqlalchemy.schema import Column, UniqueConstraint, ForeignKey -from sqlalchemy.types import Integer, Text, Boolean - -from gittip.orm import db - -class Elsewhere(db.Model): - __tablename__ = 'elsewhere' - __table_args__ = ( - UniqueConstraint('platform', 'participant', - name='elsewhere_platform_participant_key'), - UniqueConstraint('platform', 'user_id', - name='elsewhere_platform_user_id_key') - ) - - id = Column(Integer, nullable=False, primary_key=True) - platform = Column(Text, nullable=False) - user_id = Column(Text, nullable=False) - user_info = Column(HSTORE) - is_locked = Column(Boolean, default=False, nullable=False) - participant = Column(Text, ForeignKey("participants.username"), \ - nullable=False) - - def resolve_unclaimed(self): - if self.platform == 'github': - out = '/on/github/%s/' % self.user_info['login'] - elif self.platform == 'twitter': - out = '/on/twitter/%s/' % self.user_info['screen_name'] - else: - out = None - return out diff --git a/gittip/models/exchange.py b/gittip/models/exchange.py deleted file mode 100644 index 37b5b13b9d..0000000000 --- a/gittip/models/exchange.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import Integer, Numeric, Text, TIMESTAMP - -from gittip.orm import db - -class Exchange(db.Model): - __tablename__ = 'exchanges' - - id = Column(Integer, nullable=False, primary_key=True) - timestamp = Column(TIMESTAMP(timezone=True), nullable=False, - default="now()") - amount = Column(Numeric(precision=35, scale=2), nullable=False) - fee = Column(Numeric(precision=35, scale=2), nullable=False) - participant = Column(Text, ForeignKey("participants.username", - onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False) diff --git a/gittip/models/goal.py b/gittip/models/goal.py deleted file mode 100644 index 03a800bd08..0000000000 --- a/gittip/models/goal.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import Integer, Numeric, Text, TIMESTAMP - -from gittip.orm import db - -class Goal(db.Model): - __tablename__ = 'goals' - - id = Column(Integer, nullable=False, primary_key=True) - ctime = Column(TIMESTAMP(timezone=True), nullable=False) - mtime = Column(TIMESTAMP(timezone=True), nullable=False, default="now()") - participant = Column(Text - , ForeignKey( "participants.id" - , onupdate="CASCADE" - , ondelete="RESTRICT" - ) - , nullable=False - ) - goal = Column(Numeric(precision=35, scale=2), nullable=True) - diff --git a/gittip/models/identification.py b/gittip/models/identification.py deleted file mode 100644 index fa0b1c69ad..0000000000 --- a/gittip/models/identification.py +++ /dev/null @@ -1,25 +0,0 @@ -from gittip.orm import db -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import BigInteger, Numeric, Text, TIMESTAMP - - -class Identification(db.Model): - __tablename__ = 'identifications' - - id = Column(BigInteger, nullable=False, primary_key=True) - ctime = Column(TIMESTAMP(timezone=True), nullable=False) - mtime = Column(TIMESTAMP(timezone=True), nullable=False, default="now()") - - individual = Column(Text, ForeignKey( "participants.username" - , onupdate="CASCADE" - , ondelete="RESTRICT" - ), nullable=False) - group = Column(Text, ForeignKey( "participants.username" - , onupdate="CASCADE" - , ondelete="RESTRICT" - ), nullable=False) - weight = Column(Numeric(precision=17, scale=16), nullable=False) - identified_by = Column(Text, ForeignKey( "participants.username" - , onupdate="CASCADE" - , ondelete="RESTRICT" - ), nullable=False) diff --git a/gittip/models/participant.py b/gittip/models/participant.py index 0a23dc4290..14ecc8ebb4 100644 --- a/gittip/models/participant.py +++ b/gittip/models/participant.py @@ -1,23 +1,30 @@ -from __future__ import unicode_literals +"""*Participant* is the name Gittip gives to people and groups that are known +to Gittip. We've got a ``participants`` table in the database, and a +:py:class:`Participant` class that we define here. We distinguish several kinds +of participant, based on certain properties. + + - *Stub* participants + - *Organizations* are plural participants + - *Teams* are plural participants with members + +""" +from __future__ import print_function, unicode_literals import datetime -import os +import random +import uuid from decimal import Decimal +import gittip import pytz +from aspen import Response from aspen.utils import typecheck -from sqlalchemy.exc import IntegrityError -from sqlalchemy import func -from sqlalchemy.orm import relationship, exc -from sqlalchemy.schema import Column, CheckConstraint, UniqueConstraint, Sequence -from sqlalchemy.types import Text, TIMESTAMP, Boolean, Numeric, BigInteger, Enum +from psycopg2 import IntegrityError +from postgres.orm import Model +from gittip.models._mixin_elsewhere import MixinElsewhere +from gittip.models._mixin_team import MixinTeam +from gittip.utils import canonicalize -import gittip -from gittip.models.tip import Tip -from gittip.orm import db -# This is loaded for now to maintain functionality until the class is fully -# migrated over to doing everything using SQLAlchemy -from gittip.participant import Participant as OldParticipant ASCII_ALLOWED_IN_USERNAME = set("0123456789" "abcdefghijklmnopqrstuvwxyz" @@ -25,90 +32,115 @@ ".,-_:@ ") NANSWERS_THRESHOLD = 0 # configured in wireup.py -class Participant(db.Model): - __tablename__ = "participants" - __table_args__ = ( - UniqueConstraint("session_token", - name="participants_session_token_key"), - ) - - id = Column(BigInteger, Sequence('participants_id_seq'), nullable=False, unique=True) - username = Column(Text, nullable=False, primary_key=True) - username_lower = Column(Text, nullable=False, unique=True) - statement = Column(Text, default="", nullable=False) - stripe_customer_id = Column(Text) - last_bill_result = Column(Text) - session_token = Column(Text) - session_expires = Column(TIMESTAMP(timezone=True), default="now()") - ctime = Column(TIMESTAMP(timezone=True), nullable=False, default="now()") - claimed_time = Column(TIMESTAMP(timezone=True)) - is_admin = Column(Boolean, nullable=False, default=False) - balance = Column(Numeric(precision=35, scale=2), - CheckConstraint("balance >= 0", name="min_balance"), - default=0.0, nullable=False) - pending = Column(Numeric(precision=35, scale=2), default=None) - anonymous = Column(Boolean, default=False, nullable=False) - goal = Column(Numeric(precision=35, scale=2), default=None) - balanced_account_uri = Column(Text) - last_ach_result = Column(Text) - api_key = Column(Text) - is_suspicious = Column(Boolean) - number = Column(Enum('singular', 'plural', nullable=False)) - - ### Relations ### - accounts_elsewhere = relationship( "Elsewhere" - , backref="participant_orm" - , lazy="dynamic" - ) - exchanges = relationship("Exchange", backref="participant_orm") - - # TODO: Once tippee/tipper are renamed to tippee_id/tipper_idd, we can go - # ahead and drop the foreign_keys & rename backrefs to tipper/tippee - - _tips_giving = relationship( "Tip" - , backref="tipper_participant" - , foreign_keys="Tip.tipper" - , lazy="dynamic" - ) - _tips_receiving = relationship( "Tip" - , backref="tippee_participant" - , foreign_keys="Tip.tippee" - , lazy="dynamic" - ) - - transferer = relationship( "Transfer" - , backref="transferer" - , foreign_keys="Transfer.tipper" - ) - transferee = relationship( "Transfer" - , backref="transferee" - , foreign_keys="Transfer.tippee" - ) - @classmethod - def from_username(cls, username): - # Note that User.from_username overrides this. It authenticates people! - try: - return cls.query.filter_by(username_lower=username.lower()).one() - except exc.NoResultFound: - return None +class Participant(Model, MixinElsewhere, MixinTeam): + """Represent a Gittip participant. + """ + + typname = 'participants' def __eq__(self, other): - return self.id == other.id + if not isinstance(other, Participant): + return False + return self.username == other.username def __ne__(self, other): - return self.id != other.id + if not isinstance(other, Participant): + return False + return self.username != other.username + + + # Constructors + # ============ + + @classmethod + def with_random_username(cls): + """Return a new participant with a random username. + """ + with cls.db.get_cursor() as cursor: + username = reserve_a_random_username(cursor) + return cls.from_username(username) + + @classmethod + def from_id(cls, id): + """Return an existing participant based on id. + """ + return cls._from_thing("id", id) + + @classmethod + def from_username(cls, username): + """Return an existing participant based on username. + """ + return cls._from_thing("username_lower", username.lower()) + + @classmethod + def from_session_token(cls, token): + """Return an existing participant based on session token. + """ + return cls._from_thing("session_token", token) - # Class-specific exceptions - class ProblemChangingUsername(Exception): pass - class UsernameTooLong(ProblemChangingUsername): pass - class UsernameContainsInvalidCharacters(ProblemChangingUsername): pass - class UsernameIsRestricted(ProblemChangingUsername): pass - class UsernameAlreadyTaken(ProblemChangingUsername): pass + @classmethod + def from_api_key(cls, api_key): + """Return an existing participant based on API key. + """ + return cls._from_thing("api_key", api_key) + + @classmethod + def _from_thing(cls, thing, value): + assert thing in ("id", "username_lower", "session_token", "api_key") + return cls.db.one(""" + + SELECT participants.*::participants + FROM participants + WHERE {}=%s + + """.format(thing), (value,)) + + + # Session Management + # ================== + + def start_new_session(self): + """Set ``session_token`` in the database to a new uuid. + + :database: One UPDATE, one row + + """ + self._update_session_token(uuid.uuid4().hex) + + def end_session(self): + """Set ``session_token`` in the database to ``NULL``. - class UnknownPlatform(Exception): pass - class TooGreedy(Exception): pass - class MemberLimitReached(Exception): pass + :database: One UPDATE, one row + + """ + self._update_session_token(None) + + def _update_session_token(self, new_token): + self.db.run( "UPDATE participants SET session_token=%s " + "WHERE id=%s AND is_suspicious IS NOT true" + , (new_token, self.id,) + ) + self.set_attributes(session_token=new_token) + + def set_session_expires(self, expires): + """Set session_expires in the database. + + :param float expires: A UNIX timestamp, which XXX we assume is UTC? + :database: One UPDATE, one row + + """ + session_expires = datetime.datetime.fromtimestamp(expires) \ + .replace(tzinfo=pytz.utc) + self.db.run( "UPDATE participants SET session_expires=%s " + "WHERE id=%s AND is_suspicious IS NOT true" + , (session_expires, self.id,) + ) + self.set_attributes(session_expires=session_expires) + + + # Number + # ====== @property def IS_SINGULAR(self): @@ -118,174 +150,508 @@ def IS_SINGULAR(self): def IS_PLURAL(self): return self.number == 'plural' - @property - def tips_giving(self): - return self._tips_giving.distinct("tips.tippee")\ - .order_by("tips.tippee, tips.mtime DESC") + def update_number(self, number): + assert number in ('singular', 'plural') + self.db.run( "UPDATE participants SET number=%s WHERE id=%s" + , (number, self.id) + ) + self.set_attributes(number=number) - @property - def tips_receiving(self): - return self._tips_receiving.distinct("tips.tipper")\ - .order_by("tips.tipper, tips.mtime DESC") + + # API Key + # ======= + + def recreate_api_key(self): + api_key = str(uuid.uuid4()) + SQL = "UPDATE participants SET api_key=%s WHERE username=%s" + gittip.db.run(SQL, (api_key, self.username)) + return api_key + + + # Claiming + # ======== + # An unclaimed Participant is a stub that's created when someone pledges to + # give to an AccountElsewhere that's not been connected on Gittip yet. + + def resolve_unclaimed(self): + """Given a username, return an URL path. + """ + rec = gittip.db.one( "SELECT platform, user_info " + "FROM elsewhere " + "WHERE participant = %s" + , (self.username,) + ) + if rec is None: + out = None + elif rec['platform'] == 'github': + out = '/on/github/%s/' % rec['user_info']['login'] + else: + assert rec['platform'] == 'twitter' + out = '/on/twitter/%s/' % rec['user_info']['screen_name'] + return out + + def set_as_claimed(self): + claimed_time = self.db.one("""\ + + UPDATE participants + SET claimed_time=CURRENT_TIMESTAMP + WHERE username=%s + AND claimed_time IS NULL + RETURNING claimed_time + + """, (self.username,)) + self.set_attributes(claimed_time=claimed_time) + + + + # Random Junk + # =========== + + def get_teams(self): + """Return a list of teams this user is a member of. + """ + return gittip.db.all(""" + + SELECT team AS name + , ( SELECT count(*) + FROM current_memberships + WHERE team=x.team + ) AS nmembers + FROM current_memberships x + WHERE member=%s; + + """, (self.username,)) @property def accepts_tips(self): return (self.goal is None) or (self.goal >= 0) - @property - def valid_tips_receiving(self): - ''' - - SELECT count(anon_1.amount) AS count_1 - FROM ( SELECT DISTINCT ON (tips.tipper) - tips.id AS id - , tips.ctime AS ctime - , tips.mtime AS mtime - , tips.tipper AS tipper - , tips.tippee AS tippee - , tips.amount AS amount - FROM tips - JOIN participants ON tips.tipper = participants.username - WHERE %(param_1)s = tips.tippee - AND participants.is_suspicious IS NOT true - AND participants.last_bill_result = %(last_bill_result_1)s - ORDER BY tips.tipper, tips.mtime DESC - ) AS anon_1 - WHERE anon_1.amount > %(amount_1)s - - ''' - return self.tips_receiving \ - .join( Participant - , Tip.tipper.op('=')(Participant.username) - ) \ - .filter( 'participants.is_suspicious IS NOT true' - , Participant.last_bill_result == '' - ) - def resolve_unclaimed(self): - if self.accounts_elsewhere: - return self.accounts_elsewhere[0].resolve_unclaimed() - else: - return None + def insert_into_communities(self, is_member, name, slug): + username = self.username + gittip.db.run(""" + + INSERT INTO communities + (ctime, name, slug, participant, is_member) + VALUES ( COALESCE (( SELECT ctime + FROM communities + WHERE (participant=%s AND slug=%s) + LIMIT 1 + ), CURRENT_TIMESTAMP) + , %s, %s, %s, %s + ) + RETURNING ( SELECT count(*) = 0 + FROM communities + WHERE participant=%s + ) + AS first_time_community + + """, (username, slug, name, slug, username, is_member, username)) - def set_as_claimed(self, claimed_at=None): - if claimed_at is None: - claimed_at = datetime.datetime.now(pytz.utc) - self.claimed_time = claimed_at - db.session.add(self) - db.session.commit() - def change_username(self, desired_username): - """Raise self.ProblemChangingUsername, or return None. + def change_username(self, suggested): + """Raise Response or return None. We want to be pretty loose with usernames. Unicode is allowed--XXX - aspen bug :(. So are spaces. Control characters aren't. We also limit - to 32 characters in length. + aspen bug :(. So are spaces.Control characters aren't. We also limit to + 32 characters in length. """ - for i, c in enumerate(desired_username): + typecheck(suggested, unicode) + for i, c in enumerate(suggested): if i == 32: - raise self.UsernameTooLong # Request Entity Too Large (more or less) + raise UsernameTooLong # Request Entity Too Large (more or less) elif ord(c) < 128 and c not in ASCII_ALLOWED_IN_USERNAME: - raise self.UsernameContainsInvalidCharacters # Yeah, no. + raise UsernameContainsInvalidCharacters # Yeah, no. elif c not in ASCII_ALLOWED_IN_USERNAME: - # XXX Burned by an Aspen bug. :`-( # https://github.com/gittip/aspen/issues/102 + raise UsernameContainsInvalidCharacters - raise self.UsernameContainsInvalidCharacters - - lowercased = desired_username.lower() + lowercased = suggested.lower() if lowercased in gittip.RESTRICTED_USERNAMES: - raise self.UsernameIsRestricted + raise UsernameIsRestricted - if desired_username != self.username: + if suggested != self.username: try: - self.username = desired_username - self.username_lower = lowercased - db.session.add(self) - db.session.commit() - # Will raise sqlalchemy.exc.IntegrityError if the - # desired_username is taken. + # Will raise IntegrityError if the desired username is taken. + actual = gittip.db.one( "UPDATE participants " + "SET username=%s, username_lower=%s " + "WHERE username=%s " + "RETURNING username, username_lower" + , (suggested, lowercased, self.username) + , back_as=tuple + ) except IntegrityError: - db.session.rollback() - raise self.UsernameAlreadyTaken - - def get_accounts_elsewhere(self): - github_account = twitter_account = bitbucket_account = \ - bountysource_account = None - for account in self.accounts_elsewhere.all(): - if account.platform == "github": - github_account = account - elif account.platform == "twitter": - twitter_account = account - elif account.platform == "bitbucket": - bitbucket_account = account - elif account.platform == "bountysource": - bountysource_account = account - else: - raise self.UnknownPlatform(account.platform) - return ( github_account - , twitter_account - , bitbucket_account - , bountysource_account - ) - - def get_img_src(self, size=128): - """Return a value for . - - Until we have our own profile pics, delegate. XXX Is this an attack - vector? Can someone inject this value? Don't think so, but if you make - it happen, let me know, eh? Thanks. :) - - https://www.gittip.com/security.txt + raise UsernameAlreadyTaken(suggested) + + assert (suggested, lowercased) == actual # sanity check + self.set_attributes(username=suggested, username_lower=lowercased) + + + def update_goal(self, goal): + typecheck(goal, (Decimal, None)) + self.db.run( "UPDATE participants SET goal=%s WHERE username=%s" + , (goal, self.username) + ) + self.set_attributes(goal=goal) + + + def set_tip_to(self, tippee, amount): + """Given participant id and amount as str, return a tuple. + + We INSERT instead of UPDATE, so that we have history to explore. The + COALESCE function returns the first of its arguments that is not NULL. + The effect here is to stamp all tips with the timestamp of the first + tip from this user to that. I believe this is used to determine the + order of transfers during payday. + + The tuple returned is the amount as a Decimal and a boolean indicating + whether this is the first time this tipper has tipped (we want to track + that as part of our conversion funnel). """ - typecheck(size, int) - src = '/assets/%s/avatar-default.gif' % os.environ['__VERSION__'] + if self.username == tippee: + raise NoSelfTipping - github, twitter, bitbucket, bountysource = self.get_accounts_elsewhere() - if github is not None: - # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ - if 'gravatar_id' in github.user_info: - gravatar_hash = github.user_info['gravatar_id'] - src = "https://www.gravatar.com/avatar/%s.jpg?s=%s" - src %= (gravatar_hash, size) + amount = Decimal(amount) # May raise InvalidOperation + if (amount < gittip.MIN_TIP) or (amount > gittip.MAX_TIP): + raise BadAmount - elif twitter is not None: - # https://dev.twitter.com/docs/api/1.1/get/users/show - if 'profile_image_url_https' in twitter.user_info: - src = twitter.user_info['profile_image_url_https'] + NEW_TIP = """\ - # For Twitter, we don't have good control over size. The - # biggest option is 73px(?!), but that's too small. Let's go - # with the original: even though it may be huge, that's - # preferrable to guaranteed blurriness. :-/ + INSERT INTO tips + (ctime, tipper, tippee, amount) + VALUES ( COALESCE (( SELECT ctime + FROM tips + WHERE (tipper=%s AND tippee=%s) + LIMIT 1 + ), CURRENT_TIMESTAMP) + , %s, %s, %s + ) + RETURNING ( SELECT count(*) = 0 FROM tips WHERE tipper=%s ) + AS first_time_tipper - src = src.replace('_normal.', '.') + """ + args = (self.username, tippee, self.username, tippee, amount, \ + self.username) + first_time_tipper = gittip.db.one(NEW_TIP, args) + return amount, first_time_tipper - return src def get_tip_to(self, tippee): - tip = self.tips_giving.filter_by(tippee=tippee).first() + """Given two user ids, return a Decimal. + """ + return self.db.one("""\ - if tip: - amount = tip.amount - else: - amount = Decimal('0.00') + SELECT amount + FROM tips + WHERE tipper=%s + AND tippee=%s + ORDER BY mtime DESC + LIMIT 1 + + """, (self.username, tippee), default=Decimal('0.00')) - return amount def get_dollars_receiving(self): - return sum(tip.amount for tip in self.valid_tips_receiving) + Decimal('0.00') + """Return a Decimal. + """ + return self.db.one("""\ + + SELECT sum(amount) + FROM ( SELECT DISTINCT ON (tipper) + amount + , tipper + FROM tips + JOIN participants p ON p.username = tipper + WHERE tippee=%s + AND last_bill_result = '' + AND is_suspicious IS NOT true + ORDER BY tipper + , mtime DESC + ) AS foo + + """, (self.username,), default=Decimal('0.00')) + + + def get_dollars_giving(self): + """Return a Decimal. + """ + return self.db.one("""\ + + SELECT sum(amount) + FROM ( SELECT DISTINCT ON (tippee) + amount + , tippee + FROM tips + JOIN participants p ON p.username = tippee + WHERE tipper=%s + AND is_suspicious IS NOT true + AND claimed_time IS NOT NULL + ORDER BY tippee + , mtime DESC + ) AS foo + + """, (self.username,), default=Decimal('0.00')) + def get_number_of_backers(self): - amount_column = self.valid_tips_receiving.subquery().columns.amount - count = func.count(amount_column) - nbackers = db.session.query(count).filter(amount_column > 0).one()[0] - return nbackers + """Given a unicode, return an int. + """ + return gittip.db.one("""\ + + SELECT count(amount) + FROM ( SELECT DISTINCT ON (tipper) + amount + , tipper + FROM tips + JOIN participants p ON p.username = tipper + WHERE tippee=%s + AND last_bill_result = '' + AND is_suspicious IS NOT true + ORDER BY tipper + , mtime DESC + ) AS foo + WHERE amount > 0 + + """, (self.username,), default=0) + + + def get_tip_distribution(self): + """ + Returns a data structure in the form of: + [ + [TIPAMOUNT1, TIPAMOUNT2...TIPAMOUNTN], + total_number_patrons_giving_to_me, + total_amount_received + ] + + where each TIPAMOUNTN is in the form: + + [amount, + number_of_tippers_for_this_amount, + total_amount_given_at_this_amount, + proportion_of_tips_at_this_amount, + proportion_of_total_amount_at_this_amount + ] + + """ + SQL = """ + + SELECT amount + , count(amount) AS ncontributing + FROM ( SELECT DISTINCT ON (tipper) + amount + , tipper + FROM tips + JOIN participants p ON p.username = tipper + WHERE tippee=%s + AND last_bill_result = '' + AND is_suspicious IS NOT true + ORDER BY tipper + , mtime DESC + ) AS foo + WHERE amount > 0 + GROUP BY amount + ORDER BY amount + + """ + + tip_amounts = [] + + npatrons = 0.0 # float to trigger float division + contributed = Decimal('0.00') + for rec in gittip.db.all(SQL, (self.username,)): + tip_amounts.append([ rec.amount + , rec.ncontributing + , rec.amount * rec.ncontributing + ]) + contributed += tip_amounts[-1][2] + npatrons += rec.ncontributing + + for row in tip_amounts: + row.append((row[1] / npatrons) if npatrons > 0 else 0) + row.append((row[2] / contributed) if contributed > 0 else 0) + + return tip_amounts, npatrons, contributed + + + def get_giving_for_profile(self, db=None): + """Given a participant id and a date, return a list and a Decimal. + + This function is used to populate a participant's page for their own + viewing pleasure. + + A half-injected dependency, that's what db is. + + """ + if db is None: + from gittip import db + + TIPS = """\ + + SELECT * FROM ( + SELECT DISTINCT ON (tippee) + amount + , tippee + , t.ctime + , p.claimed_time + , p.username_lower + FROM tips t + JOIN participants p ON p.username = t.tippee + WHERE tipper = %s + AND p.is_suspicious IS NOT true + AND p.claimed_time IS NOT NULL + ORDER BY tippee + , t.mtime DESC + ) AS foo + ORDER BY amount DESC + , username_lower + + """ + tips = db.all(TIPS, (self.username,)) + + UNCLAIMED_TIPS = """\ + + SELECT * FROM ( + SELECT DISTINCT ON (tippee) + amount + , tippee + , t.ctime + , p.claimed_time + , e.platform + , e.user_info + FROM tips t + JOIN participants p ON p.username = t.tippee + JOIN elsewhere e ON e.participant = t.tippee + WHERE tipper = %s + AND p.is_suspicious IS NOT true + AND p.claimed_time IS NULL + ORDER BY tippee + , t.mtime DESC + ) AS foo + ORDER BY amount DESC + , lower(user_info->'screen_name') + , lower(user_info->'username') + , lower(user_info->'login') + + """ + unclaimed_tips = db.all(UNCLAIMED_TIPS, (self.username,)) + + + # Compute the total. + # ================== + # For payday we only want to process payments to tippees who have + # themselves opted into Gittip. For the tipper's profile page we want + # to show the total amount they've pledged (so they're not surprised + # when someone *does* start accepting tips and all of a sudden they're + # hit with bigger charges. + + total = sum([t.amount for t in tips]) + if not total: + # If tips is an empty list, total is int 0. We want a Decimal. + total = Decimal('0.00') + + unclaimed_total = sum([t.amount for t in unclaimed_tips]) + if not unclaimed_total: + unclaimed_total = Decimal('0.00') + + return tips, total, unclaimed_tips, unclaimed_total + + + def get_tips_and_total(self, for_payday=False): + """Given a participant id and a date, return a list and a Decimal. + + This function is used by the payday function. If for_payday is not + False it must be a date object. Originally we also used this function + to populate the profile page, but our requirements there changed while, + oddly, our requirements in payday *also* changed to match the old + requirements of the profile page. So this function keeps the for_payday + parameter after all. + + """ + + if for_payday: + + # For payday we want the oldest relationship to be paid first. + order_by = "ctime ASC" + + + # This is where it gets crash-proof. + # ================================== + # We need to account for the fact that we may have crashed during + # Payday and we're re-running that function. We only want to select + # tips that existed before Payday started, but haven't been + # processed as part of this Payday yet. + # + # It's a bug if the paydays subselect returns > 1 rows. + # + # XXX If we crash during Payday and we rerun it after a timezone + # change, will we get burned? How? + + ts_filter = """\ + + AND mtime < %s + AND ( SELECT id + FROM transfers + WHERE tipper=t.tipper + AND tippee=t.tippee + AND timestamp >= %s + ) IS NULL + + """ + args = (self.username, for_payday, for_payday) + else: + order_by = "amount DESC" + ts_filter = "" + args = (self.username,) + + TIPS = """\ + + SELECT * FROM ( + SELECT DISTINCT ON (tippee) + amount + , tippee + , t.ctime + , p.claimed_time + FROM tips t + JOIN participants p ON p.username = t.tippee + WHERE tipper = %%s + AND p.is_suspicious IS NOT true + %s + ORDER BY tippee + , t.mtime DESC + ) AS foo + ORDER BY %s + , tippee + + """ % (ts_filter, order_by) # XXX, No injections here, right?! + tips = self.db.all(TIPS, args, back_as=dict) + + + # Compute the total. + # ================== + # For payday we only want to process payments to tippees who have + # themselves opted into Gittip. For the tipper's profile page we want + # to show the total amount they've pledged (so they're not surprised + # when someone *does* start accepting tips and all of a sudden they're + # hit with bigger charges. + + if for_payday: + to_total = [t for t in tips if t['claimed_time'] is not None] + else: + to_total = tips + total = sum([t['amount'] for t in to_total]) + + if not total: + # If to_total is an empty list, total is int 0. We want a Decimal. + total = Decimal('0.00') + + return tips, total + def get_og_title(self): out = self.username @@ -299,6 +665,7 @@ def get_og_title(self): out += " is" return out + " on Gittip" + def get_age_in_seconds(self): out = -1 if self.claimed_time is not None: @@ -306,214 +673,112 @@ def get_age_in_seconds(self): out = (now - self.claimed_time).total_seconds() return out - def get_teams(self): - """Return a list of teams this user is a member of. - """ - return list(gittip.db.all(""" - SELECT team AS name - , ( SELECT count(*) - FROM current_memberships - WHERE team=x.team - ) AS nmembers - FROM current_memberships x - WHERE member=%s; +# Exceptions +# ========== - """, (self.username,))) +class ProblemChangingUsername(Exception): + def __str__(self): + return self.msg.format(self.args[0]) +class UsernameTooLong(ProblemChangingUsername): + msg = "The username '{}' is restricted." - # Participant as Team - # =================== +class UsernameContainsInvalidCharacters(ProblemChangingUsername): + msg = "The username '{}' contains invalid characters." - def show_as_team(self, user): - """Return a boolean, whether to show this participant as a team. - """ - if not self.IS_PLURAL: - return False - if user.ADMIN: - return True - if not self.get_members(): - if self != user: - return False - return True - - def add_member(self, member): - """Add a member to this team. - """ - assert self.IS_PLURAL - if len(self.get_members()) == 149: - raise self.MemberLimitReached - self.__set_take_for(member, Decimal('0.01'), self) +class UsernameIsRestricted(ProblemChangingUsername): + msg = "The username '{}' is restricted." - def remove_member(self, member): - """Remove a member from this team. - """ - assert self.IS_PLURAL - self.__set_take_for(member, Decimal('0.00'), self) +class UsernameAlreadyTaken(ProblemChangingUsername): + msg = "The username '{}' is already taken." - def member_of(self, team): - """Given a Participant object, return a boolean. - """ - assert team.IS_PLURAL - for member in team.get_members(): - if member['username'] == self.username: - return True - return False - - def get_take_last_week_for(self, member): - """What did the user actually take most recently? Used in throttling. - """ - assert self.IS_PLURAL - membername = member.username if hasattr(member, 'username') \ - else member['username'] - rec = gittip.db.one(""" +class TooGreedy(Exception): pass +class NoSelfTipping(Exception): pass +class BadAmount(Exception): pass - SELECT amount - FROM transfers - WHERE tipper=%s AND tippee=%s - AND timestamp > - (SELECT ts_start FROM paydays ORDER BY ts_start DESC LIMIT 1) - ORDER BY timestamp DESC LIMIT 1 - """, (self.username, membername)) +# Username Helpers +# ================ - if rec is None: - return Decimal('0.00') - else: - return rec['amount'] +def gen_random_usernames(): + """Yield up to 100 random 12-hex-digit unicodes. - def get_take_for(self, member): - """Return a Decimal representation of the take for this member, or 0. - """ - assert self.IS_PLURAL - rec = gittip.db.one( "SELECT take FROM current_memberships " - "WHERE member=%s AND team=%s" - , (member.username, self.username) - ) - if rec is None: - return Decimal('0.00') + We raise :py:exc:`StopIteration` after 100 usernames as a safety + precaution. + + """ + seatbelt = 0 + while 1: + yield hex(int(random.random() * 16**12))[2:].zfill(12).decode('ASCII') + seatbelt += 1 + if seatbelt > 100: + raise StopIteration + + +def reserve_a_random_username(txn): + """Reserve a random username. + + :param txn: a :py:class:`psycopg2.cursor` managed as a :py:mod:`postgres` + transaction + :database: one ``INSERT`` on average + :returns: a 12-hex-digit unicode + :raises: :py:class:`StopIteration` if no acceptable username is found + within 100 attempts + + The returned value is guaranteed to have been reserved in the database. + + """ + for username in gen_random_usernames(): + try: + txn.execute( "INSERT INTO participants (username, username_lower) " + "VALUES (%s, %s)" + , (username, username.lower()) + ) + except IntegrityError: # Collision, try again with another value. + pass else: - return rec['take'] + break - def compute_max_this_week(self, last_week): - """2x last week's take, but at least a dollar. - """ - return max(last_week * Decimal('2'), Decimal('1.00')) + return username - def set_take_for(self, member, take, recorder): - """Sets member's take from the team pool. - """ - assert self.IS_PLURAL - from gittip.models.user import User # lazy to avoid circular import - typecheck( member, Participant - , take, Decimal - , recorder, (Participant, User) - ) - - last_week = self.get_take_last_week_for(member) - max_this_week = self.compute_max_this_week(last_week) - if take > max_this_week: - take = max_this_week - - self.__set_take_for(member, take, recorder) - return take - - def __set_take_for(self, member, take, recorder): - assert self.IS_PLURAL - # XXX Factored out for testing purposes only! :O Use .set_take_for. - gittip.db.run(""" - INSERT INTO memberships (ctime, member, team, take, recorder) - VALUES ( COALESCE (( SELECT ctime - FROM memberships - WHERE member=%s - AND team=%s - LIMIT 1 - ), CURRENT_TIMESTAMP) - , %s - , %s - , %s - , %s - ) - - """, (member.username, self.username, member.username, self.username, \ - take, recorder.username)) - - def get_members(self): - assert self.IS_PLURAL - return list(gittip.db.all(""" - - SELECT member AS username, take, ctime, mtime - FROM current_memberships - WHERE team=%s - ORDER BY ctime DESC - - """, (self.username,))) - - def get_teams_membership(self): - assert self.IS_PLURAL - TAKE = "SELECT sum(take) FROM current_memberships WHERE team=%s" - total_take = gittip.db.one(TAKE, (self.username,))['sum'] - total_take = 0 if total_take is None else total_take - team_take = max(self.get_dollars_receiving() - total_take, 0) - membership = { "ctime": None - , "mtime": None - , "username": self.username - , "take": team_take - } - return membership - - def get_memberships(self, current_user): - assert self.IS_PLURAL - members = self.get_members() - members.append(self.get_teams_membership()) - budget = balance = self.get_dollars_receiving() - for member in members: - member['removal_allowed'] = current_user == self - member['editing_allowed'] = False - if member['username'] == current_user.username: - member['is_current_user'] = True - if member['ctime'] is not None: - # current user, but not the team itself - member['editing_allowed']= True - take = member['take'] - member['take'] = take - member['last_week'] = last_week = \ - self.get_take_last_week_for(member) - member['max_this_week'] = self.compute_max_this_week(last_week) - amount = min(take, balance) - balance -= amount - member['balance'] = balance - member['percentage'] = (amount / budget) if budget > 0 else 0 - return members - - - # TODO: Move these queries into this class. +def typecast(request): + """Given a Request, raise Response or return Participant. - def set_tip_to(self, tippee, amount): - return OldParticipant(self.username).set_tip_to(tippee, amount) + If user is not None then we'll restrict access to owners and admins. - def insert_into_communities(self, is_member, name, slug): - return OldParticipant(self.username).insert_into_communities( is_member - , name - , slug - ) + """ - def get_dollars_giving(self): - return OldParticipant(self.username).get_dollars_giving() + # XXX We can't use this yet because we don't have an inbound Aspen hook + # that fires after the first page of the simplate is exec'd. - def get_tip_distribution(self): - return OldParticipant(self.username).get_tip_distribution() + path = request.line.uri.path + if 'username' not in path: + return - def get_giving_for_profile(self, db=None): - return OldParticipant(self.username).get_giving_for_profile(db) + slug = path['username'] - def get_tips_and_total(self, for_payday=False, db=None): - return OldParticipant(self.username).get_tips_and_total(for_payday, db) + participant = gittip.db.one( "SELECT participants.*::participants " + "FROM participants " + "WHERE username_lower=%s" + , (slug.lower()) + ) - def take_over(self, account_elsewhere, have_confirmation=False): - OldParticipant(self.username).take_over(account_elsewhere, - have_confirmation) + if participant is None: + raise Response(404) - def recreate_api_key(self): - return OldParticipant(self.username).recreate_api_key() + canonicalize(request.line.uri.path.raw, '/', participant.username, slug) + + if participant.claimed_time is None: + + # This is a stub participant record for someone on another platform + # who hasn't actually registered with Gittip yet. Let's bounce the + # viewer over to the appropriate platform page. + + to = participant.resolve_unclaimed() + if to is None: + raise Response(404) + request.redirect(to) + + path['participant'] = participant diff --git a/gittip/models/payday.py b/gittip/models/payday.py deleted file mode 100644 index 5b49c96b03..0000000000 --- a/gittip/models/payday.py +++ /dev/null @@ -1,36 +0,0 @@ -import datetime - -import pytz -from sqlalchemy.schema import Column, UniqueConstraint -from sqlalchemy.types import Integer, Numeric, Text, TIMESTAMP - -from gittip.orm import db - -class Payday(db.Model): - __tablename__ = 'paydays' - __table_args__ = ( - UniqueConstraint('ts_end', name='paydays_ts_end_key'), - ) - - # TODO: Move this to a different module? - EPOCH = datetime.datetime(1970, 1, 1, tzinfo=pytz.utc) - - id = Column(Integer, nullable=False, primary_key=True) - ts_start = Column(TIMESTAMP(timezone=True), nullable=False, - default="now()") - ts_end = Column(TIMESTAMP(timezone=True), nullable=False, - default=EPOCH) - nparticipants = Column(Integer, default=0) - ntippers = Column(Integer, default=0) - ntips = Column(Integer, default=0) - ntransfers = Column(Integer, default=0) - transfer_volume = Column(Numeric(precision=35, scale=2), default=0.0) - ncc_failing = Column(Integer, default=0) - ncc_missing = Column(Integer, default=0) - ncharges = Column(Integer, default=0) - charge_volume = Column(Numeric(precision=35, scale=2), default=0.0) - charge_fees_volume = Column(Numeric(precision=35, scale=2), default=0.0) - nachs = Column(Integer, default=0) - ach_volume = Column(Numeric(precision=35, scale=2), default=0.0) - ach_fees_volume = Column(Numeric(precision=35, scale=2), default=0.0) - nach_failing = Column(Integer, default=0) \ No newline at end of file diff --git a/gittip/models/tip.py b/gittip/models/tip.py deleted file mode 100644 index 194203c27c..0000000000 --- a/gittip/models/tip.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import Integer, Numeric, Text, TIMESTAMP - -from gittip.orm import db - -class Tip(db.Model): - __tablename__ = 'tips' - - id = Column(Integer, nullable=False, primary_key=True) - ctime = Column(TIMESTAMP(timezone=True), nullable=False) - mtime = Column(TIMESTAMP(timezone=True), nullable=False, default="now()") - tipper = Column(Text, ForeignKey("participants.username", onupdate="CASCADE", - ondelete="RESTRICT"), nullable=False) - tippee = Column(Text, ForeignKey("participants.username", onupdate="CASCADE", - ondelete="RESTRICT"), nullable=False) - amount = Column(Numeric(precision=35, scale=2), nullable=False) diff --git a/gittip/models/transfer.py b/gittip/models/transfer.py deleted file mode 100644 index 3652c1e114..0000000000 --- a/gittip/models/transfer.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy.schema import Column, ForeignKey -from sqlalchemy.types import Integer, Numeric, Text, TIMESTAMP - -from gittip.orm import db - -class Transfer(db.Model): - __tablename__ = 'transfers' - - id = Column(Integer, nullable=False, primary_key=True) - timestamp = Column(TIMESTAMP(timezone=True), nullable=False, - default="now()") - tipper = Column(Text, ForeignKey("participants.id", onupdate="CASCADE", - ondelete="RESTRICT"), nullable=False) - tippee = Column(Text, ForeignKey("participants.id", onupdate="CASCADE", - ondelete="RESTRICT"), nullable=False) - amount = Column(Numeric(precision=35, scale=2), nullable=False) \ No newline at end of file diff --git a/gittip/models/user.py b/gittip/models/user.py deleted file mode 100644 index 5dca1527b9..0000000000 --- a/gittip/models/user.py +++ /dev/null @@ -1,70 +0,0 @@ -import uuid - -from gittip.orm import db -from gittip.models.participant import Participant - - -class User(Participant): - """Represent a website user. - - Every current website user is also a participant, though if the user is - anonymous then the methods from gittip.Participant will fail with - NoParticipantId. - - """ - - @classmethod - def from_session_token(cls, token): - return cls._from_token(User.session_token, token) - - @classmethod - def from_api_key(cls, token): - return cls._from_token(User.api_key, token) - - @classmethod - def _from_token(cls, field, token): - - # This used to read User.query.filter_by(session_token=token), but that - # generates "session_token is NULL" when token is None, and we need - # "session_token = NULL", or else we will match arbitrary users(!). - # This is a bit of WTF from SQLAlchemy here, IMO: it dangerously opts - # for idiomatic Python over idiomatic SQL. We fell prey, at least. :-/ - - user = User.query.filter(field.op('=')(token)).first() - - if user and not user.is_suspicious: - user = user - else: - user = User() - return user - - - @classmethod - def from_username(cls, username): - user = User.query.filter_by(username_lower=username.lower()).first() - if user is None or user.is_suspicious: - user = User() - else: - user.session_token = uuid.uuid4().hex - db.session.add(user) - db.session.commit() - return user - - def sign_out(self): - token = self.session_token - if token is not None: - self.session_token = None - db.session.add(self) - db.session.commit() - return User() - - @property - def ADMIN(self): - return self.username is not None and self.is_admin - - @property - def ANON(self): - return self.username is None - - def __unicode__(self): - return '' % getattr(self, 'username', 'Anonymous') diff --git a/gittip/orm/__init__.py b/gittip/orm/__init__.py deleted file mode 100644 index bee3b5b829..0000000000 --- a/gittip/orm/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import unicode_literals -import os - -import gittip -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.exc import OperationalError - - -class Model(object): - - def __repr__(self): - cols = self.__mapper__.c.keys() - class_name = self.__class__.__name__ - items = ', '.join(['%s=%s' % (col, repr(getattr(self, col))) for col - in cols]) - return '%s(%s)' % (class_name, items) - - def attrs_dict(self): - keys = self.__mapper__.c.keys() - attrs = {} - for key in keys: - attrs[key] = getattr(self, key) - return attrs - - -class SQLAlchemy(object): - - def __init__(self): - self.session = self.create_session() - self.Model = self.make_declarative_base() - - @property - def engine(self): - dburl = os.environ['DATABASE_URL'] - maxconn = int(os.environ['DATABASE_MAXCONN']) - return create_engine(dburl, pool_size=maxconn, max_overflow=0) - - @property - def metadata(self): - return self.Model.metadata - - def create_session(self): - session = scoped_session(sessionmaker()) - session.configure(bind=self.engine) - return session - - def make_declarative_base(self): - base = declarative_base(cls=Model) - base.query = self.session.query_property() - return base - - def empty_tables(self): - gittip.db.run("DELETE FROM memberships") # *sigh* - gittip.db.run("DELETE FROM log_participant_number") # *sigh* - tables = reversed(self.metadata.sorted_tables) - for table in tables: - try: - self.session.execute(table.delete()) - self.session.commit() - except OperationalError: - self.session.rollback() - self.session.remove() - - def drop_all(self): - self.Model.metadata.drop_all(bind=self.engine) - - def create_all(self): - self.Model.metadata.create_all(bind=self.engine) - - -db = SQLAlchemy() - -all = [db] - -def rollback(*_): - db.session.rollback() diff --git a/gittip/participant.py b/gittip/participant.py deleted file mode 100644 index a075d1db33..0000000000 --- a/gittip/participant.py +++ /dev/null @@ -1,919 +0,0 @@ -"""Defines a Participant class. -""" -import random -import re -import uuid -from decimal import Decimal - -import gittip -from aspen import Response -from aspen.utils import typecheck -from psycopg2 import IntegrityError -from postgres import TooFew - - -ASCII_ALLOWED_IN_USERNAME = set("0123456789" - "abcdefghijklmnopqrstuvwxyz" - "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - ".,-_;:@ ") - - -class NoParticipantId(Exception): - """Represent a bug where we treat an anonymous user as a participant. - """ - - -class NeedConfirmation(Exception): - """We need confirmation before we'll proceed. - """ - - def __init__(self, a, b, c): - self.other_is_a_real_participant = a - self.this_is_others_last_account_elsewhere = b - self.we_already_have_that_kind_of_account = c - self._all = (a, b, c) - - def __repr__(self): - return "" % self._all - __str__ = __repr__ - - def __eq__(self, other): - return self._all == other._all - - def __ne__(self, other): - return not self.__eq__(other) - - def __nonzero__(self): - # bool(need_confirmation) - A, B, C = self._all - return A or C - - -def gen_random_usernames(): - """Yield up to 100 random usernames. - """ - seatbelt = 0 - while 1: - yield hex(int(random.random() * 16**12))[2:].zfill(12).decode('ASCII') - seatbelt += 1 - if seatbelt > 100: - raise StopIteration - - -def reserve_a_random_username(txn): - """Reserve a random username. - - The returned value is guaranteed to have been reserved in the database. - - """ - for username in gen_random_usernames(): - try: - txn.execute( "INSERT INTO participants (username, username_lower) " - "VALUES (%s, %s)" - , (username, username.lower()) - ) - except IntegrityError: # Collision, try again with another value. - pass - else: - break - - return username - - -def require_username(func): - # XXX This should be done with a metaclass, maybe? - def wrapped(self, *a, **kw): - if self.username is None: - raise NoParticipantId("User does not participate, apparently.") - return func(self, *a, **kw) - return wrapped - - -class Participant(object): - """Represent a Gittip participant. - """ - - class NoSelfTipping(Exception): pass - class BadAmount(Exception): pass - - - def __init__(self, username): - typecheck(username, (unicode, None)) - self.username = username - - - @require_username - def get_details(self): - """Return a dictionary. - """ - SELECT = """ - - SELECT * - FROM participants - WHERE username = %s - - """ - return gittip.db.one(SELECT, (self.username,)) - - - # API Key - # ======= - - @require_username - def recreate_api_key(self): - api_key = str(uuid.uuid4()) - SQL = "UPDATE participants SET api_key=%s WHERE username=%s" - gittip.db.run(SQL, (api_key, self.username)) - return api_key - - - # Claiming - # ======== - # An unclaimed Participant is a stub that's created when someone pledges to - # give to an AccountElsewhere that's not been connected on Gittip yet. - - @require_username - def resolve_unclaimed(self): - """Given a username, return an URL path. - """ - rec = gittip.db.one("SELECT platform, user_info FROM elsewhere " - "WHERE participant = %s", (self.username,)) - if rec is None: - out = None - elif rec['platform'] == 'github': - out = '/on/github/%s/' % rec['user_info']['login'] - else: - assert rec['platform'] == 'twitter' - out = '/on/twitter/%s/' % rec['user_info']['screen_name'] - return out - - @require_username - def set_as_claimed(self): - CLAIM = """\ - - UPDATE participants - SET claimed_time=CURRENT_TIMESTAMP - WHERE username=%s - AND claimed_time IS NULL - - """ - gittip.db.run(CLAIM, (self.username,)) - - @require_username - def insert_into_communities(self, is_member, name, slug): - username = self.username - gittip.db.run(""" - - INSERT INTO communities - (ctime, name, slug, participant, is_member) - VALUES ( COALESCE (( SELECT ctime - FROM communities - WHERE (participant=%s AND slug=%s) - LIMIT 1 - ), CURRENT_TIMESTAMP) - , %s, %s, %s, %s - ) - RETURNING ( SELECT count(*) = 0 - FROM communities - WHERE participant=%s - ) - AS first_time_community - - """, (username, slug, name, slug, username, is_member, username)) - - @require_username - def change_username(self, suggested): - """Raise Response or return None. - - We want to be pretty loose with usernames. Unicode is allowed--XXX - aspen bug :(. So are spaces.Control characters aren't. We also limit to - 32 characters in length. - - """ - for i, c in enumerate(suggested): - if i == 32: - raise Response(413) # Request Entity Too Large (more or less) - elif ord(c) < 128 and c not in ASCII_ALLOWED_IN_USERNAME: - raise Response(400) # Yeah, no. - elif c not in ASCII_ALLOWED_IN_USERNAME: - raise Response(400) # XXX Burned by an Aspen bug. :`-( - # https://github.com/whit537/aspen/issues/102 - - if suggested in gittip.RESTRICTED_USERNAMES: - raise Response(400) - - if suggested != self.username: - # Will raise IntegrityError if the desired username is taken. - rec = gittip.db.one("UPDATE participants " - "SET username=%s WHERE username=%s " - "RETURNING username", - (suggested, self.username)) - - assert rec is not None # sanity check - assert suggested == rec['username'] # sanity check - self.username = suggested - - - @require_username - def get_accounts_elsewhere(self): - """Return a two-tuple of elsewhere dicts. - """ - ACCOUNTS = """ - SELECT * FROM elsewhere WHERE participant=%s; - """ - accounts = gittip.db.all(ACCOUNTS, (self.username,)) - assert accounts is not None - twitter_account = None - github_account = None - for account in accounts: - if account['platform'] == 'github': - github_account = account - else: - assert account['platform'] == 'twitter', account['platform'] - twitter_account = account - return (github_account, twitter_account) - - - @require_username - def set_tip_to(self, tippee, amount): - """Given participant id and amount as str, return a tuple. - - We INSERT instead of UPDATE, so that we have history to explore. The - COALESCE function returns the first of its arguments that is not NULL. - The effect here is to stamp all tips with the timestamp of the first - tip from this user to that. I believe this is used to determine the - order of transfers during payday. - - The tuple returned is the amount as a Decimal and a boolean indicating - whether this is the first time this tipper has tipped (we want to track - that as part of our conversion funnel). - - """ - - if self.username == tippee: - raise self.NoSelfTipping - - amount = Decimal(amount) # May raise InvalidOperation - if (amount < gittip.MIN_TIP) or (amount > gittip.MAX_TIP): - raise self.BadAmount - - NEW_TIP = """\ - - INSERT INTO tips - (ctime, tipper, tippee, amount) - VALUES ( COALESCE (( SELECT ctime - FROM tips - WHERE (tipper=%s AND tippee=%s) - LIMIT 1 - ), CURRENT_TIMESTAMP) - , %s, %s, %s - ) - RETURNING ( SELECT count(*) = 0 FROM tips WHERE tipper=%s ) - AS first_time_tipper - - """ - args = (self.username, tippee, self.username, tippee, amount, \ - self.username) - first_time_tipper = \ - gittip.db.one(NEW_TIP, args)['first_time_tipper'] - return amount, first_time_tipper - - - @require_username - def get_tip_to(self, tippee): - """Given two user ids, return a Decimal. - """ - TIP = """\ - - SELECT amount - FROM tips - WHERE tipper=%s - AND tippee=%s - ORDER BY mtime DESC - LIMIT 1 - - """ - rec = gittip.db.one(TIP, (self.username, tippee)) - if rec is None: - tip = Decimal('0.00') - else: - tip = rec['amount'] - return tip - - - @require_username - def get_dollars_receiving(self): - """Return a Decimal. - """ - - BACKED = """\ - - SELECT sum(amount) AS dollars_receiving - FROM ( SELECT DISTINCT ON (tipper) - amount - , tipper - FROM tips - JOIN participants p ON p.username = tipper - WHERE tippee=%s - AND last_bill_result = '' - AND is_suspicious IS NOT true - ORDER BY tipper - , mtime DESC - ) AS foo - - """ - rec = gittip.db.one(BACKED, (self.username,)) - if rec is None: - amount = None - else: - amount = rec['dollars_receiving'] # might be None - - if amount is None: - amount = Decimal('0.00') - - return amount - - - @require_username - def get_dollars_giving(self): - """Return a Decimal. - """ - - BACKED = """\ - - SELECT sum(amount) AS dollars_giving - FROM ( SELECT DISTINCT ON (tippee) - amount - , tippee - FROM tips - JOIN participants p ON p.username = tippee - WHERE tipper=%s - AND is_suspicious IS NOT true - AND claimed_time IS NOT NULL - ORDER BY tippee - , mtime DESC - ) AS foo - - """ - rec = gittip.db.one(BACKED, (self.username,)) - if rec is None: - amount = None - else: - amount = rec['dollars_giving'] # might be None - - if amount is None: - amount = Decimal('0.00') - - return amount - - - @require_username - def get_number_of_backers(self): - """Given a unicode, return an int. - """ - - BACKED = """\ - - SELECT count(amount) AS nbackers - FROM ( SELECT DISTINCT ON (tipper) - amount - , tipper - FROM tips - JOIN participants p ON p.username = tipper - WHERE tippee=%s - AND last_bill_result = '' - AND is_suspicious IS NOT true - ORDER BY tipper - , mtime DESC - ) AS foo - WHERE amount > 0 - - """ - rec = gittip.db.one(BACKED, (self.username,)) - if rec is None: - nbackers = None - else: - nbackers = rec['nbackers'] # might be None - - if nbackers is None: - nbackers = 0 - - return nbackers - - - @require_username - def get_tip_distribution(self): - """ - Returns a data structure in the form of: - [ - [TIPAMOUNT1, TIPAMOUNT2...TIPAMOUNTN], - total_number_patrons_giving_to_me, - total_amount_received - ] - - where each TIPAMOUNTN is in the form: - - [amount, - number_of_tippers_for_this_amount, - total_amount_given_at_this_amount, - proportion_of_tips_at_this_amount, - proportion_of_total_amount_at_this_amount - ] - - """ - SQL = """ - - SELECT amount - , count(amount) AS ncontributing - FROM ( SELECT DISTINCT ON (tipper) - amount - , tipper - FROM tips - JOIN participants p ON p.username = tipper - WHERE tippee=%s - AND last_bill_result = '' - AND is_suspicious IS NOT true - ORDER BY tipper - , mtime DESC - ) AS foo - WHERE amount > 0 - GROUP BY amount - ORDER BY amount - - """ - - tip_amounts = [] - - npatrons = 0.0 # float to trigger float division - contributed = Decimal('0.00') - for rec in gittip.db.all(SQL, (self.username,)): - tip_amounts.append([ rec['amount'] - , rec['ncontributing'] - , rec['amount'] * rec['ncontributing'] - ]) - contributed += tip_amounts[-1][2] - npatrons += rec['ncontributing'] - - for row in tip_amounts: - row.append((row[1] / npatrons) if npatrons > 0 else 0) - row.append((row[2] / contributed) if contributed > 0 else 0) - - return tip_amounts, npatrons, contributed - - - @require_username - def get_giving_for_profile(self, db=None): - """Given a participant id and a date, return a list and a Decimal. - - This function is used to populate a participant's page for their own - viewing pleasure. - - A half-injected dependency, that's what db is. - - """ - if db is None: - from gittip import db - - TIPS = """\ - - SELECT * FROM ( - SELECT DISTINCT ON (tippee) - amount - , tippee - , t.ctime - , p.claimed_time - , p.username_lower - FROM tips t - JOIN participants p ON p.username = t.tippee - WHERE tipper = %s - AND p.is_suspicious IS NOT true - AND p.claimed_time IS NOT NULL - ORDER BY tippee - , t.mtime DESC - ) AS foo - ORDER BY amount DESC - , username_lower - - """ - tips = db.all(TIPS, (self.username,)) - - UNCLAIMED_TIPS = """\ - - SELECT * FROM ( - SELECT DISTINCT ON (tippee) - amount - , tippee - , t.ctime - , p.claimed_time - , e.platform - , e.user_info - FROM tips t - JOIN participants p ON p.username = t.tippee - JOIN elsewhere e ON e.participant = t.tippee - WHERE tipper = %s - AND p.is_suspicious IS NOT true - AND p.claimed_time IS NULL - ORDER BY tippee - , t.mtime DESC - ) AS foo - ORDER BY amount DESC - , lower(user_info->'screen_name') - , lower(user_info->'username') - , lower(user_info->'login') - - """ - unclaimed_tips = db.all(UNCLAIMED_TIPS, (self.username,)) - - - # Compute the total. - # ================== - # For payday we only want to process payments to tippees who have - # themselves opted into Gittip. For the tipper's profile page we want - # to show the total amount they've pledged (so they're not surprised - # when someone *does* start accepting tips and all of a sudden they're - # hit with bigger charges. - - total = sum([t['amount'] for t in tips]) - if not total: - # If tips is an empty list, total is int 0. We want a Decimal. - total = Decimal('0.00') - - unclaimed_total = sum([t['amount'] for t in unclaimed_tips]) - if not unclaimed_total: - unclaimed_total = Decimal('0.00') - - return tips, total, unclaimed_tips, unclaimed_total - - - @require_username - def get_tips_and_total(self, for_payday=False, db=None): - """Given a participant id and a date, return a list and a Decimal. - - This function is used by the payday function. If for_payday is not - False it must be a date object. Originally we also used this function - to populate the profile page, but our requirements there changed while, - oddly, our requirements in payday *also* changed to match the old - requirements of the profile page. So this function keeps the for_payday - parameter after all. - - A half-injected dependency, that's what db is. - - """ - if db is None: - from gittip import db - - if for_payday: - - # For payday we want the oldest relationship to be paid first. - order_by = "ctime ASC" - - - # This is where it gets crash-proof. - # ================================== - # We need to account for the fact that we may have crashed during - # Payday and we're re-running that function. We only want to select - # tips that existed before Payday started, but haven't been - # processed as part of this Payday yet. - # - # It's a bug if the paydays subselect returns > 1 rows. - # - # XXX If we crash during Payday and we rerun it after a timezone - # change, will we get burned? How? - - ts_filter = """\ - - AND mtime < %s - AND ( SELECT id - FROM transfers - WHERE tipper=t.tipper - AND tippee=t.tippee - AND timestamp >= %s - ) IS NULL - - """ - args = (self.username, for_payday, for_payday) - else: - order_by = "amount DESC" - ts_filter = "" - args = (self.username,) - - TIPS = """\ - - SELECT * FROM ( - SELECT DISTINCT ON (tippee) - amount - , tippee - , t.ctime - , p.claimed_time - FROM tips t - JOIN participants p ON p.username = t.tippee - WHERE tipper = %%s - AND p.is_suspicious IS NOT true - %s - ORDER BY tippee - , t.mtime DESC - ) AS foo - ORDER BY %s - , tippee - - """ % (ts_filter, order_by) # XXX, No injections here, right?! - tips = db.all(TIPS, args) - - - # Compute the total. - # ================== - # For payday we only want to process payments to tippees who have - # themselves opted into Gittip. For the tipper's profile page we want - # to show the total amount they've pledged (so they're not surprised - # when someone *does* start accepting tips and all of a sudden they're - # hit with bigger charges. - - if for_payday: - to_total = [t for t in tips if t['claimed_time'] is not None] - else: - to_total = tips - total = sum([t['amount'] for t in to_total]) - - if not total: - # If to_total is an empty list, total is int 0. We want a Decimal. - total = Decimal('0.00') - - return tips, total - - - - # Accounts Elsewhere - # ================== - - @require_username - def take_over(self, account_elsewhere, have_confirmation=False): - """Given two unicodes, raise WontProceed or return None. - - This method associates an account on another platform (GitHub, Twitter, - etc.) with the Gittip participant represented by self. Every account - elsewhere has an associated Gittip participant account, even if its - only a stub participant (it allows us to track pledges to that account - should they ever decide to join Gittip). - - In certain circumstances, we want to present the user with a - confirmation before proceeding to reconnect the account elsewhere to - the new Gittip account; NeedConfirmation is the signal to request - confirmation. If it was the last account elsewhere connected to the old - Gittip account, then we absorb the old Gittip account into the new one, - effectively archiving the old account. - - Here's what absorbing means: - - - consolidated tips to and fro are set up for the new participant - - Amounts are summed, so if alice tips bob $1 and carl $1, and - then bob absorbs carl, then alice tips bob $2(!) and carl $0. - - And if bob tips alice $1 and carl tips alice $1, and then bob - absorbs carl, then bob tips alice $2(!) and carl tips alice $0. - - The ctime of each new consolidated tip is the older of the two - tips that are being consolidated. - - If alice tips bob $1, and alice absorbs bob, then alice tips - bob $0. - - If alice tips bob $1, and bob absorbs alice, then alice tips - bob $0. - - - all tips to and from the other participant are set to zero - - the absorbed username is released for reuse - - the absorption is recorded in an absorptions table - - This is done in one transaction. - - """ - platform = account_elsewhere.platform - user_id = account_elsewhere.user_id - - typecheck(platform, unicode, user_id, unicode, have_confirmation, bool) - - CONSOLIDATE_TIPS_RECEIVING = """ - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT min(ctime), tipper, %s AS tippee, sum(amount) - FROM ( SELECT DISTINCT ON (tipper, tippee) - ctime, tipper, tippee, amount - FROM tips - ORDER BY tipper, tippee, mtime DESC - ) AS unique_tips - WHERE (tippee=%s OR tippee=%s) - AND NOT (tipper=%s AND tippee=%s) - AND NOT (tipper=%s) - GROUP BY tipper - - """ - - CONSOLIDATE_TIPS_GIVING = """ - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT min(ctime), %s AS tipper, tippee, sum(amount) - FROM ( SELECT DISTINCT ON (tipper, tippee) - ctime, tipper, tippee, amount - FROM tips - ORDER BY tipper, tippee, mtime DESC - ) AS unique_tips - WHERE (tipper=%s OR tipper=%s) - AND NOT (tipper=%s AND tippee=%s) - AND NOT (tippee=%s) - GROUP BY tippee - - """ - - ZERO_OUT_OLD_TIPS_RECEIVING = """ - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT DISTINCT ON (tipper) ctime, tipper, tippee, 0 AS amount - FROM tips - WHERE tippee=%s - - """ - - ZERO_OUT_OLD_TIPS_GIVING = """ - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT DISTINCT ON (tippee) ctime, tipper, tippee, 0 AS amount - FROM tips - WHERE tipper=%s - - """ - - with gittip.db.get_transaction() as txn: - - # Load the existing connection. - # ============================= - # Every account elsewhere has at least a stub participant account - # on Gittip. - - txn.execute(""" - - SELECT participant - , claimed_time IS NULL AS is_stub - FROM elsewhere - JOIN participants ON participant=participants.username - WHERE elsewhere.platform=%s AND elsewhere.user_id=%s - - """, (platform, user_id)) - rec = txn.fetchone() - assert rec is not None # sanity check - - other_username = rec['participant'] - - - # Make sure we have user confirmation if needed. - # ============================================== - # We need confirmation in whatever combination of the following - # three cases: - # - # - the other participant is not a stub; we are taking the - # account elsewhere away from another viable Gittip - # participant - # - # - the other participant has no other accounts elsewhere; taking - # away the account elsewhere will leave the other Gittip - # participant without any means of logging in, and it will be - # archived and its tips absorbed by us - # - # - we already have an account elsewhere connected from the given - # platform, and it will be handed off to a new stub - # participant - - # other_is_a_real_participant - other_is_a_real_participant = not rec['is_stub'] - - # this_is_others_last_account_elsewhere - txn.execute( "SELECT count(*) AS nelsewhere FROM elsewhere " - "WHERE participant=%s" - , (other_username,) - ) - nelsewhere = txn.fetchone()['nelsewhere'] - assert nelsewhere > 0 # sanity check - this_is_others_last_account_elsewhere = nelsewhere == 1 - - # we_already_have_that_kind_of_account - txn.execute( "SELECT count(*) AS nparticipants FROM elsewhere " - "WHERE participant=%s AND platform=%s" - , (self.username, platform) - ) - nparticipants = txn.fetchone()['nparticipants'] - assert nparticipants in (0, 1) # sanity check - we_already_have_that_kind_of_account = nparticipants == 1 - - need_confirmation = NeedConfirmation( other_is_a_real_participant - , this_is_others_last_account_elsewhere - , we_already_have_that_kind_of_account - ) - if need_confirmation and not have_confirmation: - raise need_confirmation - - - # We have user confirmation. Proceed. - # =================================== - # There is a race condition here. The last person to call this will - # win. XXX: I'm not sure what will happen to the DB and UI for the - # loser. - - - # Move any old account out of the way. - # ==================================== - - if we_already_have_that_kind_of_account: - new_stub_username = reserve_a_random_username(txn) - txn.execute( "UPDATE elsewhere SET participant=%s " - "WHERE platform=%s AND participant=%s" - , (new_stub_username, platform, self.username) - ) - - - # Do the deal. - # ============ - # If other_is_not_a_stub, then other will have the account - # elsewhere taken away from them with this call. If there are other - # browsing sessions open from that account, they will stay open - # until they expire (XXX Is that okay?) - - txn.execute( "UPDATE elsewhere SET participant=%s " - "WHERE platform=%s AND user_id=%s" - , (self.username, platform, user_id) - ) - - - # Fold the old participant into the new as appropriate. - # ===================================================== - # We want to do this whether or not other is a stub participant. - - if this_is_others_last_account_elsewhere: - - # Take over tips. - # =============== - - x, y = self.username, other_username - txn.execute(CONSOLIDATE_TIPS_RECEIVING, (x, x,y, x,y, x)) - txn.execute(CONSOLIDATE_TIPS_GIVING, (x, x,y, x,y, x)) - txn.execute(ZERO_OUT_OLD_TIPS_RECEIVING, (other_username,)) - txn.execute(ZERO_OUT_OLD_TIPS_GIVING, (other_username,)) - - - # Archive the old participant. - # ============================ - # We always give them a new, random username. We sign out - # the old participant. - - for archive_username in gen_random_usernames(): - try: - txn.execute(""" - - UPDATE participants - SET username=%s - , username_lower=%s - , session_token=NULL - , session_expires=now() - WHERE username=%s - RETURNING username - - """, ( archive_username - , archive_username.lower() - , other_username) - ) - rec = txn.fetchone() - except IntegrityError: - continue # archive_username is already taken; - # extremely unlikely, but ... - # XXX But can the UPDATE fail in other ways? - else: - assert rec is not None # sanity checks - assert rec['username'] == archive_username - break - - - # Record the absorption. - # ====================== - # This is for preservation of history. - - txn.execute( "INSERT INTO absorptions " - "(absorbed_was, absorbed_by, archived_as) " - "VALUES (%s, %s, %s)" - , (other_username, self.username, archive_username) - ) - - - # Lastly, keep account_elsewhere in sync. - # ======================================= - # Bandaid for - # - # https://github.com/gittip/www.gittip.com/issues/421 - # - # XXX This is why we're porting to SQLAlchemy: - # - # https://github.com/gittip/www.gittip.com/issues/129 - - account_elsewhere.participant = self.username diff --git a/gittip/security/__init__.py b/gittip/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gittip/authentication.py b/gittip/security/authentication.py similarity index 62% rename from gittip/authentication.py rename to gittip/security/authentication.py index 96fe190c11..6c64055f32 100644 --- a/gittip/authentication.py +++ b/gittip/security/authentication.py @@ -1,18 +1,28 @@ """Defines website authentication helpers. """ -import datetime import rfc822 import time -import pytz from aspen import Response -from gittip import csrf -from gittip.orm import db -from gittip.models import User +from gittip.security import csrf +from gittip.security.user import User BEGINNING_OF_EPOCH = rfc822.formatdate(0) TIMEOUT = 60 * 60 * 24 * 7 # one week +ROLES = ['anonymous', 'authenticated', 'owner', 'admin'] +ROLES_SHOULD_BE = "It should be one of: {}.".format(', '.join(ROLES)) + + +class NoMinimumRoleSpecified(Exception): + def __str__(self): + return "There is no minimum_role specified in the simplate at {}. {}" \ + .format(self.args[0], ROLES_SHOULD_BE) + +class BadMinimumRole(Exception): + def __str__(self): + return "The minimum_role specific in {} is bad: {}. {}" \ + .format(self.args[0], self.args[1], ROLES_SHOULD_BE) def inbound(request): @@ -42,6 +52,29 @@ def inbound(request): request.context['user'] = user +def check_role(request): + """Given a request object, possibly raise Response(403). + """ + + # XXX We can't use this yet because we don't have an inbound Aspen hook + # that fires after the first page of the simplate is exec'd. + + context = request.context + path = request.line.uri.path + + if 'minimum_role' not in context: + raise NoMinimumRoleSpecified(request.fs) + + minimum_role = context['minimum_role'] + if minimum_role not in ROLES: + raise BadMinimumRole(request.fs, minimum_role) + + user = context['user'] + highest_role = user.get_highest_role(path.get('username', None)) + if ROLES.index(highest_role) < ROLES.index(minimum_role): + request.redirect('..') + + def outbound(response): if 'user' in response.request.context: user = response.request.context['user'] @@ -60,14 +93,10 @@ def outbound(response): response.headers.cookie['session'] = '' expires = 0 else: # user is authenticated - user = User.from_session_token(user.session_token) response.headers['Expires'] = BEGINNING_OF_EPOCH # don't cache - response.headers.cookie['session'] = user.session_token + response.headers.cookie['session'] = user.participant.session_token expires = time.time() + TIMEOUT - user.session_expires = datetime.datetime.fromtimestamp(expires)\ - .replace(tzinfo=pytz.utc) - db.session.add(user) - db.session.commit() + user.keep_signed_in_until(expires) cookie = response.headers.cookie['session'] # I am not setting domain, because it is supposed to default to what we diff --git a/gittip/crypto.py b/gittip/security/crypto.py similarity index 100% rename from gittip/crypto.py rename to gittip/security/crypto.py diff --git a/gittip/csrf.py b/gittip/security/csrf.py similarity index 100% rename from gittip/csrf.py rename to gittip/security/csrf.py diff --git a/gittip/security/user.py b/gittip/security/user.py new file mode 100644 index 0000000000..af681acbf4 --- /dev/null +++ b/gittip/security/user.py @@ -0,0 +1,104 @@ +from gittip.models.participant import Participant + + +class User(object): + """Represent a user of our website. + """ + + participant = None + + + # Constructors + # ============ + + @classmethod + def from_session_token(cls, token): + """Find a participant based on token and return a User. + """ + self = cls() + self.participant = Participant.from_session_token(token) + return self + + @classmethod + def from_api_key(cls, api_key): + """Find a participant based on token and return a User. + """ + self = cls() + self.participant = Participant.from_api_key(api_key) + return self + + @classmethod + def from_username(cls, username): + """Find a participant based on username and return a User. + """ + self = cls() + self.participant = Participant.from_username(username) + return self + + def __str__(self): + if self.participant is None: + out = '' + else: + out = '' % self.participant.username + return out + __repr__ = __str__ + + + # Authentication Helpers + # ====================== + + def sign_in(self): + """Start a new session for the user. + """ + self.participant.start_new_session() + + def keep_signed_in_until(self, expires): + """Extend the user's current session. + + :param float expires: A UNIX timestamp (XXX timezone?) + + """ + self.participant.set_session_expires(expires) + + def sign_out(self): + """End the user's current session. + """ + self.participant.end_session() + self.participant = None + + + # Roles + # ===== + + @property + def ADMIN(self): + return not self.ANON and self.participant.is_admin + + @property + def ANON(self): + return self.participant is None or self.participant.is_suspicious is True + # Append "is True" here because otherwise Python will return the result + # of evaluating the right side of the or expression, which can be None. + + def get_highest_role(self, owner): + """Return a string representing the highest role this user has. + + :param string owner: the username of the owner of the resource we're + concerned with, or None + + """ + def is_owner(): + if self.participant is not None: + if owner is not None: + if self.participant.username == owner: + return True + return False + + if self.ADMIN: + return 'admin' + elif is_owner(): + return 'owner' + elif not self.ANON: + return 'authenticated' + else: + return 'anonymous' diff --git a/gittip/testing/__init__.py b/gittip/testing/__init__.py index 8b65fcccf6..0de5219c02 100644 --- a/gittip/testing/__init__.py +++ b/gittip/testing/__init__.py @@ -1,100 +1,113 @@ """Helpers for testing Gittip. """ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import datetime -import copy -import os import random -import re import unittest from decimal import Decimal from os.path import join, dirname, realpath import gittip +import pytz from aspen import resources from aspen.testing import Website, StubRequest from aspen.utils import utcnow -from gittip import orm, wireup -from gittip.models.participant import Participant -from gittip.authentication import User from gittip.billing.payday import Payday +from gittip.models.participant import Participant +from gittip.security.user import User +from psycopg2 import IntegrityError TOP = join(realpath(dirname(dirname(__file__))), '..') SCHEMA = open(join(TOP, "schema.sql")).read() -DUMMY_GITHUB_JSON = u'{"html_url":"https://github.com/whit537","type":"User","public_repos":25,"blog":"http://whit537.org/","gravatar_id":"fb054b407a6461e417ee6b6ae084da37","public_gists":29,"following":15,"updated_at":"2013-01-14T13:43:23Z","company":"Gittip","events_url":"https://api.github.com/users/whit537/events{/privacy}","repos_url":"https://api.github.com/users/whit537/repos","gists_url":"https://api.github.com/users/whit537/gists{/gist_id}","email":"chad@zetaweb.com","organizations_url":"https://api.github.com/users/whit537/orgs","hireable":false,"received_events_url":"https://api.github.com/users/whit537/received_events","starred_url":"https://api.github.com/users/whit537/starred{/owner}{/repo}","login":"whit537","created_at":"2009-10-03T02:47:57Z","bio":"","url":"https://api.github.com/users/whit537","avatar_url":"https://secure.gravatar.com/avatar/fb054b407a6461e417ee6b6ae084da37?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png","followers":90,"name":"Chad Whitacre","followers_url":"https://api.github.com/users/whit537/followers","following_url":"https://api.github.com/users/whit537/following","id":134455,"location":"Pittsburgh, PA","subscriptions_url":"https://api.github.com/users/whit537/subscriptions"}' -"JSON data as returned from github for whit537 ;)" - -GITHUB_USER_UNREGISTERED_LGTEST = u'{"public_repos":0,"html_url":"https://github.com/lgtest","type":"User","repos_url":"https://api.github.com/users/lgtest/repos","gravatar_id":"d41d8cd98f00b204e9800998ecf8427e","following":0,"public_gists":0,"updated_at":"2013-01-04T17:24:57Z","received_events_url":"https://api.github.com/users/lgtest/received_events","gists_url":"https://api.github.com/users/lgtest/gists{/gist_id}","events_url":"https://api.github.com/users/lgtest/events{/privacy}","organizations_url":"https://api.github.com/users/lgtest/orgs","avatar_url":"https://secure.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png","login":"lgtest","created_at":"2012-05-24T20:09:07Z","starred_url":"https://api.github.com/users/lgtest/starred{/owner}{/repo}","url":"https://api.github.com/users/lgtest","followers":0,"followers_url":"https://api.github.com/users/lgtest/followers","following_url":"https://api.github.com/users/lgtest/following","id":1775515,"subscriptions_url":"https://api.github.com/users/lgtest/subscriptions"}' -"JSON data as returned from github for unregistered user ``lgtest``" - -DUMMY_BOUNTYSOURCE_JSON = u'{"slug": "6-corytheboyd","updated_at": "2013-05-24T01:45:20Z","last_name": "Boyd","id": 6,"last_seen_at": "2013-05-24T01:45:20Z","email": "corytheboyd@gmail.com","fundraisers": [],"frontend_path": "#users/6-corytheboyd","display_name": "corytheboyd","frontend_url": "https://www.bountysource.com/#users/6-corytheboyd","created_at": "2012-09-14T03:28:07Z","first_name": "Cory","bounties": [],"image_url": "https://secure.gravatar.com/avatar/bdeaea505d059ccf23d8de5714ae7f73?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png"}' -"JSON data as returned from bountysource for corytheboyd! hello, whit537 ;)" - - -def create_schema(db): - db.run(SCHEMA) +DUMMY_GITHUB_JSON = u'{"html_url":"https://github.com/whit537","type":"User",'\ +'"public_repos":25,"blog":"http://whit537.org/","gravatar_id":"fb054b407a6461'\ +'e417ee6b6ae084da37","public_gists":29,"following":15,"updated_at":"2013-01-1'\ +'4T13:43:23Z","company":"Gittip","events_url":"https://api.github.com/users/w'\ +'hit537/events{/privacy}","repos_url":"https://api.github.com/users/whit537/r'\ +'epos","gists_url":"https://api.github.com/users/whit537/gists{/gist_id}","em'\ +'ail":"chad@zetaweb.com","organizations_url":"https://api.github.com/users/wh'\ +'it537/orgs","hireable":false,"received_events_url":"https://api.github.com/u'\ +'sers/whit537/received_events","starred_url":"https://api.github.com/users/wh'\ +'it537/starred{/owner}{/repo}","login":"whit537","created_at":"2009-10-03T02:'\ +'47:57Z","bio":"","url":"https://api.github.com/users/whit537","avatar_url":"'\ +'https://secure.gravatar.com/avatar/fb054b407a6461e417ee6b6ae084da37?d=https:'\ +'//a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-4'\ +'20.png","followers":90,"name":"Chad Whitacre","followers_url":"https://api.g'\ +'ithub.com/users/whit537/followers","following_url":"https://api.github.com/u'\ +'sers/whit537/following","id":134455,"location":"Pittsburgh, PA","subscriptio'\ +'ns_url":"https://api.github.com/users/whit537/subscriptions"}' +# JSON data as returned from github for whit537 ;) + +GITHUB_USER_UNREGISTERED_LGTEST = u'{"public_repos":0,"html_url":"https://git'\ +'hub.com/lgtest","type":"User","repos_url":"https://api.github.com/users/lgte'\ +'st/repos","gravatar_id":"d41d8cd98f00b204e9800998ecf8427e","following":0,"pu'\ +'blic_gists":0,"updated_at":"2013-01-04T17:24:57Z","received_events_url":"htt'\ +'ps://api.github.com/users/lgtest/received_events","gists_url":"https://api.g'\ +'ithub.com/users/lgtest/gists{/gist_id}","events_url":"https://api.github.com'\ +'/users/lgtest/events{/privacy}","organizations_url":"https://api.github.com/'\ +'users/lgtest/orgs","avatar_url":"https://secure.gravatar.com/avatar/d41d8cd9'\ +'8f00b204e9800998ecf8427e?d=https://a248.e.akamai.net/assets.github.com%2Fima'\ +'ges%2Fgravatars%2Fgravatar-user-420.png","login":"lgtest","created_at":"2012'\ +'-05-24T20:09:07Z","starred_url":"https://api.github.com/users/lgtest/starred'\ +'{/owner}{/repo}","url":"https://api.github.com/users/lgtest","followers":0,"'\ +'followers_url":"https://api.github.com/users/lgtest/followers","following_ur'\ +'l":"https://api.github.com/users/lgtest/following","id":1775515,"subscriptio'\ +'ns_url":"https://api.github.com/users/lgtest/subscriptions"}' +# JSON data as returned from github for unregistered user ``lgtest`` + +DUMMY_BOUNTYSOURCE_JSON = u'{"slug": "6-corytheboyd","updated_at": "2013-05-2'\ +'4T01:45:20Z","last_name": "Boyd","id": 6,"last_seen_at": "2013-05-24T01:45:2'\ +'0Z","email": "corytheboyd@gmail.com","fundraisers": [],"frontend_path": "#us'\ +'ers/6-corytheboyd","display_name": "corytheboyd","frontend_url": "https://ww'\ +'w.bountysource.com/#users/6-corytheboyd","created_at": "2012-09-14T03:28:07Z'\ +'","first_name": "Cory","bounties": [],"image_url": "https://secure.gravatar.'\ +'com/avatar/bdeaea505d059ccf23d8de5714ae7f73?d=https://a248.e.akamai.net/asse'\ +'ts.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png"}' +# JSON data as returned from bountysource for corytheboyd! hello, whit537 ;) class Harness(unittest.TestCase): @classmethod def setUpClass(cls): - cls.db = orm.db - cls.session = orm.db.session - """ Assign gittip.db directly because wireup.db() is called earlier """ - cls.postgres = gittip.db - - def setUp(self): - pass + cls.db = gittip.db + cls._tablenames = cls.db.all("SELECT tablename FROM pg_tables " + "WHERE schemaname='public'") + cls.clear_tables(cls.db, cls._tablenames[:]) def tearDown(self): - self.db.empty_tables() - - def make_participant(self, username, number='singular', **kw): - participant = Participant( username=username - , username_lower=username.lower() - , number=number - , **kw - ) - self.session.add(participant) - self.session.commit() - return participant - + self.clear_tables(self.db, self._tablenames[:]) + + @staticmethod + def clear_tables(db, tablenames): + while tablenames: + tablename = tablenames.pop() + try: + # I tried TRUNCATE but that was way slower for me. + db.run("DELETE FROM %s CASCADE" % tablename) + except IntegrityError: + tablenames.insert(0, tablename) + + def make_participant(self, username, **kw): + participant = Participant.with_random_username() + participant.change_username(username) + + # brute force update for use in testing + for k,v in kw.items(): + if k == 'claimed_time': + if v == 'now': + v = datetime.datetime.now(pytz.utc) + self.db.run("UPDATE participants SET {}=%s WHERE username=%s" \ + .format(k), (v, participant.username)) + participant.set_attributes(**kw) -class GittipBaseDBTest(unittest.TestCase): - """ - - Will setup a db connection so we can perform db operations. Everything is - performed in a transaction and will be rolled back at the end of the test - so we don't clutter up the db. - - """ - def setUp(self): - self.conn = self.db.get_connection() + return participant - @classmethod - def setUpClass(cls): - cls.db = gittip.db = wireup.db() - def tearDown(self): - # TODO: rollback transaction here so we don't fill up test db. - # TODO: hack for now, truncate all tables. - tables = [ 'participants' - , 'elsewhere' - , 'tips' - , 'transfers' - , 'paydays' - , 'exchanges' - , 'absorptions' - ] - for t in tables: - self.db.run('truncate table %s cascade' % t) - - -class GittipPaydayTest(GittipBaseDBTest): +class GittipPaydayTest(Harness): def setUp(self): super(GittipPaydayTest, self).setUp() diff --git a/gittip/testing/client.py b/gittip/testing/client.py index 8b7f47fb92..17b130390e 100644 --- a/gittip/testing/client.py +++ b/gittip/testing/client.py @@ -1,14 +1,16 @@ +from __future__ import print_function, unicode_literals + from Cookie import SimpleCookie from StringIO import StringIO from aspen.http.request import Request from aspen.testing import StubWSGIRequest - -from gittip.authentication import User +from gittip.security.user import User from gittip.testing import test_website -BOUNDARY = 'BoUnDaRyStRiNg' -MULTIPART_CONTENT = 'multipart/form-data; boundary=%s' % BOUNDARY + +BOUNDARY = b'BoUnDaRyStRiNg' +MULTIPART_CONTENT = b'multipart/form-data; boundary=%s' % BOUNDARY def encode_multipart(boundary, data): @@ -24,17 +26,17 @@ def encode_multipart(boundary, data): for (key, value) in data.items(): lines.extend([ - '--' + boundary, - 'Content-Disposition: form-data; name="%s"' % str(key), - '', + b'--' + boundary, + b'Content-Disposition: form-data; name="%s"' % str(key), + b'', str(value) ]) lines.extend([ - '--' + boundary + '--', - '', + b'--' + boundary + b'--', + b'', ]) - return '\r\n'.join(lines) + return b'\r\n'.join(lines) # XXX TODO: Move the TestClient up into the Aspen code base so it can be shared @@ -47,19 +49,22 @@ def __init__(self): def get_request(self, path, method="GET", body=None, **extra): - env = StubWSGIRequest(path) - env['REQUEST_METHOD'] = method - env['wsgi.input'] = StringIO(body) - env['HTTP_COOKIE'] = self.cookies.output(header='', sep='; ') - env.update(extra) + env = StubWSGIRequest(path.encode('utf8')) + env[b'REQUEST_METHOD'] = method.encode('utf8') + env[b'wsgi.input'] = StringIO(body) + env[b'HTTP_COOKIE'] = self.cookies.output(header='', sep='; ').encode('utf8') + for k,v in extra.items(): + env[k.encode('utf8')] = v.encode('utf8') return Request.from_wsgi(env) def perform_request(self, request, user): request.website = test_website if user is not None: user = User.from_username(user) + user.sign_in() # Note that Cookie needs a bytestring. - request.headers.cookie[str('session')] = user.session_token + request.headers.cookie[str('session')] = \ + user.participant.session_token response = test_website.handle_safely(request) if response.headers.cookie: diff --git a/gittip/utils.py b/gittip/utils/__init__.py similarity index 71% rename from gittip/utils.py rename to gittip/utils/__init__.py index 7b9699f459..ed7068b453 100644 --- a/gittip/utils.py +++ b/gittip/utils/__init__.py @@ -1,7 +1,10 @@ -from aspen import Response +import time + +import gittip +from aspen import log_dammit, Response from aspen.utils import typecheck from tornado.escape import linkify -from gittip.models.participant import Participant + COUNTRIES = ( ('AF', u'Afghanistan'), @@ -307,8 +310,11 @@ def get_participant(request, restrict=True): if user.ANON: request.redirect(u'/%s/' % slug) - participant = \ - Participant.query.filter_by(username_lower=slug.lower()).first() + participant = gittip.db.one( "SELECT participants.*::participants " + "FROM participants " + "WHERE username_lower=%s" + , (slug.lower(),) + ) if participant is None: raise Response(404) @@ -327,9 +333,87 @@ def get_participant(request, restrict=True): request.redirect(to) if restrict: - if participant != user: + if participant != user.participant: if not user.ADMIN: raise Response(403) return participant + +def update_homepage_queries_once(db): + with db.get_cursor() as cursor: + log_dammit("updating homepage queries") + start = time.time() + cursor.execute(""" + + DROP TABLE IF EXISTS _homepage_new_participants; + CREATE TABLE _homepage_new_participants AS + SELECT username, claimed_time FROM ( + SELECT DISTINCT ON (p.username) + p.username + , claimed_time + FROM participants p + JOIN elsewhere e + ON p.username = participant + WHERE claimed_time IS NOT null + AND is_suspicious IS NOT true + ) AS foo + ORDER BY claimed_time DESC; + + DROP TABLE IF EXISTS _homepage_top_givers; + CREATE TABLE _homepage_top_givers AS + SELECT tipper AS username, anonymous, sum(amount) AS amount + FROM ( SELECT DISTINCT ON (tipper, tippee) + amount + , tipper + FROM tips + JOIN participants p ON p.username = tipper + JOIN participants p2 ON p2.username = tippee + JOIN elsewhere ON elsewhere.participant = tippee + WHERE p.last_bill_result = '' + AND p.is_suspicious IS NOT true + AND p2.claimed_time IS NOT NULL + AND elsewhere.is_locked = false + ORDER BY tipper, tippee, mtime DESC + ) AS foo + JOIN participants p ON p.username = tipper + WHERE is_suspicious IS NOT true + GROUP BY tipper, anonymous + ORDER BY amount DESC; + + DROP TABLE IF EXISTS _homepage_top_receivers; + CREATE TABLE _homepage_top_receivers AS + SELECT tippee AS username, claimed_time, sum(amount) AS amount + FROM ( SELECT DISTINCT ON (tipper, tippee) + amount + , tippee + FROM tips + JOIN participants p ON p.username = tipper + JOIN elsewhere ON elsewhere.participant = tippee + WHERE last_bill_result = '' + AND elsewhere.is_locked = false + AND is_suspicious IS NOT true + AND claimed_time IS NOT null + ORDER BY tipper, tippee, mtime DESC + ) AS foo + JOIN participants p ON p.username = tippee + WHERE is_suspicious IS NOT true + GROUP BY tippee, claimed_time + ORDER BY amount DESC; + + DROP TABLE IF EXISTS homepage_new_participants; + ALTER TABLE _homepage_new_participants + RENAME TO homepage_new_participants; + + DROP TABLE IF EXISTS homepage_top_givers; + ALTER TABLE _homepage_top_givers + RENAME TO homepage_top_givers; + + DROP TABLE IF EXISTS homepage_top_receivers; + ALTER TABLE _homepage_top_receivers + RENAME TO homepage_top_receivers; + + """) + end = time.time() + elapsed = end - start + log_dammit("updated homepage queries in %.2f seconds" % elapsed) diff --git a/gittip/cache_static.py b/gittip/utils/cache_static.py similarity index 100% rename from gittip/cache_static.py rename to gittip/utils/cache_static.py diff --git a/gittip/utils/fake_data.py b/gittip/utils/fake_data.py new file mode 100644 index 0000000000..635aa0a25e --- /dev/null +++ b/gittip/utils/fake_data.py @@ -0,0 +1,170 @@ +from faker import Factory +from gittip import wireup, MAX_TIP, MIN_TIP +from gittip.models.participant import Participant + +import decimal +import random +import string + +faker = Factory.create() + +platforms = ['github', 'twitter', 'bitbucket'] + + +def _fake_thing(db, tablename, **kw): + column_names = [] + column_value_placeholders = [] + column_values = [] + + for k,v in kw.items(): + column_names.append(k) + column_value_placeholders.append("%s") + column_values.append(v) + + column_names = ", ".join(column_names) + column_value_placeholders = ", ".join(column_value_placeholders) + + db.run( "INSERT INTO {} ({}) VALUES ({})" + .format(tablename, column_names, column_value_placeholders) + , column_values + ) + + +def fake_text_id(size=6, chars=string.ascii_lowercase + string.digits): + """Create a random text id. + """ + return ''.join(random.choice(chars) for x in range(size)) + + +def fake_balance(max_amount=100): + """Return a random amount between 0 and max_amount. + """ + return random.random() * max_amount + + +def fake_int_id(nmax=2 ** 31 -1): + """Create a random int id. + """ + return random.randint(0, nmax) + + +def fake_participant(db, number="singular", is_admin=False, anonymous=False): + """Create a fake User. + """ + username = faker.firstName() + fake_text_id(3) + _fake_thing( db + , "participants" + , id=fake_int_id() + , username=username + , username_lower=username.lower() + , statement=faker.sentence() + , ctime=faker.dateTimeThisYear() + , is_admin=is_admin + , balance=fake_balance() + , anonymous=anonymous + , goal=fake_balance() + , balanced_account_uri=faker.uri() + , last_ach_result='' + , is_suspicious=False + , last_bill_result='' # Needed to not be suspicious + , claimed_time=faker.dateTimeThisYear() + , number=number + ) + return Participant.from_username(username) + + +def fake_tip_amount(): + amount = ((decimal.Decimal(random.random()) * (MAX_TIP - MIN_TIP)) + + MIN_TIP) + + decimal_amount = decimal.Decimal(amount).quantize(decimal.Decimal('.01')) + + return decimal_amount + + +def fake_tip(db, tipper, tippee): + """Create a fake tip. + """ + _fake_thing( db + , "tips" + , id=fake_int_id() + , ctime=faker.dateTimeThisYear() + , mtime=faker.dateTimeThisMonth() + , tipper=tipper.username + , tippee=tippee.username + , amount=fake_tip_amount() + ) + + +def fake_elsewhere(db, participant, platform=None): + """Create a fake elsewhere. + """ + if platform is None: + platform = random.choice(platforms) + + info_templates = { + "github": { + "name": participant.username, + "html_url": "https://github.com/" + participant.username, + "type": "User", + "login": participant.username + }, + "twitter": { + "name": participant.username, + "html_url": "https://twitter.com/" + participant.username, + "screen_name": participant.username + }, + "bitbucket": { + "display_name": participant.username, + "username": participant.username, + "is_team": "False", + "html_url": "https://bitbucket.org/" + participant.username, + } + } + + _fake_thing( db + , "elsewhere" + , id=fake_int_id() + , platform=platform + , user_id=fake_text_id() + , is_locked=False + , participant=participant.username + , user_info=info_templates[platform] + ) + + +def populate_db(db, num_participants=100, num_tips=50, num_teams=5): + """Populate DB with fake data. + """ + #Make the participants + participants = [] + for i in xrange(num_participants): + p = fake_participant(db) + participants.append(p) + + #Make the "Elsewhere's" + for p in participants: + #All participants get between 1 and 3 elsewheres + num_elsewheres = random.randint(1, 3) + for platform_name in platforms[:num_elsewheres]: + fake_elsewhere(db, p, platform_name) + + #Make teams + for i in xrange(num_teams): + t = fake_participant(db, number="plural") + #Add 1 to 3 members to the team + members = random.sample(participants, random.randint(1, 3)) + for p in members: + t.add_member(p) + + #Make the tips + for i in xrange(num_tips): + tipper, tippee = random.sample(participants, 2) + fake_tip(db, tipper, tippee) + + +def main(): + populate_db(wireup.db()) + +if __name__ == '__main__': + main() diff --git a/gittip/mixpanel.py b/gittip/utils/mixpanel.py similarity index 100% rename from gittip/mixpanel.py rename to gittip/utils/mixpanel.py diff --git a/gittip/query_cache.py b/gittip/utils/query_cache.py similarity index 100% rename from gittip/query_cache.py rename to gittip/utils/query_cache.py diff --git a/gittip/swaddle.py b/gittip/utils/swaddle.py similarity index 100% rename from gittip/swaddle.py rename to gittip/utils/swaddle.py diff --git a/gittip/wireup.py b/gittip/wireup.py index e33c78da10..f2b02eea48 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -9,9 +9,10 @@ import raven import psycopg2 import stripe -import gittip.mixpanel +import gittip.utils.mixpanel +from gittip.models.community import Community +from gittip.models.participant import Participant from postgres import Postgres -from psycopg2.extensions import cursor as RegularCursor def canonical(): @@ -23,14 +24,16 @@ def canonical(): def db(): dburl = os.environ['DATABASE_URL'] maxconn = int(os.environ['DATABASE_MAXCONN']) - gittip.db = Postgres(dburl, maxconn=maxconn, strict_one=False) + db = gittip.db = Postgres(dburl, maxconn=maxconn) - # register hstore type (but don't use RealDictCursor) - with gittip.db.get_connection() as conn: - curs = conn.cursor(cursor_factory=RegularCursor) - psycopg2.extras.register_hstore(curs, globally=True, unicode=True) + # register hstore type + with db.get_cursor() as cursor: + psycopg2.extras.register_hstore(cursor, globally=True, unicode=True) - return gittip.db + db.register_model(Community) + db.register_model(Participant) + + return db def billing(): @@ -64,7 +67,7 @@ def tell_sentry(request): def mixpanel(website): website.mixpanel_token = os.environ['MIXPANEL_TOKEN'] - gittip.mixpanel.MIXPANEL_TOKEN = os.environ['MIXPANEL_TOKEN'] + gittip.utils.mixpanel.MIXPANEL_TOKEN = os.environ['MIXPANEL_TOKEN'] def nanswers(): from gittip.models import participant diff --git a/requirements.txt b/requirements.txt index 763937521d..9d427619f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,9 +7,8 @@ ./vendor/tornado-1.2.1.tar.gz ./vendor/aspen-tornado-0.2.tar.bz2 -./vendor/psycopg2-2.4.5.tar.gz -./vendor/postgres-1.0.1.tar.gz -./vendor/SQLAlchemy-0.8.0b2dev.tar.gz +./vendor/psycopg2-2.5.1.tar.gz +./vendor/postgres.py-master.zip ./vendor/simplejson-2.3.2.tar.gz diff --git a/setup.py b/setup.py index 5741564adc..e4ac1008b6 100644 --- a/setup.py +++ b/setup.py @@ -14,8 +14,8 @@ def get_version(): , packages=find_packages() , entry_points = { 'console_scripts' : [ 'payday=gittip.cli:payday' - , 'swaddle=gittip.swaddle:main' - , 'fake_data=gittip.fake_data:main' + , 'swaddle=gittip.utils.swaddle:main' + , 'fake_data=gittip.utils.fake_data:main' ] } ) diff --git a/templates/base.html b/templates/base.html index 166ebe39b0..4442ee410d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -39,8 +39,8 @@ @@ -64,11 +64,11 @@

{% include sign-in-using.html %} {% else %}
- {{ user.username }} – + {{ user.participant.username }}sign out
- Giving: ${{ user.get_dollars_giving() }}/wk
- Receiving: ${{ user.get_dollars_receiving() }}/wk + Giving: ${{ user.participant.get_dollars_giving() }}/wk
+ Receiving: ${{ user.participant.get_dollars_receiving() }}/wk
{% end %} diff --git a/templates/connected-accounts.html b/templates/connected-accounts.html index b0a8125abf..9fc0eaf704 100644 --- a/templates/connected-accounts.html +++ b/templates/connected-accounts.html @@ -7,7 +7,7 @@

Connected Accounts

{% if twitter_account is None %} - {% if not user.ANON and user == participant %} + {% if not user.ANON and user.participant == participant %} Connect a Twitter account. {% else %} No Twitter account connected. @@ -31,7 +31,7 @@

Connected Accounts

{% if github_account is None %} - {% if not user.ANON and user == participant %} + {% if not user.ANON and user.participant == participant %} Connect a GitHub account. {% else %} No GitHub account connected. @@ -54,7 +54,7 @@

Connected Accounts

{% if bitbucket_account is None %} - {% if not user.ANON and user == participant %} + {% if not user.ANON and user.participant == participant %} Connect a Bitbucket account. {% else %} No Bitbucket account connected. @@ -78,7 +78,7 @@

Connected Accounts

{% if bountysource_account is None %} - {% if not user.ANON and user == participant %} + {% if not user.ANON and user.participant == participant %} Connect a Bountysource account. {% else %} No Bountysource account connected. @@ -101,7 +101,7 @@

Connected Accounts

- {% if user == participant %} + {% if user.participant == participant %} Credit card:

You are - {{ escape(user.username) }} - + {{ escape(user.participant.username) }} + @@ -75,13 +75,13 @@

Funding Goal

- {% if user.goal > 0 %} + {% if user.participant.goal > 0 %} {{ MY }} goal is to receive - ${{ locale.format("%.2f", user.goal, grouping=True) }} per week on + ${{ locale.format("%.2f", user.participant.goal, grouping=True) }} per week on Gittip. - {% elif user.goal == 0 %} + {% elif user.participant.goal == 0 %} {{ I_M }} here as a patron. - {% elif user.goal is None %} + {% elif user.participant.goal is None %} {{ I_M }} grateful for gifts, but don't have a specific funding goal. {% else %} @@ -92,27 +92,27 @@

Funding Goal {% for tip in tips %} - {% set my_tip = tip['amount'] %} - {% set tippee = tip['tippee'] %} + {% set my_tip = tip.amount %} + {% set tippee = tip.tippee %} {% if my_tip > 0 %} @@ -91,12 +91,12 @@ first_time = qs.get('first_time') == '1' {% end %} {% for tip in unclaimed_tips %} - {% set my_tip = tip['amount'] %} - {% set tippee = tip['tippee'] %} + {% set my_tip = tip.amount %} + {% set tippee = tip.tippee %} {% if my_tip > 0 %} diff --git a/www/%username/goal.json.spt b/www/%username/goal.json.spt index a5870170e6..9c1e646591 100644 --- a/www/%username/goal.json.spt +++ b/www/%username/goal.json.spt @@ -24,7 +24,7 @@ if goal is not None: raise Response(400, "Bad input.") participant = get_participant(request) -participant.goal = goal +participant.update_goal(goal) if goal is not None: goal = locale.format("%.2f", goal, grouping=True) diff --git a/www/%username/history/index.html.spt b/www/%username/history/index.html.spt index b3b9548d95..8a9cc38207 100644 --- a/www/%username/history/index.html.spt +++ b/www/%username/history/index.html.spt @@ -11,17 +11,17 @@ class Paydays(object): def __init__(self, username, balance): self._username = username self._paydays = db.all("SELECT ts_start, ts_end FROM paydays " - "ORDER BY ts_end DESC") + "ORDER BY ts_end DESC", back_as=dict) self._npaydays = len(self._paydays) self._exchanges = db.all( "SELECT * FROM exchanges " "WHERE participant=%s " "ORDER BY timestamp ASC" - , (username,) + , (username,), back_as=dict ) self._transfers = db.all( "SELECT * FROM transfers " "WHERE tipper=%s OR tippee=%s " "ORDER BY timestamp ASC" - , (username, username) + , (username, username), back_as=dict ) self._balance = balance @@ -159,12 +159,12 @@ locked = False
0 %} checked="true"{% end %}/> + {% if user.participant.goal > 0 %} checked="true"{% end %}/>
+ {% if user.participant.goal is None %} checked="true"{% end %}/>
+ {% if user.participant.goal == 0 %} checked="true"{% end %}/> @@ -120,7 +120,7 @@

Funding Goal + {% if (user.participant.goal is not None) and user.participant.goal < 0 %} checked="true"{% end %}/> diff --git a/templates/profile.html b/templates/profile.html index d035aafac8..f695fbf478 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -14,7 +14,7 @@ {% block nav %} {% set current_page = path.raw.split('/')[2] %} -{% if participant == user or user.ADMIN %} +{% if user.participant == participant or user.ADMIN %}

{{ tippee }}
- + {{ _extract_username(tip) }} {% include "templates/my-tip-bulk.html" %}
{% if event['tippee'] == participant.username %} - {% if user.ADMIN and (participant.username != user.username or 'override' in qs) %} + {% if user.ADMIN and (participant != user.participant or 'override' in qs) %} {% else %} diff --git a/www/%username/history/record-an-exchange.spt b/www/%username/history/record-an-exchange.spt index eb081cd779..4be8e01c4d 100644 --- a/www/%username/history/record-an-exchange.spt +++ b/www/%username/history/record-an-exchange.spt @@ -23,14 +23,16 @@ if not note: raise Response(400) -with gittip.db.get_transaction() as txn: +with gittip.db.get_cursor() as cursor: + params = (amount, fee, participant.username, user.participant.username, \ + note) SQL = "INSERT INTO exchanges " \ "(amount, fee, participant, recorder, note) " \ "VALUES (%s, %s, %s, %s, %s)" - txn.execute(SQL, (amount, fee, participant.username, user.username, note)) + cursor.execute(SQL, params) SQL = "UPDATE participants SET balance = balance + %s WHERE username=%s" - txn.execute(SQL, (amount, participant.username)) + cursor.execute(SQL, (amount, participant.username)) request.redirect('/%s/history/' % participant.username) diff --git a/www/%username/horn/toot.json.spt b/www/%username/horn/toot.json.spt index f41535d28a..31560265d6 100644 --- a/www/%username/horn/toot.json.spt +++ b/www/%username/horn/toot.json.spt @@ -5,7 +5,7 @@ from gittip import db if user.ANON: raise Response(400) -tooter = user.username +tooter = user.participant.username tootee = path['username'] toot = body['toot'].strip() if len(toot) > 140 or len(toot) < 1: diff --git a/www/%username/horn/toots.json.spt b/www/%username/horn/toots.json.spt index 17834593b3..05e4f87ce3 100644 --- a/www/%username/horn/toots.json.spt +++ b/www/%username/horn/toots.json.spt @@ -51,7 +51,7 @@ DEFAULT_LIMIT = 200 [------------------------------] participant = get_participant(request, restrict=False) username = participant.username -current_user = user.username +current_user = getattr(user.participant, 'username', None) try: limit = min(int(qs.get('limit', DEFAULT_LIMIT)), DEFAULT_LIMIT) diff --git a/www/%username/index.html.spt b/www/%username/index.html.spt index 636cab216c..2dd1cfd0c8 100644 --- a/www/%username/index.html.spt +++ b/www/%username/index.html.spt @@ -28,7 +28,7 @@ username = participant.username # used in footer shared with on/$platform/ github_account, twitter_account, bitbucket_account, bountysource_account = \ participant.get_accounts_elsewhere() long_statement = len(participant.statement) > LONG_STATEMENT -communities = [c for c in community.get_list_for(participant) if c['is_member']] +communities = [c for c in community.get_list_for(user) if c.is_member] if participant.number == 'singular': I_AM = "I am" I_M = "I'm" @@ -55,7 +55,7 @@ else: {% end %} {% block page %} -{% if user == participant %} +{% if user.participant == participant %}
{% include "templates/profile-edit.html" %}
@@ -80,8 +80,8 @@ else:
    {% for community in communities %}
  • - {{ community['name'] }} - {% set nothers = community['nmembers'] - 1 %} + {{ community.name }} + {% set nothers = community.nmembers - 1 %}
    with {{ nothers }} other{{ plural(nothers) }}
  • {% end %} diff --git a/www/%username/members/%membername.json.spt b/www/%username/members/%membername.json.spt index 8d5017f293..54b2d46e3b 100644 --- a/www/%username/members/%membername.json.spt +++ b/www/%username/members/%membername.json.spt @@ -25,7 +25,7 @@ proceed = True if POST: - if user not in (member, team): + if user.participant not in (member, team): raise Response(403) try: @@ -64,8 +64,8 @@ if POST: raise Response(403) if proceed: - team.set_take_for(member, take, user) - out = team.get_memberships(user) + team.set_take_for(member, take, user.participant) + out = team.get_memberships(user.participant) else: if member == team: diff --git a/www/%username/members/index.json.spt b/www/%username/members/index.json.spt index 7cab86eb2c..2f09922d41 100644 --- a/www/%username/members/index.json.spt +++ b/www/%username/members/index.json.spt @@ -10,4 +10,4 @@ team = get_participant(request, restrict=False) if not team.show_as_team(user): raise Response(404) -response.body = team.get_memberships(user) +response.body = team.get_memberships(user.participant) diff --git a/www/%username/public.json.spt b/www/%username/public.json.spt index 5bdacb498f..8bc4d1d8b1 100644 --- a/www/%username/public.json.spt +++ b/www/%username/public.json.spt @@ -63,10 +63,10 @@ output["giving"] = giving # 3.00 - user tips this person this amount if not user.ANON: - if user.username == path['username']: + if user.participant.username == path['username']: my_tip = "self" else: - my_tip = user.get_tip_to(path['username']) + my_tip = user.participant.get_tip_to(path['username']) output["my_tip"] = str(my_tip) diff --git a/www/%username/statement.json.spt b/www/%username/statement.json.spt index 3115ef7d8e..8f855c4a99 100644 --- a/www/%username/statement.json.spt +++ b/www/%username/statement.json.spt @@ -1,3 +1,5 @@ +from __future__ import print_function, unicode_literals + from aspen import Response from gittip import db from gittip.utils import wrap @@ -14,14 +16,14 @@ number = request.body["number"] if number not in ("singular", "plural"): raise Response(400) -if number != user.number: +if number != user.participant.number: db.run( "UPDATE participants SET statement=%s, number=%s " "WHERE username=%s" - , (statement, number, user.username) + , (statement, number, user.participant.username) ) else: db.run( "UPDATE participants SET statement=%s WHERE username=%s" - , (statement, user.username) + , (statement, user.participant.username) ) response.body = {"statement": wrap(statement), "number": wrap(number)} diff --git a/www/%username/tip.json.spt b/www/%username/tip.json.spt index 52871c55e8..fed5578d29 100644 --- a/www/%username/tip.json.spt +++ b/www/%username/tip.json.spt @@ -3,7 +3,7 @@ from decimal import InvalidOperation from aspen import Response -from gittip.participant import Participant +from gittip.models.participant import Participant [-----------------------------------------------------------------------------] @@ -15,8 +15,8 @@ if not user.ANON: # XXX We could/should enforce that tips cannot be pledged at all to locked # accounts. - tipper = user.username - tippee = path['username'] + tipper = user.participant + tippee = Participant.from_username(path['username']) # Get and maybe set amount. @@ -27,15 +27,17 @@ if not user.ANON: amount = None elif POST and 'amount' in body: try: - amount, first_time_tipper = user.set_tip_to(tippee, body['amount']) + amount, first_time_tipper = tipper.set_tip_to( tippee.username + , body['amount'] + ) except (InvalidOperation, ValueError): raise Response(400, "bad amount") else: - amount = user.get_tip_to(tippee) + amount = tipper.get_tip_to(tippee.username) - total_giving = user.get_dollars_giving() - total_receiving = user.get_dollars_receiving() - total_receiving_tippee = Participant(tippee).get_dollars_receiving() + total_giving = tipper.get_dollars_giving() + total_receiving = tipper.get_dollars_receiving() + total_receiving_tippee = tippee.get_dollars_receiving() out = { "amount": str(amount) , "total_giving": str(total_giving) diff --git a/www/%username/tips.json.spt b/www/%username/tips.json.spt index 07967e54dd..932aec7e53 100644 --- a/www/%username/tips.json.spt +++ b/www/%username/tips.json.spt @@ -40,11 +40,11 @@ else: out = [] for tip in tips: - if tip['amount'] == 0: + if tip.amount == 0: continue - out.append({ "username": tip['tippee'] + out.append({ "username": tip.tippee , "platform": "gittip" - , "amount": str(tip['amount']) + , "amount": str(tip.amount) }) response.body = out diff --git a/www/%username/toggle-is-suspicious.json.spt b/www/%username/toggle-is-suspicious.json.spt index 78bfcf0899..949bca0895 100644 --- a/www/%username/toggle-is-suspicious.json.spt +++ b/www/%username/toggle-is-suspicious.json.spt @@ -1,5 +1,4 @@ from aspen import Response -from gittip import db [---] if not user.ADMIN: raise Response(400) @@ -9,7 +8,7 @@ if not to in ('true', 'false', None): raise Response(400) if to is None: - rec = db.one(""" + is_suspicious = website.db.one(""" UPDATE participants SET is_suspicious = (is_suspicious IS NULL) OR (is_suspicious IS false) @@ -18,7 +17,7 @@ if to is None: """, (path['username'],)) else: - rec = db.one(""" + is_suspicious = website.db.one(""" UPDATE participants SET is_suspicious = %s @@ -27,6 +26,4 @@ else: """, (to == 'true', path['username'],)) -assert rec is not None - -response.body = {"is_suspicious": rec['is_suspicious']} +response.body = {"is_suspicious": is_suspicious} diff --git a/www/%username/username.json.spt b/www/%username/username.json.spt index 205c3592b9..44bbb3c6de 100644 --- a/www/%username/username.json.spt +++ b/www/%username/username.json.spt @@ -1,5 +1,11 @@ from aspen import Response, log_dammit -from gittip.models import Participant +from gittip.models.participant import Participant +from gittip.models.participant import ( UsernameContainsInvalidCharacters + , UsernameIsRestricted + , UsernameAlreadyTaken + , UsernameTooLong + ) + [-----------------------------------------------------------------------------] @@ -9,13 +15,13 @@ if user.ANON: new_username = request.body['username'] try: - old_username = user.username - user.change_username(new_username) + old_username = user.participant.username + user.participant.change_username(new_username) response.body = {"username": new_username} log_dammit("user with username %s has become username %s" % (old_username, new_username)) -except (Participant.UsernameContainsInvalidCharacters, Participant.UsernameIsRestricted): +except (UsernameContainsInvalidCharacters, UsernameIsRestricted): raise Response(400) -except Participant.UsernameAlreadyTaken: +except UsernameAlreadyTaken: raise Response(409) -except Participant.UsernameTooLong: +except UsernameTooLong: raise Response(413) diff --git a/www/%username/widget.html.spt b/www/%username/widget.html.spt index 7beefbe52c..6664994b5f 100644 --- a/www/%username/widget.html.spt +++ b/www/%username/widget.html.spt @@ -1,12 +1,12 @@ -from gittip.models import Participant +from gittip.models.participant import Participant [---] -participant = Participant.query.get(path['username']) +participant = Participant.from_username(path['username']) if user.ANON: button_text = "Gittip" -elif user == participant: +elif user.participant == participant: button_text = "You!" else: - button_text = user.get_tip_to(participant.username) + button_text = user.participant.get_tip_to(participant.username) if button_text == 0: button_text = "Gittip" response.headers['X-Frame-Options'] = "ALLOWALL" diff --git a/www/about/fraud/2012-11-05.html.spt b/www/about/fraud/2012-11-05.html.spt index 717f743181..8292405f8e 100644 --- a/www/about/fraud/2012-11-05.html.spt +++ b/www/about/fraud/2012-11-05.html.spt @@ -17,9 +17,7 @@ _suspicious = db.all(""" AND ctime < '2012-11-05'::timestamptz ORDER BY ctime -""") -if _suspicious is None: - _suspicious = [] +""", back_as=dict) def suspicious(): @@ -37,7 +35,7 @@ def suspicious(): ORDER BY tips.ctime - """, (person['username'],)) + """, (person['username'],), back_as=dict) person['receives_from'] = db.all(""" SELECT DISTINCT tipper AS username @@ -50,7 +48,7 @@ def suspicious(): AND tips.ctime < '2012-11-05'::timestamptz ORDER BY tips.ctime - """, (person['username'],)) + """, (person['username'],), back_as=dict) person['transfers'] = db.all(""" @@ -64,7 +62,8 @@ def suspicious(): ORDER BY timestamp - """, (person['username'], person['username'])) + """, (person['username'], person['username']), \ + back_as=dict) person['exchanges'] = db.all(""" @@ -75,7 +74,7 @@ def suspicious(): ORDER BY timestamp - """, (person['username'],)) + """, (person['username'],), back_as=dict) yield person suspicious = list(suspicious()) @@ -129,7 +128,7 @@ overall = db.one(""" AND timestamp > %s AND timestamp < '2012-11-01'::timestamptz -""", (earliest.date(),)) +""", (earliest.date(),), back_as=dict) overall_volume = overall['sum'] overall_transactions = overall['count'] percentage_stolen = (total_charged / overall_volume) * 100 @@ -157,7 +156,7 @@ bystander_balances = db.all(""" SELECT username, balance FROM participants WHERE username = ANY(%s) -""", (list(bystanders),)) +""", (list(bystanders),), back_as=dict) if bystander_balances is None: bystander_balances = [] for row in bystander_balances: diff --git a/www/about/goals.html.spt b/www/about/goals.html.spt index 184a4b5059..656b62c45e 100644 --- a/www/about/goals.html.spt +++ b/www/about/goals.html.spt @@ -4,7 +4,8 @@ from gittip import db [-------] -ngoals = db.one("SELECT count(id) FROM participants WHERE goal > 0")['count'] +ngoals = db.one("SELECT count(id) FROM participants " + "WHERE goal > 0")['count'] goals = db.all(""" SELECT * diff --git a/www/about/me.html.spt b/www/about/me.html.spt index fff146b57b..dd620aa4d9 100644 --- a/www/about/me.html.spt +++ b/www/about/me.html.spt @@ -1,5 +1,5 @@ if not user.ANON: - request.redirect('/%s/' % user.username) + request.redirect('/%s/' % user.participant.username) title = "Sign In" [---] {% extends templates/base.html %} diff --git a/www/about/me/%redirect_to.spt b/www/about/me/%redirect_to.spt index 5adf70b0fa..b9f2babfbf 100644 --- a/www/about/me/%redirect_to.spt +++ b/www/about/me/%redirect_to.spt @@ -1,5 +1,5 @@ [---] -if user.username is None: +if user.ANON: request.redirect('/') -request.redirect(u'/' + user.username + u'/' + path['redirect_to']) +request.redirect(u'/' + user.participant.username + u'/' + path['redirect_to']) ^L text/plain diff --git a/www/about/stats.spt b/www/about/stats.spt index 70b2c396f2..4ce34e853e 100644 --- a/www/about/stats.spt +++ b/www/about/stats.spt @@ -1,3 +1,5 @@ +from __future__ import print_function, unicode_literals + import datetime import locale from aspen import json @@ -17,25 +19,20 @@ def commaize(number, places=0): title = "Stats" yesterday = datetime.datetime.utcnow() - datetime.timedelta(hours=24) -escrow = db.one("SELECT sum(balance) FROM participants")['sum'] -escrow = 0 if escrow is None else escrow -nach = db.one("SELECT count(*) AS n FROM participants WHERE last_ach_result = '' AND is_suspicious IS NOT true")['n'] -nach = 0 if nach is None else nach +escrow = db.one("SELECT sum(balance) FROM participants", default=0) +nach = db.one("SELECT count(*) FROM participants WHERE last_ach_result = '' AND is_suspicious IS NOT true") if nach < 10: nach = CARDINALS[nach].title() else: nach = commaize(nach) payday = db.one( "SELECT ts_start, ts_end FROM paydays WHERE ts_start > %s" , (yesterday,) - , strict=False ) -npeople = db.one("SELECT count(*) AS n FROM participants WHERE claimed_time IS NOT NULL AND is_suspicious IS NOT true")['n'] -ncc = db.one("SELECT count(*) AS n FROM participants WHERE last_bill_result = '' AND is_suspicious IS NOT true")['n'] -ncc = 0 if ncc is None else ncc +npeople = db.one("SELECT count(*) FROM participants WHERE claimed_time IS NOT NULL AND is_suspicious IS NOT true") +ncc = db.one("SELECT count(*) FROM participants WHERE last_bill_result = '' AND is_suspicious IS NOT true") pcc = "%5.1f" % ((ncc * 100.0) / npeople) if npeople > 0 else 0.0 statements = db.all("SELECT username, statement FROM participants WHERE statement != '' AND is_suspicious IS NOT true ORDER BY random(), username LIMIT 16") -transfer_volume = db.one("SELECT transfer_volume AS v FROM paydays ORDER BY ts_end DESC LIMIT 1", strict=False) -transfer_volume = 0 if transfer_volume is None else transfer_volume['v'] +transfer_volume = db.one("SELECT transfer_volume FROM paydays ORDER BY ts_end DESC LIMIT 1", default=0) tip_amounts = db.one(""" SELECT avg(amount), sum(amount) FROM ( SELECT DISTINCT ON (tipper, tippee) amount @@ -53,10 +50,10 @@ if tip_amounts is None: average_tip = 0 total_backed_tips = 0 else: - average_tip = tip_amounts['avg'] if tip_amounts['avg'] is not None else 0 - total_backed_tips = tip_amounts['sum'] if tip_amounts['sum'] is not None else 0 + average_tip = tip_amounts.avg if tip_amounts.avg is not None else 0 + total_backed_tips = tip_amounts.sum if tip_amounts.sum is not None else 0 -average_tippees = db.one("""\ +average_tippees = int(db.one("""\ SELECT round(avg(ntippees)) FROM ( SELECT count(tippee) as NTIPPEES FROM ( SELECT DISTINCT ON (tipper, tippee) @@ -73,8 +70,7 @@ average_tippees = db.one("""\ GROUP BY tipper, tippee, mtime, amount ORDER BY tipper, tippee, mtime DESC ) AS foo WHERE amount > 0 GROUP BY tipper) AS bar -""")['round'] -average_tippees = 0 if average_tippees is None else int(average_tippees) +""", default=0)) word = "people" if average_tippees == 1: @@ -108,7 +104,7 @@ _tip_distribution = db.all(""" GROUP BY amount ORDER BY amount -""") +""", back_as=dict) tip_n = sum([row['count'] for row in _tip_distribution]) @@ -126,9 +122,9 @@ def part(s): ngivers = db.one("select count(distinct tipper) from transfers " - "where timestamp > (now() - interval '7 days')")['count'] + "where timestamp > (now() - interval '7 days')") nreceivers = db.one("select count(distinct tippee) from transfers " - "where timestamp > (now() - interval '7 days')")['count'] + "where timestamp > (now() - interval '7 days')") noverlap = db.one(""" select count(*) from ( @@ -141,7 +137,7 @@ noverlap = db.one(""" ) as anon -""")['count'] +""") nactive = db.one(""" select count(*) from ( @@ -154,7 +150,7 @@ nactive = db.one(""" ) as anon -""")['count'] +""") assert nactive == ngivers + nreceivers - noverlap @@ -169,10 +165,10 @@ now = datetime.datetime.utcnow() if now.weekday() == WEDNESDAY: this_thursday = "tomorrow" if now.weekday() == THURSDAY: - if payday is None or payday['ts_end'] is None: + if payday is None or payday.ts_end is None: # Payday hasn't started yet. this_thursday = "today" - elif payday['ts_end'].year == 1970: + elif payday.ts_end.year == 1970: # Payday is going on right now. future_processing_text = "is changing hands" this_thursday = "right now!" @@ -271,17 +267,19 @@ names = ['ncc', 'pcc', 'statements', 'transfer_volume',

    {{ commaize(npeople) }} people have joined Gittip. Of those, {{ pcc }}% ({{ commaize(ncc) }}) have a working credit card on - file.{% if user.last_bill_result == '' %} You're one of them. - {% elif not user.ANON %} You're not one of them.

    Click here to set up a - credit card.{% end %}

    + file.{% if not user.ANON and user.participant.last_bill_result == '' %} + You're one of them.{% elif not user.ANON %} You're not one of them.

    + +

    Click here to set up a + credit card.{% end %}

    ${{ commaize(escrow, 2) }} is escrowed within Gittip. {{ nach }} people have connected a bank account for withdrawals. - {% if user.last_ach_result == '' %}You're one of them. - {% elif not user.ANON %} You're not one of them.

    Click here to connect a bank - account.{% end %}

    + {% if not user.ANON and user.participant.last_ach_result == '' %}You're one of them. + {% elif not user.ANON %}You're not one of them.

    + +

    Click here to connect + a bank account.{% end %}

    ${{ commaize(transfer_volume, 2) }} changed hands {{ last_thursday }}.

    @@ -321,7 +319,7 @@ names = ['ncc', 'pcc', 'statements', 'transfer_volume', y: 0, dx: 5} }); - + tips.map(function(tip){ var tick = Math.floor(tip/interval) @@ -331,7 +329,7 @@ names = ['ncc', 'pcc', 'statements', 'transfer_volume', tips_by_value[tick].y += tip }); - + var formatCount = d3.format(",.0f"); var margin = {top: 10, right: 30, bottom: 50, left: 20}, @@ -403,9 +401,9 @@ names = ['ncc', 'pcc', 'statements', 'transfer_volume', } create_histogram("#distribution-by-number", data, "number of tips"); - create_histogram("#distribution-by-value", tips_by_value, + create_histogram("#distribution-by-value", tips_by_value, "total value of tips in US dollars ($)"); - + }); @@ -419,8 +417,8 @@ names = ['ncc', 'pcc', 'statements', 'transfer_volume',

    {% for statement in statements %} - {{ escape(statement['username']) }} - is {{ escape(part(statement['statement'])) }}
    + {{ escape(statement.username) }} + is {{ escape(part(statement.statement)) }}
    {% end %}

    diff --git a/www/about/tip-distribution.json.spt b/www/about/tip-distribution.json.spt index ea95283356..873dde2782 100644 --- a/www/about/tip-distribution.json.spt +++ b/www/about/tip-distribution.json.spt @@ -3,7 +3,7 @@ from gittip import db [---] -_tip_distribution = db.all(""" +response.body = db.all(""" SELECT amount FROM (SELECT DISTINCT ON (tipper, tippee) @@ -22,6 +22,4 @@ _tip_distribution = db.all(""" WHERE amount > 0 ORDER BY amount -""") -tips = [tip['amount'] for tip in _tip_distribution] -response.body = sorted(tips) +""", back_as=dict) diff --git a/www/bank-account-complete.html.spt b/www/bank-account-complete.html.spt index dc369a4901..bc9ee5ea3f 100644 --- a/www/bank-account-complete.html.spt +++ b/www/bank-account-complete.html.spt @@ -14,16 +14,16 @@ request.allow('GET') # no validation here, if any of this stuff errors out, it's because someone has # done something dodgy (or we f'd up) -assert user.balanced_account_uri is not None, user.username +assert user.participant.balanced_account_uri is not None, user.participant.username -account = billing.get_balanced_account( user.username - , user.balanced_account_uri +account = billing.get_balanced_account( user.participant.username + , user.participant.balanced_account_uri ) account.merchant_uri = qs['merchant_uri'] account.save() bank_account = account.bank_accounts.all()[-1] -billing.associate(u"bank account", user.username, account, bank_account.uri) +billing.associate(u"bank account", user.participant.username, account, bank_account.uri) request.redirect('/bank-account.html') [-----------------------------------------------------------------------------] diff --git a/www/bank-account.html.spt b/www/bank-account.html.spt index 5f2371da63..0819d541d6 100644 --- a/www/bank-account.html.spt +++ b/www/bank-account.html.spt @@ -13,11 +13,11 @@ bank_account = None status = ". . ." if not user.ANON: - balanced_account_uri = user.balanced_account_uri + balanced_account_uri = user.participant.balanced_account_uri status = "not connected" if balanced_account_uri: - working = user.last_ach_result == "" + working = user.participant.last_ach_result == "" status = "connected" if working else "not connected" account = balanced.Account.find(balanced_account_uri) @@ -33,7 +33,7 @@ if not user.ANON: assert balanced_account_uri == _bank_account_account_uri - username = user.username + username = user.participant.username title = "Bank Account" [-----------------------------------------------------------------------------] @@ -66,7 +66,7 @@ title = "Bank Account"
-

{{ participant.username == user.username and "You" or participant.username }} joined +

{{ participant == user.participant and "You" or participant.username }} joined {{ to_age(participant.claimed_time) }}.

-

{{ participant.username == user.username and "Your" or "Their" }} balance is +

{{ participant == user.participant and "Your" or "Their" }} balance is ${{ participant.balance }}.

{% if user.ADMIN %}

Record an Exchange.

@@ -268,7 +268,7 @@ locked = False
{{ event['balance'] }}from {{ event['tipper'] }}from someone