diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..0a722d7 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix; diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..f0f21f0 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,6 @@ +.git +.DS_Store +*.pyc +*.nix +.envrc +#!include:.gitignore diff --git a/.gitignore b/.gitignore index 0d20b64..0b27afb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ *.pyc +.direnv +__pycache__ diff --git a/Makefile b/Makefile index fc5af25..1fcbacf 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,19 @@ .PHONY: serve test_deps test check appcfg-update deploy serve: - dev_appserver.py --log_level=debug . --host=0.0.0.0 + honcho start test_deps: pip install -r requirements-dev.txt -test check: - python -m unittest discover -p '*_test.py' +test: + pytest + +typecheck: + pyright appcfg-update deploy: - gcloud app deploy --project "${APP}" + gcloud app deploy + +create-indexes: + gcloud datastore indexes create index.yaml diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..f3350d9 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +datastore: gcloud beta emulators datastore start +web: $(gcloud beta emulators datastore env-init); python main.py diff --git a/app.yaml b/app.yaml index c4cdfdb..218c01e 100644 --- a/app.yaml +++ b/app.yaml @@ -1,7 +1,15 @@ -runtime: python27 -threadsafe: yes -api_version: 1 +runtime: python310 default_expiration: "365d" +app_engine_apis: true + +# Slack has a very short timeout on API callbacks, so we need to keep at least 1 +# process running for it +automatic_scaling: + min_instances: 1 + +# min_instances requires warmup requests to work +inbound_services: + - warmup handlers: - url: /static @@ -13,23 +21,13 @@ handlers: upload: static/favicon.ico - url: /admin/.* - script: snippets.application + script: auto login: admin + secure: always -- url: .* - script: snippets.application - -skip_files: -- .git -- .DS_Store -- .*.pyc +- url: /.* + script: auto + secure: always builtins: - remote_api: on - -libraries: -- name: jinja2 - version: "2.6" -# This also brings in webapp2_extras: -- name: webapp2 - version: "2.5.1" diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..15c726f --- /dev/null +++ b/conftest.py @@ -0,0 +1,35 @@ +"""conftest - loaded automatically by the pytest runner""" + +from unittest.mock import MagicMock + +from google.cloud import ndb +from google.cloud.ndb import _datastore_api +from InMemoryCloudDatastoreStub.datastore_stub import LocalDatastoreStub +import pytest +from _pytest.monkeypatch import MonkeyPatch + + +@pytest.fixture(autouse=True) +def ndb_stub(monkeypatch: MonkeyPatch) -> LocalDatastoreStub: + stub = LocalDatastoreStub() + monkeypatch.setattr(_datastore_api, "stub", MagicMock(return_value=stub)) + return stub + + +@pytest.fixture(autouse=True) +def ndb_context(init_ndb_env_vars): + client = ndb.Client() + with client.context() as context: + yield context + + +@pytest.fixture(autouse=True) +def init_ndb_env_vars(monkeypatch: MonkeyPatch) -> None: + """Set environment variables for the test ndb client. + + Initializing an ndb Client in a test env requires some environment variables + to be set. For now, these are just garbage values intended to give the + library _something_ (we don't expect them to actually work yet) + """ + monkeypatch.setenv("DATASTORE_EMULATOR_HOST", "localhost") + monkeypatch.setenv("DATASTORE_DATASET", "datastore-stub-test") diff --git a/cron.yaml b/cron.yaml index f20cfb1..c526358 100644 --- a/cron.yaml +++ b/cron.yaml @@ -2,7 +2,7 @@ cron: - description: snippets chat -- early reminder to write snippets url: /admin/send_friday_reminder_chat - schedule: every friday 16:00 + schedule: every friday 13:00 timezone: US/Pacific - description: snippets email -- reminder to write snippets diff --git a/main.py b/main.py new file mode 100644 index 0000000..65537a1 --- /dev/null +++ b/main.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +import snippets + +import google.appengine.api +import google.cloud.ndb + + +class NDBMiddleware: + """WSGI middleware to wrap the app in Google Cloud NDB context""" + def __init__(self, app): + self.app = app + self.client = google.cloud.ndb.Client() + + def __call__(self, environ, start_response): + with self.client.context(): + return self.app(environ, start_response) + +app = snippets.app +app.wsgi_app = google.appengine.api.wrap_wsgi_app(app.wsgi_app) +app.wsgi_app = NDBMiddleware(app.wsgi_app) + + +if __name__ == '__main__': + # This is used when running locally only. When deploying to Google App + # Engine, a webserver process such as Gunicorn will serve the app. You + # can configure startup instructions by adding `entrypoint` to app.yaml. + # + # To control listening IP and port, set SERVER_NAME in the environment. + # e.g. SERVER_NAME=127.0.0.1:8080 + # Default is to listen on 127.0.0.1:5000 + app.run(debug=True) diff --git a/models.py b/models.py index c6f0f10..427ec33 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ import hashlib import os -from google.appengine.ext import db +from google.cloud import ndb from google.appengine.api import users @@ -21,54 +21,55 @@ # support that later. -class User(db.Model): +class User(ndb.Model): """User preferences.""" - created = db.DateTimeProperty() - last_modified = db.DateTimeProperty(auto_now=True) - email = db.StringProperty(required=True) # The key to this record - is_hidden = db.BooleanProperty(default=False) # hide 'empty' snippets - category = db.StringProperty(default=NULL_CATEGORY) # groups snippets - uses_markdown = db.BooleanProperty(default=True) # interpret snippet text - private_snippets = db.BooleanProperty(default=False) # private by default? - wants_email = db.BooleanProperty(default=True) # get nag emails? + created = ndb.DateTimeProperty(auto_now_add=True) + last_modified = ndb.DateTimeProperty(auto_now=True) + email = ndb.StringProperty(required=True) # The key to this record + is_hidden = ndb.BooleanProperty(default=False) # hide 'empty' snippets + category = ndb.StringProperty(default=NULL_CATEGORY) # groups snippets + uses_markdown = ndb.BooleanProperty(default=True) # interpret snippet text + private_snippets = ndb.BooleanProperty(default=False) # private by default? + wants_email = ndb.BooleanProperty(default=True) # get nag emails? # TODO(csilvers): make a ListProperty instead. - wants_to_view = db.TextProperty(default='all') # comma-separated list - display_name = db.TextProperty(default='') # display name of the user + wants_to_view = ndb.TextProperty(default='all') # comma-separated list + display_name = ndb.TextProperty(default='') # Display name of the user + slack_id = ndb.StringProperty(default='') # Slack member ID (not nickname!) -class Snippet(db.Model): +class Snippet(ndb.Model): """Every snippet is identified by the monday of the week it goes with.""" - created = db.DateTimeProperty() - last_modified = db.DateTimeProperty(auto_now=True) - display_name = db.StringProperty() # display name of the user - email = db.StringProperty(required=True) # week+email: key to this record - week = db.DateProperty(required=True) # the monday of the week - text = db.TextProperty() - private = db.BooleanProperty(default=False) # snippet is private? - is_markdown = db.BooleanProperty(default=False) # text is markdown? + created = ndb.DateTimeProperty(auto_now_add=True) + last_modified = ndb.DateTimeProperty(auto_now=True) + display_name = ndb.StringProperty() # display name of the user + email = ndb.StringProperty(required=True) # week+email: key to this record + week = ndb.DateProperty(required=True) # the monday of the week + text = ndb.TextProperty() + private = ndb.BooleanProperty(default=False) # snippet is private? + is_markdown = ndb.BooleanProperty(default=False) # text is markdown? @property def email_md5_hash(self): m = hashlib.md5() - m.update(self.email) + m.update(self.email.encode('utf-8')) return m.hexdigest() -class AppSettings(db.Model): +class AppSettings(ndb.Model): """Application-wide preferences.""" - created = db.DateTimeProperty() - last_modified = db.DateTimeProperty(auto_now=True) + created = ndb.DateTimeProperty(auto_now_add=True) + last_modified = ndb.DateTimeProperty(auto_now=True) # Application settings - domains = db.StringListProperty(required=True) - hostname = db.StringProperty(required=True) # used for emails - default_private = db.BooleanProperty(default=False) # new-user default - default_markdown = db.BooleanProperty(default=True) # new-user default - default_email = db.BooleanProperty(default=True) # new-user default + domains = ndb.StringProperty(repeated=True) + hostname = ndb.StringProperty(required=True) # used for emails + default_private = ndb.BooleanProperty(default=False) # new-user default + default_markdown = ndb.BooleanProperty(default=True) # new-user default + default_email = ndb.BooleanProperty(default=True) # new-user default # Chat and email settings - email_from = db.StringProperty(default='') - slack_channel = db.StringProperty(default='') - slack_token = db.StringProperty(default='') - slack_slash_token = db.StringProperty(default='') + email_from = ndb.StringProperty(default='') + slack_channel = ndb.StringProperty(default='') + slack_token = ndb.StringProperty(default='') + slack_slash_token = ndb.StringProperty(default='') @staticmethod def get(create_if_missing=False, domains=None): @@ -79,12 +80,12 @@ def get(create_if_missing=False, domains=None): are initialized with the given value for 'domains'. The new entity is *not* put to the datastore. """ - retval = AppSettings.get_by_key_name('global_settings') + retval = AppSettings.get_by_id('global_settings') if retval: return retval elif create_if_missing: # We default to sending email, and having it look like it's - # comint from the current user. We add a '+snippets' in there + # coming from the current user. We add a '+snippets' in there # to allow for filtering email_address = users.get_current_user().email() email_address = email_address.replace('@', '+snippets@') @@ -93,7 +94,7 @@ def get(create_if_missing=False, domains=None): # you accessed the site on here. hostname = '%s://%s' % (os.environ.get('wsgi.url_scheme', 'http'), os.environ['HTTP_HOST']) - return AppSettings(key_name='global_settings', + return AppSettings(id='global_settings', created=datetime.datetime.now(), domains=domains, hostname=hostname, diff --git a/nix/sources.json b/nix/sources.json new file mode 100644 index 0000000..70e5847 --- /dev/null +++ b/nix/sources.json @@ -0,0 +1,14 @@ +{ + "nixpkgs": { + "branch": "nixos-22.11", + "description": "Nix Packages collection", + "homepage": null, + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eef86b8a942913a828b9ef13722835f359deef29", + "sha256": "1ig0mc7f2n1pxzd5y9m4vz38zp515vn5s576kwhkhj62zdw99p2v", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/eef86b8a942913a828b9ef13722835f359deef29.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" + } +} diff --git a/nix/sources.nix b/nix/sources.nix new file mode 100644 index 0000000..fe3dadf --- /dev/null +++ b/nix/sources.nix @@ -0,0 +1,198 @@ +# This file has been generated by Niv. + +let + + # + # The fetchers. fetch_ fetches specs of type . + # + + fetch_file = pkgs: name: spec: + let + name' = sanitizeName name + "-src"; + in + if spec.builtin or true then + builtins_fetchurl { inherit (spec) url sha256; name = name'; } + else + pkgs.fetchurl { inherit (spec) url sha256; name = name'; }; + + fetch_tarball = pkgs: name: spec: + let + name' = sanitizeName name + "-src"; + in + if spec.builtin or true then + builtins_fetchTarball { name = name'; inherit (spec) url sha256; } + else + pkgs.fetchzip { name = name'; inherit (spec) url sha256; }; + + fetch_git = name: spec: + let + ref = + spec.ref or ( + if spec ? branch then "refs/heads/${spec.branch}" else + if spec ? tag then "refs/tags/${spec.tag}" else + abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!" + ); + submodules = spec.submodules or false; + submoduleArg = + let + nixSupportsSubmodules = builtins.compareVersions builtins.nixVersion "2.4" >= 0; + emptyArgWithWarning = + if submodules + then + builtins.trace + ( + "The niv input \"${name}\" uses submodules " + + "but your nix's (${builtins.nixVersion}) builtins.fetchGit " + + "does not support them" + ) + { } + else { }; + in + if nixSupportsSubmodules + then { inherit submodules; } + else emptyArgWithWarning; + in + builtins.fetchGit + ({ url = spec.repo; inherit (spec) rev; inherit ref; } // submoduleArg); + + fetch_local = spec: spec.path; + + fetch_builtin-tarball = name: throw + ''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`. + $ niv modify ${name} -a type=tarball -a builtin=true''; + + fetch_builtin-url = name: throw + ''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`. + $ niv modify ${name} -a type=file -a builtin=true''; + + # + # Various helpers + # + + # https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695 + sanitizeName = name: + ( + concatMapStrings (s: if builtins.isList s then "-" else s) + ( + builtins.split "[^[:alnum:]+._?=-]+" + ((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name) + ) + ); + + # The set of packages used when specs are fetched using non-builtins. + mkPkgs = sources: system: + let + sourcesNixpkgs = + import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; }; + hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath; + hasThisAsNixpkgsPath = == ./.; + in + if builtins.hasAttr "nixpkgs" sources + then sourcesNixpkgs + else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then + import { } + else + abort + '' + Please specify either (through -I or NIX_PATH=nixpkgs=...) or + add a package called "nixpkgs" to your sources.json. + ''; + + # The actual fetching function. + fetch = pkgs: name: spec: + + if ! builtins.hasAttr "type" spec then + abort "ERROR: niv spec ${name} does not have a 'type' attribute" + else if spec.type == "file" then fetch_file pkgs name spec + else if spec.type == "tarball" then fetch_tarball pkgs name spec + else if spec.type == "git" then fetch_git name spec + else if spec.type == "local" then fetch_local spec + else if spec.type == "builtin-tarball" then fetch_builtin-tarball name + else if spec.type == "builtin-url" then fetch_builtin-url name + else + abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}"; + + # If the environment variable NIV_OVERRIDE_${name} is set, then use + # the path directly as opposed to the fetched source. + replace = name: drv: + let + saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name; + ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}"; + in + if ersatz == "" then drv else + # this turns the string into an actual Nix path (for both absolute and + # relative paths) + if builtins.substring 0 1 ersatz == "/" then /. + ersatz else /. + builtins.getEnv "PWD" + "/${ersatz}"; + + # Ports of functions for older nix versions + + # a Nix version of mapAttrs if the built-in doesn't exist + mapAttrs = builtins.mapAttrs or ( + f: set: with builtins; + listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set)) + ); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295 + range = first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257 + stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1)); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269 + stringAsChars = f: s: concatStrings (map f (stringToCharacters s)); + concatMapStrings = f: list: concatStrings (map f list); + concatStrings = builtins.concatStringsSep ""; + + # https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331 + optionalAttrs = cond: as: if cond then as else { }; + + # fetchTarball version that is compatible between all the versions of Nix + builtins_fetchTarball = { url, name ? null, sha256 }@attrs: + let + inherit (builtins) lessThan nixVersion fetchTarball; + in + if lessThan nixVersion "1.12" then + fetchTarball ({ inherit url; } // (optionalAttrs (name != null) { inherit name; })) + else + fetchTarball attrs; + + # fetchurl version that is compatible between all the versions of Nix + builtins_fetchurl = { url, name ? null, sha256 }@attrs: + let + inherit (builtins) lessThan nixVersion fetchurl; + in + if lessThan nixVersion "1.12" then + fetchurl ({ inherit url; } // (optionalAttrs (name != null) { inherit name; })) + else + fetchurl attrs; + + # Create the final "sources" from the config + mkSources = config: + mapAttrs + ( + name: spec: + if builtins.hasAttr "outPath" spec + then + abort + "The values in sources.json should not have an 'outPath' attribute" + else + spec // { outPath = replace name (fetch config.pkgs name spec); } + ) + config.sources; + + # The "config" used by the fetchers + mkConfig = + { sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null + , sources ? if sourcesFile == null then { } else builtins.fromJSON (builtins.readFile sourcesFile) + , system ? builtins.currentSystem + , pkgs ? mkPkgs sources system + }: rec { + # The sources, i.e. the attribute set of spec name to spec + inherit sources; + + # The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers + inherit pkgs; + }; + +in +mkSources (mkConfig { }) // { __functor = _: settings: mkSources (mkConfig settings); } diff --git a/requirements-dev.txt b/requirements-dev.txt index 7829dbe..abf84ca 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,4 @@ webtest>=2.0.16 +pytest +InMemoryCloudDatastoreStub +time-machine>=2.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a7f2e5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +appengine-python-standard>=1.0.0 +Flask>=2.2.2 +Jinja2>=3.1.2 +google-cloud-ndb>=1.11.1 +google-cloud-logging>=3.2.5 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..7a670c1 --- /dev/null +++ b/shell.nix @@ -0,0 +1,105 @@ +{ sources ? import ./nix/sources.nix }: + +let + pkgs = import sources.nixpkgs {}; + + python = pkgs.python310.override { + packageOverrides = self: super: { + + google-cloud-datastore-stub = super.buildPythonPackage rec { + pname = "InMemoryCloudDatastoreStub"; + version = "0.0.15"; + src = super.fetchPypi { + inherit pname version; + sha256 = "sha256-nuJNhnlXSbAg6CdWW8u7rGDDd07FvLNRtsTfzUBh7bE="; + }; + propagatedBuildInputs = [ + self.google-cloud-ndb + ]; + buildInputs = [ + super.pytest + ]; + }; + + google-cloud-ndb = super.buildPythonPackage rec { + pname = "google-cloud-ndb"; + version = "1.11.1"; + + src = super.fetchPypi { + inherit pname version; + sha256 = "sha256-ooEt7uTNEumYeN5EbmpVSqNKj/60OCVM4HHiUT7FaYY="; + }; + propagatedBuildInputs = [ + super.setuptools + super.google-cloud-core + self.google-cloud-datastore + super.pymemcache + super.redis + super.pytz + self.protobuf + ]; + doCheck = false; + }; + + google-cloud-datastore = super.buildPythonPackage rec { + pname = "google-cloud-datastore"; + version = "1.15.5"; + src = super.fetchPypi { + inherit pname version; + sha256 = "sha256-2tyhYxCIt6c38xHfh1dKQX8lTOcdapgIDzbzW9yWsw8="; + }; + propagatedBuildInputs = [ + self.protobuf + super.google-cloud-core + ]; + doCheck = false; + }; + + protobuf = super.protobuf.override { protobuf = pkgs.protobuf3_20; }; + + appengine-python-standard = super.buildPythonPackage rec { + pname = "appengine-python-standard"; + version = "1.0.0"; + src = super.fetchPypi { + inherit pname version; + sha256 = "sha256-h/HLQC6Ez4oaFkW2Kkkwb0e+QSmUQVC0wrJD4igkkrk="; + }; + propagatedBuildInputs = [ + super.requests + self.protobuf + super.attrs + super.google-auth + super.pillow + super.pytz + super.frozendict + super.ruamel-yaml + super.mock + ]; + }; + }; + + }; + + pythonEnv = python.withPackages (ps: [ + ps.google-cloud-logging + ps.google-cloud-ndb + ps.appengine-python-standard + ps.flask + ps.pytest + ps.google-cloud-datastore-stub + ps.time-machine + ]); + + +in pkgs.mkShell { + buildInputs = with pkgs; [ + nodePackages.pyright # Type checker + honcho # Procfile runner + pythonEnv + (google-cloud-sdk.withExtraComponents ([ + google-cloud-sdk.components.app-engine-python + google-cloud-sdk.components.app-engine-python-extras + ])) + jre # gcloud datastore emulator needs jre + ]; +} diff --git a/slacklib.py b/slacklib.py index 10a4b4d..eaaf6a0 100644 --- a/slacklib.py +++ b/slacklib.py @@ -1,6 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals - """Snippets server -> Slack integration. This provides Slack integration with the snippet server, for @@ -21,14 +19,12 @@ import json import logging import re -import os import textwrap -import urllib -import urllib2 -import webapp2 +import urllib.error +import urllib.parse +import urllib.request -from google.appengine.ext import db -from google.appengine.api import memcache +import flask import models import util @@ -40,11 +36,7 @@ _REQUIRE_SLASH_TOKEN = True -# This allows mocking in a different day, for testing. -_TODAY_FN = datetime.datetime.now - -# The web URL we point people to as the base for web operations -_WEB_URL = 'http://' + os.environ.get('SERVER_NAME', 'localhost') +app_blueprint = flask.Blueprint("slack", __name__, url_prefix="/slack") def _web_api(api_method, payload): @@ -59,7 +51,8 @@ def _web_api(api_method, payload): app_settings = models.AppSettings.get() payload.setdefault('token', app_settings.slack_token) uri = 'https://slack.com/api/' + api_method - r = urllib2.urlopen(uri, urllib.urlencode(payload)) + r = urllib.request.urlopen(uri, + urllib.parse.urlencode(payload).encode('utf-8')) # check return code for server errors if r.getcode() != 200: @@ -72,40 +65,6 @@ def _web_api(api_method, payload): return reply -def _get_user_email(uid): - """Retrieve the email address for a specific userid from the Slack Web API. - - Raises ValueError if could not be retrieved. - """ - reply = _web_api('users.info', {'user': uid}) # possible ValueError - email = reply.get('user', {}).get('profile', {}).get('email', None) - if email is None: - raise ValueError('Slack user profile did not have email') - return email - - -def _get_user_email_cached(uid, force_refresh=False): - """Retrieve the email address for a specific user id, with a cache. - - Results are stored in memcache for up to a day. - - If force_refresh parameter is specified, cached data will be refreshed. - - Raises ValueError if could not be retrieved. - """ - key = 'slack_profile_email_' + uid - cached_data = memcache.get(key) - if (cached_data is None) or force_refresh: - logging.debug("cache miss/refresh for slack email lookup %s", uid) - email = _get_user_email(uid) # possible ValueError - if not memcache.set(key=key, value=email, time=86400): - logging.error('memcache set failed!') - return email - else: - logging.debug("cache hit for slack email lookup %s", uid) - return cached_data - - def send_to_slack_channel(channel, msg): """Send a plaintext message to a Slack channel.""" try: @@ -116,7 +75,7 @@ def send_to_slack_channel(channel, msg): 'icon_emoji': ':pencil:', 'unfurl_links': False, # no link previews, please }) - except ValueError, why: + except ValueError as why: logging.error('Failed sending message to slack: %s', why) @@ -138,20 +97,25 @@ def command_usage(): def command_help(): """Return the help string for slash commands.""" + app_settings = models.AppSettings.get() return ( "I can help you manage your " - "<{}|weekly snippets>! :pencil:".format(_WEB_URL) + + "<{}|weekly snippets>! :pencil:".format(app_settings.hostname) + command_usage() ) -def _no_user_error(user_email): - return ( - "You don't appear to have a snippets account yet!\n" - "To create one, go to {}\n" - "We looked for your Slack email address: {}" - .format(_WEB_URL, user_email) - ) +def _no_user_error(user_id): + try: + app_settings = models.AppSettings.get() + return (f"You don't seem to be logged in! Please configure your slack " + f"user ID (which is `{user_id}`) in the snippets server's " + f"settings page: {app_settings.hostname}/settings") + except ValueError: + return ( + "You don't seem to be logged in, and this snippets server is " + "unconfigured! Please ask your snippets admin to set it up " + "before trying again.") def _user_snippet(user_email, weeks_back=0): @@ -175,7 +139,8 @@ def _user_snippet(user_email, weeks_back=0): ) filled_snips = util.fill_in_missing_snippets(user_snips, account, - user_email, _TODAY_FN()) + user_email, + datetime.datetime.now()) logging.debug( 'User %s snippets *filled* to: %s', user_email, len(filled_snips) ) @@ -217,7 +182,7 @@ def _format_snippet_items(items): return "\n".join(fi) -def command_list(user_email): +def command_list(user_email: str) -> str: """Return the users current snippets for the week in pretty format.""" try: items = _snippet_items(_user_snippet(user_email)) @@ -305,12 +270,11 @@ def command_add(user_email, new_item): snippet.is_markdown = True # TODO(mroth): we should abstract out DB writes to a library wrapper - db.put(snippet) - db.get(snippet.key()) # ensure db consistency for HRD + snippet.put() return "Added *{}* to your weekly snippets.".format(new_item) -def command_del(user_email, args): +def command_del(user_email, args) -> str: """Delete an item at an index from the users current snippets. The `args` parameter should be the args passed to the command. We @@ -354,8 +318,7 @@ def command_del(user_email, args): snippet.text = _markdown_list(items) snippet.is_markdown = True - db.put(snippet) - db.get(snippet.key()) # ensure db consistency for HRD + snippet.put() return "Removed *{}* from your weekly snippets.".format(removed_item) @@ -368,95 +331,85 @@ def command_dump(user_email): return "```{}```".format(snippet.text or 'No snippet yet for this week') -class SlashCommand(webapp2.RequestHandler): - def post(self): - """Process an incoming slash command from Slack. - - Incoming request POST looks like the following (example taken from - https://api.slack.com/slash-commands): - token=gIkuvaNzQIHg97ATvDxqgjtO - team_id=T0001 - team_domain=example - channel_id=C2147483705 - channel_name=test - user_id=U2147483697 - user_name=Steve - command=/weather - text=94070 - """ - req, res = self.request, self.response - - expected_token = models.AppSettings.get().slack_slash_token - - if not expected_token: - res.write('Slack slash commands disabled. An admin ' - 'can enable them at /admin/settings') - return - - # verify slash API post token for security - if _REQUIRE_SLASH_TOKEN: - token = req.get('token') - if token != expected_token: - logging.error("POST MADE WITH INVALID TOKEN") - res.write("OH NO YOU DIDNT! Security issue plz contact admin.") - return - - user_name = req.get('user_name') - user_id = req.get('user_id') - text = req.get('text') - - try: - user_email = _get_user_email_cached(user_id) - except ValueError: - logging.error("Failed getting %s email from Slack API", user_name) - res.write( - "Error getting your email address from the Slack API! " - "Please contact an admin and report the time of this error." - ) - return +def get_user_by_slack_id(slack_id: str) -> models.User: + return models.User.query(models.User.slack_id == slack_id).get() + - words = text.strip().split() - if not words: - logging.info('null (list) command from user %s', user_name) - res.write(command_list(user_email)) +# This route is actually /slack because app_blueprint sets url_prefix +@app_blueprint.route("/", methods=["POST"]) +def slash_command_handler() -> str: + """Process an incoming slash command from Slack. + + Incoming request POST looks like the following (example taken from + https://api.slack.com/slash-commands): + token=gIkuvaNzQIHg97ATvDxqgjtO + team_id=T0001 + team_domain=example + channel_id=C2147483705 + channel_name=test + user_id=U2147483697 + user_name=Steve + command=/weather + text=94070 + """ + req = flask.request + + app_settings = models.AppSettings.get() + expected_token = app_settings.slack_slash_token + + if not expected_token: + return ('Slack slash commands disabled. An admin ' + 'can enable them at /admin/settings') + + # verify slash API post token for security + if _REQUIRE_SLASH_TOKEN: + token = req.form.get('token') + if token != expected_token: + logging.error("POST MADE WITH INVALID TOKEN") + return "OH NO YOU DIDNT! Security issue plz contact admin." + + user_name = req.form.get('user_name') + user_id = req.form.get('user_id') + text = req.form.get('text') + + user = get_user_by_slack_id(user_id) + if user is None: + logging.info("Slack command from unrecognized user_id: %s", user_id) + return _no_user_error(user_id) + + words = text.strip().split() + if not words: + logging.info('null (list) command from user %s', user_name) + return command_list(user.email) + else: + cmd, args = words[0], words[1:] + if cmd == 'help': + logging.info('help command from user %s', user_name) + return command_help() + elif cmd == 'whoami': + # undocumented command to echo user email back + logging.info('whoami command from user %s', user_name) + return str(user.email) + elif cmd == 'list': + # this is the same as the null command, but support for UX + logging.info('list command from user %s', user_name) + return command_list(user.email) + elif cmd == 'last': + logging.info('last command from user %s', user_name) + return command_last(user.email) + elif cmd == 'add': + logging.info('add command from user %s', user_name) + return command_add(user.email, " ".join(args)) + elif cmd == 'del': + logging.info('del command from user %s', user_name) + return command_del(user.email, args) + elif cmd == 'dump': + logging.info('dump command from user %s', user_name) + return command_dump(user.email) else: - cmd, args = words[0], words[1:] - if cmd == 'help': - logging.info('help command from user %s', user_name) - res.write(command_help()) - elif cmd == 'whoami': - # undocumented command to echo user email back - logging.info('whoami command from user %s', user_name) - res.write(user_email) - elif cmd == 'whoami!': - # whoami! forces a refresh of cache, for debugging - logging.info('whoami! command from user %s', user_name) - logging.info('whoami! potential cached email for %s: %s', - user_name, user_email) - refreshed = _get_user_email_cached(user_id, force_refresh=True) - logging.info('whoami! refreshed email for %s: %s', - user_name, refreshed) - res.write(refreshed) - elif cmd == 'list': - # this is the same as the null command, but support for UX - logging.info('list command from user %s', user_name) - res.write(command_list(user_email)) - elif cmd == 'last': - logging.info('last command from user %s', user_name) - res.write(command_last(user_email)) - elif cmd == 'add': - logging.info('add command from user %s', user_name) - res.write(command_add(user_email, " ".join(args))) - elif cmd == 'del': - logging.info('del command from user %s', user_name) - res.write(command_del(user_email, args)) - elif cmd == 'dump': - logging.info('dump command from user %s', user_name) - res.write(command_dump(user_email)) - else: - logging.info('unknown command %s from user %s', cmd, user_name) - res.write( - "I don't understand what you said! " - "Perhaps you meant one of these?\n```%s```\n" - % command_usage() - ) + logging.info('unknown command %s from user %s', cmd, user_name) + return ( + "I don't understand what you said! " + "Perhaps you meant one of these?\n```%s```\n" + % command_usage() + ) diff --git a/slacklib_test.py b/slacklib_test.py index 11cc2c7..5bc2905 100644 --- a/slacklib_test.py +++ b/slacklib_test.py @@ -1,39 +1,45 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from __future__ import unicode_literals import datetime import textwrap import unittest +from zoneinfo import ZoneInfo -# Update sys.path so it can find these. We just need to add -# 'google_appengine', but we add all of $PATH to be easy. This -# assumes the google_appengine directory is on the path. -import os -import sys -sys.path.extend(os.environ['PATH'].split(':')) -import dev_appserver -dev_appserver.fix_sys_path() -from google.appengine.ext import db from google.appengine.ext import testbed +import time_machine import models import slacklib - +# The fictional date for these tests is Wednesday, July 29, 2015 +@time_machine.travel(datetime.datetime(2015, 7, 29, tzinfo=ZoneInfo("UTC"))) class SlashCommandTest(unittest.TestCase): - def _mock_data(self): - # The fictional day for these tests Wednesday, July 29, 2015 - slacklib._TODAY_FN = lambda: datetime.datetime(2015, 7, 29) + def setUp(self): + self.testbed = testbed.Testbed() + self.testbed.activate() + self.testbed.init_user_stub() + + self.testbed.setup_env( + user_email="bob@example.com", + user_id="bob@example.com", + user_is_admin='0', + overwrite=True + ) + appsettings = models.AppSettings.get( + create_if_missing=True, + domains=["khanacademy.org"], + ) + appsettings.put() # Stuart created his account, but has never once filled out a snippet - db.put(models.User(email='stuart@khanacademy.org')) + models.User(email='stuart@khanacademy.org').put() # Fleetwood has two recent snippets, and always uses markdown lists, # but sometimes uses different list indicators or indention. - db.put(models.User(email='fleetwood@khanacademy.org')) - db.put(models.Snippet( + models.User(email='fleetwood@khanacademy.org').put() + models.Snippet( email='fleetwood@khanacademy.org', week=datetime.date(2015, 7, 27), text=textwrap.dedent(""" @@ -41,21 +47,21 @@ def _mock_data(self): * sniffed some things * hoping to sniff more things! #yolo """) - )) - db.put(models.Snippet( + ).put() + models.Snippet( email='fleetwood@khanacademy.org', week=datetime.date(2015, 7, 20), text=textwrap.dedent(""" - lots of walks this week - not enough sniffing, hope to remedy next week! """) - )) + ).put() # Toby has filled out two snippets, but missed a week in-between while # on vacation. When he got back from vacation he was still jetlagged so # he wrote a longform paragraph instead of a list. - db.put(models.User(email='toby@khanacademy.org')) - db.put(models.Snippet( + models.User(email='toby@khanacademy.org').put() + models.Snippet( email='toby@khanacademy.org', week=datetime.date(2015, 7, 13), text=textwrap.dedent(""" @@ -63,8 +69,8 @@ def _mock_data(self): """) - )) - db.put(models.Snippet( + ).put() + models.Snippet( email='toby@khanacademy.org', week=datetime.date(2015, 7, 27), text=textwrap.dedent(""" @@ -76,37 +82,30 @@ def _mock_data(self): LUNCHTIME SUCKERS! """) - )) + ).put() # Fozzie tried hard to create an entry manually in the previous week, # but didn't understand markdown list syntax and got discouraged (so # has no entry this week, and a malformed one last week). - db.put(models.User(email='fozzie@khanacademy.org')) - db.put(models.Snippet( + models.User(email='fozzie@khanacademy.org').put() + models.Snippet( email='fozzie@khanacademy.org', week=datetime.date(2015, 7, 20), text=textwrap.dedent(""" -is this how I list? -why is it not formatting??!? """) - )) - - def _most_recent_snippet(self, user_email): - snippets_q = models.Snippet.all() - snippets_q.filter('email = ', user_email) - snippets_q.order('-week') # newest snippet first - return snippets_q.fetch(1)[0] - - def setUp(self): - self.testbed = testbed.Testbed() - self.testbed.activate() - self.testbed.init_datastore_v3_stub() - self.testbed.init_memcache_stub() - self._mock_data() + ).put() def tearDown(self): self.testbed.deactivate() + def _most_recent_snippet(self, user_email): + snippets_q = models.Snippet.query( + models.Snippet.email == user_email + ).order('-week') # newest snippet first + return snippets_q.fetch(1)[0] + def testDumpCommand_empty(self): # user without a recent snippet should just see null text response = slacklib.command_dump('stuart@khanacademy.org') @@ -120,8 +119,8 @@ def testDumpCommand_formatting(self): def testDumpCommand_noAccount(self): # user without an account should get a helpful error message response = slacklib.command_dump('bob@bob.com') - self.assertIn("You don't appear to have a snippets account", response) - self.assertIn("Slack email address: bob@bob.com", response) + self.assertIn("You don't seem to be logged in!", response) + self.assertIn("bob@bob.com", response) def testListCommand_empty(self): # user without a recent snippet should get a helpful message @@ -142,7 +141,7 @@ def testListCommand_formatting(self): def testListCommand_noAccount(self): # user without an account should get a helpful error message response = slacklib.command_list('bob@bob.com') - self.assertIn("You don't appear to have a snippets account", response) + self.assertIn("You don't seem to be logged in!", response) def testLastCommand_empty(self): # user without a snippet last week should get a helpful message @@ -160,7 +159,7 @@ def testLastCommand_formatting(self): def testLastCommand_noAccount(self): # user without an account should get a helpful error message response = slacklib.command_last('bob@bob.com') - self.assertIn("You don't appear to have a snippets account", response) + self.assertIn("You don't seem to be logged in!", response) def testBadMarkdown_listCommand(self): toby_recent = slacklib.command_list('toby@khanacademy.org') @@ -175,8 +174,8 @@ def testAddCommand_blank(self): r = slacklib.command_add('stuart@khanacademy.org', 'went to the park') t = self._most_recent_snippet('stuart@khanacademy.org') self.assertIn("Added *went to the park* to your weekly snippets", r) - self.assertEquals('- went to the park', t.text) - self.assertEquals(True, t.is_markdown) + self.assertEqual('- went to the park', t.text) + self.assertEqual(True, t.is_markdown) def testAddCommand_existing(self): # on this one, the user markdown formatting gets altered/standardized @@ -189,7 +188,7 @@ def testAddCommand_existing(self): - went to the park """).strip() self.assertEqual(expected, t.text) - self.assertEquals(True, t.is_markdown) + self.assertEqual(True, t.is_markdown) def testAddCommand_existingIsMalformed(self): # we should be told we cannot to add to a snippet that is malformed! @@ -200,7 +199,7 @@ def testAddCommand_existingIsMalformed(self): t = self._most_recent_snippet(toby_email) self.assertNotIn("went to the park", t.text) self.assertIn("LUNCHTIME SUCKERS!", t.text) - self.assertEquals(False, t.is_markdown) + self.assertEqual(False, t.is_markdown) def testAddCommand_noArgs(self): # we need to handle when they try to add nothing! @@ -210,7 +209,7 @@ def testAddCommand_noArgs(self): def testAddCommand_noAccount(self): # dont crash horribly if user doesnt exist r = slacklib.command_add('bob@bob.com', 'how is account formed?') - self.assertIn("You don't appear to have a snippets account", r) + self.assertIn("You don't seem to be logged in!", r) def testAddCommand_markupUsernames(self): # usernames should be marked up properly so they get syntax highlighted @@ -220,7 +219,7 @@ def testAddCommand_markupUsernames(self): self.assertIn("- ate w/ <@toby>, yay", t.text) def testAddCommand_unicode(self): - r = slacklib.command_add('stuart@khanacademy.org', u'i “like” food') + r = slacklib.command_add('stuart@khanacademy.org', 'i “like” food') t = self._most_recent_snippet('stuart@khanacademy.org') self.assertIn('i “like” food', r) self.assertIn('i “like” food', t.text) @@ -233,7 +232,7 @@ def testDelCommand_noArgs(self): def testDelCommand_noAccount(self): # dont crash horribly if user doesnt exist r = slacklib.command_del('bob@bob.com', ['1']) - self.assertIn("You don't appear to have a snippets account", r) + self.assertIn("You don't seem to be logged in!", r) def testDelCommand_normalCase(self): r = slacklib.command_del('fleetwood@khanacademy.org', ['1']) @@ -245,7 +244,7 @@ def testDelCommand_normalCase(self): - hoping to sniff more things! #yolo """).strip() self.assertEqual(expected, t.text) - self.assertEquals(True, t.is_markdown) + self.assertEqual(True, t.is_markdown) def testDelCommand_nonexistentIndex(self): r1 = slacklib.command_del('stuart@khanacademy.org', ['0']) @@ -265,7 +264,4 @@ def testDelCommand_existingIsMalformed(self): # ...and the existing snippets should not have been touched t = self._most_recent_snippet('toby@khanacademy.org') self.assertIn("I had fun", t.text) - self.assertEquals(False, t.is_markdown) - -if __name__ == '__main__': - unittest.main() + self.assertEqual(False, t.is_markdown) diff --git a/snippets.py b/snippets.py index 9d6f286..8d49679 100644 --- a/snippets.py +++ b/snippets.py @@ -13,38 +13,47 @@ import os import re import time -import urllib +import urllib.parse from google.appengine.api import mail from google.appengine.api import users -from google.appengine.ext import db -import webapp2 -from webapp2_extras import jinja2 +from google.cloud import ndb +import google.cloud.logging +import flask import models import slacklib import util +if os.getenv("GAE_ENV", "").startswith("standard"): + # We're running in the real appengine environment, hopefully. + # Set up cloud logging + logging_client = google.cloud.logging.Client() + logging_client.setup_logging(log_level=logging.INFO) -# This allows mocking in a different day, for testing. -_TODAY_FN = datetime.datetime.now +app = flask.Flask( + __name__, + # Drop /static prefix for things like /favicon.ico + static_url_path="", + static_folder="static", + template_folder="templates") +app.register_blueprint(slacklib.app_blueprint) -jinja2.default_config['template_path'] = os.path.join( - os.path.dirname(__file__), - "templates" -) -jinja2.default_config['filters'] = { - 'readable_date': ( - lambda value: value.strftime('%B %d, %Y').replace(' 0', ' ')), - 'iso_date': ( - lambda value: value.strftime('%m-%d-%Y')), -} +@app.template_filter("readable_date") +def _readable_date_filter(value: datetime.date): + return value.strftime('%B %d, %Y').replace(' 0', ' ') -def _login_page(request, redirector): + +@app.template_filter("iso_date") +def _iso_date_filter(value: datetime.date): + return value.strftime("%m-%d-%Y") + + +def _login_page(request): """Redirect the user to a page where they can log in.""" - redirector.redirect(users.create_login_url(request.uri)) + return flask.redirect(users.create_login_url(request.url)) def _current_user_email(): @@ -70,19 +79,21 @@ def _get_or_create_user(email, put_new_user=True): # TODO(csilvers): move this get/update/put atomic into a txn user.is_hidden = False user.put() + return user elif not _logged_in_user_has_permission_for(email): # TODO(csilvers): turn this into a 403 somewhere raise IndexError('User "%s" not found; did you specify' ' the full email address?' % email) else: - # You can only create a new user under one of the app-listed domains. try: app_settings = models.AppSettings.get() except ValueError: # TODO(csilvers): do this instead: # /admin/settings?redirect_to=user_setting - return None + # return None + raise # TODO(benley) implement Redirect exception + # You can only create a new user under one of the app-listed domains. domain = email.split('@')[-1] allowed_domains = app_settings.domains if domain not in allowed_domains: @@ -93,15 +104,14 @@ def _get_or_create_user(email, put_new_user=True): % (' or '.join(allowed_domains), domain)) # Set the user defaults based on the global app defaults. - user = models.User(created=_TODAY_FN(), + user = models.User(created=datetime.datetime.now(), email=email, uses_markdown=app_settings.default_markdown, private_snippets=app_settings.default_private, wants_email=app_settings.default_email) if put_new_user: - db.put(user) - db.get(user.key()) # ensure db consistency for HRD - return user + user.put() + return user def _logged_in_user_has_permission_for(email): @@ -146,527 +156,530 @@ def _send_to_chat(msg, url_path): slacklib.send_to_slack_channel(slack_channel, msg) -class BaseHandler(webapp2.RequestHandler): - """Set up as per the jinja2.py docstring.""" - @webapp2.cached_property - def jinja2(self): - return jinja2.get_jinja2() +@app.route("/") +def user_page_handler(): + """Show all the snippets for a single user.""" - def render_response(self, template_filename, context): - html = self.jinja2.render_template(template_filename, **context) - self.response.write(html) + if not users.get_current_user(): + return _login_page(flask.request) + user_email = flask.request.args.get('u', _current_user_email()) + user = util.get_user(user_email) -class UserPage(BaseHandler): - """Show all the snippets for a single user.""" + now = datetime.datetime.now() - def get(self): - if not users.get_current_user(): - return _login_page(self.request, self) - - user_email = self.request.get('u', _current_user_email()) - user = util.get_user(user_email) - - if not user: - # If there are no app settings, set those up before setting - # up the user settings. - if users.is_current_user_admin(): - try: - models.AppSettings.get() - except ValueError: - self.redirect("/admin/settings?redirect_to=user_setting" - "&msg=Welcome+to+the+snippet+server!+" - "Please+take+a+moment+to+configure+it.") - return - - template_values = { - 'new_user': True, - 'login_url': users.create_login_url(self.request.uri), - 'logout_url': users.create_logout_url('/'), - 'username': user_email, - } - self.render_response('new_user.html', template_values) - return - - snippets = util.snippets_for_user(user_email) - - if not _can_view_private_snippets(_current_user_email(), user_email): - snippets = [snippet for snippet in snippets if not snippet.private] - snippets = util.fill_in_missing_snippets(snippets, user, - user_email, _TODAY_FN()) - snippets.reverse() # get to newest snippet first + if not user: + # If there are no app settings, set those up before setting + # up the user settings. + if users.is_current_user_admin(): + try: + models.AppSettings.get() + except ValueError: + return flask.redirect( + "/admin/settings?redirect_to=user_setting" + "&msg=Welcome+to+the+snippet+server!+" + "Please+take+a+moment+to+configure+it.") template_values = { + 'new_user': True, + 'login_url': users.create_login_url(flask.request.url), 'logout_url': users.create_logout_url('/'), - 'message': self.request.get('msg'), 'username': user_email, - 'is_admin': users.is_current_user_admin(), - 'domain': user_email.split('@')[-1], - 'view_week': util.existingsnippet_monday(_TODAY_FN()), - # Snippets for the week of are due today. - 'one_week_ago': _TODAY_FN().date() - datetime.timedelta(days=7), - 'eight_days_ago': _TODAY_FN().date() - datetime.timedelta(days=8), - 'editable': (_logged_in_user_has_permission_for(user_email) and - self.request.get('edit', '1') == '1'), - 'user': user, - 'snippets': snippets, - 'null_category': models.NULL_CATEGORY, } - self.render_response('user_snippets.html', template_values) + return flask.render_template('new_user.html', **template_values) + + snippets = util.snippets_for_user(user_email) + + if not _can_view_private_snippets(_current_user_email(), user_email): + snippets = [snippet for snippet in snippets if not snippet.private] + snippets = util.fill_in_missing_snippets(snippets, user, user_email, now) + snippets.reverse() # get to newest snippet first + + template_values = { + 'logout_url': users.create_logout_url('/'), + 'message': flask.request.args.get('msg'), + 'username': user_email, + 'is_admin': users.is_current_user_admin(), + 'domain': user_email.split('@')[-1], + 'view_week': util.existingsnippet_monday(now), + # Snippets for the week of are due today. + 'one_week_ago': now.date() - datetime.timedelta(days=7), + 'eight_days_ago': now.date() - datetime.timedelta(days=8), + 'editable': (_logged_in_user_has_permission_for(user_email) and + flask.request.args.get('edit', '1') == '1'), + 'user': user, + 'snippets': snippets, + 'null_category': models.NULL_CATEGORY, + } + return flask.render_template('user_snippets.html', **template_values) def _title_case(s): """Like string.title(), but does not uppercase 'and'.""" - # Smarter would be to use 'pip install titlecase'. - SMALL = 'a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v\.?|via|vs\.?' + # TODO(benley): use titlecase from pypi? + SMALL = r'a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v\.?|via|vs\.?' # We purposefully don't match small words at the beginning of a string. SMALL_RE = re.compile(r' (%s)\b' % SMALL, re.I) return SMALL_RE.sub(lambda m: ' ' + m.group(1).lower(), s.title().strip()) -class SummaryPage(BaseHandler): +@app.route("/weekly") +def summary_page_handler(): """Show all the snippets for a single week.""" - def get(self): - if not users.get_current_user(): - return _login_page(self.request, self) + if not users.get_current_user(): + return _login_page(flask.request) - week_string = self.request.get('week') - if week_string: - week = datetime.datetime.strptime(week_string, '%m-%d-%Y').date() + week_string = flask.request.args.get('week') + if week_string: + week = datetime.datetime.strptime(week_string, '%m-%d-%Y').date() + else: + week = util.existingsnippet_monday(datetime.datetime.now()) + + snippets_q = models.Snippet.query( + models.Snippet.week == week + ) + snippets = snippets_q.fetch(1000) # good for many users... + # TODO(csilvers): filter based on wants_to_view + + # Get all the user records so we can categorize snippets. + user_q = models.User.query() + results = user_q.fetch(1000) + email_to_category = {} + email_to_user = {} + for result in results: + # People aren't very good about capitalizing their + # categories consistently, so we enforce title-case, + # with exceptions for 'and'. + email_to_category[result.email] = _title_case(result.category) + email_to_user[result.email] = result + + # Collect the snippets and users by category. As we see each email, + # delete it from email_to_category. At the end of this, + # email_to_category will hold people who did not give + # snippets this week. + snippets_and_users_by_category = {} + for snippet in snippets: + # Ignore this snippet if we don't have permission to view it. + if (snippet.private and + not _can_view_private_snippets(_current_user_email(), + snippet.email)): + continue + category = email_to_category.get( + snippet.email, models.NULL_CATEGORY + ) + if snippet.email in email_to_user: + snippets_and_users_by_category.setdefault(category, []).append( + (snippet, email_to_user[snippet.email]) + ) else: - week = util.existingsnippet_monday(_TODAY_FN()) - - snippets_q = models.Snippet.all() - snippets_q.filter('week = ', week) - snippets = snippets_q.fetch(1000) # good for many users... - # TODO(csilvers): filter based on wants_to_view - - # Get all the user records so we can categorize snippets. - user_q = models.User.all() - results = user_q.fetch(1000) - email_to_category = {} - email_to_user = {} - for result in results: - # People aren't very good about capitalizing their - # categories consistently, so we enforce title-case, - # with exceptions for 'and'. - email_to_category[result.email] = _title_case(result.category) - email_to_user[result.email] = result - - # Collect the snippets and users by category. As we see each email, - # delete it from email_to_category. At the end of this, - # email_to_category will hold people who did not give - # snippets this week. - snippets_and_users_by_category = {} - for snippet in snippets: - # Ignore this snippet if we don't have permission to view it. - if (snippet.private and - not _can_view_private_snippets(_current_user_email(), - snippet.email)): - continue - category = email_to_category.get( - snippet.email, models.NULL_CATEGORY + snippets_and_users_by_category.setdefault(category, []).append( + (snippet, models.User(email=snippet.email)) ) - if snippet.email in email_to_user: - snippets_and_users_by_category.setdefault(category, []).append( - (snippet, email_to_user[snippet.email]) - ) - else: - snippets_and_users_by_category.setdefault(category, []).append( - (snippet, models.User(email=snippet.email)) - ) - - if snippet.email in email_to_category: - del email_to_category[snippet.email] - - # Add in empty snippets for the people who didn't have any -- - # unless a user is marked 'hidden'. (That's what 'hidden' - # means: pretend they don't exist until they have a non-empty - # snippet again.) - for (email, category) in email_to_category.iteritems(): - if not email_to_user[email].is_hidden: - snippet = models.Snippet(email=email, week=week) - snippets_and_users_by_category.setdefault(category, []).append( - (snippet, email_to_user[snippet.email]) - ) - - # Now get a sorted list, categories in alphabetical order and - # each snippet-author within the category in alphabetical - # order. - # The data structure is ((category, ((snippet, user), ...)), ...) - categories_and_snippets = [] - for (category, - snippets_and_users) in snippets_and_users_by_category.iteritems(): - snippets_and_users.sort(key=lambda (snippet, user): snippet.email) - categories_and_snippets.append((category, snippets_and_users)) - categories_and_snippets.sort() - template_values = { - 'logout_url': users.create_logout_url('/'), - 'message': self.request.get('msg'), - # Used only to switch to 'username' mode and to modify settings. - 'username': _current_user_email(), - 'is_admin': users.is_current_user_admin(), - 'prev_week': week - datetime.timedelta(7), - 'view_week': week, - 'next_week': week + datetime.timedelta(7), - 'categories_and_snippets': categories_and_snippets, - } - self.render_response('weekly_snippets.html', template_values) + if snippet.email in email_to_category: + del email_to_category[snippet.email] + + # Add in empty snippets for the people who didn't have any -- + # unless a user is marked 'hidden'. (That's what 'hidden' + # means: pretend they don't exist until they have a non-empty + # snippet again.) + for (email, category) in email_to_category.items(): + if not email_to_user[email].is_hidden: + snippet = models.Snippet(email=email, week=week) + snippets_and_users_by_category.setdefault(category, []).append( + (snippet, email_to_user[snippet.email]) + ) + + # Now get a sorted list, categories in alphabetical order and + # each snippet-author within the category in alphabetical + # order. + # The data structure is ((category, ((snippet, user), ...)), ...) + categories_and_snippets = [] + for category, snippets_and_users in snippets_and_users_by_category.items(): + # This looks stupid but python no longer allows lambda (snippet, user): snippet.email + snippets_and_users.sort(key=lambda snippet_user: snippet_user[0].email) + categories_and_snippets.append((category, snippets_and_users)) + categories_and_snippets.sort() + + template_values = { + 'logout_url': users.create_logout_url('/'), + 'message': flask.request.args.get('msg'), + # Used only to switch to 'username' mode and to modify settings. + 'username': _current_user_email(), + 'is_admin': users.is_current_user_admin(), + 'prev_week': week - datetime.timedelta(7), + 'view_week': week, + 'next_week': week + datetime.timedelta(7), + 'categories_and_snippets': categories_and_snippets, + } + return flask.render_template('weekly_snippets.html', **template_values) + + +def update_snippet(email: str, + week: datetime.date, + text: str, + private: bool, + is_markdown: bool): + assert week.weekday() == 0, 'passed-in date must be a Monday' + + # TODO(csilvers): make this get-update-put atomic. + # (maybe make the snippet id be email + week). + q = models.Snippet.query( + models.Snippet.email == email, + models.Snippet.week == week + ) + snippet = q.get() + + # When adding a snippet, make sure we create a user record for + # that email as well, if it doesn't already exist. + user = _get_or_create_user(email) + + # Store user's display_name in snippet so that if a user is later + # deleted, we could still show his / her display_name. + if snippet: + snippet.text = text # just update the snippet text + snippet.display_name = user.display_name + snippet.private = private + snippet.is_markdown = is_markdown + else: + # add the snippet to the db + snippet = models.Snippet(created=datetime.datetime.now(), + display_name=user.display_name, + email=email, week=week, + text=text, private=private, + is_markdown=is_markdown) + snippet.put() -class UpdateSnippet(BaseHandler): - def update_snippet(self, email): - week_string = self.request.get('week') +@app.route("/update_snippet", methods=["POST"]) +def update_snippet_handler_post(): + """Handle ajax updates via POST. + + In particular, return status via json rather than redirects and hard + exceptions. This isn't actually RESTy, it's just status codes and json. + """ + + data = flask.request.form + week_string = data.get('week', '') + text = data.get('snippet', '') + private = data.get('private') == 'True' + is_markdown = data.get('is_markdown') == 'True' + + # TODO(marcos): consider using PUT? + + if not users.get_current_user(): + return flask.make_response( + {"status": 401, "message": "not logged in"}, 401) + + email = data.get('u', _current_user_email()) + if not _logged_in_user_has_permission_for(email): + # TODO(marcos): present these messages to the ajax client + error = ('You do not have permissions to update user' + ' snippets for %s' % email) + return flask.make_response({"status": 403, "message": error}, 403) + + try: week = datetime.datetime.strptime(week_string, '%m-%d-%Y').date() - assert week.weekday() == 0, 'passed-in date must be a Monday' - - text = self.request.get('snippet') - - private = self.request.get('private') == 'True' - is_markdown = self.request.get('is_markdown') == 'True' - - # TODO(csilvers): make this get-update-put atomic. - # (maybe make the snippet id be email + week). - q = models.Snippet.all() - q.filter('email = ', email) - q.filter('week = ', week) - snippet = q.get() - - # When adding a snippet, make sure we create a user record for - # that email as well, if it doesn't already exist. - user = _get_or_create_user(email) - - # Store user's display_name in snippet so that if a user is later - # deleted, we could still show his / her display_name. - if snippet: - snippet.text = text # just update the snippet text - snippet.display_name = user.display_name - snippet.private = private - snippet.is_markdown = is_markdown - else: - # add the snippet to the db - snippet = models.Snippet(created=_TODAY_FN(), - display_name=user.display_name, - email=email, week=week, - text=text, private=private, - is_markdown=is_markdown) - db.put(snippet) - db.get(snippet.key()) # ensure db consistency for HRD - - self.response.set_status(200) - - def post(self): - """handle ajax updates via POST - - in particular, return status via json rather than redirects and - hard exceptions. This isn't actually RESTy, it's just status - codes and json. - """ - # TODO(marcos): consider using PUT? - - self.response.headers['Content-Type'] = 'application/json' - - if not users.get_current_user(): - # 403s are the catch-all 'please log in error' here - self.response.set_status(403) - self.response.out.write('{"status": 403, ' - '"message": "not logged in"}') - return - - email = self.request.get('u', _current_user_email()) - - if not _logged_in_user_has_permission_for(email): - # TODO(marcos): present these messages to the ajax client - self.response.set_status(403) - error = ('You do not have permissions to update user' - ' snippets for %s' % email) - self.response.out.write('{"status": 403, ' - '"message": "%s"}' % error) - return - - self.update_snippet(email) - self.response.out.write('{"status": 200, "message": "ok"}') - - def get(self): - if not users.get_current_user(): - return _login_page(self.request, self) - - email = self.request.get('u', _current_user_email()) - if not _logged_in_user_has_permission_for(email): - # TODO(csilvers): return a 403 here instead. - raise RuntimeError('You do not have permissions to update user' - ' snippets for %s' % email) - - self.update_snippet(email) - - email = self.request.get('u', _current_user_email()) - self.redirect("/?msg=Snippet+saved&u=%s" % urllib.quote(email)) - - -class Settings(BaseHandler): - """Page to display a user's settings (from class User) for modification.""" + except (ValueError, TypeError): + return flask.make_response( + {"status": 400, + "message": "Invalid week. Expected format: MM-DD-YYYY"}, + 400) - def get(self): - if not users.get_current_user(): - return _login_page(self.request, self) - - user_email = self.request.get('u', _current_user_email()) - if not _logged_in_user_has_permission_for(user_email): - # TODO(csilvers): return a 403 here instead. - raise RuntimeError('You do not have permissions to view user' - ' settings for %s' % user_email) - # We won't put() the new user until the settings are saved. - user = _get_or_create_user(user_email, put_new_user=False) - try: - user.key() - is_new_user = False - except db.NotSavedError: - is_new_user = True + try: + update_snippet(email, week, text, private, is_markdown) + except AssertionError as err: + return flask.make_response({"status": 400, "message": err}, 400) - template_values = { - 'logout_url': users.create_logout_url('/'), - 'message': self.request.get('msg'), - 'username': user.email, - 'is_admin': users.is_current_user_admin(), - 'view_week': util.existingsnippet_monday(_TODAY_FN()), - 'user': user, - 'is_new_user': is_new_user, - 'redirect_to': self.request.get('redirect_to', ''), - # We could get this from user, but we want to replace - # commas with newlines for printing. - 'wants_to_view': user.wants_to_view.replace(',', '\n'), - } - self.render_response('settings.html', template_values) + return flask.make_response({"status": 200, "message": "ok"}) -class UpdateSettings(BaseHandler): - """Updates the db with modifications from the Settings page.""" +@app.route("/update_snippet", methods=["GET"]) +def update_snippet_handler_get(): + data = flask.request.args + week_string = data.get('week', '') + text = data.get('snippet', '') + private = data.get('private') == 'True' + is_markdown = data.get('is_markdown') == 'True' - def get(self): - if not users.get_current_user(): - return _login_page(self.request, self) - - user_email = self.request.get('u', _current_user_email()) - if not _logged_in_user_has_permission_for(user_email): - # TODO(csilvers): return a 403 here instead. - raise RuntimeError('You do not have permissions to modify user' - ' settings for %s' % user_email) - # TODO(csilvers): make this get/update/put atomic (put in a txn) - user = _get_or_create_user(user_email) - - # First, check if the user clicked on 'delete' or 'hide' - # rather than 'save'. - if self.request.get('hide'): - user.is_hidden = True - user.put() - time.sleep(0.1) # some time for eventual consistency - self.redirect('/weekly?msg=You+are+now+hidden.+Have+a+nice+day!') - return - elif self.request.get('delete'): - db.delete(user) - self.redirect('/weekly?msg=Your+account+has+been+deleted.+' - '(Note+your+existing+snippets+have+NOT+been+' - 'deleted.)+Have+a+nice+day!') - return - - display_name = self.request.get('display_name') - category = self.request.get('category') - uses_markdown = self.request.get('markdown') == 'yes' - private_snippets = self.request.get('private') == 'yes' - wants_email = self.request.get('reminder_email') == 'yes' - - # We want this list to be comma-separated, but people are - # likely to use whitespace to separate as well. Convert here. - wants_to_view = self.request.get('to_view') - wants_to_view = re.sub(r'\s+', ',', wants_to_view) - wants_to_view = wants_to_view.split(',') - wants_to_view = [w for w in wants_to_view if w] # deal with ',,' - wants_to_view = ','.join(wants_to_view) # TODO(csilvers): keep as list - - # Changing their settings is the kind of activity that unhides - # someone who was hidden, unless they specifically ask to be - # hidden. - is_hidden = self.request.get('is_hidden', 'no') == 'yes' - - user.is_hidden = is_hidden - user.display_name = display_name - user.category = category or models.NULL_CATEGORY - user.uses_markdown = uses_markdown - user.private_snippets = private_snippets - user.wants_email = wants_email - user.wants_to_view = wants_to_view - db.put(user) - db.get(user.key()) # ensure db consistency for HRD - - redirect_to = self.request.get('redirect_to') - if redirect_to == 'snippet_entry': # true for new_user.html - self.redirect('/?u=%s' % urllib.quote(user_email)) - else: - self.redirect("/settings?msg=Changes+saved&u=%s" - % urllib.quote(user_email)) + if not users.get_current_user(): + return _login_page(flask.request) + email = data.get('u', _current_user_email()) + if not _logged_in_user_has_permission_for(email): + # TODO(benley): Add a friendlier error page template, maybe? + return flask.make_response("You do not have permission to update" + " user snippets for %s" % email, 403) -class AppSettings(BaseHandler): - """Page to display settings for the whole app, for modification. + try: + week = datetime.datetime.strptime(week_string, '%m-%d-%Y').date() + except (ValueError, TypeError): + return flask.make_response( + "Invalid week. Expected format: MM-DD-YYYY", 400) - This page should be restricted to admin users via app.yaml. - """ + try: + update_snippet(email, week, text, private, is_markdown) + except AssertionError as err: + return flask.make_response("Failed to save snippet: %s" % err, 400) - def get(self): - my_domain = _current_user_email().split('@')[-1] - app_settings = models.AppSettings.get(create_if_missing=True, - domains=[my_domain]) + return flask.redirect( + "/?msg=Snippet+saved&u=%s" % urllib.parse.quote(email)) - template_values = { - 'logout_url': users.create_logout_url('/'), - 'message': self.request.get('msg'), - 'username': _current_user_email(), - 'is_admin': users.is_current_user_admin(), - 'view_week': util.existingsnippet_monday(_TODAY_FN()), - 'redirect_to': self.request.get('redirect_to', ''), - 'settings': app_settings, - 'slack_slash_commands': ( - slacklib.command_usage().strip()) - } - self.render_response('app_settings.html', template_values) +@app.route("/settings") +def settings_handler(): + """Page to display a user's settings (from class User) for modification.""" -class UpdateAppSettings(BaseHandler): - """Updates the db with modifications from the App-Settings page. + if not users.get_current_user(): + return _login_page(flask.request) + + user_email = flask.request.args.get('u', _current_user_email()) + if not _logged_in_user_has_permission_for(user_email): + return flask.make_response('You do not have permissions to view user' + ' settings for %s' % user_email, 403) + # We won't put() the new user until the settings are saved. + user = _get_or_create_user(user_email, put_new_user=False) + + # TODO(benley): https://stackoverflow.com/questions/12083254/is-it-possible-to-determine-with-ndb-if-model-is-persistent-in-the-datastore-or/12096066#12096066 + # NOTE: this will break if you explicitly set the key when creating the + # user entity! + # See https://groups.google.com/g/google-appengine/c/Tm8NDWIvc70 + is_new_user = bool(user.key and user.key.id()) + + template_values = { + 'logout_url': users.create_logout_url('/'), + 'message': flask.request.args.get('msg'), + 'username': user.email, + 'is_admin': users.is_current_user_admin(), + 'view_week': util.existingsnippet_monday(datetime.datetime.now()), + 'user': user, + 'is_new_user': is_new_user, + 'redirect_to': flask.request.args.get('redirect_to', ''), + # We could get this from user, but we want to replace + # commas with newlines for printing. + 'wants_to_view': user.wants_to_view.replace(',', '\n'), + } + return flask.render_template('settings.html', **template_values) + + +@app.route("/update_settings") +def update_settings_handler(): + """Updates the db with modifications from the Settings page.""" + + if not users.get_current_user(): + return _login_page(flask.request) + + user_email = flask.request.args.get('u', _current_user_email()) + if not _logged_in_user_has_permission_for(user_email): + return flask.make_response('You do not have permissions to modify user' + ' settings for %s' % user_email, 403) + # TODO(csilvers): make this get/update/put atomic (put in a txn) + user = _get_or_create_user(user_email) + + # First, check if the user clicked on 'delete' or 'hide' + # rather than 'save'. + if flask.request.args.get('hide'): + user.is_hidden = True + user.put() + return flask.redirect('/weekly?msg=You+are+now+hidden.+Have+a+nice+day!') + elif flask.request.args.get('delete'): + user.key.delete() + return flask.redirect('/weekly?msg=Your+account+has+been+deleted.+' + '(Note+your+existing+snippets+have+NOT+been+' + 'deleted.)+Have+a+nice+day!') + + display_name = flask.request.args.get('display_name') + category = flask.request.args.get('category') + slack_id = flask.request.args.get('slack_id') + uses_markdown = flask.request.args.get('markdown') == 'yes' + private_snippets = flask.request.args.get('private') == 'yes' + wants_email = flask.request.args.get('reminder_email') == 'yes' + + # We want this list to be comma-separated, but people are + # likely to use whitespace to separate as well. Convert here. + wants_to_view = flask.request.args.get('to_view', '') + wants_to_view = re.sub(r'\s+', ',', wants_to_view) + wants_to_view = wants_to_view.split(',') + wants_to_view = [w for w in wants_to_view if w] # deal with ',,' + wants_to_view = ','.join(wants_to_view) # TODO(csilvers): keep as list + + # Changing their settings is the kind of activity that unhides + # someone who was hidden, unless they specifically ask to be + # hidden. + is_hidden = flask.request.args.get('is_hidden', 'no') == 'yes' + + user.is_hidden = is_hidden + user.display_name = display_name + user.category = category or models.NULL_CATEGORY + user.slack_id = slack_id + user.uses_markdown = uses_markdown + user.private_snippets = private_snippets + user.wants_email = wants_email + user.wants_to_view = wants_to_view + user.put() + + redirect_to = flask.request.args.get('redirect_to') + if redirect_to == 'snippet_entry': # true for new_user.html + return flask.redirect('/?u=%s' % urllib.parse.quote(user_email)) + else: + return flask.redirect("/settings?msg=Changes+saved&u=%s" + % urllib.parse.quote(user_email)) + + +@app.route("/admin/settings") +def admin_settings_handler(): + """Page to display settings for the whole app, for modification. This page should be restricted to admin users via app.yaml. """ + my_domain = _current_user_email().split('@')[-1] + app_settings = models.AppSettings.get(create_if_missing=True, + domains=[my_domain]) + + template_values = { + 'logout_url': users.create_logout_url('/'), + 'message': flask.request.args.get('msg'), + 'username': _current_user_email(), + 'is_admin': users.is_current_user_admin(), + 'view_week': util.existingsnippet_monday(datetime.datetime.now()), + 'redirect_to': flask.request.args.get('redirect_to', ''), + 'settings': app_settings, + 'slack_slash_commands': slacklib.command_usage().strip() + } + return flask.render_template('app_settings.html', **template_values) + + +@app.route("/admin/update_settings", methods=["POST"]) +def admin_update_settings_handler(): + """Updates the db with modifications from the App-Settings page. - def get(self): - _get_or_create_user(_current_user_email()) - - domains = self.request.get('domains') - default_private = self.request.get('private') == 'yes' - default_markdown = self.request.get('markdown') == 'yes' - default_email = self.request.get('reminder_email') == 'yes' - email_from = self.request.get('email_from') - slack_channel = self.request.get('slack_channel') - slack_token = self.request.get('slack_token') - slack_slash_token = self.request.get('slack_slash_token') - - # Turn domains into a list. Allow whitespace or comma to separate. - domains = re.sub(r'\s+', ',', domains) - domains = [d for d in domains.split(',') if d] - - @db.transactional - def update_settings(): - app_settings = models.AppSettings.get(create_if_missing=True, - domains=domains) - app_settings.domains = domains - app_settings.default_private = default_private - app_settings.default_markdown = default_markdown - app_settings.default_email = default_email - app_settings.email_from = email_from - app_settings.slack_channel = slack_channel - app_settings.slack_token = slack_token - app_settings.slack_slash_token = slack_slash_token - app_settings.put() - - update_settings() - - redirect_to = self.request.get('redirect_to') - if redirect_to == 'user_setting': # true for new_user.html - self.redirect('/settings?redirect_to=snippet_entry' - '&msg=Now+enter+your+personal+user+settings.') - else: - self.redirect("/admin/settings?msg=Changes+saved") + This page should be restricted to admin users via app.yaml. + """ + domains = flask.request.form.get('domains') + default_private = flask.request.form.get('private') == 'yes' + default_markdown = flask.request.form.get('markdown') == 'yes' + default_email = flask.request.form.get('reminder_email') == 'yes' + email_from = flask.request.form.get('email_from') + hipchat_room = flask.request.form.get('hipchat_room') + hipchat_token = flask.request.form.get('hipchat_token') + hostname = flask.request.form.get('hostname') + slack_channel = flask.request.form.get('slack_channel') + slack_token = flask.request.form.get('slack_token') + slack_slash_token = flask.request.form.get('slack_slash_token') + + # Turn domains into a list. Allow whitespace or comma to separate. + domains = re.sub(r'\s+', ',', domains) + domains = [d for d in domains.split(',') if d] + + @ndb.transactional() + def update_settings(): + app_settings = models.AppSettings.get(create_if_missing=True, + domains=domains) + app_settings.domains = domains + app_settings.default_private = default_private + app_settings.default_markdown = default_markdown + app_settings.default_email = default_email + app_settings.email_from = email_from + app_settings.hostname = hostname + app_settings.slack_channel = slack_channel + app_settings.slack_token = slack_token + app_settings.slack_slash_token = slack_slash_token + app_settings.put() + + update_settings() + + _get_or_create_user(_current_user_email()) + + redirect_to = flask.request.form.get('redirect_to') + if redirect_to == 'user_setting': # true for new_user.html + return flask.redirect('/settings?redirect_to=snippet_entry' + '&msg=Now+enter+your+personal+user+settings.') + else: + return flask.redirect("/admin/settings?msg=Changes+saved") -class ManageUsers(BaseHandler): +@app.route("/admin/manage_users", methods=["GET", "POST"]) +def admin_manage_users_handler(): """Lets admins delete and otherwise manage users.""" - - def get(self): - # options are 'email', 'creation_time', 'last_snippet_time' - sort_by = self.request.get('sort_by', 'creation_time') - - # First, check if the user had clicked on a button. - for (name, value) in self.request.params.iteritems(): - if name.startswith('hide '): - email_of_user_to_hide = name[len('hide '):] - # TODO(csilvers): move this get/update/put atomic into a txn - user = util.get_user_or_die(email_of_user_to_hide) - user.is_hidden = True - user.put() - time.sleep(0.1) # encourage eventual consistency - self.redirect('/admin/manage_users?sort_by=%s&msg=%s+hidden' - % (sort_by, email_of_user_to_hide)) - return - if name.startswith('unhide '): - email_of_user_to_unhide = name[len('unhide '):] - # TODO(csilvers): move this get/update/put atomic into a txn - user = util.get_user_or_die(email_of_user_to_unhide) - user.is_hidden = False - user.put() - time.sleep(0.1) # encourage eventual consistency - self.redirect('/admin/manage_users?sort_by=%s&msg=%s+unhidden' - % (sort_by, email_of_user_to_unhide)) - return - if name.startswith('delete '): - email_of_user_to_delete = name[len('delete '):] - user = util.get_user_or_die(email_of_user_to_delete) - db.delete(user) - time.sleep(0.1) # encourage eventual consistency - self.redirect('/admin/manage_users?sort_by=%s&msg=%s+deleted' - % (sort_by, email_of_user_to_delete)) - return - - user_q = models.User.all() - results = user_q.fetch(1000) - - # Tuple: (email, is-hidden, creation-time, days since last snippet) - user_data = [] - for user in results: - # Get the last snippet for that user. - last_snippet = util.most_recent_snippet_for_user(user.email) - if last_snippet: - seconds_since_snippet = ( - (_TODAY_FN().date() - last_snippet.week).total_seconds()) - weeks_since_snippet = int( - seconds_since_snippet / - datetime.timedelta(days=7).total_seconds()) - else: - weeks_since_snippet = None - user_data.append((user.email, user.is_hidden, - user.created, weeks_since_snippet)) - - # We have to use 'cmp' here since we want ascending in the - # primary key and descending in the secondary key, sometimes. - if sort_by == 'email': - user_data.sort(lambda x, y: cmp(x[0], y[0])) - elif sort_by == 'creation_time': - user_data.sort(lambda x, y: (-cmp(x[2] or datetime.datetime.min, - y[2] or datetime.datetime.min) - or cmp(x[0], y[0]))) - elif sort_by == 'last_snippet_time': - user_data.sort(lambda x, y: (-cmp(1000 if x[3] is None else x[3], - 1000 if y[3] is None else y[3]) - or cmp(x[0], y[0]))) + # options are 'email', 'creation_time', 'last_snippet_time' + sort_by = flask.request.args.get('sort_by', 'creation_time') + + # First, check if the user had clicked on a button. + if flask.request.form.get("action") == "hide": + email_of_user_to_hide = flask.request.form.get("email") + # TODO(csilvers): move this get/update/put atomic into a txn + user = util.get_user_or_die(email_of_user_to_hide) + user.is_hidden = True + user.put() + logging.info("Hide user: %s", user.email) + return flask.redirect('/admin/manage_users?sort_by=%s&msg=%s+hidden' + % (sort_by, user.email)) + elif flask.request.form.get("action") == "unhide": + email_of_user_to_unhide = flask.request.form.get("email") + # TODO(csilvers): move this get/update/put atomic into a txn + user = util.get_user_or_die(email_of_user_to_unhide) + user.is_hidden = False + user.put() + logging.info("Unhide user: %s", user.email) + return flask.redirect('/admin/manage_users?sort_by=%s&msg=%s+unhidden' + % (sort_by, user.email)) + elif flask.request.form.get("action") == "delete": + email_of_user_to_delete = flask.request.form.get("email") + user = util.get_user_or_die(email_of_user_to_delete) + user.key.delete() + logging.info("Delete user: %s", user.email) + return flask.redirect('/admin/manage_users?sort_by=%s&msg=%s+deleted' + % (sort_by, user.email)) + + user_q = models.User.query() + results = user_q.fetch(1000) + + now = datetime.datetime.now() + + # Tuple: (email, is-hidden, creation-time, days since last snippet) + user_data = [] + for user in results: + # Get the last snippet for that user. + last_snippet = util.most_recent_snippet_for_user(user.email) + if last_snippet: + seconds_since_snippet = (now.date() - + last_snippet.week).total_seconds() + weeks_since_snippet = int( + seconds_since_snippet / + datetime.timedelta(days=7).total_seconds()) else: - raise ValueError('Invalid sort_by value "%s"' % sort_by) + weeks_since_snippet = None + user_data.append((user.email, user.is_hidden, + user.created, weeks_since_snippet)) + + if sort_by == 'email': + user_data.sort(key=lambda x: x[0]) + elif sort_by == 'creation_time': + user_data.sort(key=lambda x: x[0]) + user_data.sort(key=lambda x: x[2] or datetime.datetime.min, reverse=True) + elif sort_by == 'last_snippet_time': + user_data.sort(key=lambda x: x[0]) + user_data.sort(key=lambda x: 1000 if x[3] is None else x[3], reverse=True) + else: + raise ValueError('Invalid sort_by value "%s"' % sort_by) - template_values = { - 'logout_url': users.create_logout_url('/'), - 'message': self.request.get('msg'), - 'username': _current_user_email(), - 'is_admin': users.is_current_user_admin(), - 'view_week': util.existingsnippet_monday(_TODAY_FN()), - 'user_data': user_data, - 'sort_by': sort_by, - } - self.render_response('manage_users.html', template_values) + template_values = { + 'logout_url': users.create_logout_url('/'), + 'message': flask.request.args.get('msg'), + 'username': _current_user_email(), + 'is_admin': users.is_current_user_admin(), + 'view_week': util.existingsnippet_monday(now), + 'user_data': user_data, + 'sort_by': sort_by, + } + return flask.render_template('manage_users.html', **template_values) # The following two classes are called by cron. -def _get_email_to_current_snippet_map(today): +def _get_email_to_current_snippet_map(today: datetime.datetime) -> dict[str, bool]: """Return a map from email to True if they've written snippets this week. Goes through all users registered on the system, and checks if @@ -686,7 +699,7 @@ def _get_email_to_current_snippet_map(today): a map from email (user.email for each user) to True or False, depending on if they've written snippets for this week or not. """ - user_q = models.User.all() + user_q = models.User.query() users = user_q.fetch(1000) retval = {} for user in users: @@ -695,8 +708,9 @@ def _get_email_to_current_snippet_map(today): retval[user.email] = False # assume the worst, for now week = util.existingsnippet_monday(today) - snippets_q = models.Snippet.all() - snippets_q.filter('week = ', week) + snippets_q = models.Snippet.query( + models.Snippet.week == week + ) snippets = snippets_q.fetch(1000) for snippet in snippets: if snippet.email in retval: # don't introduce new keys here @@ -716,12 +730,11 @@ def _maybe_send_snippets_mail(to, subject, template_path, template_values): template_values.setdefault('hostname', app_settings.hostname) - jinja2_instance = jinja2.get_jinja2() mail.send_mail(sender=app_settings.email_from, to=to, subject=subject, - body=jinja2_instance.render_template(template_path, - **template_values)) + body=flask.render_template(template_path, + **template_values)) # Appengine has a quota of 32 emails per minute: # https://developers.google.com/appengine/docs/quotas#Mail # We pause 2 seconds between each email to make sure we @@ -729,66 +742,62 @@ def _maybe_send_snippets_mail(to, subject, template_path, template_values): time.sleep(2) -class SendFridayReminderChat(BaseHandler): +@app.route("/admin/send_friday_reminder_chat") +def admin_send_friday_reminder_chat_handler() -> flask.Response: """Send a chat message to the configured chat room(s).""" - - def get(self): - msg = 'Reminder: Weekly snippets due Monday at 5pm.' - _send_to_chat(msg, "/") + msg = 'Reminder: Weekly snippets due Monday at 5pm.' + _send_to_chat(msg, "/") + return flask.make_response({"status": 200, "message": "Sent friday reminder chat"}) -class SendReminderEmail(BaseHandler): +@app.route("/admin/send_reminder_email") +def admin_send_reminder_email_handler() -> flask.Response: """Send an email to everyone who doesn't have a snippet for this week.""" - def _send_mail(self, email): + def _send_mail(email): template_values = {} _maybe_send_snippets_mail(email, 'Weekly snippets due today at 5pm', 'reminder_email.txt', template_values) - def get(self): - email_to_has_snippet = _get_email_to_current_snippet_map(_TODAY_FN()) - for (user_email, has_snippet) in email_to_has_snippet.iteritems(): - if not has_snippet: - self._send_mail(user_email) - logging.debug('sent reminder email to %s' % user_email) - else: - logging.debug('did not send reminder email to %s: ' - 'has a snippet already' % user_email) + email_to_has_snippet = _get_email_to_current_snippet_map( + datetime.datetime.now()) + for user_email, has_snippet in email_to_has_snippet.items(): + if not has_snippet: + _send_mail(user_email) + logging.debug('sent reminder email to %s', user_email) + else: + logging.debug('did not send reminder email to %s: ' + 'has a snippet already', user_email) - msg = 'Reminder: Weekly snippets due today at 5pm.' - _send_to_chat(msg, "/") + msg = 'Reminder: Weekly snippets due today at 5pm.' + _send_to_chat(msg, "/") + return flask.make_response({"status": 200, "message": "Sent reminder emails"}) -class SendViewEmail(BaseHandler): +@app.route("/admin/send_view_email") +def admin_send_view_email_handler() -> flask.Response: """Send an email to everyone to look at the week's snippets.""" - def _send_mail(self, email, has_snippets): + def _send_mail(email, has_snippets): template_values = {'has_snippets': has_snippets} _maybe_send_snippets_mail(email, 'Weekly snippets are ready!', 'view_email.txt', template_values) - def get(self): - email_to_has_snippet = _get_email_to_current_snippet_map(_TODAY_FN()) - for (user_email, has_snippet) in email_to_has_snippet.iteritems(): - self._send_mail(user_email, has_snippet) - logging.debug('sent "view" email to %s' % user_email) - - msg = 'Weekly snippets are ready!' - _send_to_chat(msg, "/weekly") - - -application = webapp2.WSGIApplication([ - ('/', UserPage), - ('/weekly', SummaryPage), - ('/update_snippet', UpdateSnippet), - ('/settings', Settings), - ('/update_settings', UpdateSettings), - ('/admin/settings', AppSettings), - ('/admin/update_settings', UpdateAppSettings), - ('/admin/manage_users', ManageUsers), - ('/admin/send_friday_reminder_chat', SendFridayReminderChat), - ('/admin/send_reminder_email', SendReminderEmail), - ('/admin/send_view_email', SendViewEmail), - ('/slack', slacklib.SlashCommand), - ], - debug=True) + email_to_has_snippet = _get_email_to_current_snippet_map( + datetime.datetime.now()) + for user_email, has_snippet in email_to_has_snippet.items(): + _send_mail(user_email, has_snippet) + logging.debug('sent "view" email to %s', user_email) + + msg = 'Weekly snippets are ready!' + _send_to_chat(msg, "/weekly") + return flask.make_response({"status": 200, "message": "Sent 'snippets are ready' emails"}) + + +@app.route('/_ah/warmup') +def warmup(): + """App engine warmup requests handler + + See https://cloud.google.com/appengine/docs/standard/configuring-warmup-requests?tab=python + """ + return "OK", 200, {} diff --git a/snippets_test.py b/snippets_test.py index 19d9e05..9ad9d4e 100755 --- a/snippets_test.py +++ b/snippets_test.py @@ -1,57 +1,44 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- """Tests for the snippets server. This tests the functionality found at weekly-snippets.appspot.com. c.f. http://code.google.com/appengine/docs/python/tools/localunittesting.html -c.f. http://webtest.pythonpaste.org/en/latest/index.html + https://flask.palletsprojects.com/en/2.2.x/testing/ + https://docs.pytest.org/en/7.1.x/how-to/unittest.html + https://github.com/adamchainz/time-machine """ __author__ = 'Craig Silverstein ' - import datetime -import os import re -import sys import time -try: # Work under either python2.5 or python2.7 - import unittest2 as unittest -except ImportError: - import unittest - -# Update sys.path so it can find these. We just need to add -# 'google_appengine', but we add all of $PATH to be easy. This -# assumes the google_appengine directory is on the path. -sys.path.extend(os.environ['PATH'].split(':')) -import dev_appserver -dev_appserver.fix_sys_path() - -from google.appengine.datastore import datastore_stub_util -from google.appengine.ext import db +import unittest +from typing import AnyStr, Pattern +import warnings +from zoneinfo import ZoneInfo + +from google.cloud import ndb from google.appengine.ext import testbed -import webtest # may need to do 'pip install webtest' +import time_machine import models import slacklib import snippets -_TEST_TODAY = datetime.datetime(2012, 2, 23) - - +# Today's pretend date is Thursday, Feb 23, 2012 +@time_machine.travel(datetime.datetime(2012, 2, 23, tzinfo=ZoneInfo("UTC"))) class SnippetsTestBase(unittest.TestCase): def setUp(self): - # We're not interested in testing consistency stuff in these tests. self.testbed = testbed.Testbed() self.testbed.activate() - policy = datastore_stub_util.PseudoRandomHRConsistencyPolicy( - probability=1) - self.testbed.init_datastore_v3_stub(consistency_policy=policy) self.testbed.init_user_stub() - self.request_fetcher = webtest.TestApp(snippets.application) - snippets._TODAY_FN = lambda: _TEST_TODAY + + self.request_fetcher = snippets.app.test_client() # Make sure we never accidentally send messages to chat. self.old_send_to_slack_channel = slacklib.send_to_slack_channel @@ -64,9 +51,12 @@ def tearDown(self): slacklib.send_to_slack_channel = self.old_send_to_slack_channel def login(self, email): - self.testbed.setup_env(user_email=email, overwrite=True) - self.testbed.setup_env(user_id=email, overwrite=True) - self.testbed.setup_env(user_is_admin='0', overwrite=True) + self.testbed.setup_env( + user_email=email, + user_id=email, + user_is_admin='0', + overwrite=True + ) # Now make sure there are global settings. settings = models.AppSettings.get( create_if_missing=True, @@ -78,6 +68,18 @@ def login(self, email): def set_is_admin(self): self.testbed.setup_env(user_is_admin='1', overwrite=True) + def assertDictContainsSubset(self, subset, dictionary, msg=None): + """Checks whether dictionary is a superset of subset. + + Suppresses the deprecation warning from upstream. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + # If python upstream removes this function entirely, you can substitute + # this in a pinch: + # self.assertLessEqual(subset.items(), dictionary.items(), msg) + super().assertDictContainsSubset(subset, dictionary, msg) + def assertNumSnippets(self, body, expected_count): """Assert the page 'body' has exactly expected_count snippets in it.""" # We annotate the div at the beginning of each snippet with @@ -118,26 +120,42 @@ def assertNotInSnippet(self, text, body, snippet_number): """For snippet-page 'body', assert 'text' is not in the ith snippet.""" self.assertNotIn(text, self._ith_snippet(body, snippet_number)) + def assertSnippetRegex(self, regex: AnyStr | Pattern[AnyStr], body, snippet_number): + """For snippet-page 'body', assert 'regex' matches the i-th snippet. + + This works for both user-pages and weekly-pages -- we figure out the + boundaries of the snippets, and check whether the regex matches the + given snippet. + + Arguments: + regex: regular expression to match against the snippet + body: the full html page + snippet_number: which snippet on the page to examine. + Index starts at 0. + """ + self.assertRegex(self._ith_snippet(body, snippet_number), regex) + class UserTestBase(SnippetsTestBase): """The most common base: someone who is logged in as user@example.com.""" def setUp(self): - super(UserTestBase, self).setUp() + super().setUp() self.login('user@example.com') class PostTestCase(SnippetsTestBase): """test the correct output from the server when POSTing""" - def testPostSnippet(self): + def testPostSnippetNormal(self): self.login('user@example.com') url = '/update_snippet' params = { 'week': '02-20-2012', 'snippet': 'my inspired snippet' } - response = self.request_fetcher.post(url, params, status=200) - self.assertIn('{"status": 200, "message": "ok"}', response) + response = self.request_fetcher.post(url, data=params) + self.assertEqual(response.status_code, 200) + self.assertDictContainsSubset({"status": 200, "message": "ok"}, response.json) def testPostSnippetAsOtherPerson(self): self.login('user@example.com') @@ -147,9 +165,10 @@ def testPostSnippetAsOtherPerson(self): 'snippet': 'my fallacious snippet', 'u': 'joeuser@example.com' } - response = self.request_fetcher.post(url, params, status=403) - self.assertIn('"status": 403', response) - self.assertIn('joeuser@example.com', response) + response = self.request_fetcher.post(url, data=params) + self.assertEqual(response.status_code, 403) + self.assertDictContainsSubset({"status": 403}, response.json) + self.assertIn('joeuser@example.com', response.text) def testPostSnippetNotLoggedIn(self): url = '/update_snippet' @@ -158,9 +177,10 @@ def testPostSnippetNotLoggedIn(self): 'snippet': 'my fallacious snippet', 'u': 'user@example.com' } - response = self.request_fetcher.post(url, params, status=403) - self.assertIn('"status": 403', response) - self.assertIn('"message": "not logged in"', response) + response = self.request_fetcher.post(url, data=params) + self.assertEqual(response.status_code, 401) + self.assertDictContainsSubset({"status": 401}, response.json) + self.assertDictContainsSubset({"message": "not logged in"}, response.json) def testPostSnippetIsolation(self): # updating a single snippet via POST should not affect other snippets @@ -180,14 +200,15 @@ def testPostSnippetIsolation(self): 'week': '02-27-2012', 'snippet': 'updated second snippet' } - self.request_fetcher.post(url, params, status=200) + response = self.request_fetcher.post(url, data=params) + self.assertEqual(response.status_code, 200) # make sure only the second snippet changed response = self.request_fetcher.get('/') - self.assertInSnippet('>my third snippet<', response.body, 0) - self.assertInSnippet('>updated second snippet<', response.body, 1) - self.assertNotInSnippet('>my second snippet<', response.body, 1) - self.assertInSnippet('>my snippet<', response.body, 2) + self.assertInSnippet('>my third snippet<', response.text, 0) + self.assertInSnippet('>updated second snippet<', response.text, 1) + self.assertNotInSnippet('>my second snippet<', response.text, 1) + self.assertInSnippet('>my snippet<', response.text, 2) class LoginRequiredTestCase(SnippetsTestBase): @@ -261,16 +282,19 @@ def testCanViewWeeklyPage(self): def testCannotEditOtherSnippets(self): url = ('/update_snippet?week=02-20-2012&snippet=my+snippet' '&u=notuser@example.com') - # Raises an error if we don't get a 500 response (meaning no perm). - self.request_fetcher.get(url, status=500) + # Raises an error if we don't get a 403 response (meaning no perm). + response = self.request_fetcher.get(url) + self.assertEqual(response.status_code, 403) def testCannotViewOtherSettings(self): url = '/settings?u=notuser@example.com' - self.request_fetcher.get(url, status=500) + response = self.request_fetcher.get(url) + self.assertEqual(response.status_code, 403) def testCannotEditOtherSettings(self): url = '/update_settings?u=notuser@example.com' - self.request_fetcher.get(url, status=500) + response = self.request_fetcher.get(url) + self.assertEqual(response.status_code, 403) def testCanEditOwnSnippets(self): url = ('/update_snippet?week=02-20-2012&snippet=my+snippet' @@ -307,65 +331,64 @@ class NewUserTestCase(UserTestBase): def testNewUserLogin(self): response = self.request_fetcher.get('/') - self.assertIn('New user', response.body) + self.assertIn('New user', response.text) def testNewUserContinueUrl(self): """After verifying settings, we should go back to snippet-entry.""" response = self.request_fetcher.get('/') - m = re.search(r'', response.body) + m = re.search(r'', response.text) continue_url = m.group(1) settings_response = self.request_fetcher.get(continue_url) self.assertIn('name="redirect_to" value="snippet_entry"', - settings_response.body) + settings_response.text) # Now kinda-simulate clicking on the submit button. done_response = self.request_fetcher.get( - '/update_settings?u=user@example.com&redirect_to=snippet_entry') - if done_response.status_int in (301, 302, 303, 304): - done_response = done_response.follow() - self.assertIn('Snippets for user@example.com', done_response.body) + '/update_settings?u=user@example.com&redirect_to=snippet_entry', + follow_redirects=True + ) + self.assertIn('Snippets for user@example.com', done_response.text) def testNewAdminWithNoAppSettings(self): """The first time someone logs in, we should go to app settings.""" self.set_is_admin() app_settings = models.AppSettings.get() - app_settings.delete() + app_settings.key.delete() - response = self.request_fetcher.get('/') - if response.status_int in (301, 302, 303, 304): - response = response.follow() - self.assertIn('Application settings', response.body) + response = self.request_fetcher.get('/', follow_redirects=True) + self.assertIn('Application settings', response.text) def testNewAdminContinueUrls(self): """We should go from app settings to user settings to snippet.""" self.set_is_admin() app_settings = models.AppSettings.get() - app_settings.delete() + app_settings.key.delete() - response = self.request_fetcher.get('/') - if response.status_int in (301, 302, 303, 304): - response = response.follow() - m = re.search(r'name="redirect_to" value="([^"]*)"', response.body) + response = self.request_fetcher.get('/', follow_redirects=True) + self.assertEqual(response.status_code, 200) + m = re.search(r'name="redirect_to" value="([^"]*)"', response.text) continue_url = m.group(1) # Now kinda-simulate clicking on the submit button. - done_response = self.request_fetcher.get( - '/admin/update_settings?domains=example.com&redirect_to=%s' - % continue_url) - if done_response.status_int in (301, 302, 303, 304): - done_response = done_response.follow() - self.assertIn('User settings', done_response.body) + done_response = self.request_fetcher.post( + '/admin/update_settings', + data={"domains": "example.com", + "hostname": "snippets.example.com", + "redirect_to": continue_url}, + follow_redirects=True + ) + self.assertIn('<title>User settings', done_response.text) self.assertIn('name="redirect_to" value="snippet_entry"', - done_response.body) + done_response.text) def testNewUserWithNoAppSettings(self): """For non-admins, we should not offer the app-settings page.""" app_settings = models.AppSettings.get() - app_settings.delete() + app_settings.key.delete() response = self.request_fetcher.get('/') - self.assertIn('<title>New user', response.body) + self.assertIn('New user', response.text) def testNewUserInheritsAppDefaults(self): app_settings = models.AppSettings.get() @@ -374,14 +397,14 @@ def testNewUserInheritsAppDefaults(self): app_settings.put() response = self.request_fetcher.get('/') - m = re.search(r'', response.body) + m = re.search(r'', response.text) continue_url = m.group(1) settings_response = self.request_fetcher.get(continue_url) - self.assertRegexpMatches(settings_response.body, - r'name="markdown"\s+value="yes"\s+checked') - self.assertRegexpMatches(settings_response.body, - r'name="private"\s+value="yes"\s+checked') + self.assertRegex(settings_response.text, + r'name="markdown"\s+value="yes"\s+checked') + self.assertRegex(settings_response.text, + r'name="private"\s+value="yes"\s+checked') # Now change the app-defaults and make sure this is reflected in # the new-user setup page. @@ -390,50 +413,55 @@ def testNewUserInheritsAppDefaults(self): app_settings.put() response = self.request_fetcher.get('/') - m = re.search(r'', response.body) + m = re.search(r'', response.text) continue_url = m.group(1) settings_response = self.request_fetcher.get(continue_url) - self.assertRegexpMatches(settings_response.body, - r'name="markdown"\s+value="no"\s+checked') - self.assertRegexpMatches(settings_response.body, - r'name="private"\s+value="no"\s+checked') + self.assertRegex(settings_response.text, + r'name="markdown"\s+value="no"\s+checked') + self.assertRegex(settings_response.text, + r'name="private"\s+value="no"\s+checked') def testNewUserInValidDomain(self): self.login('newuser@example.com') response = self.request_fetcher.get('/settings') - self.assertIn('User settings', response.body) + self.assertIn('<title>User settings', response.text) def testNewUserInInvalidDomain(self): """Test you can not register as a new user from a random domain.""" self.login('newuser@notallowed.com') # TODO(csilvers): give a nice error page instead of a 500. - self.request_fetcher.get('/settings', status=500) + response = self.request_fetcher.get('/settings') + self.assertEqual(response.status_code, 500) def testSettingsPageDoesNotCreateANewUser(self): """Only *saving* the settings should create a new user.""" response = self.request_fetcher.get('/') - self.assertIn('<title>New user', response.body) - m = re.search(r'', response.body) + self.assertIn('New user', response.text) + m = re.search(r'', response.text) continue_url = m.group(1) self.request_fetcher.get(continue_url) # visit /settings # Now if we go to / again, we should get the new-user page again # because the settings were never saved. response = self.request_fetcher.get('/') - self.assertIn('New user', response.body) + self.assertIn('New user', response.text) class AppSettingsTestCase(UserTestBase): """Test the app-settings page.""" def setUp(self): - super(AppSettingsTestCase, self).setUp() + super().setUp() self.set_is_admin() def testDomainsParsing(self): - self.request_fetcher.get( - '/admin/update_settings?domains=a.com,b.com++c.com%0Bd.com,%0B') + self.request_fetcher.post( + '/admin/update_settings', + data={ + "domains": "a.com,b.com\nc.com d.com,", + "hostname": "example.com", + }) app_settings = models.AppSettings.get() self.assertEqual(['a.com', 'b.com', 'c.com', 'd.com'], app_settings.domains) @@ -443,12 +471,12 @@ class UserSettingsTestCase(UserTestBase): """Test setting and using user settings.""" def assertInputIsChecked(self, name, body, snippet_number): - self.assertRegexpMatches(body, r'name="%s" value="True"\s+checked\s*>' - % name, snippet_number) + self.assertRegex(body, r'name="%s" value="True"\s+checked\s*>' % name, + snippet_number) def assertInputIsNotChecked(self, name, body, snippet_number): - self.assertRegexpMatches(body, r'name="%s" value="True"\s*>' % name, - snippet_number) + self.assertRegex(body, r'name="%s" value="True"\s*>' % name, + snippet_number) def testDefaultUserSettings(self): # Make sure the rest of the tests are actually testing @@ -457,61 +485,61 @@ def testDefaultUserSettings(self): response = self.request_fetcher.get('/') # Neither private nor 'is-markdown' are checked by default. - self.assertInputIsNotChecked('private', response.body, 0) - self.assertInputIsNotChecked('is_markdown', response.body, 0) + self.assertInputIsNotChecked('private', response.text, 0) + self.assertInputIsNotChecked('is_markdown', response.text, 0) def testPrivateUser(self): self.request_fetcher.get( '/update_settings?u=user@example.com&private=yes') response = self.request_fetcher.get('/') - self.assertInputIsChecked('private', response.body, 0) - self.assertInputIsNotChecked('is_markdown', response.body, 0) + self.assertInputIsChecked('private', response.text, 0) + self.assertInputIsNotChecked('is_markdown', response.text, 0) def testMarkdownUser(self): self.request_fetcher.get( '/update_settings?u=user@example.com&markdown=yes') response = self.request_fetcher.get('/') - self.assertInputIsNotChecked('private', response.body, 0) - self.assertInputIsChecked('is_markdown', response.body, 0) + self.assertInputIsNotChecked('private', response.text, 0) + self.assertInputIsChecked('is_markdown', response.text, 0) def testSettingsForFilledInSnippets(self): url = '/update_snippet?week=02-21-2011&snippet=old+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 53) - self.assertInputIsNotChecked('private', response.body, 9) - self.assertInputIsNotChecked('is_markdown', response.body, 9) - self.assertInSnippet('old snippet', response.body, 52) - self.assertInputIsNotChecked('private', response.body, 52) - self.assertInputIsNotChecked('is_markdown', response.body, 52) + self.assertNumSnippets(response.text, 53) + self.assertInputIsNotChecked('private', response.text, 9) + self.assertInputIsNotChecked('is_markdown', response.text, 9) + self.assertInSnippet('old snippet', response.text, 52) + self.assertInputIsNotChecked('private', response.text, 52) + self.assertInputIsNotChecked('is_markdown', response.text, 52) self.request_fetcher.get( '/update_settings?u=user@example.com&markdown=yes') response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 53) - self.assertInputIsNotChecked('private', response.body, 9) - self.assertInputIsChecked('is_markdown', response.body, 9) + self.assertNumSnippets(response.text, 53) + self.assertInputIsNotChecked('private', response.text, 9) + self.assertInputIsChecked('is_markdown', response.text, 9) # But the existing snippet is unaffected. - self.assertInSnippet('old snippet', response.body, 52) - self.assertInputIsNotChecked('private', response.body, 52) - self.assertInputIsNotChecked('is_markdown', response.body, 52) + self.assertInSnippet('old snippet', response.text, 52) + self.assertInputIsNotChecked('private', response.text, 52) + self.assertInputIsNotChecked('is_markdown', response.text, 52) self.request_fetcher.get( '/update_settings?u=user@example.com&private=yes') response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 53) - self.assertInputIsChecked('private', response.body, 9) - self.assertInputIsNotChecked('is_markdown', response.body, 9) - self.assertInSnippet('old snippet', response.body, 52) - self.assertInputIsNotChecked('private', response.body, 52) - self.assertInputIsNotChecked('is_markdown', response.body, 52) + self.assertNumSnippets(response.text, 53) + self.assertInputIsChecked('private', response.text, 9) + self.assertInputIsNotChecked('is_markdown', response.text, 9) + self.assertInSnippet('old snippet', response.text, 52) + self.assertInputIsNotChecked('private', response.text, 52) + self.assertInputIsNotChecked('is_markdown', response.text, 52) def testCategoryUnset(self): self.request_fetcher.get('/update_settings?u=user@example.com') response = self.request_fetcher.get('/') self.assertInSnippet( 'WARNING: Snippet will go in the "(unknown)"', - response.body, 0 + response.text, 0 ) def testCategorySet(self): @@ -521,7 +549,7 @@ def testCategorySet(self): response = self.request_fetcher.get('/') self.assertNotInSnippet( 'WARNING: Snippet will go in the "(unknown)"', - response.body, 0 + response.text, 0 ) def testCategoryUnsetButSnippetHasContent(self): @@ -531,22 +559,22 @@ def testCategoryUnsetButSnippetHasContent(self): response = self.request_fetcher.get('/') self.assertInSnippet( 'WARNING: Snippet will go in the "(unknown)"', - response.body, 0 + response.text, 0 ) def testCategoryCheckForFilledInSnippets(self): url = '/update_snippet?week=02-21-2011&snippet=old+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 53) + self.assertNumSnippets(response.text, 53) self.assertInSnippet( 'WARNING: Snippet will go in the "(unknown)"', - response.body, 9 + response.text, 9 ) - self.assertInSnippet('old snippet', response.body, 52) + self.assertInSnippet('old snippet', response.text, 52) self.assertInSnippet( 'WARNING: Snippet will go in the "(unknown)"', - response.body, 52 + response.text, 52 ) def testHiddenUser(self): @@ -554,20 +582,20 @@ def testHiddenUser(self): self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 2) + self.assertNumSnippets(response.text, 2) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 1) # "no snippet this week" + self.assertNumSnippets(response.text, 1) # "no snippet this week" url = '/update_settings?u=user@example.com&is_hidden=yes' self.request_fetcher.get(url) response = self.request_fetcher.get('/') # Hiding doesn't affect the user-snippets page, just the weekly one. - self.assertNumSnippets(response.body, 2) + self.assertNumSnippets(response.text, 2) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 0) + self.assertNumSnippets(response.text, 0) # And it doesn't affect existing snippets, just empty ones. response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) def testNewSnippetUnhides(self): url = '/update_snippet?week=02-13-2012&snippet=my+snippet' @@ -576,15 +604,15 @@ def testNewSnippetUnhides(self): url = '/update_settings?u=user@example.com&is_hidden=yes' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 0) + self.assertNumSnippets(response.text, 0) url = '/update_snippet?week=02-27-2012&snippet=new+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) def testChangingSettingsUnhides(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' @@ -593,12 +621,12 @@ def testChangingSettingsUnhides(self): url = '/update_settings?u=user@example.com&is_hidden=yes' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 0) + self.assertNumSnippets(response.text, 0) url = '/update_settings?u=user@example.com' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) def testHideButton(self): # First, register the user. @@ -609,14 +637,14 @@ def testHideButton(self): self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) # Now hide using the hide button. url = '/update_settings?u=user@example.com&hide=Hide' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 0) + self.assertNumSnippets(response.text, 0) def testDeleteButton(self): # First, register the user. @@ -624,13 +652,13 @@ def testDeleteButton(self): self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNotIn('New user', response.body) + self.assertNotIn('New user', response.text) url = '/update_settings?u=user@example.com&delete=Delete' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertIn('New user', response.body) + self.assertIn('New user', response.text) class SetAndViewSnippetsTestCase(UserTestBase): @@ -640,22 +668,22 @@ def testSetAndViewInUserMode(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 2) - self.assertInSnippet('>my snippet<', response.body, 0) + self.assertNumSnippets(response.text, 2) + self.assertInSnippet('>my snippet<', response.text, 0) def testSetAndViewInWeeklyMode(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('>my snippet<', response.body, 0) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('>my snippet<', response.text, 0) def testCannotSeeInOtherWeek(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertNumSnippets(response.body, 1) - self.assertNotIn('>my snippet<', response.body) + self.assertNumSnippets(response.text, 1) + self.assertNotIn('>my snippet<', response.text) def testViewSnippetsForTwoUsers(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' @@ -666,32 +694,32 @@ def testViewSnippetsForTwoUsers(self): # This is done as other response = self.request_fetcher.get('/') - self.assertIn('other@example.com', response.body) - self.assertNumSnippets(response.body, 2) - self.assertInSnippet('>other snippet<', response.body, 0) - self.assertNotIn('user@example.com', response.body) - self.assertNotIn('>my snippet<', response.body) + self.assertIn('other@example.com', response.text) + self.assertNumSnippets(response.text, 2) + self.assertInSnippet('>other snippet<', response.text, 0) + self.assertNotIn('user@example.com', response.text) + self.assertNotIn('>my snippet<', response.text) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 2) - self.assertInSnippet('other@example.com', response.body, 0) - self.assertInSnippet('>other snippet<', response.body, 0) - self.assertInSnippet('user@example.com', response.body, 1) - self.assertInSnippet('>my snippet<', response.body, 1) + self.assertNumSnippets(response.text, 2) + self.assertInSnippet('other@example.com', response.text, 0) + self.assertInSnippet('>other snippet<', response.text, 0) + self.assertInSnippet('user@example.com', response.text, 1) + self.assertInSnippet('>my snippet<', response.text, 1) # This is done as user self.login('user@example.com') response = self.request_fetcher.get('/') - self.assertIn('user@example.com', response.body) - self.assertNumSnippets(response.body, 2) - self.assertInSnippet('>my snippet<', response.body, 0) - self.assertNotIn('other@example.com', response.body) - self.assertNotIn('>other snippet<', response.body) + self.assertIn('user@example.com', response.text) + self.assertNumSnippets(response.text, 2) + self.assertInSnippet('>my snippet<', response.text, 0) + self.assertNotIn('other@example.com', response.text) + self.assertNotIn('>other snippet<', response.text) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 2) - self.assertInSnippet('other@example.com', response.body, 0) - self.assertInSnippet('>other snippet<', response.body, 0) - self.assertInSnippet('user@example.com', response.body, 1) - self.assertInSnippet('>my snippet<', response.body, 1) + self.assertNumSnippets(response.text, 2) + self.assertInSnippet('other@example.com', response.text, 0) + self.assertInSnippet('>other snippet<', response.text, 0) + self.assertInSnippet('user@example.com', response.text, 1) + self.assertInSnippet('>my snippet<', response.text, 1) def testViewSnippetsForTwoWeeks(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' @@ -700,27 +728,27 @@ def testViewSnippetsForTwoWeeks(self): self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 3) + self.assertNumSnippets(response.text, 3) # Snippets go in reverse chronological order (i.e. newest first) - self.assertInSnippet('>my second snippet<', response.body, 0) - self.assertInSnippet('>my snippet<', response.body, 1) + self.assertInSnippet('>my second snippet<', response.text, 0) + self.assertInSnippet('>my snippet<', response.text, 1) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('user@example.com', response.body, 0) - self.assertInSnippet('>my snippet<', response.body, 0) - self.assertNotIn('>my second snippet<', response.body) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('user@example.com', response.text, 0) + self.assertInSnippet('>my snippet<', response.text, 0) + self.assertNotIn('>my second snippet<', response.text) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('user@example.com', response.body, 0) - self.assertInSnippet('>my second snippet<', response.body, 0) - self.assertNotIn('>my snippet<', response.body) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('user@example.com', response.text, 0) + self.assertInSnippet('>my second snippet<', response.text, 0) + self.assertNotIn('>my snippet<', response.text) response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertNumSnippets(response.body, 1) - self.assertNotIn('>my snippet<', response.body) - self.assertNotIn('>my second snippet<', response.body) + self.assertNumSnippets(response.text, 1) + self.assertNotIn('>my snippet<', response.text) + self.assertNotIn('>my second snippet<', response.text) def testViewEmptySnippetsInWeekMode(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' @@ -730,17 +758,17 @@ def testViewEmptySnippetsInWeekMode(self): self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 2) + self.assertNumSnippets(response.text, 2) # Other-user comes first alphabetically. - self.assertInSnippet('other@example.com', response.body, 0) - self.assertInSnippet('user@example.com', response.body, 1) - self.assertInSnippet('>my snippet<', response.body, 1) + self.assertInSnippet('other@example.com', response.text, 0) + self.assertInSnippet('user@example.com', response.text, 1) + self.assertInSnippet('>my snippet<', response.text, 1) response = self.request_fetcher.get('/weekly?week=02-27-2012') - self.assertNumSnippets(response.body, 2) - self.assertInSnippet('other@example.com', response.body, 0) - self.assertInSnippet('>other snippet<', response.body, 0) - self.assertInSnippet('user@example.com', response.body, 1) + self.assertNumSnippets(response.text, 2) + self.assertInSnippet('other@example.com', response.text, 0) + self.assertInSnippet('>other snippet<', response.text, 0) + self.assertInSnippet('user@example.com', response.text, 1) def testViewEmptySnippetsInUserMode(self): """Occurs when there's a gap between two snippets.""" @@ -750,9 +778,9 @@ def testViewEmptySnippetsInUserMode(self): self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 3) - self.assertInSnippet('>my snippet<', response.body, 0) - self.assertInSnippet('>my old snippet<', response.body, 2) + self.assertNumSnippets(response.text, 3) + self.assertInSnippet('>my snippet<', response.text, 0) + self.assertInSnippet('>my old snippet<', response.text, 2) def testCategorizeSnippets(self): """Weekly view should sort based on user categories.""" @@ -782,11 +810,11 @@ def testCategorizeSnippets(self): # Order should be 4 ((unknown) category), 2 and 3 (a 1st) and # then 1 (b 2nd). response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 4) - self.assertInSnippet('4@example.com', response.body, 0) - self.assertInSnippet('2@example.com', response.body, 1) - self.assertInSnippet('3@example.com', response.body, 2) - self.assertInSnippet('1@example.com', response.body, 3) + self.assertNumSnippets(response.text, 4) + self.assertInSnippet('4@example.com', response.text, 0) + self.assertInSnippet('2@example.com', response.text, 1) + self.assertInSnippet('3@example.com', response.text, 2) + self.assertInSnippet('1@example.com', response.text, 3) def testViewSnippetAfterAUserIsDeleted(self): """When a user is deleted, their snippet should still show up.""" @@ -797,146 +825,146 @@ def testViewSnippetAfterAUserIsDeleted(self): self.request_fetcher.get(url) # Now delete user 2 - u = models.User.all().filter('email =', '2@example.com').get() - u.delete() + u = models.User.query(models.User.email == '2@example.com').get() + u.key.delete() response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('2@example.com', response.body, 0) - self.assertTrue('(unknown)' in response.body) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('2@example.com', response.text, 0) + self.assertTrue('(unknown)' in response.text) def testWarningsWhenDue(self): url = '/update_snippet?week=02-06-2012&snippet=old+snippet' self.request_fetcher.get(url) - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 19) - response = self.request_fetcher.get('/') - self.assertNotIn('Due today', response.body) - self.assertNotIn('OVERDUE', response.body) + with time_machine.travel(datetime.datetime(2012, 2, 19)): + response = self.request_fetcher.get('/') + self.assertNotIn('Due today', response.text) + self.assertNotIn('OVERDUE', response.text) - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20) - response = self.request_fetcher.get('/') - self.assertIn('Due today', response.body) - self.assertNotIn('OVERDUE', response.body) + with time_machine.travel(datetime.datetime(2012, 2, 20)): + response = self.request_fetcher.get('/') + self.assertIn('Due today', response.text) + self.assertNotIn('OVERDUE', response.text) - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 21) - response = self.request_fetcher.get('/') - self.assertNotIn('Due today', response.body) - self.assertIn('OVERDUE', response.body) + with time_machine.travel(datetime.datetime(2012, 2, 21)): + response = self.request_fetcher.get('/') + self.assertNotIn('Due today', response.text) + self.assertIn('OVERDUE', response.text) - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 22) - response = self.request_fetcher.get('/') - self.assertNotIn('Due today', response.body) - self.assertIn('OVERDUE', response.body) + with time_machine.travel(datetime.datetime(2012, 2, 22)): + response = self.request_fetcher.get('/') + self.assertNotIn('Due today', response.text) + self.assertIn('OVERDUE', response.text) def testWarningsWhenNotDue(self): url = '/update_snippet?week=02-13-2012&snippet=my+snippet' self.request_fetcher.get(url) for date in (19, 20, 21, 22): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, date) - response = self.request_fetcher.get('/') - self.assertNotIn('Due today', response.body) - self.assertNotIn('OVERDUE', response.body) + with time_machine.travel(datetime.datetime(2012, 2, date)): + response = self.request_fetcher.get('/') + self.assertNotIn('Due today', response.text) + self.assertNotIn('OVERDUE', response.text) def testPrettyDateFormatting(self): # Just so we're not a new user. url = '/update_snippet?week=02-06-2012&snippet=my+snippet' self.request_fetcher.get(url) - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 6) - response = self.request_fetcher.get('/') - self.assertIn('February 6, 2012', response.body) + with time_machine.travel(datetime.datetime(2012, 2, 6)): + response = self.request_fetcher.get('/') + self.assertIn('February 6, 2012', response.text) def testUrlize(self): url = '/update_snippet?week=02-20-2012&snippet=visit+http://foo.com' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet( - '>visit http://foo.com<', - response.body, 0) + self.assertNumSnippets(response.text, 1) + self.assertSnippetRegex( + r'>visit http://foo.com<', + response.text, 0) # Also make sure we urlize on the user page. self.login('2@example.com') response = self.request_fetcher.get('/?u=user@example.com') - self.assertNumSnippets(response.body, 2) - self.assertInSnippet( - '>visit http://foo.com<', - response.body, 0) + self.assertNumSnippets(response.text, 2) + self.assertSnippetRegex( + r'>visit http://foo.com<', + response.text, 0) def testEditMode(self): url = '/update_snippet?week=02-20-2012&snippet=hello' self.request_fetcher.get(url) response = self.request_fetcher.get('/?u=user@example.com') - self.assertIn('Make snippet private', response.body) + self.assertIn('Make snippet private', response.text) response = self.request_fetcher.get('/?u=user@example.com&edit=1') - self.assertIn('Make snippet private', response.body) + self.assertIn('Make snippet private', response.text) response = self.request_fetcher.get('/?u=user@example.com&edit=0') - self.assertNotIn('Make snippet private', response.body) + self.assertNotIn('Make snippet private', response.text) class ShowCorrectWeekTestCase(UserTestBase): """Test we show the right snippets for edit/view based on day of week.""" def setUp(self): - super(ShowCorrectWeekTestCase, self).setUp() + super().setUp() # Register the user so snippet-fetching works. url = '/update_settings?category=dummy' self.request_fetcher.get(url) def testMonday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 20)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) # For *viewing*'s snippets, we always show last week's snippets. response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) def testTuesday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 21) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 21)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) def testWednesday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 22) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 22)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) def testThursday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 23) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 23)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) def testFriday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 24) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 24)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) def testSaturday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 25) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 25)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) def testSunday(self): - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 26) - response = self.request_fetcher.get('/') - self.assertInSnippet('February 20, 2012', response.body, 0) + with time_machine.travel(datetime.datetime(2012, 2, 26)): + response = self.request_fetcher.get('/') + self.assertInSnippet('February 20, 2012', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertIn('February 13, 2012', response.body) + self.assertIn('February 13, 2012', response.text) class NosnippetGapFillingTestCase(UserTestBase): @@ -945,23 +973,23 @@ class NosnippetGapFillingTestCase(UserTestBase): def testNoSnippets(self): # If nobody is registered, the user-db will be empty. response = self.request_fetcher.get('/weekly') - self.assertNumSnippets(response.body, 0) + self.assertNumSnippets(response.text, 0) url = '/update_settings?category=dummy' # register the user self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 2) + self.assertNumSnippets(response.text, 2) def testOneSnippetInDistantPast(self): url = '/update_snippet?week=02-21-2011&snippet=old+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 53) - self.assertInSnippet('old snippet', response.body, 52) + self.assertNumSnippets(response.text, 53) + self.assertInSnippet('old snippet', response.text, 52) response = self.request_fetcher.get('/weekly') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) def testTwoSnippetsInDistantPast(self): url = '/update_snippet?week=08-22-2011&snippet=oldish+snippet' @@ -969,22 +997,22 @@ def testTwoSnippetsInDistantPast(self): url = '/update_snippet?week=02-21-2011&snippet=old+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 53) - self.assertInSnippet('oldish snippet', response.body, 26) - self.assertInSnippet('old snippet', response.body, 52) + self.assertNumSnippets(response.text, 53) + self.assertInSnippet('oldish snippet', response.text, 26) + self.assertInSnippet('old snippet', response.text, 52) response = self.request_fetcher.get('/weekly') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) def testSnippetInTheFuture(self): url = '/update_snippet?week=02-18-2013&snippet=future+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 54) - self.assertInSnippet('future snippet', response.body, 0) + self.assertNumSnippets(response.text, 54) + self.assertInSnippet('future snippet', response.text, 0) response = self.request_fetcher.get('/weekly') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) def testSnippetInThePastAndFuture(self): url = '/update_snippet?week=02-21-2011&snippet=old+snippet' @@ -992,19 +1020,19 @@ def testSnippetInThePastAndFuture(self): url = '/update_snippet?week=02-18-2013&snippet=future+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/') - self.assertNumSnippets(response.body, 105) - self.assertInSnippet('future snippet', response.body, 0) - self.assertInSnippet('old snippet', response.body, 104) + self.assertNumSnippets(response.text, 105) + self.assertInSnippet('future snippet', response.text, 0) + self.assertInSnippet('old snippet', response.text, 104) response = self.request_fetcher.get('/weekly') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) class PrivateSnippetTestCase(UserTestBase): """Tests that we properly restrict viewing of private snippets.""" def setUp(self): - super(PrivateSnippetTestCase, self).setUp() + super().setUp() # Set up a user with some private and some not-private snippets, # another user with only private, and another with only public. self.login('private@example.com') @@ -1036,51 +1064,51 @@ def setUp(self): def testAdminCanSeeAllSnippets(self): self.set_is_admin() response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertNumSnippets(response.body, 4) + self.assertNumSnippets(response.text, 4) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 4) + self.assertNumSnippets(response.text, 4) def testUserCanSeeAllSnippetsFromTheirDomain(self): # As user@example.com, we can see all but some_other_domain.com response = self.request_fetcher.get('/weekly?week=02-13-2012') # For private@some_other_domain.com, we should see 'no snippet found'. - self.assertNumSnippets(response.body, 4) - self.assertInSnippet('private@some_other_domain.com', response.body, 2) - self.assertNotInSnippet('foreign', response.body, 2) + self.assertNumSnippets(response.text, 4) + self.assertInSnippet('private@some_other_domain.com', response.text, 2) + self.assertNotInSnippet('foreign', response.text, 2) # We *should* see stuff from our domain, but marked private. - self.assertInSnippet('private@example.com', response.body, 1) - self.assertInSnippet('snippet-tag-private', response.body, 1) - self.assertInSnippet('no see um', response.body, 1) + self.assertInSnippet('private@example.com', response.text, 1) + self.assertInSnippet('snippet-tag-private', response.text, 1) + self.assertInSnippet('no see um', response.text, 1) # And we should see public snippets, not marked private. - self.assertInSnippet('public@example.com', response.body, 3) - self.assertNotInSnippet('snippet-tag-private', response.body, 3) - self.assertInSnippet('see me', response.body, 3) + self.assertInSnippet('public@example.com', response.text, 3) + self.assertNotInSnippet('snippet-tag-private', response.text, 3) + self.assertInSnippet('see me', response.text, 3) self.login('random@some_other_domain.com') response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertNumSnippets(response.body, 4) - self.assertInSnippet('private@some_other_domain.com', response.body, 2) - self.assertInSnippet('foreign', response.body, 2) - self.assertInSnippet('snippet-tag-private', response.body, 2) + self.assertNumSnippets(response.text, 4) + self.assertInSnippet('private@some_other_domain.com', response.text, 2) + self.assertInSnippet('foreign', response.text, 2) + self.assertInSnippet('snippet-tag-private', response.text, 2) # Now we shouldn't see stuff from example.com - self.assertInSnippet('private@example.com', response.body, 1) - self.assertNotInSnippet('no see um', response.body, 1) + self.assertInSnippet('private@example.com', response.text, 1) + self.assertNotInSnippet('no see um', response.text, 1) # And we should also see public snippets, not in gray. - self.assertInSnippet('public@example.com', response.body, 3) - self.assertNotInSnippet('snippet-tag-private', response.body, 3) - self.assertInSnippet('see me', response.body, 3) + self.assertInSnippet('public@example.com', response.text, 3) + self.assertNotInSnippet('snippet-tag-private', response.text, 3) + self.assertInSnippet('see me', response.text, 3) def testPrivacyIsPerSnippet(self): self.login('random@some_other_domain.com') response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertNumSnippets(response.body, 4) - self.assertInSnippet('mixed@example.com', response.body, 0) - self.assertNotInSnippet('cautious', response.body, 0) + self.assertNumSnippets(response.text, 4) + self.assertInSnippet('mixed@example.com', response.text, 0) + self.assertNotInSnippet('cautious', response.text, 0) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 4) - self.assertInSnippet('mixed@example.com', response.body, 0) - self.assertInSnippet('not cautious', response.body, 0) + self.assertNumSnippets(response.text, 4) + self.assertInSnippet('mixed@example.com', response.text, 0) + self.assertInSnippet('not cautious', response.text, 0) def testDomainMatching(self): # Let's make it legal for all these domains to log in. @@ -1107,10 +1135,10 @@ def testDomainMatching(self): self.login('user@example.com') response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertNumSnippets(response.body, 8) + self.assertNumSnippets(response.text, 8) for i in (0, 1, 2, 3): # the 4 close@ snippets should sort first - self.assertInSnippet('close@', response.body, i) - self.assertNotInSnippet('whoa', response.body, i) + self.assertInSnippet('close@', response.text, i) + self.assertNotInSnippet('whoa', response.text, i) class MarkdownSnippetTestCase(UserTestBase): @@ -1121,7 +1149,7 @@ class MarkdownSnippetTestCase(UserTestBase): class. """ def setUp(self): - super(MarkdownSnippetTestCase, self).setUp() + super().setUp() # Set up some snippets as markdown, and some not. url = ('/update_snippet?week=02-13-2012&snippet=*+item+1%0A*+item+2' @@ -1132,35 +1160,35 @@ def setUp(self): def testMarkdownRendering(self): response = self.request_fetcher.get('/weekly?week=02-13-2012') - self.assertInSnippet('class="snippet-text-markdown', response.body, 0) + self.assertInSnippet('class="snippet-text-markdown', response.text, 0) def testTextRendering(self): response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertInSnippet('class="snippet-text', response.body, 0) + self.assertInSnippet('class="snippet-text', response.text, 0) class ManageUsersTestCase(UserTestBase): """Test we can delete users properly.""" def setUp(self): - super(ManageUsersTestCase, self).setUp() + super().setUp() # Have users with various snippet characteristics. - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 0) - self.login('has_one_snippet@example.com') - self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s1') + with time_machine.travel(datetime.datetime(2012, 2, 20, 12, 0, 0)): + self.login('has_one_snippet@example.com') + self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s1') - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 1) - self.login('has_many_snippets@example.com') - self.request_fetcher.get('/update_snippet?week=01-30-2012&snippet=s2') - self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s3') + with time_machine.travel(datetime.datetime(2012, 2, 20, 12, 0, 1)): + self.login('has_many_snippets@example.com') + self.request_fetcher.get('/update_snippet?week=01-30-2012&snippet=s2') + self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s3') - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 2) - self.login('has_old_snippet@example.com') - self.request_fetcher.get('/update_snippet?week=02-14-2011&snippet=s4') + with time_machine.travel(datetime.datetime(2012, 2, 20, 12, 0, 2)): + self.login('has_old_snippet@example.com') + self.request_fetcher.get('/update_snippet?week=02-14-2011&snippet=s4') - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 3) - self.login('has_no_snippets@example.com') - self.request_fetcher.get('/update_settings') + with time_machine.travel(datetime.datetime(2012, 2, 20, 12, 0, 3)): + self.login('has_no_snippets@example.com') + self.request_fetcher.get('/update_settings') def get_user_list(self, body): """Returns the email usernames of the user-list, in order.""" @@ -1175,8 +1203,8 @@ def testSortByEmail(self): '?sort_by=email') expected = ['has_many_snippets', 'has_no_snippets', 'has_old_snippet', 'has_one_snippet'] - self.assertEqual(expected, self.get_user_list(response.body)) - self.assertNotIn('@example.com deleted', response.body) # we didn't + self.assertEqual(expected, self.get_user_list(response.text)) + self.assertNotIn('@example.com deleted', response.text) # we didn't def testSortByCreation(self): response = self.request_fetcher.get('/admin/manage_users' @@ -1184,8 +1212,8 @@ def testSortByCreation(self): # Reverse order from when we created them above. expected = ['has_no_snippets', 'has_old_snippet', 'has_many_snippets', 'has_one_snippet'] - self.assertEqual(expected, self.get_user_list(response.body)) - self.assertNotIn('@example.com deleted', response.body) # we didn't + self.assertEqual(expected, self.get_user_list(response.text)) + self.assertNotIn('@example.com deleted', response.text) # we didn't def testSortByLastSnippet(self): response = self.request_fetcher.get('/admin/manage_users' @@ -1193,78 +1221,114 @@ def testSortByLastSnippet(self): # 'many' and 'one' are tied; the tiebreak is email. expected = ['has_no_snippets', 'has_old_snippet', 'has_many_snippets', 'has_one_snippet'] - self.assertEqual(expected, self.get_user_list(response.body)) - self.assertNotIn('@example.com deleted', response.body) # we didn't + self.assertEqual(expected, self.get_user_list(response.text)) + self.assertNotIn('@example.com deleted', response.text) # we didn't def testBadSortBy(self): # status=500 means we expect to get back a 500 error for this. - self.request_fetcher.get('/admin/manage_users?sort_by=unknown', - status=500) + response = self.request_fetcher.get('/admin/manage_users?sort_by=unknown') + self.assertEqual(response.status_code, 500) def testDelete(self): - response = self.request_fetcher.get( - '/admin/manage_users?delete+has_old_snippet@example.com=Delete') - if response.status_int in (301, 302, 303, 304): - response = response.follow() + response = self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "delete", + "email": "has_old_snippet@example.com", + }, + follow_redirects=True) expected = ['has_no_snippets', 'has_many_snippets', 'has_one_snippet'] - self.assertEqual(expected, self.get_user_list(response.body)) - self.assertIn('has_old_snippet@example.com deleted', response.body) + self.assertEqual(expected, self.get_user_list(response.text)) + self.assertIn('has_old_snippet@example.com deleted', response.text) def testHide(self): - response = self.request_fetcher.get( - '/admin/manage_users?hide+has_old_snippet@example.com=Hide') - if response.status_int in (301, 302, 303, 304): - response = response.follow() + response = self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "hide", + "email": "has_old_snippet@example.com", + }, + follow_redirects=True) expected = ['has_no_snippets', 'has_old_snippet', 'has_many_snippets', 'has_one_snippet'] - self.assertEqual(expected, self.get_user_list(response.body)) - self.assertIn('has_old_snippet@example.com hidden', response.body) - self.assertIn('value="Unhide"', response.body) + self.assertEqual(expected, self.get_user_list(response.text)) + self.assertIn('has_old_snippet@example.com hidden', response.text) + self.assertIn('value="Unhide"', response.text) def testPreserveSortBy(self): response = self.request_fetcher.get( '/admin/manage_users?hide+has_old_snippet@example.com=Hide' - '&sort_by=last_snippet_time') - if response.status_int in (301, 302, 303, 304): - response = response.follow() + '&sort_by=last_snippet_time', + follow_redirects=True) # "Last snippet" shouldn't have a link letting you sort by # last snippet, because it should already be doing so! - self.assertIn('Last snippet', response.body) + self.assertIn('Last snippet', response.text) def testUnhide(self): - self.request_fetcher.get( - '/admin/manage_users?hide+has_old_snippet@example.com=Hide') - response = self.request_fetcher.get( - '/admin/manage_users?unhide+has_old_snippet@example.com=Hide') - if response.status_int in (301, 302, 303, 304): - response = response.follow() + self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "hide", + "email": "has_old_snippet@example.com", + }) + response = self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "unhide", + "email": "has_old_snippet@example.com", + }, + follow_redirects=True) expected = ['has_no_snippets', 'has_old_snippet', 'has_many_snippets', 'has_one_snippet'] - self.assertEqual(expected, self.get_user_list(response.body)) - self.assertIn('has_old_snippet@example.com unhidden', response.body) - self.assertNotIn('value="Unhide"', response.body) + self.assertEqual(expected, self.get_user_list(response.text)) + self.assertIn('has_old_snippet@example.com unhidden', response.text) + self.assertNotIn('value="Unhide"', response.text) def testInvalidButton(self): - self.request_fetcher.get('/admin/manage_users' - '?delete+has_old_snippet=Delete', - status=500) - self.request_fetcher.get('/admin/manage_users' - '?hide+has_old_snippet=Hide', - status=500) - self.request_fetcher.get('/admin/manage_users' - '?unhide+has_old_snippet=Unhide', - status=500) + self.assertEqual( + self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "delete", + "email": "has_old_snippet", + } + ).status_code, + 500 + ) + self.assertEqual( + self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "hide", + "email": "has_old_snippet", + } + ).status_code, + 500 + ) + self.assertEqual( + self.request_fetcher.post( + '/admin/manage_users', + data={ + "action": "unhide", + "email": "has_old_snippet", + } + ).status_code, + 500 + ) +# We send out mail on Sunday nights and Monday mornings, so we'll set 'today' to +# be Sunday right around midnight. +@time_machine.travel(datetime.datetime(2012, 2, 19, 23, 50, 0, tzinfo=ZoneInfo("UTC"))) class SendingEmailTestCase(UserTestBase): """Test we correctly send cron emails.""" def setUp(self): - super(SendingEmailTestCase, self).setUp() + super().setUp() self.testbed.init_mail_stub() self.mail_stub = self.testbed.get_stub(testbed.MAIL_SERVICE_NAME) # The email-senders sleep 2 seconds between sends for quota @@ -1273,10 +1337,6 @@ def setUp(self): self.sleep_fn = time.sleep time.sleep = lambda sec: sec - # We send out mail on Sunday nights and Monday mornings, so - # we'll set 'today' to be Sunday right around midnight. - snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 19, 23, 50, 0) - # For our mail tests, we set up a db with a few users, some of # whom have snippets for this week ('this week' being 13 Feb # 2012), some of whom don't. @@ -1296,8 +1356,8 @@ def setUp(self): self.login('user@example.com') # back to the normal user def tearDown(self): - UserTestBase.tearDown(self) time.sleep = self.sleep_fn + super().tearDown() def assertEmailSentTo(self, email): r = self.mail_stub.get_sent_messages(to=email) @@ -1323,7 +1383,7 @@ def testMustBeAdminToSendMail(self): def testDoNotSendMailWithoutSetting(self): app_settings = models.AppSettings.get() - app_settings.delete() + app_settings.key.delete() self.request_fetcher.get('/admin/send_reminder_email') self.assertEmailNotSentTo('does_not_have_snippet@example.com') @@ -1411,8 +1471,8 @@ def count_calls_per_minute(sleep_seconds): # We'll do 500 users. Rather than go through the request # API, we modify the db directly; it's much faster. users = [models.User(email='snippets%d@example.com' % i) - for i in xrange(500)] - db.put(users) + for i in range(500)] + ndb.put_multi(users) self.request_fetcher.get('/admin/send_view_email') # https://developers.google.com/appengine/docs/quotas#Mail @@ -1428,7 +1488,7 @@ class SendingChatTestCase(UserTestBase): """Test we correctly send to Slack.""" def setUp(self): # (The superclass sets up slack_sends for us.) - super(SendingChatTestCase, self).setUp() + super().setUp() # Let's set up default chat configs. app_settings = models.AppSettings.get() app_settings.slack_channel = '#slack_chann3l' @@ -1474,15 +1534,15 @@ def testTrimTrailingSpaces(self): class DisplayNameTestCase(UserTestBase): """Manipulate a user's display name and check it in weekly page.""" def setUp(self): - super(UserTestBase, self).setUp() + super().setUp() self.login('user@example.com') def testUserHasEmptyDisplayName(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('

user@example.com:

', response.body, 0) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('

user@example.com:

', response.text, 0) def testUserHasDisplayName(self): self.request_fetcher.get( @@ -1490,9 +1550,9 @@ def testUserHasDisplayName(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

test name (user@example.com):

', - response.body, 0) + response.text, 0) def testUserChangesDisplayName(self): self.request_fetcher.get( @@ -1500,16 +1560,16 @@ def testUserChangesDisplayName(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

test name (user@example.com):

', - response.body, 0) + response.text, 0) self.request_fetcher.get( '/update_settings?u=user@example.com&display_name=fancy+name') response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

fancy name (user@example.com):

', - response.body, 0) + response.text, 0) def testSnippetHasDisplayName(self): self.request_fetcher.get( @@ -1519,29 +1579,29 @@ def testSnippetHasDisplayName(self): self.request_fetcher.get( '/update_settings?u=user@example.com&display_name=') response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

test name (user@example.com):

', - response.body, 0) + response.text, 0) def testSnippetFromDeletedUser(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('

user@example.com:

', response.body, 0) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('

user@example.com:

', response.text, 0) self.request_fetcher.get( '/update_settings?u=user@example.com&display_name=test+name') response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

test name (user@example.com):

', - response.body, 0) + response.text, 0) url = '/update_settings?u=user@example.com&delete=Delete' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) - self.assertInSnippet('

user@example.com:

', response.body, 0) + self.assertNumSnippets(response.text, 1) + self.assertInSnippet('

user@example.com:

', response.text, 0) def testSnippetFromDeletedUser2(self): self.request_fetcher.get( @@ -1549,16 +1609,13 @@ def testSnippetFromDeletedUser2(self): url = '/update_snippet?week=02-20-2012&snippet=my+snippet' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

test name (user@example.com):

', - response.body, 0) + response.text, 0) url = '/update_settings?u=user@example.com&delete=Delete' self.request_fetcher.get(url) response = self.request_fetcher.get('/weekly?week=02-20-2012') - self.assertNumSnippets(response.body, 1) + self.assertNumSnippets(response.text, 1) self.assertInSnippet('

test name (user@example.com):

', - response.body, 0) - -if __name__ == '__main__': - unittest.main() + response.text, 0) diff --git a/static/manage_users.js b/static/manage_users.js index 540d8a8..338168f 100644 --- a/static/manage_users.js +++ b/static/manage_users.js @@ -8,7 +8,7 @@ $(function() { if ($inputButton.value === "Hide") { $inputButton.value = "Hiding..."; $inputButton.disabled = true; - $.ajax("/admin/manage_users?hide%20" + email) + $.post("/admin/manage_users", {action: "hide", email: email}) .then(function() { $inputButton.name = "unhide " + email; $inputButton.value = "Unhide"; @@ -20,7 +20,7 @@ $(function() { } else { $inputButton.value = "Unhiding..."; $inputButton.disabled = true; - $.ajax("/admin/manage_users?unhide%20" + email) + $.post("/admin/manage_users", {action: "unhide", email: email}) .then(function() { $inputButton.name = "hide " + email; $inputButton.value = "Hide"; @@ -37,7 +37,7 @@ $(function() { var email = $($inputButton).attr("data-email"); $inputButton.value = "Deleting..."; $inputButton.disabled = true; - $.ajax("/admin/manage_users?delete%20" + email) + $.post("/admin/manage_users", {action: "delete", email: email}) .then(function() { $inputButton.value = "Deleted"; // TODO(csilvers): change the whole row to indicate deleted diff --git a/templates/app_settings.html b/templates/app_settings.html index 67bbbc4..ef5075c 100644 --- a/templates/app_settings.html +++ b/templates/app_settings.html @@ -9,7 +9,7 @@

Manage Users

hide users

-