├── .gitignore ├── requirements-dev.txt ├── static ├── favicon.ico ├── manage_users.js ├── snippets.js └── snippets.css ├── templates ├── footer.html ├── reminder_email.txt ├── view_email.txt ├── new_user.html ├── header.html ├── manage_users.html ├── weekly_snippets.html ├── user_snippets.html ├── settings.html └── app_settings.html ├── .arcconfig ├── Makefile ├── .arclint ├── cron.yaml ├── app.yaml ├── .travis.yml ├── index.yaml ├── LICENSE ├── models.py ├── util.py ├── README.md ├── slacklib_test.py ├── slacklib.py ├── snippets.py └── snippets_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | webtest>=2.0.16 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Khan/snippets/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /templates/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.arcconfig: -------------------------------------------------------------------------------- 1 | { 2 | "project_id": "snippets", 3 | "conduit_uri": "https://phabricator.khanacademy.org/", 4 | "lint.engine": "ArcanistConfigurationDrivenLintEngine" 5 | } 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: serve test_deps test check appcfg-update deploy 2 | 3 | serve: 4 | dev_appserver.py --log_level=debug . --host=0.0.0.0 5 | 6 | test_deps: 7 | pip install -r requirements-dev.txt 8 | 9 | test check: 10 | python -m unittest discover -p '*_test.py' 11 | 12 | appcfg-update deploy: 13 | gcloud app deploy --project "${APP}" 14 | -------------------------------------------------------------------------------- /templates/reminder_email.txt: -------------------------------------------------------------------------------- 1 | Just a reminder that weekly snippets are due at 5pm today! Our 2 | records show you have not yet entered snippet information for last 3 | week. To do so, visit 4 | {{hostname}} 5 | 6 | If you'd like to stop getting these reminder emails, visit 7 | {{hostname}}/settings 8 | and click 'no' under 'Receive reminder emails'. 9 | 10 | Regards, 11 | your friendly neighborhood snippet server 12 | -------------------------------------------------------------------------------- /.arclint: -------------------------------------------------------------------------------- 1 | { 2 | "linters": { 3 | "khan-linter": { 4 | "type": "script-and-regex", 5 | "script-and-regex.script": "ka-lint --always-exit-0 --blacklist=yes --propose-arc-fixes", 6 | "script-and-regex.regex": "\/^((?P[^:]*):(?P\\d+):((?P\\d+):)? (?P((?PE)|(?PW))\\S+) (?P[^\\x00\n]*)(\\x00(?P[^\\x00]*)\\x00(?P[^\\x00]*)\\x00)?)|(?PSKIPPING.*)$\/m" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/view_email.txt: -------------------------------------------------------------------------------- 1 | The weekly snippets for last week have been posted. To see them, visit 2 | {{hostname}}/weekly 3 | {% if not has_snippets %} 4 | It's not too late to enter in snippets for last week if you haven't 5 | already! To do so, visit 6 | {{hostname}} 7 | {% endif %} 8 | If you'd like to stop getting these reminder emails, visit 9 | {{hostname}}/settings 10 | and click 'no' under 'Receive reminder emails'. 11 | 12 | Enjoy! 13 | your friendly neighborhood snippet server 14 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | 3 | - description: snippets chat -- early reminder to write snippets 4 | url: /admin/send_friday_reminder_chat 5 | schedule: every friday 16:00 6 | timezone: US/Pacific 7 | 8 | - description: snippets email -- reminder to write snippets 9 | url: /admin/send_reminder_email 10 | schedule: every sunday 23:50 11 | timezone: US/Pacific 12 | 13 | - description: snippets email -- notification that snippets are ready to view 14 | url: /admin/send_view_email 15 | schedule: every monday 19:00 16 | timezone: US/Pacific 17 | -------------------------------------------------------------------------------- /templates/new_user.html: -------------------------------------------------------------------------------- 1 | {%- set title='New user' -%} 2 | {% include "header.html" %} 3 | 4 | 5 |

New User

6 | 7 |

We have no records for {{username}}. If you are a new 8 | user, verify 9 | your settings and you can get started writing snippets!

10 | 11 |

Another possibility is you accidentally logged in as the wrong 12 | user. You can switch users, 13 | or log out and log back in 14 | as the correct user.

15 | 16 | {% include "footer.html" %} 17 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python27 2 | threadsafe: yes 3 | api_version: 1 4 | default_expiration: "365d" 5 | 6 | handlers: 7 | - url: /static 8 | static_dir: static 9 | 10 | - url: /favicon.ico 11 | static_files: static/favicon.ico 12 | mime_type: image/x-icon 13 | upload: static/favicon.ico 14 | 15 | - url: /admin/.* 16 | script: snippets.application 17 | login: admin 18 | 19 | - url: .* 20 | script: snippets.application 21 | 22 | skip_files: 23 | - .git 24 | - .DS_Store 25 | - .*.pyc 26 | 27 | builtins: 28 | - remote_api: on 29 | 30 | libraries: 31 | - name: jinja2 32 | version: "2.6" 33 | # This also brings in webapp2_extras: 34 | - name: webapp2 35 | version: "2.5.1" 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | 4 | cache: 5 | pip: true 6 | directories: 7 | - "$HOME/google-cloud-sdk/" 8 | 9 | env: 10 | global: 11 | - PATH=$PATH:${HOME}/google-cloud-sdk/bin 12 | - GAE_PYTHONPATH=${HOME}/google-cloud-sdk/platform/google_appengine 13 | - PYTHONPATH=${PYTHONPATH}:${GAE_PYTHONPATH} 14 | - CLOUDSDK_CORE_DISABLE_PROMPTS=1 15 | 16 | install: 17 | - if [ ! -d ${HOME}/google-cloud-sdk/bin ]; then 18 | rm -rf ${HOME}/google-cloud-sdk; 19 | curl -s https://sdk.cloud.google.com | bash; 20 | fi 21 | - gcloud components update app-engine-python 22 | 23 | before_script: 24 | - make test_deps 25 | 26 | script: 27 | - make test 28 | -------------------------------------------------------------------------------- /index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | 3 | # AUTOGENERATED 4 | 5 | # This index.yaml is automatically updated whenever the dev_appserver 6 | # detects that a new type of query is run. If you want to manage the 7 | # index.yaml file manually, remove the above marker line (the line 8 | # saying "# AUTOGENERATED"). If you want to manage some indexes 9 | # manually, move them above the marker line. The index.yaml file is 10 | # automatically uploaded to the admin console when you next deploy 11 | # your application using appcfg.py. 12 | 13 | - kind: Snippet 14 | properties: 15 | - name: email 16 | - name: week 17 | 18 | - kind: Snippet 19 | properties: 20 | - name: email 21 | - name: week 22 | direction: desc 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Khan Academy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% if not new_user %} 14 |
15 |
16 | 17 | 22 |
23 |
24 | 25 |
26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 | 37 |
38 |
39 |
40 | 41 | {% endif %} 42 | 43 |
44 |
45 | 46 | {% if message %} 47 |
{{message}}
48 | {% endif %} 49 | -------------------------------------------------------------------------------- /templates/manage_users.html: -------------------------------------------------------------------------------- 1 | {%- set title='Manage users' -%} 2 | {% include "header.html" %} 3 | 4 | 5 |

Hide means that a user, while not being fully deleted, will 6 | not show up with a 'No snippet' tag on weekly-snippet pages 7 | until the next time they enter a snippet (at which point their 8 | status will be reset to 'un-hidden'). This should normally be 9 | favored over delete. (That way returning interns will 10 | retain their old snippets.) 11 |

12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | {% for (key, header) in (("email", "Email"), 20 | ("creation_time", "Joined"), 21 | ("last_snippet_time", "Last snippet")) %} 22 | 23 | {% endfor %} 24 | 25 | {% for (email, hidden, created, weeks_since_snippet) in user_data %} 26 | 27 | 35 | 38 | 41 | 44 | 45 | {% endfor %} 46 | 47 |
{%if sort_by == key %}{{ header }}{% else %}{{ header }}{% endif %}
28 | {% if hidden %} 29 | 30 | {% else %} 31 | 32 | {% endif %} 33 | 34 | 36 | {{ email }} 37 | 39 | {% if created %}{{ created|iso_date }}{% else %}(unknown){% endif %} 40 | 42 | {% if weeks_since_snippet == 1 %}1 week ago{% elif weeks_since_snippet != None %}{{ weeks_since_snippet }} weeks ago{% else %}--{% endif %} 43 |
48 |
49 | 50 | 51 | 52 | 53 | {% include "footer.html" %} 54 | -------------------------------------------------------------------------------- /templates/weekly_snippets.html: -------------------------------------------------------------------------------- 1 | {%- set title='Snippets for the week starting ' + view_week|readable_date -%} 2 | {% include "header.html" %} 3 | 4 |
5 | 6 |

7 | Snippets for the week starting 8 | {{view_week|readable_date}} 9 |

10 | 11 |
12 | 13 | {% for category_and_snippets in categories_and_snippets %} 14 |
15 |

{{category_and_snippets.0}}

16 | 17 | {% for (snippet, user) in category_and_snippets.1 %} 18 |
19 | 20 | {% if user.display_name %} 21 |

{{user.display_name}} ({{user.email}}):

22 | {% elif snippet.display_name %} 23 |

{{snippet.display_name}} ({{user.email}}):

24 | {% else %} 25 |

{{user.email}}:

26 | {% endif %} 27 | {% if snippet.private %}Private{% endif %} 28 | {% if not snippet.text %}No snippet{% endif %} 29 | {% if snippet.text %} 30 | {% if snippet.is_markdown %} 31 |
{{snippet.text|safe}}
32 | {% else %} 33 |
{{snippet.text|urlize}}
34 | {% endif %} 35 | {% endif %} 36 |
37 | {% endfor %} 38 |
39 | {% endfor %} 40 | 41 | 42 | 43 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /static/manage_users.js: -------------------------------------------------------------------------------- 1 | // Handlers for the manage_users.html template. 2 | // 3 | $(function() { 4 | // Function to attach to each 'hide' button that toggles the hide state. 5 | function toggleHide(event) { 6 | var $inputButton = event.target; 7 | var email = $($inputButton).attr("data-email"); 8 | if ($inputButton.value === "Hide") { 9 | $inputButton.value = "Hiding..."; 10 | $inputButton.disabled = true; 11 | $.ajax("/admin/manage_users?hide%20" + email) 12 | .then(function() { 13 | $inputButton.name = "unhide " + email; 14 | $inputButton.value = "Unhide"; 15 | $inputButton.disabled = false; 16 | }, function() { 17 | $inputButton.value = "Re-hide (hiding failed!)"; 18 | $inputButton.disabled = false; 19 | }); 20 | } else { 21 | $inputButton.value = "Unhiding..."; 22 | $inputButton.disabled = true; 23 | $.ajax("/admin/manage_users?unhide%20" + email) 24 | .then(function() { 25 | $inputButton.name = "hide " + email; 26 | $inputButton.value = "Hide"; 27 | $inputButton.disabled = false; 28 | }, function() { 29 | $inputButton.value = "Re-unhide (unhiding failed!)"; 30 | $inputButton.disabled = false; 31 | }); 32 | } 33 | } 34 | 35 | function doDelete(event) { 36 | var $inputButton = event.target; 37 | var email = $($inputButton).attr("data-email"); 38 | $inputButton.value = "Deleting..."; 39 | $inputButton.disabled = true; 40 | $.ajax("/admin/manage_users?delete%20" + email) 41 | .then(function() { 42 | $inputButton.value = "Deleted"; 43 | // TODO(csilvers): change the whole row to indicate deleted 44 | }, function() { 45 | $inputButton.value = "Re-delete (deleting failed!)"; 46 | $inputButton.disabled = false; 47 | }); 48 | } 49 | 50 | $(".hide-account-button").map(function() { 51 | $(this).on("click", toggleHide.bind(this)); 52 | }); 53 | 54 | $(".delete-account-button").map(function() { 55 | $(this).on("click", doDelete.bind(this)); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /templates/user_snippets.html: -------------------------------------------------------------------------------- 1 | {%- set title='Snippets for ' + username -%} 2 | {% include "header.html" %} 3 | 4 |

Snippets for {{username}}

5 | 6 | {% for snippet in snippets %} 7 |
8 | {% if editable %} 9 |
10 | 11 |
12 |

13 | Snippets for the week starting 14 | {{snippet.week|readable_date}} 15 | {% if not snippet.text and snippet.week == one_week_ago %} 16 | Due today! 17 | {% endif %} 18 | {% if not snippet.text and snippet.week <= eight_days_ago %} 19 | OVERDUE! 20 | {% endif %} 21 |

22 |
23 | 24 | 28 |
29 |
30 | 31 | 32 | 33 | {% if user.category == null_category %} 34 |
35 | 36 | WARNING: Snippet will go in the "{{null_category}}" category. 37 | 38 | Set your snippet category! 40 |
41 | {% endif %} 42 |
43 | 48 |
49 |
50 | 55 |
56 | 57 |
58 |

Snippet preview:

59 | Private 61 | No snippet 63 |
64 |
65 |
66 | {% else %} 67 |

68 | Snippets for the week starting 69 | {{snippet.week|readable_date}} 70 |

71 | {% if snippet.text %} 72 | {% if snippet.is_markdown %} 73 |
{{(snippet.text or '')|safe}}
74 | {% else %} 75 |
{{(snippet.text or '')|urlize}}
76 | {% endif %} 77 | {% endif %} 78 | {% endif %} 79 |
80 | {% endfor %} 81 | 82 | 83 | 84 | 85 | 86 | 94 | 95 | 96 | {% include "footer.html" %} 97 | -------------------------------------------------------------------------------- /templates/settings.html: -------------------------------------------------------------------------------- 1 | {%- block page_header -%} 2 | {%- set title='User settings for ' + username -%} 3 | {% include "header.html" %} 4 | {%- endblock page_header -%} 5 | 6 |

User settings for {{username}}

7 | 8 | 103 | 104 | {% include "footer.html" %} 105 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | import os 4 | 5 | from google.appengine.ext import db 6 | from google.appengine.api import users 7 | 8 | 9 | NULL_CATEGORY = '(unknown)' 10 | 11 | # Note: I use email address rather than a UserProperty to uniquely 12 | # identify a user. As per 13 | # http://code.google.com/appengine/docs/python/users/userobjects.html 14 | # a UserProperty is an email+unique id, so if a person changes their 15 | # email the UserProperty also changes; it's not a persistent 16 | # identifier across email changes the way the unique-id alone is. But 17 | # non-google users can't have a unique id, so if I want to expand the 18 | # snippet server later that won't scale. So might as well use email 19 | # as our unique identifier. If someone changes email address and 20 | # wants to take their snippets with them, we can add functionality to 21 | # support that later. 22 | 23 | 24 | class User(db.Model): 25 | """User preferences.""" 26 | created = db.DateTimeProperty() 27 | last_modified = db.DateTimeProperty(auto_now=True) 28 | email = db.StringProperty(required=True) # The key to this record 29 | is_hidden = db.BooleanProperty(default=False) # hide 'empty' snippets 30 | category = db.StringProperty(default=NULL_CATEGORY) # groups snippets 31 | uses_markdown = db.BooleanProperty(default=True) # interpret snippet text 32 | private_snippets = db.BooleanProperty(default=False) # private by default? 33 | wants_email = db.BooleanProperty(default=True) # get nag emails? 34 | # TODO(csilvers): make a ListProperty instead. 35 | wants_to_view = db.TextProperty(default='all') # comma-separated list 36 | display_name = db.TextProperty(default='') # display name of the user 37 | 38 | 39 | class Snippet(db.Model): 40 | """Every snippet is identified by the monday of the week it goes with.""" 41 | created = db.DateTimeProperty() 42 | last_modified = db.DateTimeProperty(auto_now=True) 43 | display_name = db.StringProperty() # display name of the user 44 | email = db.StringProperty(required=True) # week+email: key to this record 45 | week = db.DateProperty(required=True) # the monday of the week 46 | text = db.TextProperty() 47 | private = db.BooleanProperty(default=False) # snippet is private? 48 | is_markdown = db.BooleanProperty(default=False) # text is markdown? 49 | 50 | @property 51 | def email_md5_hash(self): 52 | m = hashlib.md5() 53 | m.update(self.email) 54 | return m.hexdigest() 55 | 56 | 57 | class AppSettings(db.Model): 58 | """Application-wide preferences.""" 59 | created = db.DateTimeProperty() 60 | last_modified = db.DateTimeProperty(auto_now=True) 61 | # Application settings 62 | domains = db.StringListProperty(required=True) 63 | hostname = db.StringProperty(required=True) # used for emails 64 | default_private = db.BooleanProperty(default=False) # new-user default 65 | default_markdown = db.BooleanProperty(default=True) # new-user default 66 | default_email = db.BooleanProperty(default=True) # new-user default 67 | # Chat and email settings 68 | email_from = db.StringProperty(default='') 69 | slack_channel = db.StringProperty(default='') 70 | slack_token = db.StringProperty(default='') 71 | slack_slash_token = db.StringProperty(default='') 72 | 73 | @staticmethod 74 | def get(create_if_missing=False, domains=None): 75 | """Return the global app settings, or raise ValueError if none found. 76 | 77 | If create_if_missing is true, we create app settings if none 78 | are found, rather than raising a ValueError. The app settings 79 | are initialized with the given value for 'domains'. The new 80 | entity is *not* put to the datastore. 81 | """ 82 | retval = AppSettings.get_by_key_name('global_settings') 83 | if retval: 84 | return retval 85 | elif create_if_missing: 86 | # We default to sending email, and having it look like it's 87 | # comint from the current user. We add a '+snippets' in there 88 | # to allow for filtering 89 | email_address = users.get_current_user().email() 90 | email_address = email_address.replace('@', '+snippets@') 91 | email_address = 'Snippet Server <%s>' % email_address 92 | # We also default to server hostname being the hostname that 93 | # you accessed the site on here. 94 | hostname = '%s://%s' % (os.environ.get('wsgi.url_scheme', 'http'), 95 | os.environ['HTTP_HOST']) 96 | return AppSettings(key_name='global_settings', 97 | created=datetime.datetime.now(), 98 | domains=domains, 99 | hostname=hostname, 100 | email_from=email_address) 101 | else: 102 | raise ValueError("Need to set global application settings.") 103 | -------------------------------------------------------------------------------- /templates/app_settings.html: -------------------------------------------------------------------------------- 1 | {%- block page_header -%} 2 | {%- set title='Application settings' -%} 3 | {% include "header.html" %} 4 | {%- endblock page_header -%} 5 | 6 |

Manage Users

7 | 8 |

Delete or 9 | hide users

10 | 11 | 12 | 118 | 119 | {% include "footer.html" %} 120 | -------------------------------------------------------------------------------- /static/snippets.js: -------------------------------------------------------------------------------- 1 | // Handlers for the user_snippets.html template. 2 | // 3 | $(function() { 4 | // A User Snippet class constructor to be called on each 5 | // `.user-snippet-form`. Handles a bunch of side-effects and adds event 6 | // listeners to form children within the constructor. 7 | function Snippet($parentForm) { 8 | this.$el = $parentForm; 9 | 10 | // Child elements of the parent form. 11 | // We use "secret" instead of "private" because "private" is reserved. 12 | this.$markdownInput = $parentForm.find("input[name=is_markdown]"); 13 | this.$noneTag = $parentForm.find(".snippet-tag-none"); 14 | this.$preview = $parentForm.find(".snippet-preview-container"); 15 | this.$previewText = $parentForm.find(".snippet-preview"); 16 | this.$saveButton = $parentForm.find(".save-button"); 17 | this.$secretInput = $parentForm.find("input[name=private]"); 18 | this.$secretTag = $parentForm.find(".snippet-tag-private"); 19 | this.$textarea = $parentForm.find("textarea"); 20 | this.$undoButton = $parentForm.find(".undo-button"); 21 | 22 | // Child element collections. 23 | this.$buttons = this.$saveButton.add(this.$undoButton); 24 | this.$inputs = 25 | this.$markdownInput.add(this.$secretInput).add(this.$textarea); 26 | 27 | // Internal state of the Snippet. 28 | // Matches the initial Snippet values to allow undo and dirty checks. 29 | this.oldState = {}; 30 | // Holds the current Snippet values. We'll initialize it soon... 31 | this.state = { 32 | content: null, 33 | markdown: null, 34 | secret: null, 35 | }; 36 | 37 | // Attach event listeners. 38 | // Internal state should be kept up-to-date with our inputs. 39 | this.$textarea.on("keyup change", (function() { 40 | this.state.content = this.$textarea.val(); 41 | }).bind(this)); 42 | this.$markdownInput.on("change", (function() { 43 | this.state.markdown = this.$markdownInput.prop("checked"); 44 | }).bind(this)); 45 | this.$secretInput.on("change", (function() { 46 | this.state.secret = this.$secretInput.prop("checked"); 47 | }).bind(this)); 48 | 49 | // Pull our initial input values into internal state. 50 | this.$inputs.trigger("change"); 51 | 52 | // Re-render whenever an input changes. 53 | this.$inputs.on("keyup change", this.render.bind(this)); 54 | 55 | // Save and undo buttons, available when state is dirty. 56 | this.$undoButton.on("click", this.undo.bind(this)); 57 | this.$saveButton.on("click", this.submit.bind(this)); 58 | 59 | // Finally, save (this also triggers a render). 60 | this.save(); 61 | } 62 | 63 | // Save the internal state of a Snippet to allow undos. 64 | Snippet.prototype.save = function(e) { 65 | e && e.preventDefault && e.preventDefault(); 66 | // Save the current values into .data. 67 | this.oldState.content = this.state.content; 68 | this.oldState.markdown = this.state.markdown; 69 | this.oldState.secret = this.state.secret; 70 | 71 | // Disable the buttons. 72 | this.render(); 73 | }; 74 | 75 | // Recover the original values from `this.oldState`. 76 | Snippet.prototype.undo = function(e) { 77 | e && e.preventDefault && e.preventDefault(); 78 | this.$textarea.val(this.oldState.content); 79 | this.$markdownInput.prop("checked", this.oldState.markdown); 80 | this.$secretInput.prop("checked", this.oldState.secret); 81 | 82 | // HACK: Reset internal state and disable the buttons. 83 | this.$inputs.trigger("change"); 84 | }; 85 | 86 | // Check if a snippet is "dirty", meaning its initial state no-longer 87 | // matches the values of all its inputs. 88 | Snippet.prototype.checkIfDirty = function() { 89 | return ( 90 | this.state.content !== this.oldState.content || 91 | this.state.markdown !== this.oldState.markdown || 92 | this.state.secret !== this.oldState.secret 93 | ); 94 | }; 95 | 96 | // Run some side-effects on the Snippet's elements. This could be diffed, 97 | // but it seems to run well enough as-is. 98 | Snippet.prototype.render = function() { 99 | var isDirty = this.checkIfDirty(); 100 | 101 | this.$el.toggleClass("dirty", isDirty); 102 | this.$buttons.prop("disabled", !isDirty); 103 | this.$noneTag.toggle(!this.state.content); 104 | this.$previewText.html(this.state.markdown ? 105 | window.marked(this.state.content) : this.state.content); 106 | this.$secretTag[this.state.secret ? "show" : "hide"](); 107 | this.$previewText.toggleClass("snippet-text-markdown", 108 | this.state.markdown); 109 | this.$previewText.toggleClass("snippet-text", !this.state.markdown); 110 | }; 111 | 112 | // Catch form submissions, submit and disable buttons. 113 | Snippet.prototype.submit = function(e) { 114 | e && e.preventDefault && e.preventDefault(); 115 | $.post(this.$el.attr("action"), this.$el.serialize(), 116 | this.save.bind(this)); 117 | }; 118 | 119 | // Create a Snippet for each week shown. 120 | var snippets = $(".user-snippet-form").map(function() { 121 | return new Snippet($(this)); 122 | }).get(); 123 | 124 | // Confirm window closings :) 125 | $(window).on("beforeunload", function() { 126 | var numDirty = snippets.filter(function(snippet) { 127 | return snippet.checkIfDirty(); 128 | }).length; 129 | 130 | if (numDirty) { 131 | var s = numDirty > 1 ? "s." : "."; 132 | var msg = "Hey!!! You have " + numDirty + " unsaved snippet" + s; 133 | return msg; 134 | } 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from models import Snippet 4 | from models import User 5 | 6 | 7 | # Functions for retrieving a user 8 | def get_user(email): 9 | """Return the user object with the given email, or None if not found.""" 10 | q = User.all() 11 | q.filter('email = ', email) 12 | return q.get() 13 | 14 | 15 | def get_user_or_die(email): 16 | user = get_user(email) 17 | if not user: 18 | raise ValueError('User "%s" not found' % email) 19 | return user 20 | 21 | 22 | def snippets_for_user(user_email): 23 | """Return all snippets for a given user, oldest snippet first.""" 24 | snippets_q = Snippet.all() 25 | snippets_q.filter('email = ', user_email) 26 | snippets_q.order('week') # this puts oldest snippet first 27 | return snippets_q.fetch(1000) # good for many years... 28 | 29 | 30 | def most_recent_snippet_for_user(user_email): 31 | """Return the most recent snippet for a given user, or None.""" 32 | snippets_q = Snippet.all() 33 | snippets_q.filter('email = ', user_email) 34 | snippets_q.order('-week') # this puts newest snippet first 35 | return snippets_q.get() 36 | 37 | 38 | # Functions around filling in snippets 39 | def newsnippet_monday(today): 40 | """Return a datetime.date object: the monday for new snippets. 41 | 42 | We just return the monday for this week. Saturday and Sunday 43 | map to the previous monday. 44 | 45 | Note that this means when you look at snippets for monday, you're 46 | offered to enter snippets for the week that has just started, even 47 | though not much has happened yet! This is for people who like to 48 | enter snippets as they go along. For those people who wait until 49 | monday to fill in the previous week's snippets, they can still do 50 | so; the second snippet box will be marked 'DUE TODAY'. 51 | 52 | Arguments: 53 | today: the current day as a datetime.datetime object, used to 54 | calculate the best monday. 55 | 56 | Returns: 57 | The Monday that we are accepting new snippets for, by default, 58 | as a datetime.date (not datetime.datetime) object. 59 | """ 60 | today_weekday = today.weekday() # monday == 0, sunday == 6 61 | end_monday = today - datetime.timedelta(today_weekday) 62 | return end_monday.date() 63 | 64 | 65 | def existingsnippet_monday(today): 66 | """Return a datetime.date object: the monday for existing snippets. 67 | 68 | The rule is that we show the snippets for the previous week. We 69 | declare a week starts on Monday...well, actually, Sunday at 11pm. 70 | The reason for this is that (for quota reasons) we sent out a 71 | reminder email Sunday at 11:50pm rather than Monday morning, and 72 | we want that to count as 'Monday' anyway... 73 | 74 | Arguments: 75 | today: the current day as a datetime.datetime object, used to 76 | calculate the best monday. 77 | 78 | Returns: 79 | The Monday that we are accepting new snippets for, by default, 80 | as a datetime.date (not datetime.datetime) object. 81 | """ 82 | today_weekday = today.weekday() # monday == 0, sunday == 6 83 | if today_weekday == 6 and today.hour >= 23: 84 | end_monday = today - datetime.timedelta(today_weekday) 85 | else: 86 | end_monday = today - datetime.timedelta(today_weekday + 7) 87 | return end_monday.date() 88 | 89 | 90 | _ONE_WEEK = datetime.timedelta(7) 91 | 92 | 93 | def _backfill_missing_snippets(user, all_snippets, 94 | current_monday, first_allowed_monday): 95 | monday_ptr = current_monday - _ONE_WEEK 96 | while monday_ptr >= first_allowed_monday: 97 | all_snippets.insert(0, 98 | Snippet( 99 | email=user.email, 100 | week=monday_ptr, 101 | private=user.private_snippets, 102 | is_markdown=user.uses_markdown)) 103 | monday_ptr -= _ONE_WEEK 104 | 105 | 106 | def fill_in_missing_snippets(existing_snippets, user, user_email, today): 107 | """Make sure that the snippets array has a Snippet entry for every week. 108 | 109 | The db may have holes in it -- weeks where the user didn't write a 110 | snippet. Augment the given snippets array so that it has no holes, 111 | by adding in default snippet entries if necessary. Also backfill empty 112 | snippets up until one week before user's registration week. 113 | Note it does not add these entries to the db, it just adds them 114 | to the array. 115 | 116 | Arguments: 117 | existing_snippets: a list of Snippet objects for a given user. 118 | The first snippet in the list is assumed to be the oldest 119 | snippet from that user (at least, it's where we start filling 120 | from). 121 | user: a User object for the person writing this snippet. 122 | user_email: the email of the person whose snippets it is. 123 | today: a datetime.datetime object representing the current day. 124 | We fill up to then. If today is wed or before, then we 125 | fill up to the previous week. If it's thurs or after, we 126 | fill up to the current week. 127 | 128 | Returns: 129 | A new list of Snippet objects, without any holes, 130 | up to one week before user's registration week. 131 | """ 132 | end_monday = newsnippet_monday(today) 133 | first_allowed_monday = newsnippet_monday(user.created) - _ONE_WEEK 134 | # No snippets at all? Fill empty snippets up to one week before user's 135 | # registration week. 136 | if not existing_snippets: 137 | all_snippets = [Snippet(email=user_email, week=end_monday, 138 | private=user.private_snippets, 139 | is_markdown=user.uses_markdown)] 140 | _backfill_missing_snippets(user, all_snippets, 141 | end_monday, first_allowed_monday) 142 | return all_snippets 143 | 144 | # Add a sentinel, one week past the last week we actually want. 145 | # We'll remove it at the end. 146 | existing_snippets.append(Snippet(email=user_email, 147 | week=end_monday + _ONE_WEEK)) 148 | 149 | all_snippets = [existing_snippets[0]] # start with the oldest snippet 150 | if all_snippets[0].week - first_allowed_monday >= _ONE_WEEK: 151 | _backfill_missing_snippets(user, all_snippets, 152 | all_snippets[0].week, first_allowed_monday) 153 | for snippet in existing_snippets[1:]: 154 | while snippet.week - all_snippets[-1].week > _ONE_WEEK: 155 | missing_week = all_snippets[-1].week + _ONE_WEEK 156 | all_snippets.append(Snippet(email=user_email, week=missing_week, 157 | private=user.private_snippets, 158 | is_markdown=user.uses_markdown)) 159 | all_snippets.append(snippet) 160 | 161 | # Get rid of the sentinel we added above. 162 | del all_snippets[-1] 163 | 164 | return all_snippets 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Snippet Server 2 | ============== 3 | 4 | This server supports writing and reading weekly snippets -- status 5 | updates -- for a group of people. 6 | 7 | When I joined Khan Academy, my first project was to write a version of 8 | the weekly-snippet server I had worked with [at 9 | Google](http://blog.idonethis.com/google-snippets-internal-tool/). 10 | Years later, with the help of many other intrepid Khan Academy 11 | employees, it's ready for the world! 12 | 13 | While there are [many](https://weekdone.com/) 14 | [snippet](https://www.workingon.co/) 15 | [systems](https://www.teamsnippets.com/) out there, this one is 16 | optimized for simplicity (also, free-ness). For instance, it prefers 17 | single webpages with lots of info over paging, queries, or fancy 18 | javascript. Filling out a snippet involves writing into a textbox: no 19 | fields or text editors or other barriers to productivity. (Markdown 20 | is available for those who want nice formatting.) This makes it easy 21 | to learn and easy to program with. 22 | 23 | 24 | What are weekly snippets? 25 | ------------------------- 26 | 27 | A weekly snippet is an (ideally) brief description of what you did the 28 | last week. To give an idea of 'brief': the snippet-entry textbox is 29 | sized for 4 bullet-point entries, each 80 characters or less. 30 | 31 | Your snippets are visible to everyone else on your email domain. (So 32 | my snippets are visible to everyone who logs in to KA snippet server 33 | with a `@khanacademy.org` email address.) Depending on your 34 | configuration options, they may also be visible to everyone else on 35 | your server. 36 | 37 | 38 | Why have snippets? 39 | ------------------ 40 | 41 | Different people might have different purposes for weekly snippets: 42 | 43 | * Instead of a weekly standup or other meeting where everyone shares 44 | what they've done in the last week, they can just read (and write) 45 | snippets. 46 | * Managers can read snippets of their direct reports to make better 47 | use of 1-on-1 meetings. 48 | * You can look over your own snippets when writing a self-evaluation 49 | or applying for a promotion, or when you have any other need to remind 50 | yourself what you've worked on. 51 | 52 | I've found this last reason is particularly compelling. I also use 53 | snippets as a simple "time and motion" study: when I have too many 54 | things to put into snippets one week, I know I'm being spread too 55 | thin! 56 | 57 | Another benefit of snippets is serendipidous helping: by reading 58 | someone's snippet, you may discover a task or problem they're working 59 | on that you can help with, that otherwise you would never have known 60 | about. 61 | 62 | 63 | What are snippets not good for? 64 | ------------------------------- 65 | 66 | Some people go into a snippet system with unrealistic expectations and 67 | are disappointed. 68 | 69 | * Snippets do not work well for large groups, say **over 100 70 | people**. If you have 1000 people using your snippet server, it is 71 | neither practical nor useful to read through everyone's snippets 72 | every week. 73 | 74 | * Snippets are, by design, a low level tool: they show you trees but 75 | not the forest. The snippet system does not support "rolling up" 76 | groups of snippets or having team-based snippets (though certain 77 | individuals could certainly choose to have their own snippets refer 78 | to a team's progress). 79 | 80 | * Snippets do not provide context. If you don't already know what 81 | someone is working on, their snippet may well be more confusing 82 | than enlightening. 83 | 84 | At Khan Academy, the entire company uses one snippet server. The 85 | snippets are divided into various categories, some functional, some 86 | project-based. I like to skim over the snippets for people in 87 | unrelated categories such as "facilities" or "recruiting." I read 88 | more closely the snippets in projects I'm interested in but not 89 | working on, such as "mobile." And I read most closely the snippets of 90 | people in my own project or closely related projects. 91 | 92 | 93 | How do you use the snippet-server? 94 | ================================== 95 | 96 | After setting up your settings, to control things like how public your 97 | snippets are and whether you want to use plain text or 98 | [markdown](https://daringfireball.net/projects/markdown/), there are 99 | only two web pages: the one where you write your snippets, and the one 100 | where you read everyone's snippets for a week. 101 | 102 | The administrator can set up the system to send you reminder emails to 103 | write snippets, or to email when snippets are ready for a week. (The 104 | snippet server can also use chat systems for this.) 105 | 106 | 107 | System requirements 108 | ------------------- 109 | 110 | The snippet server is built on top of [Google 111 | AppEngine](https://cloud.google.com/appengine/docs), and uses Google 112 | services for authentication. To use it, you need to clone the 113 | [snippet github project](https://github.com/Khan/snippets) and then 114 | upload it to your own appengine instance. (It uses few resources, so 115 | Google's "free tier" would work fine.) 116 | 117 | The people using your snippet server must log in using Google (aka 118 | Gmail) accounts. The snippet server works particularly well with 119 | companies that use [Google Apps for Work](https://apps.google.com). 120 | 121 | 122 | Access control 123 | -------------- 124 | 125 | When a snippet server is first set up, the administrator restricts it 126 | to specific domains. (The Khan Academy server, for instance, is 127 | restricted to `@khanacademy.org`.) If you want to create a snippet on 128 | the server, you must log in via an email address from one of those 129 | domains. 130 | 131 | You can set your snippet to be either "public" or "private". "Public" 132 | snippets are visible to everyone who has access to your snippet 133 | server. "Private" snippets are visible only to people on the same 134 | domain as you. So if you logged in as `jane@example.com`, only other 135 | users at `example.com` would be able to see your Snippet. 136 | 137 | 138 | Email and chat 139 | -------------- 140 | 141 | The snippet server integrates with email and Slack. 142 | 143 | It can send individual emails to people who have not written a snippet 144 | for this week, reminding them to do so. (Users can turn this feature 145 | off in their preferences.) It can also send an email to all 146 | registered users, at 5pm on Monday, to say snippets are ready. 147 | 148 | It can also send reminders and ready messages via chat. (In this 149 | case, the reminder isn't individualized.) 150 | 151 | 152 | Installing and administering the snippet server 153 | =============================================== 154 | 155 | To install the snippet-server you will need to [download the Google 156 | AppEngine SDK](https://cloud.google.com/appengine/downloads) 157 | 158 | Second, you will need a Google AppEngine project set up where this 159 | code will live. You can create one at 160 | https://console.developers.google.com: click on "Select a project..." 161 | in the top navbar and then "Create a project." 162 | 163 | You will then need a name for your AppEngine project. Let's suppose 164 | you call it `mycompany-snippets`, you can then deploy with 165 | 166 | ``` 167 | make deploy APP=mycompany-snippets 168 | ``` 169 | 170 | Your app will then be available at `mycompany-snippets.appspot.com`. 171 | 172 | If you get an error like 'gcloud: not found', it means you need to 173 | add the appengine-SDK location to your `$PATH`. 174 | 175 | You may also need to manually trigger an index build for datastore 176 | 177 | ``` 178 | gcloud datastore create-indexes index.yaml 179 | ``` 180 | (try this if you're seeing 500 errors) 181 | 182 | 183 | Adding administrators 184 | --------------------- 185 | 186 | By dint of creating the AppEngine project, you are an administrator of 187 | that project, and thus an administrator for the snippet-server as 188 | well. In fact, the only way to be an app administrator is to also be 189 | an administrator of the AppEngine project. You can add administrators 190 | at `https://console.developers.google.com/permissions/projectpermissions?project=mycompany-snippets`. 191 | 192 | Everyone who is an administrator on the underlying Google AppEngine 193 | account is also an administrator of the Snippet Server. 194 | 195 | 196 | First-time setup 197 | ---------------- 198 | 199 | When you log into the snippet server for the first time, you will be 200 | brought to the 'global settings' page. Fill out these settings and 201 | click 'Save'. Most are self-explanatory. The hostname is probably 202 | pre-filled to be `mycompany-snippets.appspot.com`, but if you have set 203 | up a 204 | [CNAME](https://cloud.google.com/appengine/docs/python/console/using-custom-domains-and-ssl) 205 | (`snippets.mycompany.com`) you can use that instead. 206 | 207 | Once you have clicked "Save", you will be prompted to fill in your own 208 | user settings. This is the first page your users will see, when they 209 | first log in, as well. 210 | 211 | Once you've entered your user settings, you'll be taken to the page to 212 | enter your first snippets! 213 | 214 | 215 | Deleting users 216 | -------------- 217 | 218 | At any time you are logged into the snippet server, you can get to the 219 | global settings page by clicking the "app settings" button in the top 220 | navbar. You can modify the global settings there at any time. You 221 | can also click "delete or hide users". This allows you to delete 222 | users who are no longer part of the snippet server. You can also hide 223 | users. 224 | 225 | "Deleting" a user in the snippet-server has some non-obvious 226 | semantics: 227 | 228 | 1. Deleting a user does **not** cause their snippets to be deleted. 229 | (Neither does hiding a user.) Old snippets will still be visible. 230 | 231 | 2. Both deleting and hiding a user means their name will not show up 232 | on subsequent weeks' snippet reports. This is useful for saving 233 | screen real estate. 234 | 235 | 3. Both deleting and hiding a user means they will not receive weekly 236 | reminder or reporting emails from now on. 237 | 238 | 4. Here is the difference between deleting and hiding: when you delete 239 | a user, the next time they log into the snippet-server they will be 240 | prompted to re-enter their user settings. When you hide a user, the 241 | next time they log into the snippet-server it will automatically use 242 | their pre-existing user settings. 243 | 244 | As you can see, hiding and deleting are almost the same thing. In my 245 | own use, I use "delete" when full-time employees leave Khan Academy, 246 | and "hide" when interns end their internship (since we hope they'll 247 | return!). 248 | -------------------------------------------------------------------------------- /static/snippets.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Open Sans", sans-serif; 3 | font-size: 12px; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | /* Reset styles */ 9 | * { 10 | box-sizing: border-box; 11 | } 12 | legend { 13 | padding: 0; 14 | display: table; 15 | } 16 | fieldset { 17 | border: 0; 18 | padding: 0.01em 0 0 0; 19 | margin: 0; 20 | min-width: 0; 21 | } 22 | body:not(:-moz-handler-blocked) fieldset { 23 | display: table-cell; 24 | } 25 | 26 | .centered-content { 27 | margin: 0 auto; 28 | max-width: 1000px; 29 | padding-left: 40px; 30 | padding-right: 40px; 31 | } 32 | 33 | /* Navigation */ 34 | .header { 35 | background: #222; 36 | color: rgba(255, 255, 255, 0.51); 37 | padding-bottom: 10px; 38 | padding-top: 10px; 39 | } 40 | 41 | .header a, 42 | .header a:hover { 43 | color: #fff; 44 | text-decoration: none; 45 | } 46 | .logo { 47 | font-size: 24px; 48 | } 49 | .navigation { 50 | float: right; 51 | } 52 | .navigation-link { 53 | display: inline-block; 54 | line-height: 24px; 55 | opacity: 0.8; 56 | padding: 4px 8px; 57 | } 58 | .navigation-link:hover, 59 | .navigation-link:focus { 60 | opacity: 1; 61 | } 62 | .sub-header { 63 | background: #eee; 64 | border-bottom: 1px solid #ccc; 65 | line-height: 2; 66 | padding-bottom: 10px; 67 | padding-top: 10px; 68 | } 69 | .sub-header form { 70 | display: -webkit-flex; 71 | display: flex; 72 | margin: 5px 0; 73 | max-width: 400px; 74 | } 75 | .sub-header input[type="text"] { 76 | -webkit-flex: 1; 77 | flex: 1; 78 | margin: 0 5px; 79 | width: auto; 80 | } 81 | .sub-header input[type="submit"] { 82 | padding: 3px 10px; 83 | } 84 | 85 | /* Alert messages */ 86 | .page-alert { 87 | /* Green background and darker green border/text */ 88 | background: #cae2ae; 89 | border: 1px solid #639b24; 90 | color: #639b24; 91 | font-size: 16px; 92 | margin-top: 10px; 93 | padding: 10px 20px; 94 | } 95 | .snippet-alert { 96 | /* Red background */ 97 | background: #d24a45; 98 | border-radius: 4px; 99 | color: #fff; 100 | display: inline-block; 101 | font-size: 14px; 102 | font-weight: 400; 103 | margin-left: 3px; 104 | padding: 2px 8px; 105 | vertical-align: text-bottom; 106 | } 107 | .snippet-warning { 108 | /* Red background and darker red border/text */ 109 | background: #fbe2e0; 110 | border: 1px solid #c52717; 111 | color: #c52717; 112 | margin-bottom: 10px; 113 | padding: 10px 20px; 114 | } 115 | 116 | /* Generic styles */ 117 | .clear-fix:after { 118 | content: ""; 119 | display: table; 120 | clear: both; 121 | } 122 | 123 | input[type="text"], 124 | .button, 125 | textarea { 126 | /* Override iOS border/gradient styles */ 127 | -webkit-border-radius: inherit; 128 | -webkit-appearance: none; 129 | } 130 | 131 | input[type="text"], 132 | textarea { 133 | border: 1px solid #ccc; 134 | padding: 2px 4px; 135 | } 136 | 137 | textarea { 138 | max-width: 100%; 139 | } 140 | 141 | a { 142 | /* Blue links */ 143 | color: #337ab7; 144 | } 145 | a:hover, 146 | a:focus { 147 | /* Darker blue for hovered/focused links */ 148 | color: #23527c; 149 | } 150 | hr { 151 | border: none; 152 | border-top: 1px solid #ddd; 153 | margin: 10px 20px; 154 | } 155 | 156 | /* Headings */ 157 | h1 { 158 | font-weight: normal; 159 | } 160 | /* Buttons */ 161 | .button { 162 | background: #fff; 163 | border: 1px solid #bbb; 164 | border-radius: 4px; 165 | color: #222; 166 | padding: 5px 10px; 167 | } 168 | .button:hover, 169 | .button:focus { 170 | background: #eee; 171 | border-color: #aaa; 172 | cursor: pointer; 173 | } 174 | .button[disabled] { 175 | background: #eee; 176 | border-color: #ccc; 177 | color: #aaa; 178 | cursor: not-allowed; 179 | opacity: 1; 180 | } 181 | 182 | /* Content */ 183 | .page-content { 184 | overflow-x: auto; 185 | padding-bottom: 10px; 186 | padding-top: 10px; 187 | } 188 | 189 | /* Snippets */ 190 | 191 | .snippets-title { 192 | color: #888; 193 | display: block; 194 | font-size: 10px; 195 | font-weight: normal; 196 | text-transform: uppercase; 197 | } 198 | 199 | .snippet { 200 | border-top: 1px solid #ccc; 201 | margin-bottom: 10px; 202 | padding: 10px 0; 203 | } 204 | .snippet-header { 205 | margin-bottom: 10px; 206 | } 207 | .snippet-header h2 { 208 | display: inline-block; 209 | margin: 0; 210 | } 211 | .snippet-actions { 212 | float: right; 213 | } 214 | .save-button:not(:disabled) { 215 | /* Green button background */ 216 | background-color: #639b24; 217 | border-color: #639b24; 218 | color: #fff; 219 | } 220 | .save-button:not(:disabled):hover { 221 | /* Darker green button background on hover */ 222 | background-color: #568522; 223 | border-color: #568522; 224 | } 225 | .hide-button:not(:disabled) { 226 | /* Yellow button background */ 227 | background-color: #f0ad4e; 228 | border-color: #eea236; 229 | color: #fff; 230 | } 231 | .hide-button:not(:disabled):hover { 232 | /* Darker yellow button background on hover */ 233 | background-color: #ec971f; 234 | border-color: #d58512; 235 | } 236 | .delete-button:not(:disabled) { 237 | /* Red button background */ 238 | background-color: #d9534f; 239 | border-color: #d43f3a; 240 | color: #fff; 241 | } 242 | .delete-button:not(:disabled):hover { 243 | /* Darker red button background on hover */ 244 | background-color: #c9302c; 245 | border-color: #ac2925; 246 | } 247 | .snippet-setting { 248 | color: #888; 249 | margin-bottom: 4px; 250 | } 251 | .snippet-setting a { 252 | color: #666; 253 | } 254 | .snippet-textarea { 255 | box-sizing: border-box; 256 | font-size: inherit; 257 | margin-top: 10px; 258 | padding: 10px 10px; 259 | } 260 | .snippet-text, 261 | .snippet-text-markdown { 262 | font-size: 14px; 263 | -webkit-text-size-adjust: 100%; 264 | } 265 | .snippet-text { 266 | font-family: "Lucida Console", Monaco, monospace; 267 | overflow-x: auto; 268 | padding-bottom: 10px; 269 | white-space: pre; 270 | } 271 | .snippet-text-markdown ul { 272 | margin: 0; 273 | } 274 | .snippet-text-markdown p { 275 | margin: 0.3em 0; 276 | } 277 | .snippet-text-markdown img { 278 | max-width: 100%; 279 | } 280 | 281 | /* Weekly snippets */ 282 | .weekly-snippet-heading { 283 | text-align: center; 284 | } 285 | .weekly-snippet-heading h1 { 286 | display: inline-block; 287 | padding: 0 10px; 288 | /* Keep about the same width so the arrows stay in the same place */ 289 | min-width: 250px; 290 | vertical-align: middle; 291 | } 292 | .weekly-snippet-nav-link { 293 | background-color: #eee; 294 | border: 1px solid #ccc; 295 | border-radius: 4px; 296 | color: #999; 297 | display: inline-block; 298 | font-size: 24px; 299 | height: 28px; 300 | line-height: 20px; 301 | text-decoration: none; 302 | width: 28px; 303 | } 304 | .weekly-snippet-nav-link:hover, 305 | .weekly-snippet-nav-link:focus { 306 | background-color: #ddd; 307 | border-color: #bbb; 308 | color: #888; 309 | } 310 | .snippet-category { 311 | padding-bottom: 10px; 312 | } 313 | .snippet-category h2 { 314 | border-bottom: 1px solid #ddd; 315 | font-weight: normal; 316 | margin-top: 10px; 317 | padding-bottom: 5px; 318 | } 319 | .snippet-section { 320 | position: relative; 321 | } 322 | .snippet-section, 323 | .snippet-preview-container { 324 | margin-top: 10px; 325 | } 326 | .snippet-section h3, 327 | .snippet-preview-container h3, 328 | .snippet-tag { 329 | display: inline-block; 330 | line-height: 23px; 331 | margin: 0; 332 | vertical-align: middle; 333 | } 334 | .snippet-tag { 335 | background-color: #ddd; 336 | border-radius: 20px; 337 | margin-left: 0.4em; 338 | padding: 0 8px; 339 | } 340 | .snippet-tag-private { 341 | background-color: #edf; 342 | } 343 | 344 | /* Avatars */ 345 | .snippet-avatar { 346 | border-radius: 2px; 347 | /* Margin + avatar width */ 348 | left: -35px; 349 | margin-right: 10px; 350 | position: absolute; 351 | width: 25px; 352 | } 353 | 354 | /* User settings */ 355 | .user-settings-label { 356 | display: inline-block; 357 | font-weight: bold; 358 | margin-bottom: 3px; 359 | } 360 | .user-settings-textarea { 361 | display: block; 362 | } 363 | .user-settings-block { 364 | margin: 1em 0; 365 | } 366 | .user-settings-full-width-input { 367 | display: block; 368 | width: 100%; 369 | } 370 | 371 | 372 | /* Manage users */ 373 | table { 374 | border-collapse: collapse; 375 | } 376 | table tr:nth-child(odd) { 377 | background-color: #f1f1f1; 378 | } 379 | table tr:nth-child(even) { 380 | background-color: #ffffff; 381 | } 382 | table th { 383 | color: #ffffff; 384 | background-color: #555555; 385 | border: 1px solid #555555; 386 | padding: 3px; 387 | vertical-align: top; 388 | text-align: left; 389 | } 390 | table th a:link, 391 | table th a:visited { 392 | color: #ffffff; 393 | } 394 | table th a:hover, 395 | table th a:focus, 396 | table th a:active { 397 | color: #EE872A; 398 | } 399 | table td { 400 | border:1px solid #d4d4d4; 401 | padding: 5px; 402 | vertical-align: top; 403 | } 404 | 405 | .mobile-visible { 406 | display: none; 407 | } 408 | .mobile-hidden { 409 | display: inherit; 410 | } 411 | /* Media Queries */ 412 | @media (max-width: 500px) { 413 | .mobile-hidden { 414 | display: none; 415 | } 416 | .mobile-visible { 417 | display: inherit; 418 | } 419 | h1 { 420 | font-size: 1.3em; 421 | } 422 | h2 { 423 | font-size: 1.2em; 424 | } 425 | h3 { 426 | font-size: 1.1em; 427 | } 428 | 429 | .centered-content { 430 | padding-left: 15px; 431 | padding-right: 15px; 432 | } 433 | .snippet-category { 434 | /* Leave room for avatars and their margin */ 435 | padding-left: 35px; 436 | } 437 | 438 | .header { 439 | text-align: center; 440 | } 441 | .navigation { 442 | float: none; 443 | } 444 | .navigation-link { 445 | display: inline-block; 446 | line-height: 12px; 447 | opacity: 0.8; 448 | padding: 4px 0; 449 | font-size: 12px; 450 | } 451 | .sub-header { 452 | font-size: 10px; 453 | } 454 | .snippet-alert { 455 | font-size: 9px; 456 | margin-left: 0; 457 | margin-top: 3px; 458 | padding: 1px 4px; 459 | vertical-align: text-top; 460 | } 461 | 462 | .weekly-snippet-heading h1 { 463 | /* Keep about the same width so the arrows stay in the same place */ 464 | min-width: 200px; 465 | } 466 | } 467 | -------------------------------------------------------------------------------- /slacklib_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | import textwrap 7 | import unittest 8 | 9 | # Update sys.path so it can find these. We just need to add 10 | # 'google_appengine', but we add all of $PATH to be easy. This 11 | # assumes the google_appengine directory is on the path. 12 | import os 13 | import sys 14 | sys.path.extend(os.environ['PATH'].split(':')) 15 | import dev_appserver 16 | dev_appserver.fix_sys_path() 17 | from google.appengine.ext import db 18 | from google.appengine.ext import testbed 19 | 20 | import models 21 | import slacklib 22 | 23 | 24 | class SlashCommandTest(unittest.TestCase): 25 | 26 | def _mock_data(self): 27 | # The fictional day for these tests Wednesday, July 29, 2015 28 | slacklib._TODAY_FN = lambda: datetime.datetime(2015, 7, 29) 29 | 30 | # Stuart created his account, but has never once filled out a snippet 31 | db.put(models.User(email='stuart@khanacademy.org')) 32 | 33 | # Fleetwood has two recent snippets, and always uses markdown lists, 34 | # but sometimes uses different list indicators or indention. 35 | db.put(models.User(email='fleetwood@khanacademy.org')) 36 | db.put(models.Snippet( 37 | email='fleetwood@khanacademy.org', 38 | week=datetime.date(2015, 7, 27), 39 | text=textwrap.dedent(""" 40 | * went for a walk 41 | * sniffed some things 42 | * hoping to sniff more things! #yolo 43 | """) 44 | )) 45 | db.put(models.Snippet( 46 | email='fleetwood@khanacademy.org', 47 | week=datetime.date(2015, 7, 20), 48 | text=textwrap.dedent(""" 49 | - lots of walks this week 50 | - not enough sniffing, hope to remedy next week! 51 | """) 52 | )) 53 | 54 | # Toby has filled out two snippets, but missed a week in-between while 55 | # on vacation. When he got back from vacation he was still jetlagged so 56 | # he wrote a longform paragraph instead of a list. 57 | db.put(models.User(email='toby@khanacademy.org')) 58 | db.put(models.Snippet( 59 | email='toby@khanacademy.org', 60 | week=datetime.date(2015, 7, 13), 61 | text=textwrap.dedent(""" 62 | - going on vacation next week, so excited! 63 | 64 | 65 | """) 66 | )) 67 | db.put(models.Snippet( 68 | email='toby@khanacademy.org', 69 | week=datetime.date(2015, 7, 27), 70 | text=textwrap.dedent(""" 71 | I JUST GOT BACK FROM VACATION IT WAS TOTALLY AWESOME AND I SNIFFED 72 | ALL SORTS OF THINGS. I GUESS I NEED TO WRITE SOMETHING HERE, HUH? 73 | 74 | OK THEN: 75 | - I had fun. 76 | 77 | LUNCHTIME SUCKERS! 78 | """) 79 | )) 80 | 81 | # Fozzie tried hard to create an entry manually in the previous week, 82 | # but didn't understand markdown list syntax and got discouraged (so 83 | # has no entry this week, and a malformed one last week). 84 | db.put(models.User(email='fozzie@khanacademy.org')) 85 | db.put(models.Snippet( 86 | email='fozzie@khanacademy.org', 87 | week=datetime.date(2015, 7, 20), 88 | text=textwrap.dedent(""" 89 | -is this how I list? 90 | -why is it not formatting??!? 91 | """) 92 | )) 93 | 94 | def _most_recent_snippet(self, user_email): 95 | snippets_q = models.Snippet.all() 96 | snippets_q.filter('email = ', user_email) 97 | snippets_q.order('-week') # newest snippet first 98 | return snippets_q.fetch(1)[0] 99 | 100 | def setUp(self): 101 | self.testbed = testbed.Testbed() 102 | self.testbed.activate() 103 | self.testbed.init_datastore_v3_stub() 104 | self.testbed.init_memcache_stub() 105 | self._mock_data() 106 | 107 | def tearDown(self): 108 | self.testbed.deactivate() 109 | 110 | def testDumpCommand_empty(self): 111 | # user without a recent snippet should just see null text 112 | response = slacklib.command_dump('stuart@khanacademy.org') 113 | self.assertIn('No snippet yet for this week', response) 114 | 115 | def testDumpCommand_formatting(self): 116 | # user with a snippet should just get it back unformatted 117 | response = slacklib.command_dump('fleetwood@khanacademy.org') 118 | self.assertIn('#yolo', response) 119 | 120 | def testDumpCommand_noAccount(self): 121 | # user without an account should get a helpful error message 122 | response = slacklib.command_dump('bob@bob.com') 123 | self.assertIn("You don't appear to have a snippets account", response) 124 | self.assertIn("Slack email address: bob@bob.com", response) 125 | 126 | def testListCommand_empty(self): 127 | # user without a recent snippet should get a helpful message 128 | response = slacklib.command_list('stuart@khanacademy.org') 129 | self.assertIn( 130 | "You don't have any snippets for this week yet!", response 131 | ) 132 | 133 | def testListCommand_formatting(self): 134 | # user with snippet should get back a formatted, numbered list 135 | response = slacklib.command_list('fleetwood@khanacademy.org') 136 | self.assertIn('> :pushpin: *[0]* went for a walk', response) 137 | self.assertIn( 138 | '> :pushpin: *[2]* hoping to sniff more things! #yolo', 139 | response 140 | ) 141 | 142 | def testListCommand_noAccount(self): 143 | # user without an account should get a helpful error message 144 | response = slacklib.command_list('bob@bob.com') 145 | self.assertIn("You don't appear to have a snippets account", response) 146 | 147 | def testLastCommand_empty(self): 148 | # user without a snippet last week should get a helpful message 149 | expect = "You didn't have any snippets last week!" 150 | # stuart never fills out 151 | self.assertIn(expect, slacklib.command_last('stuart@khanacademy.org')) 152 | # toby skipped last week 153 | self.assertIn(expect, slacklib.command_last('toby@khanacademy.org')) 154 | 155 | def testLastCommand_formatting(self): 156 | # user with snippet should get back a formatted, numbered list 157 | response = slacklib.command_last('fleetwood@khanacademy.org') 158 | self.assertIn('> :pushpin: *[0]* lots of walks this week', response) 159 | 160 | def testLastCommand_noAccount(self): 161 | # user without an account should get a helpful error message 162 | response = slacklib.command_last('bob@bob.com') 163 | self.assertIn("You don't appear to have a snippets account", response) 164 | 165 | def testBadMarkdown_listCommand(self): 166 | toby_recent = slacklib.command_list('toby@khanacademy.org') 167 | self.assertIn("not in a format I understand", toby_recent) 168 | 169 | def testBadMarkdown_lastCommand(self): 170 | fozzie_last = slacklib.command_last('fozzie@khanacademy.org') 171 | self.assertIn("not in a format I understand", fozzie_last) 172 | 173 | def testAddCommand_blank(self): 174 | # blank slate should be easy... 175 | r = slacklib.command_add('stuart@khanacademy.org', 'went to the park') 176 | t = self._most_recent_snippet('stuart@khanacademy.org') 177 | self.assertIn("Added *went to the park* to your weekly snippets", r) 178 | self.assertEquals('- went to the park', t.text) 179 | self.assertEquals(True, t.is_markdown) 180 | 181 | def testAddCommand_existing(self): 182 | # on this one, the user markdown formatting gets altered/standardized 183 | slacklib.command_add('fleetwood@khanacademy.org', 'went to the park') 184 | t = self._most_recent_snippet('fleetwood@khanacademy.org') 185 | expected = textwrap.dedent(""" 186 | - went for a walk 187 | - sniffed some things 188 | - hoping to sniff more things! #yolo 189 | - went to the park 190 | """).strip() 191 | self.assertEqual(expected, t.text) 192 | self.assertEquals(True, t.is_markdown) 193 | 194 | def testAddCommand_existingIsMalformed(self): 195 | # we should be told we cannot to add to a snippet that is malformed! 196 | toby_email = 'toby@khanacademy.org' 197 | r = slacklib.command_add(toby_email, 'went to the park') 198 | self.assertIn("Your snippets are not in a format I understand", r) 199 | # ...and the existing snippets should not have been touched 200 | t = self._most_recent_snippet(toby_email) 201 | self.assertNotIn("went to the park", t.text) 202 | self.assertIn("LUNCHTIME SUCKERS!", t.text) 203 | self.assertEquals(False, t.is_markdown) 204 | 205 | def testAddCommand_noArgs(self): 206 | # we need to handle when they try to add nothing! 207 | r = slacklib.command_add('stuart@khanacademy.org', '') 208 | self.assertIn("*what* do you want me to add exactly?", r) 209 | 210 | def testAddCommand_noAccount(self): 211 | # dont crash horribly if user doesnt exist 212 | r = slacklib.command_add('bob@bob.com', 'how is account formed?') 213 | self.assertIn("You don't appear to have a snippets account", r) 214 | 215 | def testAddCommand_markupUsernames(self): 216 | # usernames should be marked up properly so they get syntax highlighted 217 | r = slacklib.command_add('stuart@khanacademy.org', 'ate w/ @toby, yay') 218 | t = self._most_recent_snippet('stuart@khanacademy.org') 219 | self.assertIn("ate w/ <@toby>, yay", r) 220 | self.assertIn("- ate w/ <@toby>, yay", t.text) 221 | 222 | def testAddCommand_unicode(self): 223 | r = slacklib.command_add('stuart@khanacademy.org', u'i “like” food') 224 | t = self._most_recent_snippet('stuart@khanacademy.org') 225 | self.assertIn('i “like” food', r) 226 | self.assertIn('i “like” food', t.text) 227 | 228 | def testDelCommand_noArgs(self): 229 | # we need to handle when they try to add nothing! 230 | r = slacklib.command_del('stuart@khanacademy.org', []) 231 | self.assertIn("*what* do you want me to delete exactly?", r) 232 | 233 | def testDelCommand_noAccount(self): 234 | # dont crash horribly if user doesnt exist 235 | r = slacklib.command_del('bob@bob.com', ['1']) 236 | self.assertIn("You don't appear to have a snippets account", r) 237 | 238 | def testDelCommand_normalCase(self): 239 | r = slacklib.command_del('fleetwood@khanacademy.org', ['1']) 240 | t = self._most_recent_snippet('fleetwood@khanacademy.org') 241 | self.assertIn( 242 | "Removed *sniffed some things* from your weekly snippets", r) 243 | expected = textwrap.dedent(""" 244 | - went for a walk 245 | - hoping to sniff more things! #yolo 246 | """).strip() 247 | self.assertEqual(expected, t.text) 248 | self.assertEquals(True, t.is_markdown) 249 | 250 | def testDelCommand_nonexistentIndex(self): 251 | r1 = slacklib.command_del('stuart@khanacademy.org', ['0']) 252 | r2 = slacklib.command_del('fleetwood@khanacademy.org', ['4']) 253 | expected = "You don't have anything at that index" 254 | self.assertIn(expected, r1) 255 | self.assertIn(expected, r2) 256 | 257 | def testDelCommand_indexNaN(self): 258 | r = slacklib.command_del('bob@bob.com', ['one']) 259 | self.assertIn("*what* do you want me to delete exactly?", r) 260 | 261 | def testDelCommand_existingIsMalformed(self): 262 | # we should be told we cannot to add to a snippet that is malformed! 263 | r = slacklib.command_del('toby@khanacademy.org', ['0']) 264 | self.assertIn("Your snippets are not in a format I understand", r) 265 | # ...and the existing snippets should not have been touched 266 | t = self._most_recent_snippet('toby@khanacademy.org') 267 | self.assertIn("I had fun", t.text) 268 | self.assertEquals(False, t.is_markdown) 269 | 270 | if __name__ == '__main__': 271 | unittest.main() 272 | -------------------------------------------------------------------------------- /slacklib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | """Snippets server -> Slack integration. 5 | 6 | This provides Slack integration with the snippet server, for 7 | organizations that use Slack for messaging. This provides Slack 8 | integration with the snippet server, as well prototype CLI style 9 | interaction with snippets via the Slack "slash commands" integration. 10 | 11 | Talking to the Slack Web API requires a token. The admin must enter 12 | the value of this token on /admin/settings. There are instructions 13 | there for how to do so. 14 | 15 | Additionally, the "slash commands" integration in Slack will post a 16 | token with each request. We check this token for security reasons, so 17 | we get that from /admin/settings as well. 18 | """ 19 | 20 | import datetime 21 | import json 22 | import logging 23 | import re 24 | import os 25 | import textwrap 26 | import urllib 27 | import urllib2 28 | import webapp2 29 | 30 | from google.appengine.ext import db 31 | from google.appengine.api import memcache 32 | 33 | import models 34 | import util 35 | 36 | # The Slack slash command token is sent to us by the Slack server with 37 | # every incoming request. We verify it here for security. To make it 38 | # easier to develop, you can disable the verification step while 39 | # testing. 40 | _REQUIRE_SLASH_TOKEN = True 41 | 42 | 43 | # This allows mocking in a different day, for testing. 44 | _TODAY_FN = datetime.datetime.now 45 | 46 | # The web URL we point people to as the base for web operations 47 | _WEB_URL = 'http://' + os.environ.get('SERVER_NAME', 'localhost') 48 | 49 | 50 | def _web_api(api_method, payload): 51 | """Send a payload to the Slack Web API, automatically inserting token. 52 | 53 | Uses AppSettings.slack_token to get the token. Callers must ensure 54 | that slack_token exists (or this call will fail). 55 | 56 | Raises a ValueError if something goes wrong. 57 | Returns a dictionary with the response. 58 | """ 59 | app_settings = models.AppSettings.get() 60 | payload.setdefault('token', app_settings.slack_token) 61 | uri = 'https://slack.com/api/' + api_method 62 | r = urllib2.urlopen(uri, urllib.urlencode(payload)) 63 | 64 | # check return code for server errors 65 | if r.getcode() != 200: 66 | raise ValueError(r.read()) 67 | # parse the JSON... 68 | # slack web API always returns either `"ok": true` or `"error": "reason"` 69 | reply = json.loads(r.read()) 70 | if not reply['ok']: 71 | raise ValueError('Slack error: %s' % reply['error']) 72 | return reply 73 | 74 | 75 | def _get_user_email(uid): 76 | """Retrieve the email address for a specific userid from the Slack Web API. 77 | 78 | Raises ValueError if could not be retrieved. 79 | """ 80 | reply = _web_api('users.info', {'user': uid}) # possible ValueError 81 | email = reply.get('user', {}).get('profile', {}).get('email', None) 82 | if email is None: 83 | raise ValueError('Slack user profile did not have email') 84 | return email 85 | 86 | 87 | def _get_user_email_cached(uid, force_refresh=False): 88 | """Retrieve the email address for a specific user id, with a cache. 89 | 90 | Results are stored in memcache for up to a day. 91 | 92 | If force_refresh parameter is specified, cached data will be refreshed. 93 | 94 | Raises ValueError if could not be retrieved. 95 | """ 96 | key = 'slack_profile_email_' + uid 97 | cached_data = memcache.get(key) 98 | if (cached_data is None) or force_refresh: 99 | logging.debug("cache miss/refresh for slack email lookup %s", uid) 100 | email = _get_user_email(uid) # possible ValueError 101 | if not memcache.set(key=key, value=email, time=86400): 102 | logging.error('memcache set failed!') 103 | return email 104 | else: 105 | logging.debug("cache hit for slack email lookup %s", uid) 106 | return cached_data 107 | 108 | 109 | def send_to_slack_channel(channel, msg): 110 | """Send a plaintext message to a Slack channel.""" 111 | try: 112 | _web_api('chat.postMessage', { 113 | 'channel': channel, 114 | 'text': msg, 115 | 'username': 'Snippets', 116 | 'icon_emoji': ':pencil:', 117 | 'unfurl_links': False, # no link previews, please 118 | }) 119 | except ValueError, why: 120 | logging.error('Failed sending message to slack: %s', why) 121 | 122 | 123 | ############################### 124 | ### SLASH COMMANDS ARE FUN! ### 125 | ############################### 126 | 127 | def command_usage(): 128 | return textwrap.dedent(""" 129 | /snippets displays your current snippets 130 | /snippets list displays your current snippets 131 | /snippets last displays your snippets from last week 132 | /snippets add [item] adds an item to your weekly snippets 133 | /snippets del [n] removes snippet number N 134 | /snippets dump shows your snippets list unformatted 135 | /snippets help display this help screen 136 | """) 137 | 138 | 139 | def command_help(): 140 | """Return the help string for slash commands.""" 141 | return ( 142 | "I can help you manage your " 143 | "<{}|weekly snippets>! :pencil:".format(_WEB_URL) + 144 | command_usage() 145 | ) 146 | 147 | 148 | def _no_user_error(user_email): 149 | return ( 150 | "You don't appear to have a snippets account yet!\n" 151 | "To create one, go to {}\n" 152 | "We looked for your Slack email address: {}" 153 | .format(_WEB_URL, user_email) 154 | ) 155 | 156 | 157 | def _user_snippet(user_email, weeks_back=0): 158 | """Return the user's most recent Snippet. 159 | 160 | If one doesn't exist, one will be automatically filled from the template 161 | (but not saved). 162 | 163 | By using the optional `weeks_back` parameter, you can step backwards in 164 | time. Note that if you go back before the user's *first* snippet, they will 165 | not be filled (the default filling seems to only go forwardwise in time), 166 | and an IndexError will be raised. 167 | 168 | Raises an IndexError if requested snippet week comes before user birth. 169 | Raises ValueError if user couldn't be found. 170 | """ 171 | account = util.get_user_or_die(user_email) # can raise ValueError 172 | user_snips = util.snippets_for_user(user_email) 173 | logging.debug( 174 | 'User %s got snippets from db: %s', user_email, len(user_snips) 175 | ) 176 | 177 | filled_snips = util.fill_in_missing_snippets(user_snips, account, 178 | user_email, _TODAY_FN()) 179 | logging.debug( 180 | 'User %s snippets *filled* to: %s', user_email, len(filled_snips) 181 | ) 182 | 183 | index = (-1) - weeks_back 184 | return filled_snips[index] 185 | 186 | 187 | def _snippet_items(snippet): 188 | """Return all markdown items in the snippet text. 189 | 190 | For this we expect it the snippet to contain *nothing* but a markdown list. 191 | We do not support "indented" list style, only one item per linebreak. 192 | 193 | Raises SyntaxError if snippet not in proper format (e.g. contains 194 | anything other than a markdown list). 195 | """ 196 | unformatted = snippet.text and snippet.text.strip() 197 | 198 | # treat null text value as empty list 199 | if not unformatted: 200 | return [] 201 | 202 | # parse out all markdown list items 203 | items = re.findall(r'^[-*+] +(.*)$', unformatted, re.MULTILINE) 204 | 205 | # if there were any lines that didn't yield an item, assume there was 206 | # something we didn't parse. since we never want to lose existing data 207 | # for a user, this is an error condition. 208 | if len(items) < len(unformatted.splitlines()): 209 | raise SyntaxError('unparsed lines in user snippet: %s' % unformatted) 210 | 211 | return items 212 | 213 | 214 | def _format_snippet_items(items): 215 | """Format snippet items for display.""" 216 | fi = ['> :pushpin: *[{}]* {}'.format(i, x) for i, x in enumerate(items)] 217 | return "\n".join(fi) 218 | 219 | 220 | def command_list(user_email): 221 | """Return the users current snippets for the week in pretty format.""" 222 | try: 223 | items = _snippet_items(_user_snippet(user_email)) 224 | except ValueError: 225 | return _no_user_error(user_email) 226 | except SyntaxError: 227 | return ( 228 | "*Your snippets are not in a format I understand.* :cry:\n" 229 | "I support markdown lists only, " 230 | "for more information see `/snippets help` ." 231 | ) 232 | 233 | if not items: 234 | return ( 235 | "*You don't have any snippets for this week yet!* :speak_no_evil:\n" 236 | ":pencil: Use `/snippets add` to create one, or try " 237 | "`/snippets help` ." 238 | ) 239 | 240 | return textwrap.dedent( 241 | "*Your snippets for the week so far:*\n" + 242 | _format_snippet_items(items) 243 | ) 244 | 245 | 246 | def command_last(user_email): 247 | """Return the users snippets for last week in a pretty format.""" 248 | try: 249 | items = _snippet_items(_user_snippet(user_email, 1)) 250 | except ValueError: 251 | return _no_user_error(user_email) 252 | except IndexError: 253 | return "*You didn't have any snippets last week!* :speak_no_evil:" 254 | except SyntaxError: 255 | return ( 256 | "*Your snippets last week are not in a format I understand.* " 257 | ":cry:\n" 258 | "I support markdown lists only. " 259 | "For more information see `/snippets help` ." 260 | ) 261 | 262 | if not items: 263 | return "*You didn't have any snippets last week!* :speak_no_evil:" 264 | 265 | return textwrap.dedent( 266 | "*Your snippets for last week:*\n" + 267 | _format_snippet_items(items) 268 | ) 269 | 270 | 271 | def _linkify_usernames(text): 272 | """Slack wants @usernames to be surrounded in <> to be highlighted.""" 273 | return re.sub(r'(?', text) 274 | 275 | 276 | def _markdown_list(items): 277 | """Transform a list of items into a markdown list.""" 278 | return "\n".join(["- {}".format(x) for x in items]) 279 | 280 | 281 | def command_add(user_email, new_item): 282 | """Add a new item to the user's current snippet list.""" 283 | if not new_item: 284 | return ( 285 | ":grey_question: Urm, *what* do you want me to add exactly?\n" 286 | "Usage: `/snippets add [item]`" 287 | ) 288 | 289 | # TODO(csilvers): move this get/update/put atomic into a txn 290 | try: 291 | snippet = _user_snippet(user_email) # may raise ValueError 292 | items = _snippet_items(snippet) # may raise SyntaxError 293 | except ValueError: 294 | return _no_user_error(user_email) 295 | except SyntaxError: 296 | return ( 297 | "*Your snippets are not in a format I understand.* :cry:\n" 298 | "So I can't add to them! FYI I support markdown lists only, " 299 | "for more information see `/snippets help` ." 300 | ) 301 | 302 | new_item = _linkify_usernames(new_item) 303 | items.append(new_item) 304 | snippet.text = _markdown_list(items) 305 | snippet.is_markdown = True 306 | 307 | # TODO(mroth): we should abstract out DB writes to a library wrapper 308 | db.put(snippet) 309 | db.get(snippet.key()) # ensure db consistency for HRD 310 | return "Added *{}* to your weekly snippets.".format(new_item) 311 | 312 | 313 | def command_del(user_email, args): 314 | """Delete an item at an index from the users current snippets. 315 | 316 | The `args` parameter should be the args passed to the command. We 317 | only expect one (for the index) but the user might not pass it, or pass 318 | extra things (which is an error condition for now). 319 | """ 320 | syntax_err_msg = ( 321 | ":grey_question: Urm, *what* do you want me to delete exactly?\n" 322 | "Usage: `/snippets del [n]`" 323 | ) 324 | if not args or len(args) != 1: 325 | return syntax_err_msg 326 | 327 | try: 328 | index = int(args[0]) 329 | except ValueError: 330 | return syntax_err_msg 331 | 332 | # TODO(csilvers): move this get/update/put atomic into a txn 333 | try: 334 | snippet = _user_snippet(user_email) # may raise ValueError 335 | items = _snippet_items(snippet) # may raise SyntaxError 336 | except ValueError: 337 | return _no_user_error(user_email) 338 | except SyntaxError: 339 | return ( 340 | "*Your snippets are not in a format I understand.* :cry:\n" 341 | "So I can't delete from them! FYI I support markdown lists only, " 342 | "for more information see `/snippets help` ." 343 | ) 344 | 345 | try: 346 | removed_item = items[index] 347 | del items[index] 348 | except IndexError: 349 | return ( 350 | ":grey_question: You don't have anything at that index?!\n" + 351 | _format_snippet_items(items) 352 | ) 353 | 354 | snippet.text = _markdown_list(items) 355 | snippet.is_markdown = True 356 | 357 | db.put(snippet) 358 | db.get(snippet.key()) # ensure db consistency for HRD 359 | return "Removed *{}* from your weekly snippets.".format(removed_item) 360 | 361 | 362 | def command_dump(user_email): 363 | """Return user's most recent snippet unformatted.""" 364 | try: 365 | snippet = _user_snippet(user_email) 366 | except ValueError: 367 | return _no_user_error(user_email) 368 | return "```{}```".format(snippet.text or 'No snippet yet for this week') 369 | 370 | 371 | class SlashCommand(webapp2.RequestHandler): 372 | def post(self): 373 | """Process an incoming slash command from Slack. 374 | 375 | Incoming request POST looks like the following (example taken from 376 | https://api.slack.com/slash-commands): 377 | token=gIkuvaNzQIHg97ATvDxqgjtO 378 | team_id=T0001 379 | team_domain=example 380 | channel_id=C2147483705 381 | channel_name=test 382 | user_id=U2147483697 383 | user_name=Steve 384 | command=/weather 385 | text=94070 386 | """ 387 | req, res = self.request, self.response 388 | 389 | expected_token = models.AppSettings.get().slack_slash_token 390 | 391 | if not expected_token: 392 | res.write('Slack slash commands disabled. An admin ' 393 | 'can enable them at /admin/settings') 394 | return 395 | 396 | # verify slash API post token for security 397 | if _REQUIRE_SLASH_TOKEN: 398 | token = req.get('token') 399 | if token != expected_token: 400 | logging.error("POST MADE WITH INVALID TOKEN") 401 | res.write("OH NO YOU DIDNT! Security issue plz contact admin.") 402 | return 403 | 404 | user_name = req.get('user_name') 405 | user_id = req.get('user_id') 406 | text = req.get('text') 407 | 408 | try: 409 | user_email = _get_user_email_cached(user_id) 410 | except ValueError: 411 | logging.error("Failed getting %s email from Slack API", user_name) 412 | res.write( 413 | "Error getting your email address from the Slack API! " 414 | "Please contact an admin and report the time of this error." 415 | ) 416 | return 417 | 418 | words = text.strip().split() 419 | if not words: 420 | logging.info('null (list) command from user %s', user_name) 421 | res.write(command_list(user_email)) 422 | else: 423 | cmd, args = words[0], words[1:] 424 | if cmd == 'help': 425 | logging.info('help command from user %s', user_name) 426 | res.write(command_help()) 427 | elif cmd == 'whoami': 428 | # undocumented command to echo user email back 429 | logging.info('whoami command from user %s', user_name) 430 | res.write(user_email) 431 | elif cmd == 'whoami!': 432 | # whoami! forces a refresh of cache, for debugging 433 | logging.info('whoami! command from user %s', user_name) 434 | logging.info('whoami! potential cached email for %s: %s', 435 | user_name, user_email) 436 | refreshed = _get_user_email_cached(user_id, force_refresh=True) 437 | logging.info('whoami! refreshed email for %s: %s', 438 | user_name, refreshed) 439 | res.write(refreshed) 440 | elif cmd == 'list': 441 | # this is the same as the null command, but support for UX 442 | logging.info('list command from user %s', user_name) 443 | res.write(command_list(user_email)) 444 | elif cmd == 'last': 445 | logging.info('last command from user %s', user_name) 446 | res.write(command_last(user_email)) 447 | elif cmd == 'add': 448 | logging.info('add command from user %s', user_name) 449 | res.write(command_add(user_email, " ".join(args))) 450 | elif cmd == 'del': 451 | logging.info('del command from user %s', user_name) 452 | res.write(command_del(user_email, args)) 453 | elif cmd == 'dump': 454 | logging.info('dump command from user %s', user_name) 455 | res.write(command_dump(user_email)) 456 | else: 457 | logging.info('unknown command %s from user %s', cmd, user_name) 458 | res.write( 459 | "I don't understand what you said! " 460 | "Perhaps you meant one of these?\n```%s```\n" 461 | % command_usage() 462 | ) 463 | -------------------------------------------------------------------------------- /snippets.py: -------------------------------------------------------------------------------- 1 | """Snippets server. 2 | 3 | The main server code for Weekly Snippets. Users can add a summary of 4 | what they did in the last week, and browse other people's snippets. 5 | They will also get weekly mail pointing to a webpage with everyone's 6 | snippets in them. 7 | """ 8 | 9 | __author__ = 'Craig Silverstein ' 10 | 11 | import datetime 12 | import logging 13 | import os 14 | import re 15 | import time 16 | import urllib 17 | 18 | from google.appengine.api import mail 19 | from google.appengine.api import users 20 | from google.appengine.ext import db 21 | import webapp2 22 | from webapp2_extras import jinja2 23 | 24 | import models 25 | import slacklib 26 | import util 27 | 28 | 29 | # This allows mocking in a different day, for testing. 30 | _TODAY_FN = datetime.datetime.now 31 | 32 | 33 | jinja2.default_config['template_path'] = os.path.join( 34 | os.path.dirname(__file__), 35 | "templates" 36 | ) 37 | jinja2.default_config['filters'] = { 38 | 'readable_date': ( 39 | lambda value: value.strftime('%B %d, %Y').replace(' 0', ' ')), 40 | 'iso_date': ( 41 | lambda value: value.strftime('%m-%d-%Y')), 42 | } 43 | 44 | 45 | def _login_page(request, redirector): 46 | """Redirect the user to a page where they can log in.""" 47 | redirector.redirect(users.create_login_url(request.uri)) 48 | 49 | 50 | def _current_user_email(): 51 | """Return the logged-in user's email address, converted into lowercase.""" 52 | return users.get_current_user().email().lower() 53 | 54 | 55 | def _get_or_create_user(email, put_new_user=True): 56 | """Return the user object with the given email, creating it if needed. 57 | 58 | Considers the permissions scope of the currently logged in web user, 59 | and raises an IndexError if the currently logged in user is not the same as 60 | the queried email address (or is an admin). 61 | 62 | NOTE: Any access that causes _get_or_create_user() is an access that 63 | indicates the user is active again, so they are "unhidden" in the db. 64 | """ 65 | user = util.get_user(email) 66 | if user: 67 | if user.is_hidden: 68 | # Any access that causes _get_or_create_user() is an access 69 | # that indicates the user is active again, so un-hide them. 70 | # TODO(csilvers): move this get/update/put atomic into a txn 71 | user.is_hidden = False 72 | user.put() 73 | elif not _logged_in_user_has_permission_for(email): 74 | # TODO(csilvers): turn this into a 403 somewhere 75 | raise IndexError('User "%s" not found; did you specify' 76 | ' the full email address?' % email) 77 | else: 78 | # You can only create a new user under one of the app-listed domains. 79 | try: 80 | app_settings = models.AppSettings.get() 81 | except ValueError: 82 | # TODO(csilvers): do this instead: 83 | # /admin/settings?redirect_to=user_setting 84 | return None 85 | 86 | domain = email.split('@')[-1] 87 | allowed_domains = app_settings.domains 88 | if domain not in allowed_domains: 89 | # TODO(csilvers): turn this into a 403 somewhere 90 | raise RuntimeError('Permission denied: ' 91 | 'This app is for users from %s.' 92 | ' But you are from %s.' 93 | % (' or '.join(allowed_domains), domain)) 94 | 95 | # Set the user defaults based on the global app defaults. 96 | user = models.User(created=_TODAY_FN(), 97 | email=email, 98 | uses_markdown=app_settings.default_markdown, 99 | private_snippets=app_settings.default_private, 100 | wants_email=app_settings.default_email) 101 | if put_new_user: 102 | db.put(user) 103 | db.get(user.key()) # ensure db consistency for HRD 104 | return user 105 | 106 | 107 | def _logged_in_user_has_permission_for(email): 108 | """True if the current logged-in appengine user can edit this user.""" 109 | return (email == _current_user_email()) or users.is_current_user_admin() 110 | 111 | 112 | def _can_view_private_snippets(my_email, snippet_email): 113 | """Return true if I have permission to view other's private snippet. 114 | 115 | I have permission to view if I am in the same domain as the person 116 | who wrote the snippet (domain is everything following the @ in the 117 | email). 118 | 119 | Arguments: 120 | my_email: the email address of the currently logged in user 121 | snippet_email: the email address of the snippet we're trying to view. 122 | 123 | Returns: 124 | True if my_email has permission to view snippet_email's private 125 | emails, or False else. 126 | """ 127 | my_at = my_email.rfind('@') 128 | snippet_at = snippet_email.rfind('@') 129 | if my_at == -1 or snippet_at == -1: 130 | return False # be safe 131 | return my_email[my_at:] == snippet_email[snippet_at:] 132 | 133 | 134 | def _send_to_chat(msg, url_path): 135 | """Send a message to the main room/channel for active chat integrations.""" 136 | try: 137 | app_settings = models.AppSettings.get() 138 | except ValueError: 139 | logging.warning('Not sending to chat: app settings not configured') 140 | return 141 | 142 | msg = "%s %s%s" % (msg, app_settings.hostname, url_path) 143 | 144 | slack_channel = app_settings.slack_channel 145 | if slack_channel: 146 | slacklib.send_to_slack_channel(slack_channel, msg) 147 | 148 | 149 | class BaseHandler(webapp2.RequestHandler): 150 | """Set up as per the jinja2.py docstring.""" 151 | @webapp2.cached_property 152 | def jinja2(self): 153 | return jinja2.get_jinja2() 154 | 155 | def render_response(self, template_filename, context): 156 | html = self.jinja2.render_template(template_filename, **context) 157 | self.response.write(html) 158 | 159 | 160 | class UserPage(BaseHandler): 161 | """Show all the snippets for a single user.""" 162 | 163 | def get(self): 164 | if not users.get_current_user(): 165 | return _login_page(self.request, self) 166 | 167 | user_email = self.request.get('u', _current_user_email()) 168 | user = util.get_user(user_email) 169 | 170 | if not user: 171 | # If there are no app settings, set those up before setting 172 | # up the user settings. 173 | if users.is_current_user_admin(): 174 | try: 175 | models.AppSettings.get() 176 | except ValueError: 177 | self.redirect("/admin/settings?redirect_to=user_setting" 178 | "&msg=Welcome+to+the+snippet+server!+" 179 | "Please+take+a+moment+to+configure+it.") 180 | return 181 | 182 | template_values = { 183 | 'new_user': True, 184 | 'login_url': users.create_login_url(self.request.uri), 185 | 'logout_url': users.create_logout_url('/'), 186 | 'username': user_email, 187 | } 188 | self.render_response('new_user.html', template_values) 189 | return 190 | 191 | snippets = util.snippets_for_user(user_email) 192 | 193 | if not _can_view_private_snippets(_current_user_email(), user_email): 194 | snippets = [snippet for snippet in snippets if not snippet.private] 195 | snippets = util.fill_in_missing_snippets(snippets, user, 196 | user_email, _TODAY_FN()) 197 | snippets.reverse() # get to newest snippet first 198 | 199 | template_values = { 200 | 'logout_url': users.create_logout_url('/'), 201 | 'message': self.request.get('msg'), 202 | 'username': user_email, 203 | 'is_admin': users.is_current_user_admin(), 204 | 'domain': user_email.split('@')[-1], 205 | 'view_week': util.existingsnippet_monday(_TODAY_FN()), 206 | # Snippets for the week of are due today. 207 | 'one_week_ago': _TODAY_FN().date() - datetime.timedelta(days=7), 208 | 'eight_days_ago': _TODAY_FN().date() - datetime.timedelta(days=8), 209 | 'editable': (_logged_in_user_has_permission_for(user_email) and 210 | self.request.get('edit', '1') == '1'), 211 | 'user': user, 212 | 'snippets': snippets, 213 | 'null_category': models.NULL_CATEGORY, 214 | } 215 | self.render_response('user_snippets.html', template_values) 216 | 217 | 218 | def _title_case(s): 219 | """Like string.title(), but does not uppercase 'and'.""" 220 | # Smarter would be to use 'pip install titlecase'. 221 | SMALL = 'a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v\.?|via|vs\.?' 222 | # We purposefully don't match small words at the beginning of a string. 223 | SMALL_RE = re.compile(r' (%s)\b' % SMALL, re.I) 224 | return SMALL_RE.sub(lambda m: ' ' + m.group(1).lower(), s.title().strip()) 225 | 226 | 227 | class SummaryPage(BaseHandler): 228 | """Show all the snippets for a single week.""" 229 | 230 | def get(self): 231 | if not users.get_current_user(): 232 | return _login_page(self.request, self) 233 | 234 | week_string = self.request.get('week') 235 | if week_string: 236 | week = datetime.datetime.strptime(week_string, '%m-%d-%Y').date() 237 | else: 238 | week = util.existingsnippet_monday(_TODAY_FN()) 239 | 240 | snippets_q = models.Snippet.all() 241 | snippets_q.filter('week = ', week) 242 | snippets = snippets_q.fetch(1000) # good for many users... 243 | # TODO(csilvers): filter based on wants_to_view 244 | 245 | # Get all the user records so we can categorize snippets. 246 | user_q = models.User.all() 247 | results = user_q.fetch(1000) 248 | email_to_category = {} 249 | email_to_user = {} 250 | for result in results: 251 | # People aren't very good about capitalizing their 252 | # categories consistently, so we enforce title-case, 253 | # with exceptions for 'and'. 254 | email_to_category[result.email] = _title_case(result.category) 255 | email_to_user[result.email] = result 256 | 257 | # Collect the snippets and users by category. As we see each email, 258 | # delete it from email_to_category. At the end of this, 259 | # email_to_category will hold people who did not give 260 | # snippets this week. 261 | snippets_and_users_by_category = {} 262 | for snippet in snippets: 263 | # Ignore this snippet if we don't have permission to view it. 264 | if (snippet.private and 265 | not _can_view_private_snippets(_current_user_email(), 266 | snippet.email)): 267 | continue 268 | category = email_to_category.get( 269 | snippet.email, models.NULL_CATEGORY 270 | ) 271 | if snippet.email in email_to_user: 272 | snippets_and_users_by_category.setdefault(category, []).append( 273 | (snippet, email_to_user[snippet.email]) 274 | ) 275 | else: 276 | snippets_and_users_by_category.setdefault(category, []).append( 277 | (snippet, models.User(email=snippet.email)) 278 | ) 279 | 280 | if snippet.email in email_to_category: 281 | del email_to_category[snippet.email] 282 | 283 | # Add in empty snippets for the people who didn't have any -- 284 | # unless a user is marked 'hidden'. (That's what 'hidden' 285 | # means: pretend they don't exist until they have a non-empty 286 | # snippet again.) 287 | for (email, category) in email_to_category.iteritems(): 288 | if not email_to_user[email].is_hidden: 289 | snippet = models.Snippet(email=email, week=week) 290 | snippets_and_users_by_category.setdefault(category, []).append( 291 | (snippet, email_to_user[snippet.email]) 292 | ) 293 | 294 | # Now get a sorted list, categories in alphabetical order and 295 | # each snippet-author within the category in alphabetical 296 | # order. 297 | # The data structure is ((category, ((snippet, user), ...)), ...) 298 | categories_and_snippets = [] 299 | for (category, 300 | snippets_and_users) in snippets_and_users_by_category.iteritems(): 301 | snippets_and_users.sort(key=lambda (snippet, user): snippet.email) 302 | categories_and_snippets.append((category, snippets_and_users)) 303 | categories_and_snippets.sort() 304 | 305 | template_values = { 306 | 'logout_url': users.create_logout_url('/'), 307 | 'message': self.request.get('msg'), 308 | # Used only to switch to 'username' mode and to modify settings. 309 | 'username': _current_user_email(), 310 | 'is_admin': users.is_current_user_admin(), 311 | 'prev_week': week - datetime.timedelta(7), 312 | 'view_week': week, 313 | 'next_week': week + datetime.timedelta(7), 314 | 'categories_and_snippets': categories_and_snippets, 315 | } 316 | self.render_response('weekly_snippets.html', template_values) 317 | 318 | 319 | class UpdateSnippet(BaseHandler): 320 | def update_snippet(self, email): 321 | week_string = self.request.get('week') 322 | week = datetime.datetime.strptime(week_string, '%m-%d-%Y').date() 323 | assert week.weekday() == 0, 'passed-in date must be a Monday' 324 | 325 | text = self.request.get('snippet') 326 | 327 | private = self.request.get('private') == 'True' 328 | is_markdown = self.request.get('is_markdown') == 'True' 329 | 330 | # TODO(csilvers): make this get-update-put atomic. 331 | # (maybe make the snippet id be email + week). 332 | q = models.Snippet.all() 333 | q.filter('email = ', email) 334 | q.filter('week = ', week) 335 | snippet = q.get() 336 | 337 | # When adding a snippet, make sure we create a user record for 338 | # that email as well, if it doesn't already exist. 339 | user = _get_or_create_user(email) 340 | 341 | # Store user's display_name in snippet so that if a user is later 342 | # deleted, we could still show his / her display_name. 343 | if snippet: 344 | snippet.text = text # just update the snippet text 345 | snippet.display_name = user.display_name 346 | snippet.private = private 347 | snippet.is_markdown = is_markdown 348 | else: 349 | # add the snippet to the db 350 | snippet = models.Snippet(created=_TODAY_FN(), 351 | display_name=user.display_name, 352 | email=email, week=week, 353 | text=text, private=private, 354 | is_markdown=is_markdown) 355 | db.put(snippet) 356 | db.get(snippet.key()) # ensure db consistency for HRD 357 | 358 | self.response.set_status(200) 359 | 360 | def post(self): 361 | """handle ajax updates via POST 362 | 363 | in particular, return status via json rather than redirects and 364 | hard exceptions. This isn't actually RESTy, it's just status 365 | codes and json. 366 | """ 367 | # TODO(marcos): consider using PUT? 368 | 369 | self.response.headers['Content-Type'] = 'application/json' 370 | 371 | if not users.get_current_user(): 372 | # 403s are the catch-all 'please log in error' here 373 | self.response.set_status(403) 374 | self.response.out.write('{"status": 403, ' 375 | '"message": "not logged in"}') 376 | return 377 | 378 | email = self.request.get('u', _current_user_email()) 379 | 380 | if not _logged_in_user_has_permission_for(email): 381 | # TODO(marcos): present these messages to the ajax client 382 | self.response.set_status(403) 383 | error = ('You do not have permissions to update user' 384 | ' snippets for %s' % email) 385 | self.response.out.write('{"status": 403, ' 386 | '"message": "%s"}' % error) 387 | return 388 | 389 | self.update_snippet(email) 390 | self.response.out.write('{"status": 200, "message": "ok"}') 391 | 392 | def get(self): 393 | if not users.get_current_user(): 394 | return _login_page(self.request, self) 395 | 396 | email = self.request.get('u', _current_user_email()) 397 | if not _logged_in_user_has_permission_for(email): 398 | # TODO(csilvers): return a 403 here instead. 399 | raise RuntimeError('You do not have permissions to update user' 400 | ' snippets for %s' % email) 401 | 402 | self.update_snippet(email) 403 | 404 | email = self.request.get('u', _current_user_email()) 405 | self.redirect("/?msg=Snippet+saved&u=%s" % urllib.quote(email)) 406 | 407 | 408 | class Settings(BaseHandler): 409 | """Page to display a user's settings (from class User) for modification.""" 410 | 411 | def get(self): 412 | if not users.get_current_user(): 413 | return _login_page(self.request, self) 414 | 415 | user_email = self.request.get('u', _current_user_email()) 416 | if not _logged_in_user_has_permission_for(user_email): 417 | # TODO(csilvers): return a 403 here instead. 418 | raise RuntimeError('You do not have permissions to view user' 419 | ' settings for %s' % user_email) 420 | # We won't put() the new user until the settings are saved. 421 | user = _get_or_create_user(user_email, put_new_user=False) 422 | try: 423 | user.key() 424 | is_new_user = False 425 | except db.NotSavedError: 426 | is_new_user = True 427 | 428 | template_values = { 429 | 'logout_url': users.create_logout_url('/'), 430 | 'message': self.request.get('msg'), 431 | 'username': user.email, 432 | 'is_admin': users.is_current_user_admin(), 433 | 'view_week': util.existingsnippet_monday(_TODAY_FN()), 434 | 'user': user, 435 | 'is_new_user': is_new_user, 436 | 'redirect_to': self.request.get('redirect_to', ''), 437 | # We could get this from user, but we want to replace 438 | # commas with newlines for printing. 439 | 'wants_to_view': user.wants_to_view.replace(',', '\n'), 440 | } 441 | self.render_response('settings.html', template_values) 442 | 443 | 444 | class UpdateSettings(BaseHandler): 445 | """Updates the db with modifications from the Settings page.""" 446 | 447 | def get(self): 448 | if not users.get_current_user(): 449 | return _login_page(self.request, self) 450 | 451 | user_email = self.request.get('u', _current_user_email()) 452 | if not _logged_in_user_has_permission_for(user_email): 453 | # TODO(csilvers): return a 403 here instead. 454 | raise RuntimeError('You do not have permissions to modify user' 455 | ' settings for %s' % user_email) 456 | # TODO(csilvers): make this get/update/put atomic (put in a txn) 457 | user = _get_or_create_user(user_email) 458 | 459 | # First, check if the user clicked on 'delete' or 'hide' 460 | # rather than 'save'. 461 | if self.request.get('hide'): 462 | user.is_hidden = True 463 | user.put() 464 | time.sleep(0.1) # some time for eventual consistency 465 | self.redirect('/weekly?msg=You+are+now+hidden.+Have+a+nice+day!') 466 | return 467 | elif self.request.get('delete'): 468 | db.delete(user) 469 | self.redirect('/weekly?msg=Your+account+has+been+deleted.+' 470 | '(Note+your+existing+snippets+have+NOT+been+' 471 | 'deleted.)+Have+a+nice+day!') 472 | return 473 | 474 | display_name = self.request.get('display_name') 475 | category = self.request.get('category') 476 | uses_markdown = self.request.get('markdown') == 'yes' 477 | private_snippets = self.request.get('private') == 'yes' 478 | wants_email = self.request.get('reminder_email') == 'yes' 479 | 480 | # We want this list to be comma-separated, but people are 481 | # likely to use whitespace to separate as well. Convert here. 482 | wants_to_view = self.request.get('to_view') 483 | wants_to_view = re.sub(r'\s+', ',', wants_to_view) 484 | wants_to_view = wants_to_view.split(',') 485 | wants_to_view = [w for w in wants_to_view if w] # deal with ',,' 486 | wants_to_view = ','.join(wants_to_view) # TODO(csilvers): keep as list 487 | 488 | # Changing their settings is the kind of activity that unhides 489 | # someone who was hidden, unless they specifically ask to be 490 | # hidden. 491 | is_hidden = self.request.get('is_hidden', 'no') == 'yes' 492 | 493 | user.is_hidden = is_hidden 494 | user.display_name = display_name 495 | user.category = category or models.NULL_CATEGORY 496 | user.uses_markdown = uses_markdown 497 | user.private_snippets = private_snippets 498 | user.wants_email = wants_email 499 | user.wants_to_view = wants_to_view 500 | db.put(user) 501 | db.get(user.key()) # ensure db consistency for HRD 502 | 503 | redirect_to = self.request.get('redirect_to') 504 | if redirect_to == 'snippet_entry': # true for new_user.html 505 | self.redirect('/?u=%s' % urllib.quote(user_email)) 506 | else: 507 | self.redirect("/settings?msg=Changes+saved&u=%s" 508 | % urllib.quote(user_email)) 509 | 510 | 511 | class AppSettings(BaseHandler): 512 | """Page to display settings for the whole app, for modification. 513 | 514 | This page should be restricted to admin users via app.yaml. 515 | """ 516 | 517 | def get(self): 518 | my_domain = _current_user_email().split('@')[-1] 519 | app_settings = models.AppSettings.get(create_if_missing=True, 520 | domains=[my_domain]) 521 | 522 | template_values = { 523 | 'logout_url': users.create_logout_url('/'), 524 | 'message': self.request.get('msg'), 525 | 'username': _current_user_email(), 526 | 'is_admin': users.is_current_user_admin(), 527 | 'view_week': util.existingsnippet_monday(_TODAY_FN()), 528 | 'redirect_to': self.request.get('redirect_to', ''), 529 | 'settings': app_settings, 530 | 'slack_slash_commands': ( 531 | slacklib.command_usage().strip()) 532 | } 533 | self.render_response('app_settings.html', template_values) 534 | 535 | 536 | class UpdateAppSettings(BaseHandler): 537 | """Updates the db with modifications from the App-Settings page. 538 | 539 | This page should be restricted to admin users via app.yaml. 540 | """ 541 | 542 | def get(self): 543 | _get_or_create_user(_current_user_email()) 544 | 545 | domains = self.request.get('domains') 546 | default_private = self.request.get('private') == 'yes' 547 | default_markdown = self.request.get('markdown') == 'yes' 548 | default_email = self.request.get('reminder_email') == 'yes' 549 | email_from = self.request.get('email_from') 550 | slack_channel = self.request.get('slack_channel') 551 | slack_token = self.request.get('slack_token') 552 | slack_slash_token = self.request.get('slack_slash_token') 553 | 554 | # Turn domains into a list. Allow whitespace or comma to separate. 555 | domains = re.sub(r'\s+', ',', domains) 556 | domains = [d for d in domains.split(',') if d] 557 | 558 | @db.transactional 559 | def update_settings(): 560 | app_settings = models.AppSettings.get(create_if_missing=True, 561 | domains=domains) 562 | app_settings.domains = domains 563 | app_settings.default_private = default_private 564 | app_settings.default_markdown = default_markdown 565 | app_settings.default_email = default_email 566 | app_settings.email_from = email_from 567 | app_settings.slack_channel = slack_channel 568 | app_settings.slack_token = slack_token 569 | app_settings.slack_slash_token = slack_slash_token 570 | app_settings.put() 571 | 572 | update_settings() 573 | 574 | redirect_to = self.request.get('redirect_to') 575 | if redirect_to == 'user_setting': # true for new_user.html 576 | self.redirect('/settings?redirect_to=snippet_entry' 577 | '&msg=Now+enter+your+personal+user+settings.') 578 | else: 579 | self.redirect("/admin/settings?msg=Changes+saved") 580 | 581 | 582 | class ManageUsers(BaseHandler): 583 | """Lets admins delete and otherwise manage users.""" 584 | 585 | def get(self): 586 | # options are 'email', 'creation_time', 'last_snippet_time' 587 | sort_by = self.request.get('sort_by', 'creation_time') 588 | 589 | # First, check if the user had clicked on a button. 590 | for (name, value) in self.request.params.iteritems(): 591 | if name.startswith('hide '): 592 | email_of_user_to_hide = name[len('hide '):] 593 | # TODO(csilvers): move this get/update/put atomic into a txn 594 | user = util.get_user_or_die(email_of_user_to_hide) 595 | user.is_hidden = True 596 | user.put() 597 | time.sleep(0.1) # encourage eventual consistency 598 | self.redirect('/admin/manage_users?sort_by=%s&msg=%s+hidden' 599 | % (sort_by, email_of_user_to_hide)) 600 | return 601 | if name.startswith('unhide '): 602 | email_of_user_to_unhide = name[len('unhide '):] 603 | # TODO(csilvers): move this get/update/put atomic into a txn 604 | user = util.get_user_or_die(email_of_user_to_unhide) 605 | user.is_hidden = False 606 | user.put() 607 | time.sleep(0.1) # encourage eventual consistency 608 | self.redirect('/admin/manage_users?sort_by=%s&msg=%s+unhidden' 609 | % (sort_by, email_of_user_to_unhide)) 610 | return 611 | if name.startswith('delete '): 612 | email_of_user_to_delete = name[len('delete '):] 613 | user = util.get_user_or_die(email_of_user_to_delete) 614 | db.delete(user) 615 | time.sleep(0.1) # encourage eventual consistency 616 | self.redirect('/admin/manage_users?sort_by=%s&msg=%s+deleted' 617 | % (sort_by, email_of_user_to_delete)) 618 | return 619 | 620 | user_q = models.User.all() 621 | results = user_q.fetch(1000) 622 | 623 | # Tuple: (email, is-hidden, creation-time, days since last snippet) 624 | user_data = [] 625 | for user in results: 626 | # Get the last snippet for that user. 627 | last_snippet = util.most_recent_snippet_for_user(user.email) 628 | if last_snippet: 629 | seconds_since_snippet = ( 630 | (_TODAY_FN().date() - last_snippet.week).total_seconds()) 631 | weeks_since_snippet = int( 632 | seconds_since_snippet / 633 | datetime.timedelta(days=7).total_seconds()) 634 | else: 635 | weeks_since_snippet = None 636 | user_data.append((user.email, user.is_hidden, 637 | user.created, weeks_since_snippet)) 638 | 639 | # We have to use 'cmp' here since we want ascending in the 640 | # primary key and descending in the secondary key, sometimes. 641 | if sort_by == 'email': 642 | user_data.sort(lambda x, y: cmp(x[0], y[0])) 643 | elif sort_by == 'creation_time': 644 | user_data.sort(lambda x, y: (-cmp(x[2] or datetime.datetime.min, 645 | y[2] or datetime.datetime.min) 646 | or cmp(x[0], y[0]))) 647 | elif sort_by == 'last_snippet_time': 648 | user_data.sort(lambda x, y: (-cmp(1000 if x[3] is None else x[3], 649 | 1000 if y[3] is None else y[3]) 650 | or cmp(x[0], y[0]))) 651 | else: 652 | raise ValueError('Invalid sort_by value "%s"' % sort_by) 653 | 654 | template_values = { 655 | 'logout_url': users.create_logout_url('/'), 656 | 'message': self.request.get('msg'), 657 | 'username': _current_user_email(), 658 | 'is_admin': users.is_current_user_admin(), 659 | 'view_week': util.existingsnippet_monday(_TODAY_FN()), 660 | 'user_data': user_data, 661 | 'sort_by': sort_by, 662 | } 663 | self.render_response('manage_users.html', template_values) 664 | 665 | 666 | # The following two classes are called by cron. 667 | 668 | 669 | def _get_email_to_current_snippet_map(today): 670 | """Return a map from email to True if they've written snippets this week. 671 | 672 | Goes through all users registered on the system, and checks if 673 | they have a snippet in the db for the appropriate snippet-week for 674 | 'today'. If so, they get entered into the return-map with value 675 | True. If not, they have value False. 676 | 677 | Note that users whose 'wants_email' field is set to False will not 678 | be included in either list. 679 | 680 | Arguments: 681 | today: a datetime.datetime object representing the 682 | 'current' day. We use the normal algorithm to determine what is 683 | the most recent snippet-week for this day. 684 | 685 | Returns: 686 | a map from email (user.email for each user) to True or False, 687 | depending on if they've written snippets for this week or not. 688 | """ 689 | user_q = models.User.all() 690 | users = user_q.fetch(1000) 691 | retval = {} 692 | for user in users: 693 | if not user.wants_email: # ignore this user 694 | continue 695 | retval[user.email] = False # assume the worst, for now 696 | 697 | week = util.existingsnippet_monday(today) 698 | snippets_q = models.Snippet.all() 699 | snippets_q.filter('week = ', week) 700 | snippets = snippets_q.fetch(1000) 701 | for snippet in snippets: 702 | if snippet.email in retval: # don't introduce new keys here 703 | retval[snippet.email] = True 704 | 705 | return retval 706 | 707 | 708 | def _maybe_send_snippets_mail(to, subject, template_path, template_values): 709 | try: 710 | app_settings = models.AppSettings.get() 711 | except ValueError: 712 | logging.error('Not sending email: app settings are not configured.') 713 | return 714 | if not app_settings.email_from: 715 | return 716 | 717 | template_values.setdefault('hostname', app_settings.hostname) 718 | 719 | jinja2_instance = jinja2.get_jinja2() 720 | mail.send_mail(sender=app_settings.email_from, 721 | to=to, 722 | subject=subject, 723 | body=jinja2_instance.render_template(template_path, 724 | **template_values)) 725 | # Appengine has a quota of 32 emails per minute: 726 | # https://developers.google.com/appengine/docs/quotas#Mail 727 | # We pause 2 seconds between each email to make sure we 728 | # don't go over that. 729 | time.sleep(2) 730 | 731 | 732 | class SendFridayReminderChat(BaseHandler): 733 | """Send a chat message to the configured chat room(s).""" 734 | 735 | def get(self): 736 | msg = 'Reminder: Weekly snippets due Monday at 5pm.' 737 | _send_to_chat(msg, "/") 738 | 739 | 740 | class SendReminderEmail(BaseHandler): 741 | """Send an email to everyone who doesn't have a snippet for this week.""" 742 | 743 | def _send_mail(self, email): 744 | template_values = {} 745 | _maybe_send_snippets_mail(email, 'Weekly snippets due today at 5pm', 746 | 'reminder_email.txt', template_values) 747 | 748 | def get(self): 749 | email_to_has_snippet = _get_email_to_current_snippet_map(_TODAY_FN()) 750 | for (user_email, has_snippet) in email_to_has_snippet.iteritems(): 751 | if not has_snippet: 752 | self._send_mail(user_email) 753 | logging.debug('sent reminder email to %s' % user_email) 754 | else: 755 | logging.debug('did not send reminder email to %s: ' 756 | 'has a snippet already' % user_email) 757 | 758 | msg = 'Reminder: Weekly snippets due today at 5pm.' 759 | _send_to_chat(msg, "/") 760 | 761 | 762 | class SendViewEmail(BaseHandler): 763 | """Send an email to everyone to look at the week's snippets.""" 764 | 765 | def _send_mail(self, email, has_snippets): 766 | template_values = {'has_snippets': has_snippets} 767 | _maybe_send_snippets_mail(email, 'Weekly snippets are ready!', 768 | 'view_email.txt', template_values) 769 | 770 | def get(self): 771 | email_to_has_snippet = _get_email_to_current_snippet_map(_TODAY_FN()) 772 | for (user_email, has_snippet) in email_to_has_snippet.iteritems(): 773 | self._send_mail(user_email, has_snippet) 774 | logging.debug('sent "view" email to %s' % user_email) 775 | 776 | msg = 'Weekly snippets are ready!' 777 | _send_to_chat(msg, "/weekly") 778 | 779 | 780 | application = webapp2.WSGIApplication([ 781 | ('/', UserPage), 782 | ('/weekly', SummaryPage), 783 | ('/update_snippet', UpdateSnippet), 784 | ('/settings', Settings), 785 | ('/update_settings', UpdateSettings), 786 | ('/admin/settings', AppSettings), 787 | ('/admin/update_settings', UpdateAppSettings), 788 | ('/admin/manage_users', ManageUsers), 789 | ('/admin/send_friday_reminder_chat', SendFridayReminderChat), 790 | ('/admin/send_reminder_email', SendReminderEmail), 791 | ('/admin/send_view_email', SendViewEmail), 792 | ('/slack', slacklib.SlashCommand), 793 | ], 794 | debug=True) 795 | -------------------------------------------------------------------------------- /snippets_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Tests for the snippets server. 4 | 5 | This tests the functionality found at weekly-snippets.appspot.com. 6 | 7 | c.f. http://code.google.com/appengine/docs/python/tools/localunittesting.html 8 | c.f. http://webtest.pythonpaste.org/en/latest/index.html 9 | """ 10 | 11 | __author__ = 'Craig Silverstein ' 12 | 13 | 14 | import datetime 15 | import os 16 | import re 17 | import sys 18 | import time 19 | try: # Work under either python2.5 or python2.7 20 | import unittest2 as unittest 21 | except ImportError: 22 | import unittest 23 | 24 | # Update sys.path so it can find these. We just need to add 25 | # 'google_appengine', but we add all of $PATH to be easy. This 26 | # assumes the google_appengine directory is on the path. 27 | sys.path.extend(os.environ['PATH'].split(':')) 28 | import dev_appserver 29 | dev_appserver.fix_sys_path() 30 | 31 | from google.appengine.datastore import datastore_stub_util 32 | from google.appengine.ext import db 33 | from google.appengine.ext import testbed 34 | import webtest # may need to do 'pip install webtest' 35 | 36 | import models 37 | import slacklib 38 | import snippets 39 | 40 | 41 | _TEST_TODAY = datetime.datetime(2012, 2, 23) 42 | 43 | 44 | class SnippetsTestBase(unittest.TestCase): 45 | def setUp(self): 46 | # We're not interested in testing consistency stuff in these tests. 47 | self.testbed = testbed.Testbed() 48 | self.testbed.activate() 49 | policy = datastore_stub_util.PseudoRandomHRConsistencyPolicy( 50 | probability=1) 51 | self.testbed.init_datastore_v3_stub(consistency_policy=policy) 52 | self.testbed.init_user_stub() 53 | self.request_fetcher = webtest.TestApp(snippets.application) 54 | snippets._TODAY_FN = lambda: _TEST_TODAY 55 | 56 | # Make sure we never accidentally send messages to chat. 57 | self.old_send_to_slack_channel = slacklib.send_to_slack_channel 58 | self.slack_sends = [] 59 | slacklib.send_to_slack_channel = ( 60 | lambda *args: self.slack_sends.append(args)) 61 | 62 | def tearDown(self): 63 | self.testbed.deactivate() 64 | slacklib.send_to_slack_channel = self.old_send_to_slack_channel 65 | 66 | def login(self, email): 67 | self.testbed.setup_env(user_email=email, overwrite=True) 68 | self.testbed.setup_env(user_id=email, overwrite=True) 69 | self.testbed.setup_env(user_is_admin='0', overwrite=True) 70 | # Now make sure there are global settings. 71 | settings = models.AppSettings.get( 72 | create_if_missing=True, 73 | domains=['example.com', 'some_other_domain.com'], 74 | ) 75 | settings.hostname = 'https://example.com' 76 | settings.put() 77 | 78 | def set_is_admin(self): 79 | self.testbed.setup_env(user_is_admin='1', overwrite=True) 80 | 81 | def assertNumSnippets(self, body, expected_count): 82 | """Assert the page 'body' has exactly expected_count snippets in it.""" 83 | # We annotate the div at the beginning of each snippet with 84 | # class=" unique-snippet". 85 | self.assertEqual(expected_count, body.count('unique-snippet'), 86 | body) 87 | 88 | def _ith_snippet(self, body, snippet_number): 89 | """For user- and weekly-pages, return the i-th snippet, 0-indexed.""" 90 | # The +1 is because the 0-th element is stuff before the 1st snippet. 91 | # If we get an IndexError, it means there aren't that many snippets. 92 | try: 93 | return body.split('unique-snippet', 94 | snippet_number + 2)[snippet_number + 1] 95 | except IndexError: 96 | raise IndexError('Has fewer than %d snippets:\n%s' 97 | % ((snippet_number + 1), body)) 98 | 99 | def assertInSnippet(self, text, body, snippet_number): 100 | """For snippet-page 'body', assert 'text' is in the i-th snippet. 101 | 102 | This works for both user-pages and weekly-pages -- we figure out 103 | the boundaries of the snippets, and check whether the text is 104 | in the given snippet. 105 | 106 | If text is a list or tuple, we check each item of text in turn. 107 | 108 | Arguments: 109 | text: the text to find in the snippet. If a list or tuple, 110 | test each item in the list in turn. 111 | body: the full html page 112 | snippet_number: which snippet on the page to examine. 113 | Index starts at 0. 114 | """ 115 | self.assertIn(text, self._ith_snippet(body, snippet_number)) 116 | 117 | def assertNotInSnippet(self, text, body, snippet_number): 118 | """For snippet-page 'body', assert 'text' is not in the ith snippet.""" 119 | self.assertNotIn(text, self._ith_snippet(body, snippet_number)) 120 | 121 | 122 | class UserTestBase(SnippetsTestBase): 123 | """The most common base: someone who is logged in as user@example.com.""" 124 | def setUp(self): 125 | super(UserTestBase, self).setUp() 126 | self.login('user@example.com') 127 | 128 | 129 | class PostTestCase(SnippetsTestBase): 130 | """test the correct output from the server when POSTing""" 131 | 132 | def testPostSnippet(self): 133 | self.login('user@example.com') 134 | url = '/update_snippet' 135 | params = { 136 | 'week': '02-20-2012', 137 | 'snippet': 'my inspired snippet' 138 | } 139 | response = self.request_fetcher.post(url, params, status=200) 140 | self.assertIn('{"status": 200, "message": "ok"}', response) 141 | 142 | def testPostSnippetAsOtherPerson(self): 143 | self.login('user@example.com') 144 | url = '/update_snippet' 145 | params = { 146 | 'week': '02-20-2012', 147 | 'snippet': 'my fallacious snippet', 148 | 'u': 'joeuser@example.com' 149 | } 150 | response = self.request_fetcher.post(url, params, status=403) 151 | self.assertIn('"status": 403', response) 152 | self.assertIn('joeuser@example.com', response) 153 | 154 | def testPostSnippetNotLoggedIn(self): 155 | url = '/update_snippet' 156 | params = { 157 | 'week': '02-20-2012', 158 | 'snippet': 'my fallacious snippet', 159 | 'u': 'user@example.com' 160 | } 161 | response = self.request_fetcher.post(url, params, status=403) 162 | self.assertIn('"status": 403', response) 163 | self.assertIn('"message": "not logged in"', response) 164 | 165 | def testPostSnippetIsolation(self): 166 | # updating a single snippet via POST should not affect other snippets 167 | self.login('user@example.com') 168 | 169 | # create three snippets 170 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 171 | self.request_fetcher.get(url) 172 | url = '/update_snippet?week=02-27-2012&snippet=my+second+snippet' 173 | self.request_fetcher.get(url) 174 | url = '/update_snippet?week=03-05-2012&snippet=my+third+snippet' 175 | self.request_fetcher.get(url) 176 | 177 | # update only middle snippet 178 | url = '/update_snippet' 179 | params = { 180 | 'week': '02-27-2012', 181 | 'snippet': 'updated second snippet' 182 | } 183 | self.request_fetcher.post(url, params, status=200) 184 | 185 | # make sure only the second snippet changed 186 | response = self.request_fetcher.get('/') 187 | self.assertInSnippet('>my third snippet<', response.body, 0) 188 | self.assertInSnippet('>updated second snippet<', response.body, 1) 189 | self.assertNotInSnippet('>my second snippet<', response.body, 1) 190 | self.assertInSnippet('>my snippet<', response.body, 2) 191 | 192 | 193 | class LoginRequiredTestCase(SnippetsTestBase): 194 | def assert_requires_login(self, response): 195 | """Assert that a response causes us to redirect to the login page.""" 196 | self.assertIn('login', response.headers.get('Location', '').lower()) 197 | 198 | def assert_does_not_require_login(self, response): 199 | """Assert that a response is not a redirect to the login page.""" 200 | self.assertNotIn('login', response.headers.get('Location', '').lower()) 201 | 202 | def testLoginRequiredForUserView(self): 203 | url = '/' 204 | response = self.request_fetcher.get(url) 205 | self.assert_requires_login(response) 206 | 207 | self.login('user@example.com') 208 | response = self.request_fetcher.get(url) 209 | self.assert_does_not_require_login(response) 210 | 211 | def testLoginRequiredForWeeklyView(self): 212 | url = '/weekly' 213 | response = self.request_fetcher.get(url) 214 | self.assert_requires_login(response) 215 | 216 | self.login('user@example.com') 217 | response = self.request_fetcher.get(url) 218 | self.assert_does_not_require_login(response) 219 | 220 | def testLoginRequiredForSettingsView(self): 221 | url = '/settings' 222 | response = self.request_fetcher.get(url) 223 | self.assert_requires_login(response) 224 | 225 | self.login('user@example.com') 226 | response = self.request_fetcher.get(url) 227 | self.assert_does_not_require_login(response) 228 | 229 | def testLoginRequiredToUpdateSnippet(self): 230 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 231 | response = self.request_fetcher.get(url) 232 | self.assert_requires_login(response) 233 | 234 | self.login('user@example.com') 235 | response = self.request_fetcher.get(url) 236 | self.assert_does_not_require_login(response) 237 | 238 | def testLoginRequiredToUpdateSettings(self): 239 | url = '/update_settings' 240 | response = self.request_fetcher.get(url) 241 | self.assert_requires_login(response) 242 | 243 | self.login('user@example.com') 244 | response = self.request_fetcher.get(url) 245 | self.assert_does_not_require_login(response) 246 | 247 | 248 | class AccessTestCase(UserTestBase): 249 | """Tests that you can't modify someone who is not yourself.""" 250 | 251 | def testCanViewOtherSnippets(self): 252 | url = '/?u=notuser@example.com' 253 | # Raises an error if we don't get a 200 response. 254 | self.request_fetcher.get(url) 255 | 256 | def testCanViewWeeklyPage(self): 257 | """u is ignored for /weekly, but included for completeness.""" 258 | url = '/weekly?u=notuser@example.com' 259 | self.request_fetcher.get(url) 260 | 261 | def testCannotEditOtherSnippets(self): 262 | url = ('/update_snippet?week=02-20-2012&snippet=my+snippet' 263 | '&u=notuser@example.com') 264 | # Raises an error if we don't get a 500 response (meaning no perm). 265 | self.request_fetcher.get(url, status=500) 266 | 267 | def testCannotViewOtherSettings(self): 268 | url = '/settings?u=notuser@example.com' 269 | self.request_fetcher.get(url, status=500) 270 | 271 | def testCannotEditOtherSettings(self): 272 | url = '/update_settings?u=notuser@example.com' 273 | self.request_fetcher.get(url, status=500) 274 | 275 | def testCanEditOwnSnippets(self): 276 | url = ('/update_snippet?week=02-20-2012&snippet=my+snippet' 277 | '&u=user@example.com') 278 | self.request_fetcher.get(url) 279 | 280 | def testCanViewOwnSettings(self): 281 | url = '/settings?u=user@example.com' 282 | self.request_fetcher.get(url) 283 | 284 | def testCanEditOwnSettings(self): 285 | url = '/update_settings?u=user@example.com' 286 | self.request_fetcher.get(url) 287 | 288 | def testCanEditOtherSnippetsAsAdmin(self): 289 | self.set_is_admin() 290 | url = ('/update_snippet?week=02-20-2012&snippet=my+snippet' 291 | '&u=notuser@example.com') 292 | self.request_fetcher.get(url) 293 | 294 | def testCanViewOtherSettingsAsAdmin(self): 295 | self.set_is_admin() 296 | url = '/settings?u=notuser@example.com' 297 | self.request_fetcher.get(url) 298 | 299 | def testCanEditOtherSettingsAsAdmin(self): 300 | self.set_is_admin() 301 | url = '/update_settings?u=notuser@example.com' 302 | self.request_fetcher.get(url) 303 | 304 | 305 | class NewUserTestCase(UserTestBase): 306 | """Test the workflow for registering as a new user.""" 307 | 308 | def testNewUserLogin(self): 309 | response = self.request_fetcher.get('/') 310 | self.assertIn('New user', response.body) 311 | 312 | def testNewUserContinueUrl(self): 313 | """After verifying settings, we should go back to snippet-entry.""" 314 | response = self.request_fetcher.get('/') 315 | m = re.search(r'', response.body) 316 | continue_url = m.group(1) 317 | 318 | settings_response = self.request_fetcher.get(continue_url) 319 | self.assertIn('name="redirect_to" value="snippet_entry"', 320 | settings_response.body) 321 | 322 | # Now kinda-simulate clicking on the submit button. 323 | done_response = self.request_fetcher.get( 324 | '/update_settings?u=user@example.com&redirect_to=snippet_entry') 325 | if done_response.status_int in (301, 302, 303, 304): 326 | done_response = done_response.follow() 327 | self.assertIn('Snippets for user@example.com', done_response.body) 328 | 329 | def testNewAdminWithNoAppSettings(self): 330 | """The first time someone logs in, we should go to app settings.""" 331 | self.set_is_admin() 332 | app_settings = models.AppSettings.get() 333 | app_settings.delete() 334 | 335 | response = self.request_fetcher.get('/') 336 | if response.status_int in (301, 302, 303, 304): 337 | response = response.follow() 338 | self.assertIn('Application settings', response.body) 339 | 340 | def testNewAdminContinueUrls(self): 341 | """We should go from app settings to user settings to snippet.""" 342 | self.set_is_admin() 343 | app_settings = models.AppSettings.get() 344 | app_settings.delete() 345 | 346 | response = self.request_fetcher.get('/') 347 | if response.status_int in (301, 302, 303, 304): 348 | response = response.follow() 349 | m = re.search(r'name="redirect_to" value="([^"]*)"', response.body) 350 | continue_url = m.group(1) 351 | 352 | # Now kinda-simulate clicking on the submit button. 353 | done_response = self.request_fetcher.get( 354 | '/admin/update_settings?domains=example.com&redirect_to=%s' 355 | % continue_url) 356 | if done_response.status_int in (301, 302, 303, 304): 357 | done_response = done_response.follow() 358 | self.assertIn('User settings', done_response.body) 359 | self.assertIn('name="redirect_to" value="snippet_entry"', 360 | done_response.body) 361 | 362 | def testNewUserWithNoAppSettings(self): 363 | """For non-admins, we should not offer the app-settings page.""" 364 | app_settings = models.AppSettings.get() 365 | app_settings.delete() 366 | 367 | response = self.request_fetcher.get('/') 368 | self.assertIn('<title>New user', response.body) 369 | 370 | def testNewUserInheritsAppDefaults(self): 371 | app_settings = models.AppSettings.get() 372 | app_settings.default_markdown = True 373 | app_settings.default_private = True 374 | app_settings.put() 375 | 376 | response = self.request_fetcher.get('/') 377 | m = re.search(r'', response.body) 378 | continue_url = m.group(1) 379 | settings_response = self.request_fetcher.get(continue_url) 380 | 381 | self.assertRegexpMatches(settings_response.body, 382 | r'name="markdown"\s+value="yes"\s+checked') 383 | self.assertRegexpMatches(settings_response.body, 384 | r'name="private"\s+value="yes"\s+checked') 385 | 386 | # Now change the app-defaults and make sure this is reflected in 387 | # the new-user setup page. 388 | app_settings.default_markdown = False 389 | app_settings.default_private = False 390 | app_settings.put() 391 | 392 | response = self.request_fetcher.get('/') 393 | m = re.search(r'', response.body) 394 | continue_url = m.group(1) 395 | settings_response = self.request_fetcher.get(continue_url) 396 | 397 | self.assertRegexpMatches(settings_response.body, 398 | r'name="markdown"\s+value="no"\s+checked') 399 | self.assertRegexpMatches(settings_response.body, 400 | r'name="private"\s+value="no"\s+checked') 401 | 402 | def testNewUserInValidDomain(self): 403 | self.login('newuser@example.com') 404 | response = self.request_fetcher.get('/settings') 405 | self.assertIn('User settings', response.body) 406 | 407 | def testNewUserInInvalidDomain(self): 408 | """Test you can not register as a new user from a random domain.""" 409 | self.login('newuser@notallowed.com') 410 | # TODO(csilvers): give a nice error page instead of a 500. 411 | self.request_fetcher.get('/settings', status=500) 412 | 413 | def testSettingsPageDoesNotCreateANewUser(self): 414 | """Only *saving* the settings should create a new user.""" 415 | response = self.request_fetcher.get('/') 416 | self.assertIn('<title>New user', response.body) 417 | m = re.search(r'', response.body) 418 | continue_url = m.group(1) 419 | self.request_fetcher.get(continue_url) # visit /settings 420 | 421 | # Now if we go to / again, we should get the new-user page again 422 | # because the settings were never saved. 423 | response = self.request_fetcher.get('/') 424 | self.assertIn('New user', response.body) 425 | 426 | 427 | class AppSettingsTestCase(UserTestBase): 428 | """Test the app-settings page.""" 429 | 430 | def setUp(self): 431 | super(AppSettingsTestCase, self).setUp() 432 | self.set_is_admin() 433 | 434 | def testDomainsParsing(self): 435 | self.request_fetcher.get( 436 | '/admin/update_settings?domains=a.com,b.com++c.com%0Bd.com,%0B') 437 | app_settings = models.AppSettings.get() 438 | self.assertEqual(['a.com', 'b.com', 'c.com', 'd.com'], 439 | app_settings.domains) 440 | 441 | 442 | class UserSettingsTestCase(UserTestBase): 443 | """Test setting and using user settings.""" 444 | 445 | def assertInputIsChecked(self, name, body, snippet_number): 446 | self.assertRegexpMatches(body, r'name="%s" value="True"\s+checked\s*>' 447 | % name, snippet_number) 448 | 449 | def assertInputIsNotChecked(self, name, body, snippet_number): 450 | self.assertRegexpMatches(body, r'name="%s" value="True"\s*>' % name, 451 | snippet_number) 452 | 453 | def testDefaultUserSettings(self): 454 | # Make sure the rest of the tests are actually testing 455 | # non-default behavior. 456 | self.request_fetcher.get('/update_settings?u=user@example.com') 457 | 458 | response = self.request_fetcher.get('/') 459 | # Neither private nor 'is-markdown' are checked by default. 460 | self.assertInputIsNotChecked('private', response.body, 0) 461 | self.assertInputIsNotChecked('is_markdown', response.body, 0) 462 | 463 | def testPrivateUser(self): 464 | self.request_fetcher.get( 465 | '/update_settings?u=user@example.com&private=yes') 466 | response = self.request_fetcher.get('/') 467 | self.assertInputIsChecked('private', response.body, 0) 468 | self.assertInputIsNotChecked('is_markdown', response.body, 0) 469 | 470 | def testMarkdownUser(self): 471 | self.request_fetcher.get( 472 | '/update_settings?u=user@example.com&markdown=yes') 473 | response = self.request_fetcher.get('/') 474 | self.assertInputIsNotChecked('private', response.body, 0) 475 | self.assertInputIsChecked('is_markdown', response.body, 0) 476 | 477 | def testSettingsForFilledInSnippets(self): 478 | url = '/update_snippet?week=02-21-2011&snippet=old+snippet' 479 | self.request_fetcher.get(url) 480 | response = self.request_fetcher.get('/') 481 | self.assertNumSnippets(response.body, 53) 482 | self.assertInputIsNotChecked('private', response.body, 9) 483 | self.assertInputIsNotChecked('is_markdown', response.body, 9) 484 | self.assertInSnippet('old snippet', response.body, 52) 485 | self.assertInputIsNotChecked('private', response.body, 52) 486 | self.assertInputIsNotChecked('is_markdown', response.body, 52) 487 | 488 | self.request_fetcher.get( 489 | '/update_settings?u=user@example.com&markdown=yes') 490 | response = self.request_fetcher.get('/') 491 | self.assertNumSnippets(response.body, 53) 492 | self.assertInputIsNotChecked('private', response.body, 9) 493 | self.assertInputIsChecked('is_markdown', response.body, 9) 494 | # But the existing snippet is unaffected. 495 | self.assertInSnippet('old snippet', response.body, 52) 496 | self.assertInputIsNotChecked('private', response.body, 52) 497 | self.assertInputIsNotChecked('is_markdown', response.body, 52) 498 | 499 | self.request_fetcher.get( 500 | '/update_settings?u=user@example.com&private=yes') 501 | response = self.request_fetcher.get('/') 502 | self.assertNumSnippets(response.body, 53) 503 | self.assertInputIsChecked('private', response.body, 9) 504 | self.assertInputIsNotChecked('is_markdown', response.body, 9) 505 | self.assertInSnippet('old snippet', response.body, 52) 506 | self.assertInputIsNotChecked('private', response.body, 52) 507 | self.assertInputIsNotChecked('is_markdown', response.body, 52) 508 | 509 | def testCategoryUnset(self): 510 | self.request_fetcher.get('/update_settings?u=user@example.com') 511 | response = self.request_fetcher.get('/') 512 | self.assertInSnippet( 513 | 'WARNING: Snippet will go in the "(unknown)"', 514 | response.body, 0 515 | ) 516 | 517 | def testCategorySet(self): 518 | self.request_fetcher.get('/update_settings?u=user@example.com') 519 | self.request_fetcher.get( 520 | '/update_settings?u=user@example.com&category=Dev') 521 | response = self.request_fetcher.get('/') 522 | self.assertNotInSnippet( 523 | 'WARNING: Snippet will go in the "(unknown)"', 524 | response.body, 0 525 | ) 526 | 527 | def testCategoryUnsetButSnippetHasContent(self): 528 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 529 | self.request_fetcher.get(url) 530 | 531 | response = self.request_fetcher.get('/') 532 | self.assertInSnippet( 533 | 'WARNING: Snippet will go in the "(unknown)"', 534 | response.body, 0 535 | ) 536 | 537 | def testCategoryCheckForFilledInSnippets(self): 538 | url = '/update_snippet?week=02-21-2011&snippet=old+snippet' 539 | self.request_fetcher.get(url) 540 | response = self.request_fetcher.get('/') 541 | self.assertNumSnippets(response.body, 53) 542 | self.assertInSnippet( 543 | 'WARNING: Snippet will go in the "(unknown)"', 544 | response.body, 9 545 | ) 546 | self.assertInSnippet('old snippet', response.body, 52) 547 | self.assertInSnippet( 548 | 'WARNING: Snippet will go in the "(unknown)"', 549 | response.body, 52 550 | ) 551 | 552 | def testHiddenUser(self): 553 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 554 | self.request_fetcher.get(url) 555 | 556 | response = self.request_fetcher.get('/') 557 | self.assertNumSnippets(response.body, 2) 558 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 559 | self.assertNumSnippets(response.body, 1) # "no snippet this week" 560 | 561 | url = '/update_settings?u=user@example.com&is_hidden=yes' 562 | self.request_fetcher.get(url) 563 | response = self.request_fetcher.get('/') 564 | # Hiding doesn't affect the user-snippets page, just the weekly one. 565 | self.assertNumSnippets(response.body, 2) 566 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 567 | self.assertNumSnippets(response.body, 0) 568 | # And it doesn't affect existing snippets, just empty ones. 569 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 570 | self.assertNumSnippets(response.body, 1) 571 | 572 | def testNewSnippetUnhides(self): 573 | url = '/update_snippet?week=02-13-2012&snippet=my+snippet' 574 | self.request_fetcher.get(url) 575 | 576 | url = '/update_settings?u=user@example.com&is_hidden=yes' 577 | self.request_fetcher.get(url) 578 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 579 | self.assertNumSnippets(response.body, 0) 580 | 581 | url = '/update_snippet?week=02-27-2012&snippet=new+snippet' 582 | self.request_fetcher.get(url) 583 | 584 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 585 | self.assertNumSnippets(response.body, 1) 586 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 587 | self.assertNumSnippets(response.body, 1) 588 | 589 | def testChangingSettingsUnhides(self): 590 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 591 | self.request_fetcher.get(url) 592 | 593 | url = '/update_settings?u=user@example.com&is_hidden=yes' 594 | self.request_fetcher.get(url) 595 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 596 | self.assertNumSnippets(response.body, 0) 597 | 598 | url = '/update_settings?u=user@example.com' 599 | self.request_fetcher.get(url) 600 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 601 | self.assertNumSnippets(response.body, 1) 602 | 603 | def testHideButton(self): 604 | # First, register the user. 605 | url = '/update_settings?u=user@example.com' 606 | self.request_fetcher.get(url) 607 | 608 | url = '/update_snippet?week=02-13-2012&snippet=my+snippet' 609 | self.request_fetcher.get(url) 610 | 611 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 612 | self.assertNumSnippets(response.body, 1) 613 | 614 | # Now hide using the hide button. 615 | url = '/update_settings?u=user@example.com&hide=Hide' 616 | self.request_fetcher.get(url) 617 | 618 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 619 | self.assertNumSnippets(response.body, 0) 620 | 621 | def testDeleteButton(self): 622 | # First, register the user. 623 | url = '/update_settings?category=dummy' 624 | self.request_fetcher.get(url) 625 | 626 | response = self.request_fetcher.get('/') 627 | self.assertNotIn('New user', response.body) 628 | 629 | url = '/update_settings?u=user@example.com&delete=Delete' 630 | self.request_fetcher.get(url) 631 | 632 | response = self.request_fetcher.get('/') 633 | self.assertIn('New user', response.body) 634 | 635 | 636 | class SetAndViewSnippetsTestCase(UserTestBase): 637 | """Set some snippets, then make sure they're viewable.""" 638 | 639 | def testSetAndViewInUserMode(self): 640 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 641 | self.request_fetcher.get(url) 642 | response = self.request_fetcher.get('/') 643 | self.assertNumSnippets(response.body, 2) 644 | self.assertInSnippet('>my snippet<', response.body, 0) 645 | 646 | def testSetAndViewInWeeklyMode(self): 647 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 648 | self.request_fetcher.get(url) 649 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 650 | self.assertNumSnippets(response.body, 1) 651 | self.assertInSnippet('>my snippet<', response.body, 0) 652 | 653 | def testCannotSeeInOtherWeek(self): 654 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 655 | self.request_fetcher.get(url) 656 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 657 | self.assertNumSnippets(response.body, 1) 658 | self.assertNotIn('>my snippet<', response.body) 659 | 660 | def testViewSnippetsForTwoUsers(self): 661 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 662 | self.request_fetcher.get(url) 663 | self.login('other@example.com') 664 | url = '/update_snippet?week=02-20-2012&snippet=other+snippet' 665 | self.request_fetcher.get(url) 666 | 667 | # This is done as other 668 | response = self.request_fetcher.get('/') 669 | self.assertIn('other@example.com', response.body) 670 | self.assertNumSnippets(response.body, 2) 671 | self.assertInSnippet('>other snippet<', response.body, 0) 672 | self.assertNotIn('user@example.com', response.body) 673 | self.assertNotIn('>my snippet<', response.body) 674 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 675 | self.assertNumSnippets(response.body, 2) 676 | self.assertInSnippet('other@example.com', response.body, 0) 677 | self.assertInSnippet('>other snippet<', response.body, 0) 678 | self.assertInSnippet('user@example.com', response.body, 1) 679 | self.assertInSnippet('>my snippet<', response.body, 1) 680 | 681 | # This is done as user 682 | self.login('user@example.com') 683 | response = self.request_fetcher.get('/') 684 | self.assertIn('user@example.com', response.body) 685 | self.assertNumSnippets(response.body, 2) 686 | self.assertInSnippet('>my snippet<', response.body, 0) 687 | self.assertNotIn('other@example.com', response.body) 688 | self.assertNotIn('>other snippet<', response.body) 689 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 690 | self.assertNumSnippets(response.body, 2) 691 | self.assertInSnippet('other@example.com', response.body, 0) 692 | self.assertInSnippet('>other snippet<', response.body, 0) 693 | self.assertInSnippet('user@example.com', response.body, 1) 694 | self.assertInSnippet('>my snippet<', response.body, 1) 695 | 696 | def testViewSnippetsForTwoWeeks(self): 697 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 698 | self.request_fetcher.get(url) 699 | url = '/update_snippet?week=02-27-2012&snippet=my+second+snippet' 700 | self.request_fetcher.get(url) 701 | 702 | response = self.request_fetcher.get('/') 703 | self.assertNumSnippets(response.body, 3) 704 | # Snippets go in reverse chronological order (i.e. newest first) 705 | self.assertInSnippet('>my second snippet<', response.body, 0) 706 | self.assertInSnippet('>my snippet<', response.body, 1) 707 | 708 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 709 | self.assertNumSnippets(response.body, 1) 710 | self.assertInSnippet('user@example.com', response.body, 0) 711 | self.assertInSnippet('>my snippet<', response.body, 0) 712 | self.assertNotIn('>my second snippet<', response.body) 713 | 714 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 715 | self.assertNumSnippets(response.body, 1) 716 | self.assertInSnippet('user@example.com', response.body, 0) 717 | self.assertInSnippet('>my second snippet<', response.body, 0) 718 | self.assertNotIn('>my snippet<', response.body) 719 | 720 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 721 | self.assertNumSnippets(response.body, 1) 722 | self.assertNotIn('>my snippet<', response.body) 723 | self.assertNotIn('>my second snippet<', response.body) 724 | 725 | def testViewEmptySnippetsInWeekMode(self): 726 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 727 | self.request_fetcher.get(url) 728 | self.login('other@example.com') 729 | url = '/update_snippet?week=02-27-2012&snippet=other+snippet' 730 | self.request_fetcher.get(url) 731 | 732 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 733 | self.assertNumSnippets(response.body, 2) 734 | # Other-user comes first alphabetically. 735 | self.assertInSnippet('other@example.com', response.body, 0) 736 | self.assertInSnippet('user@example.com', response.body, 1) 737 | self.assertInSnippet('>my snippet<', response.body, 1) 738 | 739 | response = self.request_fetcher.get('/weekly?week=02-27-2012') 740 | self.assertNumSnippets(response.body, 2) 741 | self.assertInSnippet('other@example.com', response.body, 0) 742 | self.assertInSnippet('>other snippet<', response.body, 0) 743 | self.assertInSnippet('user@example.com', response.body, 1) 744 | 745 | def testViewEmptySnippetsInUserMode(self): 746 | """Occurs when there's a gap between two snippets.""" 747 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 748 | self.request_fetcher.get(url) 749 | url = '/update_snippet?week=02-06-2012&snippet=my+old+snippet' 750 | self.request_fetcher.get(url) 751 | 752 | response = self.request_fetcher.get('/') 753 | self.assertNumSnippets(response.body, 3) 754 | self.assertInSnippet('>my snippet<', response.body, 0) 755 | self.assertInSnippet('>my old snippet<', response.body, 2) 756 | 757 | def testCategorizeSnippets(self): 758 | """Weekly view should sort based on user categories.""" 759 | # I give the users numeric names to make it easy to see sorting. 760 | self.login('2@example.com') 761 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 762 | self.request_fetcher.get(url) 763 | url = '/update_settings?category=a+1st' 764 | self.request_fetcher.get(url) 765 | 766 | self.login('3@example.com') 767 | url = '/update_snippet?week=02-20-2012&snippet=also+snippet' 768 | self.request_fetcher.get(url) 769 | url = '/update_settings?category=a+1st' 770 | self.request_fetcher.get(url) 771 | 772 | self.login('1@example.com') 773 | url = '/update_snippet?week=02-20-2012&snippet=late+snippet' 774 | self.request_fetcher.get(url) 775 | url = '/update_settings?category=b+2nd' 776 | self.request_fetcher.get(url) 777 | 778 | self.login('4@example.com') 779 | url = '/update_snippet?week=02-20-2012&snippet=late+snippet' 780 | self.request_fetcher.get(url) 781 | 782 | # Order should be 4 ((unknown) category), 2 and 3 (a 1st) and 783 | # then 1 (b 2nd). 784 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 785 | self.assertNumSnippets(response.body, 4) 786 | self.assertInSnippet('4@example.com', response.body, 0) 787 | self.assertInSnippet('2@example.com', response.body, 1) 788 | self.assertInSnippet('3@example.com', response.body, 2) 789 | self.assertInSnippet('1@example.com', response.body, 3) 790 | 791 | def testViewSnippetAfterAUserIsDeleted(self): 792 | """When a user is deleted, their snippet should still show up.""" 793 | self.login('2@example.com') 794 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 795 | self.request_fetcher.get(url) 796 | url = '/update_settings?category=a+1st' 797 | self.request_fetcher.get(url) 798 | 799 | # Now delete user 2 800 | u = models.User.all().filter('email =', '2@example.com').get() 801 | u.delete() 802 | 803 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 804 | self.assertNumSnippets(response.body, 1) 805 | self.assertInSnippet('2@example.com', response.body, 0) 806 | self.assertTrue('(unknown)' in response.body) 807 | 808 | def testWarningsWhenDue(self): 809 | url = '/update_snippet?week=02-06-2012&snippet=old+snippet' 810 | self.request_fetcher.get(url) 811 | 812 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 19) 813 | response = self.request_fetcher.get('/') 814 | self.assertNotIn('Due today', response.body) 815 | self.assertNotIn('OVERDUE', response.body) 816 | 817 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20) 818 | response = self.request_fetcher.get('/') 819 | self.assertIn('Due today', response.body) 820 | self.assertNotIn('OVERDUE', response.body) 821 | 822 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 21) 823 | response = self.request_fetcher.get('/') 824 | self.assertNotIn('Due today', response.body) 825 | self.assertIn('OVERDUE', response.body) 826 | 827 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 22) 828 | response = self.request_fetcher.get('/') 829 | self.assertNotIn('Due today', response.body) 830 | self.assertIn('OVERDUE', response.body) 831 | 832 | def testWarningsWhenNotDue(self): 833 | url = '/update_snippet?week=02-13-2012&snippet=my+snippet' 834 | self.request_fetcher.get(url) 835 | 836 | for date in (19, 20, 21, 22): 837 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, date) 838 | response = self.request_fetcher.get('/') 839 | self.assertNotIn('Due today', response.body) 840 | self.assertNotIn('OVERDUE', response.body) 841 | 842 | def testPrettyDateFormatting(self): 843 | # Just so we're not a new user. 844 | url = '/update_snippet?week=02-06-2012&snippet=my+snippet' 845 | self.request_fetcher.get(url) 846 | 847 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 6) 848 | response = self.request_fetcher.get('/') 849 | self.assertIn('February 6, 2012', response.body) 850 | 851 | def testUrlize(self): 852 | url = '/update_snippet?week=02-20-2012&snippet=visit+http://foo.com' 853 | self.request_fetcher.get(url) 854 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 855 | self.assertNumSnippets(response.body, 1) 856 | self.assertInSnippet( 857 | '>visit http://foo.com<', 858 | response.body, 0) 859 | 860 | # Also make sure we urlize on the user page. 861 | self.login('2@example.com') 862 | response = self.request_fetcher.get('/?u=user@example.com') 863 | self.assertNumSnippets(response.body, 2) 864 | self.assertInSnippet( 865 | '>visit http://foo.com<', 866 | response.body, 0) 867 | 868 | def testEditMode(self): 869 | url = '/update_snippet?week=02-20-2012&snippet=hello' 870 | self.request_fetcher.get(url) 871 | 872 | response = self.request_fetcher.get('/?u=user@example.com') 873 | self.assertIn('Make snippet private', response.body) 874 | 875 | response = self.request_fetcher.get('/?u=user@example.com&edit=1') 876 | self.assertIn('Make snippet private', response.body) 877 | 878 | response = self.request_fetcher.get('/?u=user@example.com&edit=0') 879 | self.assertNotIn('Make snippet private', response.body) 880 | 881 | 882 | class ShowCorrectWeekTestCase(UserTestBase): 883 | """Test we show the right snippets for edit/view based on day of week.""" 884 | 885 | def setUp(self): 886 | super(ShowCorrectWeekTestCase, self).setUp() 887 | # Register the user so snippet-fetching works. 888 | url = '/update_settings?category=dummy' 889 | self.request_fetcher.get(url) 890 | 891 | def testMonday(self): 892 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20) 893 | response = self.request_fetcher.get('/') 894 | self.assertInSnippet('February 20, 2012', response.body, 0) 895 | # For *viewing*'s snippets, we always show last week's snippets. 896 | response = self.request_fetcher.get('/weekly') 897 | self.assertIn('February 13, 2012', response.body) 898 | 899 | def testTuesday(self): 900 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 21) 901 | response = self.request_fetcher.get('/') 902 | self.assertInSnippet('February 20, 2012', response.body, 0) 903 | response = self.request_fetcher.get('/weekly') 904 | self.assertIn('February 13, 2012', response.body) 905 | 906 | def testWednesday(self): 907 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 22) 908 | response = self.request_fetcher.get('/') 909 | self.assertInSnippet('February 20, 2012', response.body, 0) 910 | response = self.request_fetcher.get('/weekly') 911 | self.assertIn('February 13, 2012', response.body) 912 | 913 | def testThursday(self): 914 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 23) 915 | response = self.request_fetcher.get('/') 916 | self.assertInSnippet('February 20, 2012', response.body, 0) 917 | response = self.request_fetcher.get('/weekly') 918 | self.assertIn('February 13, 2012', response.body) 919 | 920 | def testFriday(self): 921 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 24) 922 | response = self.request_fetcher.get('/') 923 | self.assertInSnippet('February 20, 2012', response.body, 0) 924 | response = self.request_fetcher.get('/weekly') 925 | self.assertIn('February 13, 2012', response.body) 926 | 927 | def testSaturday(self): 928 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 25) 929 | response = self.request_fetcher.get('/') 930 | self.assertInSnippet('February 20, 2012', response.body, 0) 931 | response = self.request_fetcher.get('/weekly') 932 | self.assertIn('February 13, 2012', response.body) 933 | 934 | def testSunday(self): 935 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 26) 936 | response = self.request_fetcher.get('/') 937 | self.assertInSnippet('February 20, 2012', response.body, 0) 938 | response = self.request_fetcher.get('/weekly') 939 | self.assertIn('February 13, 2012', response.body) 940 | 941 | 942 | class NosnippetGapFillingTestCase(UserTestBase): 943 | """Test we show correct text when folks miss a week for snippets.""" 944 | 945 | def testNoSnippets(self): 946 | # If nobody is registered, the user-db will be empty. 947 | response = self.request_fetcher.get('/weekly') 948 | self.assertNumSnippets(response.body, 0) 949 | 950 | url = '/update_settings?category=dummy' # register the user 951 | self.request_fetcher.get(url) 952 | 953 | response = self.request_fetcher.get('/') 954 | self.assertNumSnippets(response.body, 2) 955 | 956 | def testOneSnippetInDistantPast(self): 957 | url = '/update_snippet?week=02-21-2011&snippet=old+snippet' 958 | self.request_fetcher.get(url) 959 | response = self.request_fetcher.get('/') 960 | self.assertNumSnippets(response.body, 53) 961 | self.assertInSnippet('old snippet', response.body, 52) 962 | 963 | response = self.request_fetcher.get('/weekly') 964 | self.assertNumSnippets(response.body, 1) 965 | 966 | def testTwoSnippetsInDistantPast(self): 967 | url = '/update_snippet?week=08-22-2011&snippet=oldish+snippet' 968 | self.request_fetcher.get(url) 969 | url = '/update_snippet?week=02-21-2011&snippet=old+snippet' 970 | self.request_fetcher.get(url) 971 | response = self.request_fetcher.get('/') 972 | self.assertNumSnippets(response.body, 53) 973 | self.assertInSnippet('oldish snippet', response.body, 26) 974 | self.assertInSnippet('old snippet', response.body, 52) 975 | 976 | response = self.request_fetcher.get('/weekly') 977 | self.assertNumSnippets(response.body, 1) 978 | 979 | def testSnippetInTheFuture(self): 980 | url = '/update_snippet?week=02-18-2013&snippet=future+snippet' 981 | self.request_fetcher.get(url) 982 | response = self.request_fetcher.get('/') 983 | self.assertNumSnippets(response.body, 54) 984 | self.assertInSnippet('future snippet', response.body, 0) 985 | 986 | response = self.request_fetcher.get('/weekly') 987 | self.assertNumSnippets(response.body, 1) 988 | 989 | def testSnippetInThePastAndFuture(self): 990 | url = '/update_snippet?week=02-21-2011&snippet=old+snippet' 991 | self.request_fetcher.get(url) 992 | url = '/update_snippet?week=02-18-2013&snippet=future+snippet' 993 | self.request_fetcher.get(url) 994 | response = self.request_fetcher.get('/') 995 | self.assertNumSnippets(response.body, 105) 996 | self.assertInSnippet('future snippet', response.body, 0) 997 | self.assertInSnippet('old snippet', response.body, 104) 998 | 999 | response = self.request_fetcher.get('/weekly') 1000 | self.assertNumSnippets(response.body, 1) 1001 | 1002 | 1003 | class PrivateSnippetTestCase(UserTestBase): 1004 | """Tests that we properly restrict viewing of private snippets.""" 1005 | 1006 | def setUp(self): 1007 | super(PrivateSnippetTestCase, self).setUp() 1008 | # Set up a user with some private and some not-private snippets, 1009 | # another user with only private, and another with only public. 1010 | self.login('private@example.com') 1011 | url = '/update_snippet?week=02-13-2012&snippet=no+see+um&private=True' 1012 | self.request_fetcher.get(url) 1013 | url = '/update_snippet?week=02-20-2012&snippet=no+see+um2&private=True' 1014 | self.request_fetcher.get(url) 1015 | 1016 | self.login('public@example.com') 1017 | url = '/update_snippet?week=02-13-2012&snippet=see+me!' 1018 | self.request_fetcher.get(url) 1019 | url = '/update_snippet?week=02-20-2012&snippet=see+me+2!' 1020 | self.request_fetcher.get(url) 1021 | 1022 | self.login('mixed@example.com') 1023 | url = '/update_snippet?week=02-13-2012&snippet=cautious&private=True' 1024 | self.request_fetcher.get(url) 1025 | url = '/update_snippet?week=02-20-2012&snippet=not+cautious' 1026 | self.request_fetcher.get(url) 1027 | 1028 | self.login('private@some_other_domain.com') 1029 | url = '/update_snippet?week=02-13-2012&snippet=foreign&private=True' 1030 | self.request_fetcher.get(url) 1031 | url = '/update_snippet?week=02-20-2012&snippet=foreign2&private=True' 1032 | self.request_fetcher.get(url) 1033 | 1034 | self.login('user@example.com') # back to the normal user 1035 | 1036 | def testAdminCanSeeAllSnippets(self): 1037 | self.set_is_admin() 1038 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 1039 | self.assertNumSnippets(response.body, 4) 1040 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1041 | self.assertNumSnippets(response.body, 4) 1042 | 1043 | def testUserCanSeeAllSnippetsFromTheirDomain(self): 1044 | # As user@example.com, we can see all but some_other_domain.com 1045 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 1046 | # For private@some_other_domain.com, we should see 'no snippet found'. 1047 | self.assertNumSnippets(response.body, 4) 1048 | self.assertInSnippet('private@some_other_domain.com', response.body, 2) 1049 | self.assertNotInSnippet('foreign', response.body, 2) 1050 | # We *should* see stuff from our domain, but marked private. 1051 | self.assertInSnippet('private@example.com', response.body, 1) 1052 | self.assertInSnippet('snippet-tag-private', response.body, 1) 1053 | self.assertInSnippet('no see um', response.body, 1) 1054 | # And we should see public snippets, not marked private. 1055 | self.assertInSnippet('public@example.com', response.body, 3) 1056 | self.assertNotInSnippet('snippet-tag-private', response.body, 3) 1057 | self.assertInSnippet('see me', response.body, 3) 1058 | 1059 | self.login('random@some_other_domain.com') 1060 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 1061 | self.assertNumSnippets(response.body, 4) 1062 | self.assertInSnippet('private@some_other_domain.com', response.body, 2) 1063 | self.assertInSnippet('foreign', response.body, 2) 1064 | self.assertInSnippet('snippet-tag-private', response.body, 2) 1065 | # Now we shouldn't see stuff from example.com 1066 | self.assertInSnippet('private@example.com', response.body, 1) 1067 | self.assertNotInSnippet('no see um', response.body, 1) 1068 | # And we should also see public snippets, not in gray. 1069 | self.assertInSnippet('public@example.com', response.body, 3) 1070 | self.assertNotInSnippet('snippet-tag-private', response.body, 3) 1071 | self.assertInSnippet('see me', response.body, 3) 1072 | 1073 | def testPrivacyIsPerSnippet(self): 1074 | self.login('random@some_other_domain.com') 1075 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 1076 | self.assertNumSnippets(response.body, 4) 1077 | self.assertInSnippet('mixed@example.com', response.body, 0) 1078 | self.assertNotInSnippet('cautious', response.body, 0) 1079 | 1080 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1081 | self.assertNumSnippets(response.body, 4) 1082 | self.assertInSnippet('mixed@example.com', response.body, 0) 1083 | self.assertInSnippet('not cautious', response.body, 0) 1084 | 1085 | def testDomainMatching(self): 1086 | # Let's make it legal for all these domains to log in. 1087 | app_settings = models.AppSettings.get() 1088 | app_settings.domains = ['example.com', 'example.comm', 'example.co', 1089 | 'my-example.com', 'ample.com'] 1090 | app_settings.put() 1091 | 1092 | self.login('close@example.comm') 1093 | url = '/update_snippet?week=02-13-2012&snippet=whoa+comm&private=True' 1094 | self.request_fetcher.get(url) 1095 | 1096 | self.login('close@example.co') 1097 | url = '/update_snippet?week=02-13-2012&snippet=whoa+co&private=True' 1098 | self.request_fetcher.get(url) 1099 | 1100 | self.login('close@my-example.com') 1101 | url = '/update_snippet?week=02-13-2012&snippet=whoa+my-&private=True' 1102 | self.request_fetcher.get(url) 1103 | 1104 | self.login('close@ample.com') 1105 | url = '/update_snippet?week=02-13-2012&snippet=whoa+ample&private=True' 1106 | self.request_fetcher.get(url) 1107 | 1108 | self.login('user@example.com') 1109 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 1110 | self.assertNumSnippets(response.body, 8) 1111 | for i in (0, 1, 2, 3): # the 4 close@ snippets should sort first 1112 | self.assertInSnippet('close@', response.body, i) 1113 | self.assertNotInSnippet('whoa', response.body, i) 1114 | 1115 | 1116 | class MarkdownSnippetTestCase(UserTestBase): 1117 | """Tests that we properly render snippets using markdown (or not). 1118 | 1119 | Sadly, the actual markdown is done in javascript, so the best we 1120 | can test here is that the content is marked with the appropriate 1121 | class. 1122 | """ 1123 | def setUp(self): 1124 | super(MarkdownSnippetTestCase, self).setUp() 1125 | 1126 | # Set up some snippets as markdown, and some not. 1127 | url = ('/update_snippet?week=02-13-2012&snippet=*+item+1%0A*+item+2' 1128 | '&is_markdown=True') 1129 | self.request_fetcher.get(url) 1130 | url = '/update_snippet?week=02-20-2012&snippet=*+item+3%0A*+item+4' 1131 | self.request_fetcher.get(url) 1132 | 1133 | def testMarkdownRendering(self): 1134 | response = self.request_fetcher.get('/weekly?week=02-13-2012') 1135 | self.assertInSnippet('class="snippet-text-markdown', response.body, 0) 1136 | 1137 | def testTextRendering(self): 1138 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1139 | self.assertInSnippet('class="snippet-text', response.body, 0) 1140 | 1141 | 1142 | class ManageUsersTestCase(UserTestBase): 1143 | """Test we can delete users properly.""" 1144 | def setUp(self): 1145 | super(ManageUsersTestCase, self).setUp() 1146 | 1147 | # Have users with various snippet characteristics. 1148 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 0) 1149 | self.login('has_one_snippet@example.com') 1150 | self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s1') 1151 | 1152 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 1) 1153 | self.login('has_many_snippets@example.com') 1154 | self.request_fetcher.get('/update_snippet?week=01-30-2012&snippet=s2') 1155 | self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s3') 1156 | 1157 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 2) 1158 | self.login('has_old_snippet@example.com') 1159 | self.request_fetcher.get('/update_snippet?week=02-14-2011&snippet=s4') 1160 | 1161 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 20, 12, 0, 3) 1162 | self.login('has_no_snippets@example.com') 1163 | self.request_fetcher.get('/update_settings') 1164 | 1165 | def get_user_list(self, body): 1166 | """Returns the email usernames of the user-list, in order.""" 1167 | return re.findall(r'name="delete ([^@]*)@example.com"', body) 1168 | 1169 | def testMustBeAdminToManageUsers(self): 1170 | # Don't know how to test this -- it's enforced by app.yaml 1171 | pass 1172 | 1173 | def testSortByEmail(self): 1174 | response = self.request_fetcher.get('/admin/manage_users' 1175 | '?sort_by=email') 1176 | expected = ['has_many_snippets', 'has_no_snippets', 1177 | 'has_old_snippet', 'has_one_snippet'] 1178 | self.assertEqual(expected, self.get_user_list(response.body)) 1179 | self.assertNotIn('@example.com deleted', response.body) # we didn't 1180 | 1181 | def testSortByCreation(self): 1182 | response = self.request_fetcher.get('/admin/manage_users' 1183 | '?sort_by=creation_time') 1184 | # Reverse order from when we created them above. 1185 | expected = ['has_no_snippets', 'has_old_snippet', 1186 | 'has_many_snippets', 'has_one_snippet'] 1187 | self.assertEqual(expected, self.get_user_list(response.body)) 1188 | self.assertNotIn('@example.com deleted', response.body) # we didn't 1189 | 1190 | def testSortByLastSnippet(self): 1191 | response = self.request_fetcher.get('/admin/manage_users' 1192 | '?sort_by=last_snippet_time') 1193 | # 'many' and 'one' are tied; the tiebreak is email. 1194 | expected = ['has_no_snippets', 'has_old_snippet', 1195 | 'has_many_snippets', 'has_one_snippet'] 1196 | self.assertEqual(expected, self.get_user_list(response.body)) 1197 | self.assertNotIn('@example.com deleted', response.body) # we didn't 1198 | 1199 | def testBadSortBy(self): 1200 | # status=500 means we expect to get back a 500 error for this. 1201 | self.request_fetcher.get('/admin/manage_users?sort_by=unknown', 1202 | status=500) 1203 | 1204 | def testDelete(self): 1205 | response = self.request_fetcher.get( 1206 | '/admin/manage_users?delete+has_old_snippet@example.com=Delete') 1207 | if response.status_int in (301, 302, 303, 304): 1208 | response = response.follow() 1209 | 1210 | expected = ['has_no_snippets', 'has_many_snippets', 'has_one_snippet'] 1211 | self.assertEqual(expected, self.get_user_list(response.body)) 1212 | self.assertIn('has_old_snippet@example.com deleted', response.body) 1213 | 1214 | def testHide(self): 1215 | response = self.request_fetcher.get( 1216 | '/admin/manage_users?hide+has_old_snippet@example.com=Hide') 1217 | if response.status_int in (301, 302, 303, 304): 1218 | response = response.follow() 1219 | 1220 | expected = ['has_no_snippets', 'has_old_snippet', 1221 | 'has_many_snippets', 'has_one_snippet'] 1222 | self.assertEqual(expected, self.get_user_list(response.body)) 1223 | self.assertIn('has_old_snippet@example.com hidden', response.body) 1224 | self.assertIn('value="Unhide"', response.body) 1225 | 1226 | def testPreserveSortBy(self): 1227 | response = self.request_fetcher.get( 1228 | '/admin/manage_users?hide+has_old_snippet@example.com=Hide' 1229 | '&sort_by=last_snippet_time') 1230 | if response.status_int in (301, 302, 303, 304): 1231 | response = response.follow() 1232 | 1233 | # "Last snippet" shouldn't have a link letting you sort by 1234 | # last snippet, because it should already be doing so! 1235 | self.assertIn('Last snippet', response.body) 1236 | 1237 | def testUnhide(self): 1238 | self.request_fetcher.get( 1239 | '/admin/manage_users?hide+has_old_snippet@example.com=Hide') 1240 | response = self.request_fetcher.get( 1241 | '/admin/manage_users?unhide+has_old_snippet@example.com=Hide') 1242 | if response.status_int in (301, 302, 303, 304): 1243 | response = response.follow() 1244 | 1245 | expected = ['has_no_snippets', 'has_old_snippet', 1246 | 'has_many_snippets', 'has_one_snippet'] 1247 | self.assertEqual(expected, self.get_user_list(response.body)) 1248 | self.assertIn('has_old_snippet@example.com unhidden', response.body) 1249 | self.assertNotIn('value="Unhide"', response.body) 1250 | 1251 | def testInvalidButton(self): 1252 | self.request_fetcher.get('/admin/manage_users' 1253 | '?delete+has_old_snippet=Delete', 1254 | status=500) 1255 | self.request_fetcher.get('/admin/manage_users' 1256 | '?hide+has_old_snippet=Hide', 1257 | status=500) 1258 | self.request_fetcher.get('/admin/manage_users' 1259 | '?unhide+has_old_snippet=Unhide', 1260 | status=500) 1261 | 1262 | 1263 | class SendingEmailTestCase(UserTestBase): 1264 | """Test we correctly send cron emails.""" 1265 | 1266 | def setUp(self): 1267 | super(SendingEmailTestCase, self).setUp() 1268 | self.testbed.init_mail_stub() 1269 | self.mail_stub = self.testbed.get_stub(testbed.MAIL_SERVICE_NAME) 1270 | # The email-senders sleep 2 seconds between sends for quota 1271 | # reasons. We don't want that for most tests, so we suppress 1272 | # it. The quota test will redefine time.sleep itself. 1273 | self.sleep_fn = time.sleep 1274 | time.sleep = lambda sec: sec 1275 | 1276 | # We send out mail on Sunday nights and Monday mornings, so 1277 | # we'll set 'today' to be Sunday right around midnight. 1278 | snippets._TODAY_FN = lambda: datetime.datetime(2012, 2, 19, 23, 50, 0) 1279 | 1280 | # For our mail tests, we set up a db with a few users, some of 1281 | # whom have snippets for this week ('this week' being 13 Feb 1282 | # 2012), some of whom don't. 1283 | self.login('has_snippet@example.com') 1284 | self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s1') 1285 | 1286 | self.login('has_many_snippets@example.com') 1287 | self.request_fetcher.get('/update_snippet?week=01-30-2012&snippet=s2') 1288 | self.request_fetcher.get('/update_snippet?week=02-13-2012&snippet=s3') 1289 | 1290 | self.login('does_not_have_snippet@example.com') 1291 | self.request_fetcher.get('/update_snippet?week=01-30-2012&snippet=s4') 1292 | 1293 | self.login('has_no_snippets@example.com') 1294 | self.request_fetcher.get('/update_settings?reminder_email=yes') 1295 | 1296 | self.login('user@example.com') # back to the normal user 1297 | 1298 | def tearDown(self): 1299 | UserTestBase.tearDown(self) 1300 | time.sleep = self.sleep_fn 1301 | 1302 | def assertEmailSentTo(self, email): 1303 | r = self.mail_stub.get_sent_messages(to=email) 1304 | self.assertEqual(1, len(r), r) 1305 | 1306 | def assertEmailNotSentTo(self, email): 1307 | r = self.mail_stub.get_sent_messages(to=email) 1308 | self.assertEqual(0, len(r), r) 1309 | 1310 | def assertEmailContains(self, email, text): 1311 | r = self.mail_stub.get_sent_messages(to=email) 1312 | self.assertEqual(1, len(r), r) 1313 | self.assertIn(text, r[0].body.decode()) 1314 | 1315 | def assertEmailDoesNotContain(self, email, text): 1316 | r = self.mail_stub.get_sent_messages(to=email) 1317 | self.assertEqual(1, len(r), r) 1318 | self.assertNotIn(text, r[0].body.decode()) 1319 | 1320 | def testMustBeAdminToSendMail(self): 1321 | # Don't know how to test this -- it's enforced by app.yaml 1322 | pass 1323 | 1324 | def testDoNotSendMailWithoutSetting(self): 1325 | app_settings = models.AppSettings.get() 1326 | app_settings.delete() 1327 | self.request_fetcher.get('/admin/send_reminder_email') 1328 | self.assertEmailNotSentTo('does_not_have_snippet@example.com') 1329 | 1330 | def testDefaultEmailFrom(self): 1331 | app_settings = models.AppSettings.get() 1332 | self.assertEqual("Snippet Server ", 1333 | app_settings.email_from) 1334 | 1335 | def testSendReminderEmail(self): 1336 | self.request_fetcher.get('/admin/send_reminder_email') 1337 | self.assertEmailSentTo('does_not_have_snippet@example.com') 1338 | self.assertEmailSentTo('has_no_snippets@example.com') 1339 | self.assertEmailNotSentTo('has_snippet@example.com') 1340 | self.assertEmailNotSentTo('has_many_snippets@example.com') 1341 | r = self.mail_stub.get_sent_messages(to='has_no_snippets@example.com') 1342 | self.assertEqual('has_no_snippets@example.com', r[0].to) 1343 | self.assertIn('Snippet Server', r[0].sender) 1344 | self.assertEqual('Weekly snippets due today at 5pm', r[0].subject) 1345 | self.assertEmailContains('has_no_snippets@example.com', 1346 | 'https://example.com') 1347 | 1348 | def testSendViewEmail(self): 1349 | self.request_fetcher.get('/admin/send_view_email') 1350 | self.assertEmailSentTo('has_snippet@example.com') 1351 | self.assertEmailSentTo('does_not_have_snippet@example.com') 1352 | self.assertEmailSentTo('has_many_snippets@example.com') 1353 | self.assertEmailSentTo('has_no_snippets@example.com') 1354 | # Check that we nag the ones who don't have snippets. 1355 | self.assertEmailDoesNotContain('has_snippet@example.com', 1356 | 'not too late') 1357 | self.assertEmailDoesNotContain('has_many_snippets@example.com', 1358 | 'not too late') 1359 | self.assertEmailContains('does_not_have_snippet@example.com', 1360 | 'not too late') 1361 | self.assertEmailContains('has_no_snippets@example.com', 1362 | 'not too late') 1363 | self.assertEmailContains('does_not_have_snippet@example.com', 1364 | 'https://example.com') 1365 | 1366 | def testViewReminderMailsSettingAndSendReminderEmail(self): 1367 | """Tests the user config-setting for getting emails.""" 1368 | self.login('does_not_have_snippet@example.com') 1369 | self.request_fetcher.get('/update_settings?reminder_email=no') 1370 | 1371 | # The control group :-) 1372 | self.login('has_no_snippets@example.com') 1373 | self.request_fetcher.get('/update_settings?reminder_email=yes') 1374 | 1375 | self.request_fetcher.get('/admin/send_reminder_email') 1376 | self.assertEmailNotSentTo('does_not_have_snippet@example.com') 1377 | self.assertEmailSentTo('has_no_snippets@example.com') 1378 | 1379 | def testViewReminderMailsSettingAndSendViewEmail(self): 1380 | self.login('does_not_have_snippet@example.com') 1381 | self.request_fetcher.get('/update_settings?reminder_email=no') 1382 | self.login('has_no_snippets@example.com') 1383 | self.request_fetcher.get('/update_settings?reminder_email=yes') 1384 | 1385 | self.request_fetcher.get('/admin/send_view_email') 1386 | self.assertEmailSentTo('has_snippet@example.com') 1387 | self.assertEmailNotSentTo('does_not_have_snippet@example.com') 1388 | self.assertEmailSentTo('has_many_snippets@example.com') 1389 | self.assertEmailSentTo('has_no_snippets@example.com') 1390 | 1391 | def testEmailQuotas(self): 1392 | """Test that we don't send more than 32 emails a minute.""" 1393 | self.total_sleep_seconds = 0 1394 | self.sleep_seconds_this_minute = 0 1395 | self.calls_this_minute = 0 1396 | self.max_calls_per_minute = 0 1397 | 1398 | def count_calls_per_minute(sleep_seconds): 1399 | self.calls_this_minute += 1 1400 | self.sleep_seconds_this_minute += sleep_seconds 1401 | self.total_sleep_seconds += sleep_seconds 1402 | # Update every time through so we count the last minute too. 1403 | self.max_calls_per_minute = max(self.max_calls_per_minute, 1404 | self.calls_this_minute) 1405 | if self.sleep_seconds_this_minute > 60: # on to the next minute 1406 | self.calls_this_minute = 0 1407 | self.sleep_seconds_this_minute %= 60 1408 | 1409 | time.sleep = lambda sec: count_calls_per_minute(sec) 1410 | 1411 | # We'll do 500 users. Rather than go through the request 1412 | # API, we modify the db directly; it's much faster. 1413 | users = [models.User(email='snippets%d@example.com' % i) 1414 | for i in xrange(500)] 1415 | db.put(users) 1416 | 1417 | self.request_fetcher.get('/admin/send_view_email') 1418 | # https://developers.google.com/appengine/docs/quotas#Mail 1419 | self.assertTrue(self.max_calls_per_minute <= 32, 1420 | '%d <= %d' % (self.max_calls_per_minute, 32)) 1421 | # Make sure we're not too slow either: say 1/2.5 seconds on average. 1422 | self.assertTrue(self.total_sleep_seconds <= len(users) * 2.5, 1423 | '%d <= %d' % (self.total_sleep_seconds, 1424 | len(users) * 2.5)) 1425 | 1426 | 1427 | class SendingChatTestCase(UserTestBase): 1428 | """Test we correctly send to Slack.""" 1429 | def setUp(self): 1430 | # (The superclass sets up slack_sends for us.) 1431 | super(SendingChatTestCase, self).setUp() 1432 | # Let's set up default chat configs. 1433 | app_settings = models.AppSettings.get() 1434 | app_settings.slack_channel = '#slack_chann3l' 1435 | app_settings.slack_token = 'st' 1436 | app_settings.slack_slash_token = 'sst' 1437 | app_settings.put() 1438 | 1439 | def test_send_to_chat(self): 1440 | self.request_fetcher.get('/admin/send_friday_reminder_chat') 1441 | 1442 | self.assertEqual(1, len(self.slack_sends)) 1443 | self.assertEqual('#slack_chann3l', self.slack_sends[0][0]) 1444 | self.assertIn('Weekly snippets due', self.slack_sends[0][1]) 1445 | self.assertIn('https://example.com', self.slack_sends[0][1]) 1446 | 1447 | def test_disable_slack(self): 1448 | app_settings = models.AppSettings.get() 1449 | app_settings.slack_channel = '' 1450 | app_settings.put() 1451 | 1452 | self.request_fetcher.get('/admin/send_friday_reminder_chat') 1453 | self.assertEqual([], self.slack_sends) 1454 | 1455 | 1456 | class TitleCaseTestCase(unittest.TestCase): 1457 | def testSimple(self): 1458 | self.assertEqual('A Word to the Wise', 1459 | snippets._title_case('a word to the wise')) 1460 | 1461 | def testWeirdCasing(self): 1462 | self.assertEqual('A Word to the Wise', 1463 | snippets._title_case('a wOrd to The WIse')) 1464 | 1465 | def testTrimLeadingSpaces(self): 1466 | self.assertEqual('A Word to the Wise', 1467 | snippets._title_case(' a word to the wise')) 1468 | 1469 | def testTrimTrailingSpaces(self): 1470 | self.assertEqual('A Word to the Wise', 1471 | snippets._title_case('a word to the wise ')) 1472 | 1473 | 1474 | class DisplayNameTestCase(UserTestBase): 1475 | """Manipulate a user's display name and check it in weekly page.""" 1476 | def setUp(self): 1477 | super(UserTestBase, self).setUp() 1478 | self.login('user@example.com') 1479 | 1480 | def testUserHasEmptyDisplayName(self): 1481 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 1482 | self.request_fetcher.get(url) 1483 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1484 | self.assertNumSnippets(response.body, 1) 1485 | self.assertInSnippet('

user@example.com:

', response.body, 0) 1486 | 1487 | def testUserHasDisplayName(self): 1488 | self.request_fetcher.get( 1489 | '/update_settings?u=user@example.com&display_name=test+name') 1490 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 1491 | self.request_fetcher.get(url) 1492 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1493 | self.assertNumSnippets(response.body, 1) 1494 | self.assertInSnippet('

test name (user@example.com):

', 1495 | response.body, 0) 1496 | 1497 | def testUserChangesDisplayName(self): 1498 | self.request_fetcher.get( 1499 | '/update_settings?u=user@example.com&display_name=test+name') 1500 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 1501 | self.request_fetcher.get(url) 1502 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1503 | self.assertNumSnippets(response.body, 1) 1504 | self.assertInSnippet('

test name (user@example.com):

', 1505 | response.body, 0) 1506 | 1507 | self.request_fetcher.get( 1508 | '/update_settings?u=user@example.com&display_name=fancy+name') 1509 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1510 | self.assertNumSnippets(response.body, 1) 1511 | self.assertInSnippet('

fancy name (user@example.com):

', 1512 | response.body, 0) 1513 | 1514 | def testSnippetHasDisplayName(self): 1515 | self.request_fetcher.get( 1516 | '/update_settings?u=user@example.com&display_name=test+name') 1517 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 1518 | self.request_fetcher.get(url) 1519 | self.request_fetcher.get( 1520 | '/update_settings?u=user@example.com&display_name=') 1521 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1522 | self.assertNumSnippets(response.body, 1) 1523 | self.assertInSnippet('

test name (user@example.com):

', 1524 | response.body, 0) 1525 | 1526 | def testSnippetFromDeletedUser(self): 1527 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 1528 | self.request_fetcher.get(url) 1529 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1530 | self.assertNumSnippets(response.body, 1) 1531 | self.assertInSnippet('

user@example.com:

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

test name (user@example.com):

', 1538 | response.body, 0) 1539 | 1540 | url = '/update_settings?u=user@example.com&delete=Delete' 1541 | self.request_fetcher.get(url) 1542 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1543 | self.assertNumSnippets(response.body, 1) 1544 | self.assertInSnippet('

user@example.com:

', response.body, 0) 1545 | 1546 | def testSnippetFromDeletedUser2(self): 1547 | self.request_fetcher.get( 1548 | '/update_settings?u=user@example.com&display_name=test+name') 1549 | url = '/update_snippet?week=02-20-2012&snippet=my+snippet' 1550 | self.request_fetcher.get(url) 1551 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1552 | self.assertNumSnippets(response.body, 1) 1553 | self.assertInSnippet('

test name (user@example.com):

', 1554 | response.body, 0) 1555 | 1556 | url = '/update_settings?u=user@example.com&delete=Delete' 1557 | self.request_fetcher.get(url) 1558 | response = self.request_fetcher.get('/weekly?week=02-20-2012') 1559 | self.assertNumSnippets(response.body, 1) 1560 | self.assertInSnippet('

test name (user@example.com):

', 1561 | response.body, 0) 1562 | 1563 | if __name__ == '__main__': 1564 | unittest.main() 1565 | --------------------------------------------------------------------------------