├── tests ├── __init__.py ├── logic │ ├── __init__.py │ ├── department_test.py │ ├── secret_test.py │ ├── subscription_test.py │ ├── email_test.py │ ├── alias_test.py │ ├── love_link_test.py │ ├── office_test.py │ └── love_test.py ├── util │ ├── __init__.py │ ├── formatting_test.py │ ├── email_test.py │ ├── recipient_test.py │ └── company_values_test.py ├── views │ ├── __init__.py │ ├── task_test.py │ └── api_test.py ├── models │ ├── __init__.py │ ├── access_key_test.py │ ├── love_link_test.py │ └── employee_test.py └── conftest.py ├── testing ├── __init__.py ├── factories │ ├── secret.py │ ├── __init__.py │ ├── subscription.py │ ├── love_link.py │ ├── love.py │ ├── alias.py │ └── employee.py └── util.py ├── .python-version ├── loveapp ├── static │ └── robots.txt ├── util │ ├── __init__.py │ ├── auth.py │ ├── converter.py │ ├── recipient.py │ ├── email.py │ ├── formatting.py │ ├── render.py │ ├── csrf.py │ ├── decorators.py │ ├── pagination.py │ └── company_values.py ├── themes │ └── default │ │ ├── static │ │ ├── img │ │ │ ├── logo.png │ │ │ ├── rocket.png │ │ │ ├── favicon.png │ │ │ ├── star_header_bg.png │ │ │ ├── user_medium_square.png │ │ │ ├── glyphicons-halflings.png │ │ │ └── glyphicons-halflings-white.png │ │ ├── js │ │ │ ├── main.js │ │ │ └── linkify.js │ │ └── css │ │ │ └── style.css │ │ ├── info.json │ │ └── templates │ │ ├── parts │ │ ├── avatar.html │ │ ├── photobox.html │ │ ├── facetile.html │ │ ├── flash.html │ │ └── love_message.html │ │ ├── import_employees_form.html │ │ ├── alias_form.html │ │ ├── import.html │ │ ├── aliases.html │ │ ├── subscription_form.html │ │ ├── keys.html │ │ ├── employees.html │ │ ├── love_link.html │ │ ├── me.html │ │ ├── sent.html │ │ ├── values.html │ │ ├── love_form.html │ │ ├── subscriptions.html │ │ ├── layout.html │ │ ├── explore.html │ │ ├── leaderboard.html │ │ ├── home.html │ │ └── email.html ├── views │ ├── __init__.py │ ├── common.py │ ├── tasks.py │ └── api.py ├── models │ ├── secret.py │ ├── toggle.py │ ├── __init__.py │ ├── alias.py │ ├── access_key.py │ ├── love_link.py │ ├── love.py │ ├── subscription.py │ ├── love_count.py │ └── employee.py ├── import │ ├── employees.csv.example │ └── employees.json.example ├── logic │ ├── subscription.py │ ├── secret.py │ ├── department.py │ ├── notifier │ │ ├── __init__.py │ │ └── lovesent_notifier.py │ ├── event.py │ ├── alias.py │ ├── toggle.py │ ├── __init__.py │ ├── leaderboard.py │ ├── notification_request.py │ ├── email.py │ ├── love_link.py │ ├── love_count.py │ ├── office.py │ ├── love.py │ └── employee.py ├── __init__.py └── config-example.py ├── queue.yaml ├── offices.yaml ├── dispatch.yaml ├── worker.yaml ├── yelplove-medium.png ├── screenshots ├── Home.png ├── Explore.png ├── LoveSent.png ├── MyLove.png └── Leaderboard.png ├── _config.yml ├── main.py ├── .gcloudignore ├── .travis.yml ├── CHANGELOG.md ├── .gitignore ├── app.yaml ├── setup.py ├── requirements-dev.txt ├── requirements.txt ├── .secrets.baseline ├── tox.ini ├── errors.py ├── cron.yaml.example ├── Makefile ├── LICENSE ├── .pre-commit-config.yaml ├── AUTHORS.md ├── index.yaml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.6 2 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /loveapp/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /queue.yaml: -------------------------------------------------------------------------------- 1 | queue: 2 | - name: events 3 | rate: 5.00/s 4 | -------------------------------------------------------------------------------- /offices.yaml: -------------------------------------------------------------------------------- 1 | - SF Office 2 | - Hamburg Office 3 | - London Office 4 | -------------------------------------------------------------------------------- /dispatch.yaml: -------------------------------------------------------------------------------- 1 | dispatch: 2 | - url: "*/tasks*" 3 | service: worker 4 | -------------------------------------------------------------------------------- /worker.yaml: -------------------------------------------------------------------------------- 1 | service: worker 2 | runtime: python311 3 | app_engine_apis: true 4 | -------------------------------------------------------------------------------- /yelplove-medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/yelplove-medium.png -------------------------------------------------------------------------------- /screenshots/Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/screenshots/Home.png -------------------------------------------------------------------------------- /screenshots/Explore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/screenshots/Explore.png -------------------------------------------------------------------------------- /screenshots/LoveSent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/screenshots/LoveSent.png -------------------------------------------------------------------------------- /screenshots/MyLove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/screenshots/MyLove.png -------------------------------------------------------------------------------- /loveapp/util/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | from . import recipient 4 | -------------------------------------------------------------------------------- /screenshots/Leaderboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/screenshots/Leaderboard.png -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/logo.png -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/rocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/rocket.png -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/favicon.png -------------------------------------------------------------------------------- /loveapp/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | from . import api 4 | from . import tasks 5 | from . import web 6 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | title: Yelp Love 3 | description: Show appreciation for the people you work with 4 | show_downloads: "true" 5 | -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/star_header_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/star_header_bg.png -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/user_medium_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/user_medium_square.png -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /loveapp/models/secret.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.ext import ndb 3 | 4 | 5 | class Secret(ndb.Model): 6 | value = ndb.StringProperty() 7 | -------------------------------------------------------------------------------- /loveapp/themes/default/static/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/love/HEAD/loveapp/themes/default/static/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | from loveapp import create_app 4 | 5 | app = create_app() 6 | 7 | if __name__ == '__main__': 8 | app.run() 9 | -------------------------------------------------------------------------------- /loveapp/themes/default/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "application": "yelplove", 3 | "author": "yelplove@yelp.com", 4 | "identifier": "default", 5 | "name": "Yelp Love Default Theme" 6 | } 7 | -------------------------------------------------------------------------------- /loveapp/import/employees.csv.example: -------------------------------------------------------------------------------- 1 | username,first_name,last_name,department,office,photo_url 2 | john,John,Doe,,,https://placehold.it/100x100 3 | janet,Janet,Doe,,,https://placehold.it/100x100 -------------------------------------------------------------------------------- /loveapp/util/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.api import users 3 | 4 | 5 | def is_admin(): 6 | return users.get_current_user() and users.is_current_user_admin() 7 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | google_appengine 2 | virtualenv_ 3 | lib 4 | .tox 5 | env 6 | .git 7 | tmp 8 | loveapp/config-example.py 9 | YelpLove.egg-info 10 | *.pyc 11 | tests/ 12 | testing/ 13 | .pytest_cache -------------------------------------------------------------------------------- /testing/factories/secret.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import Secret 3 | 4 | 5 | def create_secret(id, value='secret'): 6 | secret = Secret(id=id, value=value) 7 | secret.put() 8 | return secret 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.11" 4 | # command to install dependencies 5 | install: "pip install tox" 6 | # command to run tests 7 | script: 8 | - cp config-example.py config.py 9 | - make test 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v1.0.0](https://github.com/Yelp/love/tree/v1.0.0) (2017-02-13) 4 | 5 | 6 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* 7 | -------------------------------------------------------------------------------- /loveapp/logic/subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models.subscription import Subscription 3 | 4 | 5 | def delete_subscription(subscription_id): 6 | subscription = Subscription.get_by_id(subscription_id) 7 | subscription.key.delete() 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.~ 3 | *.so 4 | *.swp 5 | *.sublime-* 6 | .DS_Store 7 | .idea 8 | .project 9 | .pydevproject 10 | .tox 11 | YelpLove.egg-info 12 | config.py 13 | google_appengine 14 | lib 15 | tmp 16 | import/*.csv 17 | import/*.json 18 | venv 19 | env 20 | .vscode -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | service: default 2 | runtime: python311 3 | app_engine_apis: true 4 | 5 | handlers: 6 | - url: /robots.txt 7 | static_files: static/robots.txt 8 | upload: static/robots.txt 9 | secure: optional 10 | - url: /static 11 | static_dir: static 12 | secure: always 13 | -------------------------------------------------------------------------------- /loveapp/util/converter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from werkzeug.routing import BaseConverter 3 | 4 | 5 | class RegexConverter(BaseConverter): 6 | 7 | def __init__(self, url_map, *items): 8 | super(RegexConverter, self).__init__(url_map) 9 | self.regex = items[0] 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | 5 | setup( 6 | name='YelpLove', 7 | version='1.0.0', 8 | description='A web application and API for expressing love and joy.', 9 | packages=['.'], 10 | url='https://github.com/Yelp/love', 11 | ) 12 | -------------------------------------------------------------------------------- /loveapp/logic/secret.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from errors import NoSuchSecret 3 | from loveapp.models import Secret 4 | 5 | 6 | def get_secret(id): 7 | secret = Secret.get_by_id(id) 8 | if secret: 9 | return secret.value 10 | else: 11 | raise NoSuchSecret(id) 12 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/parts/avatar.html: -------------------------------------------------------------------------------- 1 | {% macro avatar_url_for(employee) -%} 2 | {% if employee and employee.get_photo_url() -%} 3 | {{ employee.get_photo_url() }} 4 | {%- else -%} 5 | {{ theme_static('img/user_medium_square.png', external=True) }} 6 | {%- endif %} 7 | {%- endmacro %} 8 | -------------------------------------------------------------------------------- /tests/models/access_key_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models.access_key import AccessKey 3 | 4 | 5 | def test_create(gae_testbed): 6 | key = AccessKey.create('description') 7 | 8 | assert key is not None 9 | assert 'description' == key.description 10 | assert key.access_key is not None 11 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/import_employees_form.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # If there are tools that a developer needs to work on your project. 2 | # Unit testing tools, mocking frameworks, debuggers, proxies, ... 3 | # These will be used for tox and other testing environments. 4 | -r requirements.txt 5 | Flask-WebTest==0.1.4 6 | ipdb 7 | mock==4.0.3 8 | pre-commit 9 | pytest==8.1.2 10 | tox==4.15.0 11 | -------------------------------------------------------------------------------- /loveapp/util/recipient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def sanitize_recipients(recipient_str): 5 | recipients = recipient_str.split(',') 6 | recipients = [ 7 | recipient.lower().strip() for recipient in recipients 8 | if recipient.lower().strip() 9 | ] 10 | recipients = set(recipients) 11 | return recipients 12 | -------------------------------------------------------------------------------- /loveapp/logic/department.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models.employee import Employee 3 | 4 | 5 | def get_all_departments(): 6 | return sorted( 7 | [ 8 | row.department 9 | for row in Employee.query(projection=['department'], distinct=True).fetch() 10 | if row.department 11 | ], 12 | ) 13 | -------------------------------------------------------------------------------- /loveapp/models/toggle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.ext import ndb 3 | 4 | 5 | LOVE_SENDING_ENABLED = 'love_sending_enabled' 6 | TOGGLE_NAMES = set([ 7 | LOVE_SENDING_ENABLED 8 | ]) 9 | 10 | TOGGLE_STATES = set([True, False]) 11 | 12 | 13 | class Toggle(ndb.Model): 14 | name = ndb.StringProperty() 15 | state = ndb.BooleanProperty() 16 | -------------------------------------------------------------------------------- /loveapp/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | from .access_key import AccessKey 4 | from .alias import Alias 5 | from .employee import Employee 6 | from .love import Love 7 | from .love_count import LoveCount 8 | from .love_link import LoveLink 9 | from .secret import Secret 10 | from .subscription import Subscription 11 | from .toggle import Toggle 12 | -------------------------------------------------------------------------------- /loveapp/models/alias.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.ext import ndb 3 | 4 | from loveapp.models.employee import Employee 5 | 6 | 7 | class Alias(ndb.Model): 8 | """Models an instance of an alias.""" 9 | name = ndb.StringProperty(required=True) 10 | owner_key = ndb.KeyProperty(kind=Employee) 11 | timestamp = ndb.DateTimeProperty(auto_now_add=True) 12 | -------------------------------------------------------------------------------- /testing/factories/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | from .alias import create_alias_with_employee_key 4 | from .alias import create_alias_with_employee_username 5 | from .employee import create_employee 6 | from .love import create_love 7 | from .love_link import create_love_link 8 | from .secret import create_secret 9 | from .subscription import create_subscription 10 | -------------------------------------------------------------------------------- /tests/models/love_link_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | 4 | from testing.factories import create_love_link 5 | 6 | 7 | @mock.patch('loveapp.models.love_link.config') 8 | def test_url(mock_config, gae_testbed): 9 | mock_config.APP_BASE_URL = 'http://foo.io/' 10 | 11 | link = create_love_link(hash_key='lOvEr') 12 | assert 'http://foo.io/l/lOvEr' == link.url 13 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/parts/photobox.html: -------------------------------------------------------------------------------- 1 | {% import theme("parts/avatar.html") as avatar %} 2 | 3 | {% macro user_icon(user) %} 4 |
5 | 6 | {{ user.full_name }} 7 | 8 |
9 | {% endmacro %} 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This requirements file lists all dependecies for this project. 2 | # Run 'make lib' to install these dependencies in this project's lib directory. 3 | appengine-python-standard>=1.0.0 4 | beautifulsoup4==4.12.3 5 | boto3==1.34.144 6 | Flask==3.0.3 7 | Flask-Themes2==1.0.1 8 | itsdangerous==2.2.0 9 | Jinja2==3.1.3 10 | MarkupSafe==2.1.5 11 | pytz==2024.1 12 | pyyaml==6.0.1 13 | urllib3==1.26.18 14 | Werkzeug==3.0.0 15 | -------------------------------------------------------------------------------- /tests/util/formatting_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from loveapp.util.formatting import format_loves 5 | 6 | 7 | @pytest.mark.parametrize('loves, expected', [ 8 | ([], ([], [])), 9 | (list(range(5)), (list(range(5)), [])), 10 | (list(range(31)), (list(range(15)) + [30], list(range(15, 30)))), 11 | ]) 12 | def test_format_loves(loves, expected): 13 | assert format_loves(loves) == expected 14 | -------------------------------------------------------------------------------- /testing/factories/subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import Subscription 3 | 4 | 5 | def create_subscription( 6 | request_url='johndoe', 7 | active=False, 8 | event='lovesent', 9 | secret='secret', 10 | ): 11 | return Subscription.create_from_dict({ 12 | 'request_url': request_url, 13 | 'active': active, 14 | 'event': event, 15 | 'secret': secret, 16 | }) 17 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/parts/facetile.html: -------------------------------------------------------------------------------- 1 | {% import theme("parts/avatar.html") as avatar %} 2 | 3 | {% macro face_icon(user) %} 4 |
5 | 6 | {{ user.full_name }} 7 |
{{ user.username }}
8 |
9 |
10 | {% endmacro %} 11 | -------------------------------------------------------------------------------- /testing/factories/love_link.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import LoveLink 3 | 4 | 5 | def create_love_link( 6 | hash_key='lOvEr', 7 | message='I <3 love links', 8 | recipient_list='johndoe, janedoe', 9 | ): 10 | 11 | new_love_link = LoveLink( 12 | hash_key=hash_key, 13 | message=message, 14 | recipient_list=recipient_list, 15 | ) 16 | new_love_link.put() 17 | 18 | return new_love_link 19 | -------------------------------------------------------------------------------- /.secrets.baseline: -------------------------------------------------------------------------------- 1 | { 2 | "exclude_regex": ".*tests/.*|\\.pre-commit-config\\.yaml", 3 | "generated_at": "2018-07-10T18:49:59Z", 4 | "plugins_used": [ 5 | { 6 | "base64_limit": 4.5, 7 | "name": "Base64HighEntropyString" 8 | }, 9 | { 10 | "hex_limit": 3, 11 | "name": "HexHighEntropyString" 12 | }, 13 | { 14 | "name": "PrivateKeyDetector" 15 | } 16 | ], 17 | "results": {}, 18 | "version": "0.9.1" 19 | } 20 | -------------------------------------------------------------------------------- /loveapp/util/email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | 5 | def get_name_and_email(email_string): 6 | """Take a string that is either 'Name ' or just an email address 7 | and return a two tuple (email, name). If there is just an email, returns 8 | None for the name.""" 9 | match = re.match(r'(.*) <(.*)>', email_string) 10 | if match: 11 | return match.group(2), match.group(1) 12 | else: 13 | return email_string, None 14 | -------------------------------------------------------------------------------- /loveapp/util/formatting.py: -------------------------------------------------------------------------------- 1 | def format_loves(loves): 2 | # organise loves into two roughly equal lists for displaying 3 | if len(loves) < 20: 4 | loves_list_one = loves 5 | loves_list_two = [] 6 | else: 7 | loves_list_one = loves[:len(loves)//2] 8 | loves_list_two = loves[len(loves)//2:] 9 | 10 | if len(loves_list_one) < len(loves_list_two): 11 | loves_list_one.append(loves_list_two.pop()) 12 | return loves_list_one, loves_list_two 13 | -------------------------------------------------------------------------------- /loveapp/import/employees.json.example: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username": "john", 4 | "first_name": "John", 5 | "last_name": "Doe", 6 | "department": "", 7 | "office": "", 8 | "photo_url": "https://placehold.it/100x100" 9 | }, 10 | { 11 | "username": "janet", 12 | "first_name": "Janet", 13 | "last_name": "Doe", 14 | "department": "", 15 | "office": "", 16 | "photo_url": "https://placehold.it/100x100" 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /loveapp/logic/notifier/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import loveapp.logic.event 3 | from errors import UnknownEvent 4 | from loveapp.logic.notifier.lovesent_notifier import LovesentNotifier 5 | 6 | 7 | EVENT_TO_NOTIFIER_MAPPING = { 8 | loveapp.logic.event.LOVESENT: LovesentNotifier, 9 | } 10 | 11 | 12 | def notifier_for_event(event): 13 | try: 14 | return EVENT_TO_NOTIFIER_MAPPING[event] 15 | except KeyError: 16 | raise UnknownEvent('There is no such event as %s' % event) 17 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/parts/flash.html: -------------------------------------------------------------------------------- 1 | {% macro display_flash_messages() %} 2 | {% with messages = get_flashed_messages(with_categories=True) %} 3 | {% if messages %} 4 | {% for category, message in messages %} 5 | {% if category == 'error' %} 6 |
{{ message }}
7 | {% else %} 8 |
{{ message }}
9 | {% endif %} 10 | {% endfor %} 11 | {% endif %} 12 | {% endwith %} 13 | {% endmacro %} 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py311 8 | 9 | [testenv] 10 | deps = -rrequirements-dev.txt 11 | commands = 12 | pre-commit install -f --install-hooks 13 | pre-commit run --all-files 14 | pytest tests 15 | 16 | [flake8] 17 | max-line-length = 120 18 | 19 | [pep8] 20 | max-line-length = 120 21 | -------------------------------------------------------------------------------- /loveapp/logic/event.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | from google.appengine.api import taskqueue 5 | 6 | 7 | LOVESENT = 'lovesent' 8 | 9 | EVENTS = set([ 10 | LOVESENT, 11 | ]) 12 | 13 | 14 | def add_event(event, options={}): 15 | payload = { 16 | 'event': event, 17 | 'options': options, 18 | } 19 | 20 | taskqueue.add( 21 | queue_name='events', 22 | url='/tasks/subscribers/notify', 23 | headers={'Content-Type': 'application/json'}, 24 | payload=json.dumps(payload), 25 | ) 26 | -------------------------------------------------------------------------------- /testing/factories/love.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import Love 3 | 4 | DEFAULT_LOVE_MESSAGE = 'So Long, and Thanks For All the Fish' 5 | 6 | 7 | def create_love( 8 | sender_key, 9 | recipient_key, 10 | message=DEFAULT_LOVE_MESSAGE, 11 | secret=False, 12 | company_values=() 13 | ): 14 | 15 | love = Love( 16 | sender_key=sender_key, 17 | recipient_key=recipient_key, 18 | message=message, 19 | secret=secret, 20 | company_values=company_values 21 | ) 22 | love.put() 23 | 24 | return love 25 | -------------------------------------------------------------------------------- /loveapp/util/render.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | from flask.helpers import make_response 5 | from flask_themes2 import render_theme_template 6 | 7 | import loveapp.config as config 8 | 9 | 10 | def get_current_theme(): 11 | return config.THEME 12 | 13 | 14 | def make_json_response(data, *args): 15 | response = make_response(json.dumps(data), *args) 16 | response.headers['Content-Type'] = 'application/json' 17 | return response 18 | 19 | 20 | def render_template(template, **context): 21 | return render_theme_template(get_current_theme(), template, **context) 22 | -------------------------------------------------------------------------------- /tests/logic/department_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.logic.department import get_all_departments 3 | from testing.factories import create_employee 4 | 5 | DEPARTMENTS = [ 6 | 'Department 1', 7 | 'Department 2', 8 | 'Department 3', 9 | 'Department 4', 10 | 'Department 4', 11 | 'Department 5', 12 | ] 13 | 14 | 15 | def test_get_all_departments(gae_testbed): 16 | for department in DEPARTMENTS: 17 | create_employee(department=department, username='{}-{}'.format('username', department)) 18 | 19 | assert set(DEPARTMENTS) == set(get_all_departments()) 20 | -------------------------------------------------------------------------------- /errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class InvalidToggleName(Exception): 5 | pass 6 | 7 | 8 | class InvalidToggleState(Exception): 9 | pass 10 | 11 | 12 | class NoSuchEmployee(Exception): 13 | pass 14 | 15 | 16 | class NoSuchSecret(Exception): 17 | pass 18 | 19 | 20 | class UnknownEvent(Exception): 21 | pass 22 | 23 | 24 | class NoSuchLoveLink(Exception): 25 | pass 26 | 27 | 28 | class TaintedLove(Exception): 29 | 30 | def __init__(self, user_message, is_error=True): 31 | self.user_message = user_message 32 | self.is_error = (is_error is True) 33 | -------------------------------------------------------------------------------- /tests/logic/secret_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import pytest 5 | 6 | from errors import NoSuchSecret 7 | from loveapp.logic.secret import get_secret 8 | from testing.factories import create_secret 9 | 10 | 11 | @pytest.mark.usefixtures('gae_testbed') 12 | class SecretTest(unittest.TestCase): 13 | 14 | def test_existing_secret(self): 15 | create_secret('FOO', value='bar') 16 | self.assertEqual('bar', get_secret('FOO')) 17 | 18 | def test_unknown_secret(self): 19 | with self.assertRaises(NoSuchSecret): 20 | get_secret('THANKS_FOR_ALL_THE_FISH') 21 | -------------------------------------------------------------------------------- /loveapp/models/access_key.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from uuid import uuid4 3 | 4 | from google.appengine.ext import ndb 5 | 6 | 7 | class AccessKey(ndb.Model): 8 | """Models an instance of an API key.""" 9 | access_key = ndb.StringProperty() 10 | description = ndb.TextProperty() 11 | 12 | @staticmethod 13 | def generate_uuid(): 14 | return uuid4().hex 15 | 16 | @classmethod 17 | def create(cls, description): 18 | new_key = cls() 19 | new_key.access_key = cls.generate_uuid() 20 | new_key.description = description 21 | new_key.put() 22 | 23 | return new_key 24 | -------------------------------------------------------------------------------- /testing/factories/alias.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import Alias 3 | from loveapp.models import Employee 4 | 5 | 6 | def create_alias_with_employee_username( 7 | name='jda', 8 | username='jd', 9 | ): 10 | alias = Alias( 11 | name=name, 12 | owner_key=Employee.get_key_for_username(username), 13 | ) 14 | alias.put() 15 | return alias 16 | 17 | 18 | def create_alias_with_employee_key( 19 | name='jda', 20 | employee_key='jd', 21 | ): 22 | alias = Alias( 23 | name=name, 24 | owner_key=employee_key, 25 | ) 26 | alias.put() 27 | return alias 28 | -------------------------------------------------------------------------------- /loveapp/models/love_link.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from time import mktime 3 | 4 | from google.appengine.ext import ndb 5 | 6 | import loveapp.config as config 7 | 8 | 9 | class LoveLink(ndb.Model): 10 | """Models an instance of a Love Link.""" 11 | hash_key = ndb.StringProperty() 12 | message = ndb.TextProperty() 13 | recipient_list = ndb.TextProperty() 14 | timestamp = ndb.DateTimeProperty(auto_now_add=True) 15 | 16 | @property 17 | def seconds_since_epoch(self): 18 | return int(mktime(self.timestamp.timetuple())) 19 | 20 | @property 21 | def url(self): 22 | return config.APP_BASE_URL + 'l/' + self.hash_key 23 | -------------------------------------------------------------------------------- /testing/factories/employee.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import Employee 3 | 4 | 5 | def create_employee( 6 | username='johndoe', 7 | department='Engineering', 8 | first_name='John', 9 | last_name='Doe', 10 | photo_url=None, 11 | office=None, 12 | ): 13 | 14 | if photo_url is None: 15 | photo_url = 'http://example.com/photos/{0}.jpg'.format(username) 16 | 17 | return Employee.create_from_dict({ 18 | 'username': username, 19 | 'department': department, 20 | 'first_name': first_name, 21 | 'last_name': last_name, 22 | 'photo_url': photo_url, 23 | 'office': office, 24 | }) 25 | -------------------------------------------------------------------------------- /loveapp/models/love.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from time import mktime 3 | 4 | from google.appengine.ext import ndb 5 | 6 | from loveapp.models import Employee 7 | 8 | 9 | class Love(ndb.Model): 10 | """Models an instance of sent love.""" 11 | message = ndb.TextProperty() 12 | recipient_key = ndb.KeyProperty(kind=Employee) 13 | secret = ndb.BooleanProperty(default=False) 14 | sender_key = ndb.KeyProperty(kind=Employee) 15 | timestamp = ndb.DateTimeProperty(auto_now_add=True) 16 | company_values = ndb.StringProperty(repeated=True) 17 | 18 | @property 19 | def seconds_since_epoch(self): 20 | return int(mktime(self.timestamp.timetuple())) 21 | -------------------------------------------------------------------------------- /cron.yaml.example: -------------------------------------------------------------------------------- 1 | # If you want to regularly import user data from S3 2 | # please uncomment the lines below and adjust to your needs 3 | # For more documentation hop over to https://cloud.google.com/appengine/docs/python/config/cron 4 | 5 | # cron: 6 | # - description: daily employee load 7 | # url: /tasks/employees/load/s3 8 | # schedule: every day 03:00 9 | # timezone: US/Pacific 10 | # 11 | # Uncomment the following section if you'd like your love_links automatically 12 | # cleaned up after they have been around for more than 30 days. 13 | # 14 | # cron: 15 | # - description: old love links cleanup 16 | # url: /tasks/lovelinks/cleanup 17 | # schedule: every day 02:00 18 | # timezone: US/Pacific 19 | -------------------------------------------------------------------------------- /tests/logic/subscription_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | 4 | import loveapp.logic.subscription 5 | from loveapp.models import Subscription 6 | from testing.factories import create_employee 7 | from testing.factories import create_subscription 8 | 9 | 10 | @mock.patch('loveapp.models.subscription.Employee', autospec=True) 11 | def test_delete_subscription(mock_model_employee, gae_testbed): 12 | mock_model_employee.get_current_employee.return_value = create_employee() 13 | 14 | subscription = create_subscription() 15 | assert Subscription.get_by_id(subscription.key.id()) is not None 16 | 17 | loveapp.logic.subscription.delete_subscription(subscription.key.id()) 18 | 19 | assert Subscription.get_by_id(subscription.key.id()) is None 20 | -------------------------------------------------------------------------------- /tests/util/email_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import loveapp.util.email 5 | 6 | 7 | class GetNameAndEmailTest(unittest.TestCase): 8 | """Tests util.email.get_name_and_email()""" 9 | 10 | def test_bare_email(self): 11 | email_string = 'darwin@example.com' 12 | email, name = loveapp.util.email.get_name_and_email(email_string) 13 | self.assertEqual(email, email_string) 14 | self.assertIsNone(name) 15 | 16 | def test_name_and_email(self): 17 | email_string = 'Darwin Stoppelman ' 18 | email, name = loveapp.util.email.get_name_and_email(email_string) 19 | self.assertEqual(email, 'darwin@example.com') 20 | self.assertEqual(name, 'Darwin Stoppelman') 21 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/alias_form.html: -------------------------------------------------------------------------------- 1 |

Add Alias

2 |
3 | 4 |
5 | 6 | 7 |
8 | 9 |
10 | 11 | 12 |
13 | 14 |
15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /loveapp/views/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.logic.employee import employees_matching_prefix 3 | from loveapp.util.render import make_json_response 4 | 5 | 6 | def autocomplete(request): 7 | """This is the implementation of the autocomplete API endpoint. It's shared between 8 | the api view and web view that is called from Javascript, only the authorization 9 | checks are different. 10 | """ 11 | matches = employees_matching_prefix(request.args.get('term', None)) 12 | users = [ 13 | { 14 | 'label': u'{} ({})'.format(full_name, username), 15 | 'value': username, 16 | 'avatar_url': photo_url, 17 | } 18 | for full_name, username, photo_url 19 | in matches 20 | ] 21 | return make_json_response(users) 22 | -------------------------------------------------------------------------------- /loveapp/logic/alias.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from loveapp.models import Alias 3 | from loveapp.models import Employee 4 | 5 | 6 | def get_alias(alias): 7 | return Alias.query(Alias.name == alias).get() 8 | 9 | 10 | def save_alias(alias, username): 11 | alias = Alias( 12 | name=alias, 13 | owner_key=Employee.get_key_for_username(username), 14 | ) 15 | alias.put() 16 | return alias 17 | 18 | 19 | def delete_alias(alias_id): 20 | alias = Alias.get_by_id(alias_id) 21 | alias.key.delete() 22 | 23 | 24 | def name_for_alias(alias_name): 25 | alias = Alias.query(Alias.name == alias_name).get() 26 | if alias: 27 | employee = alias.owner_key.get() 28 | if employee: 29 | return employee.username 30 | else: 31 | return alias_name 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all test clean run-dev deploy deploy_build 2 | 3 | all: test 4 | 5 | run-dev: loveapp/config.py lib 6 | dev_appserver.py --dev_appserver_log_level debug dispatch.yaml app.yaml worker.yaml 7 | 8 | deploy: deploy_build 9 | gcloud app deploy app.yaml worker.yaml index.yaml dispatch.yaml --no-promote 10 | # If you are using cron.yaml uncomment the line below 11 | # gcloud app deploy cron.yaml 12 | 13 | deploy_build: loveapp/config.py clean lib test 14 | @echo "\033[31mHave you bumped the app version? Hit ENTER to continue, CTRL-C to abort.\033[0m" 15 | @read ignored 16 | 17 | lib: requirements.txt 18 | mkdir -p lib 19 | rm -rf lib/* 20 | pip install -r requirements.txt -t lib 21 | 22 | test: 23 | pytest tests 24 | 25 | clean: 26 | find . -name '*.pyc' -delete 27 | rm -rf lib 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /loveapp/util/csrf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import binascii 3 | import os 4 | 5 | from flask import abort 6 | from flask import request 7 | from flask import session 8 | 9 | 10 | def check_csrf_protection(): 11 | """Make sure POST requests are sent with a CSRF token unless they're part of the API. 12 | In the future we might want to think about a system where we can disable CSRF protection 13 | on a per-view basis, maybe with a decorator. 14 | """ 15 | if request.method == 'POST': 16 | token = session.pop('_csrf_token', None) 17 | if not token or token != request.form.get('_csrf_token'): 18 | abort(403) 19 | 20 | 21 | def generate_csrf_token(): 22 | if '_csrf_token' not in session: 23 | session['_csrf_token'] = str(binascii.hexlify(os.urandom(16))) 24 | return session['_csrf_token'] 25 | -------------------------------------------------------------------------------- /loveapp/themes/default/static/js/main.js: -------------------------------------------------------------------------------- 1 | function setupDateFormatting() { 2 | var today = new Date(); 3 | today.setHours(0,0,0,0); 4 | var yesterday = new Date(today.getTime() - 86400000); 5 | $('span.nicetime').each(function(elem) { 6 | var d = new Date(parseInt($(this).text(), 10)); 7 | var dateStr = ''; 8 | var preDateStr = ''; 9 | if (d.compareTo(today) >= 0) { 10 | dateStr = 'today'; 11 | } else if (d.compareTo(yesterday) >= 0) { 12 | dateStr = 'yesterday'; 13 | } else { 14 | preDateStr = 'on '; 15 | dateStr = d.toString('MMM d, yyyy'); 16 | } 17 | 18 | var timeStr = d.toString('h:mm tt'); 19 | $(this).text(preDateStr + dateStr + ' at ' + timeStr); 20 | }); 21 | } 22 | 23 | function setupLinkify() { 24 | $('.love-message').linkify(); 25 | } 26 | 27 | $(document).ready(function () { 28 | setupDateFormatting(); 29 | setupLinkify(); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/util/recipient_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | from loveapp.util.recipient import sanitize_recipients 5 | 6 | 7 | class SanitizeRecipientsTest(unittest.TestCase): 8 | 9 | def _test_sanitization(self, input_recipients, expected_recipients): 10 | sanitized_recipients = sanitize_recipients(input_recipients) 11 | self.assertEqual( 12 | sanitized_recipients, 13 | expected_recipients, 14 | ) 15 | 16 | def test_one_recipient(self): 17 | self._test_sanitization( 18 | 'wwu', 19 | set(['wwu']), 20 | ) 21 | 22 | def test_comma_separated_recipients(self): 23 | self._test_sanitization( 24 | 'wwu,Basher,AMFONTS,yoann', 25 | set(['wwu', 'basher', 'amfonts', 'yoann']), 26 | ) 27 | 28 | def test_comma_space_sparated_recipients(self): 29 | self._test_sanitization( 30 | 'wwu, Basher, AMFONTS, yoann', 31 | set(['wwu', 'basher', 'amfonts', 'yoann']), 32 | ) 33 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/parts/love_message.html: -------------------------------------------------------------------------------- 1 | {% import theme("parts/photobox.html") as photobox %} 2 | 3 | {% macro love(sender_name, sender_username, recipient_name, recipient_username, icon_user, message, ts, secret) %} 4 |
5 | {{ photobox.user_icon(icon_user) }} 6 |
7 | {{ message|linkify_company_values }} 8 | {% if secret %} 9 | SECRET 10 | {% endif %} 11 |
12 | 20 |
21 | {% endmacro %} 22 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/import.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/flash.html") as flash %} 4 | 5 | {% block title %}Import employees{% endblock %} 6 | 7 | {% block body %} 8 | {{ flash.display_flash_messages() }} 9 | 10 |

Import employees

11 | 12 | {% if import_file_exists %} 13 |

Great, we have found an employees.csv file. Let‘s import that!

14 | {% include theme("import_employees_form.html") %} 15 | 16 | Note: Once you hit the Import button the import will be kicked off and handled 17 | in the background. Depending on how many employees you are importing this process can take a few seconds. 18 | When the import finishes successfully you will see the imported employees 19 | here. 20 | 21 | {% else %} 22 |

23 | Oh, we couldn‘t find an employees.csv file. Please read the instructions 24 | in the README on what options you have to import employee data. 25 |

26 | {% endif %} 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Yelp, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 3 | rev: v0.7.1 4 | hooks: 5 | - id: autopep8-wrapper 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-byte-order-marker 9 | - id: check-case-conflict 10 | - id: check-docstring-first 11 | - id: check-json 12 | - id: check-merge-conflict 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: detect-private-key 16 | - id: double-quote-string-fixer 17 | - id: end-of-file-fixer 18 | - id: flake8 19 | language_version: python3.11 20 | - id: fix-encoding-pragma 21 | - id: name-tests-test 22 | - id: pretty-format-json 23 | args: ['--autofix'] 24 | - id: requirements-txt-fixer 25 | - id: trailing-whitespace 26 | - repo: https://github.com/asottile/reorder-python-imports 27 | rev: v3.12.0 28 | hooks: 29 | - id: reorder-python-imports 30 | 31 | - repo: https://github.com/Yelp/detect-secrets 32 | rev: 0.9.1 33 | hooks: 34 | - id: detect-secrets 35 | args: ['--baseline', '.secrets.baseline'] 36 | exclude: .*tests/.*|\.pre-commit-config\.yaml 37 | -------------------------------------------------------------------------------- /loveapp/logic/toggle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from time import sleep 3 | 4 | from errors import InvalidToggleName 5 | from errors import InvalidToggleState 6 | from loveapp.models import Toggle 7 | from loveapp.models.toggle import TOGGLE_NAMES 8 | from loveapp.models.toggle import TOGGLE_STATES 9 | 10 | 11 | def _validate_and_maybe_create_toggle(name, state): 12 | if name not in TOGGLE_NAMES: 13 | raise InvalidToggleName(name) 14 | if state not in TOGGLE_STATES: 15 | raise InvalidToggleState(str(state)) 16 | 17 | toggle = Toggle.query(Toggle.name == name).get() 18 | if toggle is None: 19 | toggle = Toggle(name=name, state=state) 20 | toggle.put() 21 | return toggle 22 | 23 | 24 | def get_toggle_state(name, default=True): 25 | toggle = _validate_and_maybe_create_toggle(name, default) 26 | return toggle.state 27 | 28 | 29 | def set_toggle_state(name, state): 30 | toggle = _validate_and_maybe_create_toggle(name, state) 31 | if toggle.state != state: 32 | toggle.state = state 33 | toggle.put() 34 | 35 | # It hurts me to have to do this, but the datastore is eventually consistent so... 36 | key = toggle.key 37 | while True: 38 | sleep(2) 39 | toggle = key.get() 40 | if toggle.state == state: 41 | break 42 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Current Maintainers 2 | * Stephan Jaensch [sjaensch](https://github.com/sjaensch) 3 | * Sven Steinheißer [rockdog](https://github.com/rockdog) 4 | * Yoann Roman [silentsound](https://github.com/silentsound) 5 | 6 | # Original Authors 7 | * Adam Rothman [adamrothman](https://github.com/adamrothman) 8 | * Ben Asher [benasher44](https://github.com/benasher44) 9 | * Andrew Martinez-Fonts [amartinezfonts](https://github.com/amartinezfonts) 10 | * Wei Wu [wuhuwei](https://github.com/wuhuwei) 11 | 12 | # Contributors 13 | * Alex Levy [mesozoic](https://github.com/mesozoic) 14 | * Anthony Sottile [asottile](https://github.com/asottile) 15 | * Jenny Lemmnitz [jetze](https://github.com/jetze) 16 | * Kurtis Freedland [KurtisFreedland](https://github.com/KurtisFreedland) 17 | * Michał Czapko [michalczapko](https://github.com/michalczapko) 18 | * Prayag Verma [pra85](https://github.com/pra85) 19 | * Stephen Brennan [brenns10](https://github.com/brenns10) 20 | * Wayne Crasta [waynecrasta](https://github.com/waynecrasta) 21 | * Dennis Coldwell [dencold](https://github.com/dencold) 22 | * Andrew Lau [ajlau](https://github.com/ajlau) 23 | * Alina Rada [transcedentalia](https://github.com/transcedentalia) 24 | * Matthew Bentley [matthewbentley](https://github.com/matthewbentley) 25 | * Kevin Hock [KevinHock](https://github.com/KevinHock) 26 | * Duncan Cook [theletterd](https://github.com/theletterd) 27 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/aliases.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/flash.html") as flash %} 4 | 5 | {% block title %}Aliases{% endblock %} 6 | 7 | {% block body %} 8 | {{ flash.display_flash_messages() }} 9 |

All Aliases

10 | {% if aliases %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for a in aliases %} 21 | 22 | 23 | 24 | 30 | 31 | {% endfor %} 32 | 33 |
AliasOwner 
{{ a.name }}{{ a.owner_key.get().username }} 25 |
26 | 27 | 28 |
29 |
34 | {% else %} 35 |

No aliases have been saved yet.

36 | {% endif %} 37 |
38 | {% include theme("alias_form.html") %} 39 | {% endblock %} 40 | 41 | {% block javascript %} 42 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /loveapp/logic/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import timedelta 3 | from itertools import zip_longest 4 | 5 | import pytz 6 | from google.appengine.ext import ndb 7 | 8 | 9 | TIMESPAN_LAST_WEEK = 'last_week' 10 | TIMESPAN_THIS_WEEK = 'this_week' 11 | 12 | 13 | def chunk(iterable, chunk_size): 14 | """Collect data into fixed-length chunks or blocks (http://docs.python.org/2/library/itertools.html#recipes)""" 15 | args = [iter(iterable)] * chunk_size 16 | return zip_longest(*args) 17 | 18 | 19 | def to_the_future(dict): 20 | for k, v in dict.items(): 21 | if issubclass(v.__class__, ndb.Future): 22 | dict[k] = v.get_result() 23 | 24 | 25 | def utc_week_limits(utc_dt): 26 | """Returns US/Pacific start (12:00 am Sunday) and end (11:59 pm Saturday) of the week containing utc_dt, in UTC.""" 27 | local_now = utc_dt.replace(tzinfo=pytz.utc).astimezone(pytz.timezone('US/Pacific')) 28 | 29 | local_week_start = local_now - timedelta( 30 | days=local_now.weekday() + 1, 31 | hours=local_now.hour, 32 | minutes=local_now.minute, 33 | seconds=local_now.second, 34 | microseconds=local_now.microsecond, 35 | ) 36 | local_week_end = local_week_start + timedelta(days=7, minutes=-1) 37 | 38 | utc_week_start = local_week_start.astimezone(pytz.utc).replace(tzinfo=None) 39 | utc_week_end = local_week_end.astimezone(pytz.utc).replace(tzinfo=None) 40 | 41 | return (utc_week_start, utc_week_end) 42 | -------------------------------------------------------------------------------- /loveapp/models/subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.ext import ndb 3 | 4 | from loveapp.logic.notification_request import CONTENT_TYPE_JSON 5 | from loveapp.models import Employee 6 | 7 | 8 | class Subscription(ndb.Model): 9 | """Models a webhook subscription.""" 10 | request_method = ndb.StringProperty(required=True, default='post') 11 | request_format = ndb.StringProperty(required=True, default=CONTENT_TYPE_JSON) 12 | request_url = ndb.StringProperty(required=True) 13 | active = ndb.BooleanProperty(required=True, default=False) 14 | event = ndb.StringProperty(required=True) 15 | secret = ndb.StringProperty(required=True) 16 | timestamp = ndb.DateTimeProperty(auto_now_add=True) 17 | owner_key = ndb.KeyProperty(kind=Employee) 18 | 19 | @classmethod 20 | def create_from_dict(cls, d, persist=True): 21 | new_subscription = cls() 22 | new_subscription.owner_key = Employee.get_current_employee().key 23 | new_subscription.request_url = d['request_url'] 24 | new_subscription.active = d['active'] 25 | new_subscription.event = d['event'] 26 | new_subscription.secret = d['secret'] 27 | 28 | if persist is True: 29 | new_subscription.put() 30 | 31 | return new_subscription 32 | 33 | @classmethod 34 | def all_active_for_event(cls, event): 35 | return cls.query( 36 | cls.active == True, # noqa 37 | cls.event == event, 38 | ) 39 | -------------------------------------------------------------------------------- /loveapp/logic/leaderboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | from datetime import timedelta 4 | 5 | import loveapp.logic.love_count 6 | from loveapp.logic import TIMESPAN_LAST_WEEK 7 | from loveapp.logic import to_the_future 8 | from loveapp.logic import utc_week_limits 9 | 10 | 11 | def get_leaderboard_data(timespan, department, office=None): 12 | # If last week, we need to subtract *before* getting the week limits to 13 | # avoid being off by one hour on weeks that include a DST transition 14 | utc_now = datetime.utcnow() 15 | if timespan == TIMESPAN_LAST_WEEK: 16 | utc_now -= timedelta(days=7) 17 | utc_week_start, _ = utc_week_limits(utc_now) 18 | 19 | top_lovers, top_lovees = loveapp.logic.love_count.top_lovers_and_lovees( 20 | utc_week_start, 21 | dept=department, 22 | office=office, 23 | ) 24 | 25 | top_lover_dicts = [ 26 | { 27 | 'employee': employee_key.get_async(), 28 | 'num_sent': sent_count 29 | } 30 | for employee_key, sent_count 31 | in top_lovers 32 | ] 33 | 34 | top_loved_dicts = [ 35 | { 36 | 'employee': employee_key.get_async(), 37 | 'num_received': received_count 38 | } 39 | for employee_key, received_count 40 | in top_lovees 41 | ] 42 | 43 | # get results for the futures set up previously 44 | list(map(to_the_future, top_lover_dicts)) 45 | list(map(to_the_future, top_loved_dicts)) 46 | return (top_lover_dicts, top_loved_dicts) 47 | -------------------------------------------------------------------------------- /loveapp/logic/notifier/lovesent_notifier.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.ext import ndb 3 | 4 | import loveapp.logic.event 5 | from loveapp.logic.notification_request import NotificationRequest 6 | from loveapp.models.love import Love 7 | from loveapp.models.subscription import Subscription 8 | 9 | 10 | class LovesentNotifier(object): 11 | 12 | event = loveapp.logic.event.LOVESENT 13 | 14 | def __init__(self, *args, **kwargs): 15 | self.love = ndb.Key(Love, kwargs.get('love_id')).get() 16 | 17 | def notify(self): 18 | subscriptions = self._subscriptions() 19 | for subscription in subscriptions: 20 | NotificationRequest(subscription, self.payload()).send() 21 | return len(subscriptions) 22 | 23 | def payload(self): 24 | sender = self.love.sender_key.get() 25 | receiver = self.love.recipient_key.get() 26 | return { 27 | 'sender': { 28 | 'full_name': sender.full_name, 29 | 'username': sender.username, 30 | 'email': sender.user.email(), 31 | }, 32 | 'receiver': { 33 | 'full_name': receiver.full_name, 34 | 'username': receiver.username, 35 | 'email': receiver.user.email(), 36 | }, 37 | 'message': self.love.message, 38 | 'timestamp': self.love.timestamp.strftime('%Y-%m-%d %H:%M:%S'), 39 | } 40 | 41 | def _subscriptions(self): 42 | return Subscription.all_active_for_event(self.event).fetch() 43 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/subscription_form.html: -------------------------------------------------------------------------------- 1 |

Add Subscription

2 |

3 | We’ll POST Json to the URL below with details of any subscribed event. 4 |

5 |
6 | 7 |
8 | 9 |
10 | 11 | 16 |
17 | 18 |
19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | We will deliver event details when this hook is triggered. 30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import mock 5 | import pytest 6 | from flask import template_rendered 7 | from flask_themes2 import load_themes_from 8 | from google.appengine.ext import testbed 9 | 10 | from loveapp import create_app 11 | 12 | 13 | @pytest.fixture 14 | def app(): # noqa 15 | # do we need this? for what? 16 | def test_loader(app): 17 | return load_themes_from(os.path.join(os.path.dirname(__file__), '../loveapp/themes/')) 18 | app = create_app(theme_loaders=[test_loader]) 19 | 20 | with app.app_context(): 21 | yield app 22 | 23 | 24 | @pytest.fixture 25 | def client(app): 26 | with app.test_client() as test_client: 27 | yield test_client 28 | 29 | 30 | @pytest.fixture 31 | def recorded_templates(app): 32 | recorded = [] 33 | 34 | def record(sender, template, context, **extra): 35 | recorded.append((template, context)) 36 | 37 | template_rendered.connect(record, app) 38 | try: 39 | yield recorded 40 | finally: 41 | template_rendered.disconnect(record, app) 42 | 43 | 44 | @pytest.fixture 45 | def mock_config(): 46 | with mock.patch('loveapp.config') as mock_config: 47 | mock_config.DOMAIN = 'example.com' 48 | yield mock_config 49 | 50 | 51 | @pytest.fixture(scope='function') 52 | def gae_testbed(): 53 | tb = testbed.Testbed() 54 | tb.activate() 55 | tb.init_memcache_stub() 56 | tb.init_datastore_v3_stub() 57 | tb.init_search_stub() 58 | tb.init_taskqueue_stub() 59 | tb.init_user_stub() 60 | 61 | yield tb 62 | 63 | tb.deactivate() 64 | -------------------------------------------------------------------------------- /tests/logic/email_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import mock 5 | import pytest 6 | 7 | import loveapp.logic.email 8 | 9 | 10 | @pytest.mark.usefixtures('gae_testbed') 11 | class EmailTest(unittest.TestCase): 12 | """We really just want to test that configuration is honored here.""" 13 | 14 | sender = 'test@example.com' 15 | recipient = 'test@example.com' 16 | subject = 'test subject' 17 | html = '

hello test

' 18 | text = 'hello test' 19 | 20 | @mock.patch('loveapp.logic.email.EMAIL_BACKENDS') 21 | @mock.patch('loveapp.logic.email.config') 22 | def test_send_email_appengine(self, mock_config, mock_backends): 23 | mock_config.EMAIL_BACKEND = 'appengine' 24 | mock_backends['appengine'] = mock.Mock() 25 | loveapp.logic.email.send_email(self.sender, self.recipient, self.subject, 26 | self.html, self.text) 27 | mock_backends['appengine'].assert_called_once_with( 28 | self.sender, self.recipient, self.subject, self.html, self.text 29 | ) 30 | 31 | @mock.patch('loveapp.logic.email.EMAIL_BACKENDS') 32 | @mock.patch('loveapp.logic.email.config') 33 | def test_send_email_sendgrid(self, mock_config, mock_backends): 34 | mock_config.EMAIL_BACKEND = 'sendgrid' 35 | mock_backends['sendgrid'] = mock.Mock() 36 | loveapp.logic.email.send_email(self.sender, self.recipient, self.subject, 37 | self.html, self.text) 38 | mock_backends['sendgrid'].assert_called_once_with( 39 | self.sender, self.recipient, self.subject, self.html, self.text 40 | ) 41 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/keys.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/love_message.html") as love_message %} 4 | {% import theme("parts/flash.html") as flash %} 5 | 6 | {% block title %}API Keys{% endblock %} 7 | 8 | {% block body %} 9 |

Existing Keys

10 | {{ flash.display_flash_messages() }} 11 | {% if keys %} 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for key in keys %} 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 | 28 |
KeyDescription
{{ key.access_key }}{{ key.description }}
29 |
30 | {% else %} 31 | No API keys have been created yet. :[ 32 | {% endif %} 33 |
34 |

Add an API Key

35 |
36 | 37 |
38 | 39 | 40 |
41 | 42 |
43 | {% endblock %} 44 | 45 | {% block javascript %} 46 | 51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /tests/logic/alias_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import pytest 5 | 6 | import loveapp.logic.alias 7 | from loveapp.models import Alias 8 | from testing.factories import create_alias_with_employee_username 9 | from testing.factories import create_employee 10 | 11 | 12 | @pytest.mark.usefixtures('gae_testbed') 13 | class AliasTest(unittest.TestCase): 14 | 15 | def test_get_alias(self): 16 | create_employee(username='fuz') 17 | create_alias_with_employee_username(name='fuzzi', username='fuz') 18 | 19 | self.assertIsNotNone(loveapp.logic.alias.get_alias('fuzzi')) 20 | 21 | def test_save_alias(self): 22 | johnd = create_employee(username='johnd') 23 | self.assertEqual(Alias.query().count(), 0) 24 | 25 | alias = loveapp.logic.alias.save_alias('johnny', 'johnd') 26 | 27 | self.assertEqual(Alias.query().count(), 1) 28 | self.assertEqual(alias.name, 'johnny') 29 | self.assertEqual(alias.owner_key, johnd.key) 30 | 31 | def test_delete_alias(self): 32 | create_employee(username='janed') 33 | alias = loveapp.logic.alias.save_alias('jane', 'janed') 34 | self.assertEqual(Alias.query().count(), 1) 35 | 36 | loveapp.logic.alias.delete_alias(alias.key.id()) 37 | 38 | self.assertEqual(Alias.query().count(), 0) 39 | 40 | def test_name_for_alias_with_alias(self): 41 | create_employee(username='janed') 42 | loveapp.logic.alias.save_alias('jane', 'janed') 43 | 44 | self.assertEqual(loveapp.logic.alias.name_for_alias('jane'), 'janed') 45 | 46 | def test_name_for_alias_with_employee_name(self): 47 | self.assertEqual(loveapp.logic.alias.name_for_alias('janed'), 'janed') 48 | -------------------------------------------------------------------------------- /loveapp/util/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from functools import wraps 3 | 4 | from flask import abort 5 | from flask import redirect 6 | from flask import request 7 | from flask.helpers import make_response 8 | from google.appengine.api import users 9 | 10 | from loveapp.models.access_key import AccessKey 11 | from loveapp.util.csrf import check_csrf_protection 12 | 13 | 14 | def user_required(func): 15 | @wraps(func) 16 | def decorated_view(*args, **kwargs): 17 | if not users.get_current_user(): 18 | return redirect(users.create_login_url(request.url)) 19 | return func(*args, **kwargs) 20 | return decorated_view 21 | 22 | 23 | def admin_required(func): 24 | @wraps(func) 25 | def decorated_view(*args, **kwargs): 26 | if users.get_current_user(): 27 | if not users.is_current_user_admin(): 28 | abort(401) # Unauthorized 29 | return func(*args, **kwargs) 30 | return redirect(users.create_login_url(request.url)) 31 | return decorated_view 32 | 33 | 34 | def api_key_required(f): 35 | @wraps(f) 36 | def decorated_function(*args, **kwargs): 37 | api_key = request.form.get('api_key') or request.args.get('api_key') 38 | valid_api_key = False 39 | if api_key is not None: 40 | valid_api_key = AccessKey.query(AccessKey.access_key == api_key).get(keys_only=True) is not None 41 | if not valid_api_key: 42 | return make_response('Invalid API Key', 401, { 43 | 'WWWAuthenticate': 'Basic realm="Login Required"', 44 | }) 45 | return f(*args, **kwargs) 46 | return decorated_function 47 | 48 | 49 | def csrf_protect(func): 50 | @wraps(func) 51 | def decorated_view(*args, **kwargs): 52 | check_csrf_protection() 53 | return func(*args, **kwargs) 54 | 55 | return decorated_view 56 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/employees.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/flash.html") as flash %} 4 | {% import theme("parts/photobox.html") as photobox %} 5 | 6 | {% block title %}Employees{% endblock %} 7 | 8 | {% block body %} 9 | {{ flash.display_flash_messages() }} 10 |

Employees

11 | {% if pagination_result.collection %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for employee in pagination_result.collection %} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% endfor %} 32 | 33 |
PhotoUsernameNameDepartmentTerminated
{{ photobox.user_icon(employee) }}{{ employee.username }}{{ employee.full_name }}{{ employee.department }}{{ employee.terminated }}
34 |
35 | {% if pagination_result.prev %} 36 | « Previous 37 | {% else %} 38 | « Previous 39 | {% endif %} 40 | | 41 | {% if pagination_result.next %} 42 | Next » 43 | {% else %} 44 | Next » 45 | {% endif %} 46 |
47 | {% else %} 48 |

Oops, there are no employees yet. Let‘s import some.

49 | {% endif %} 50 | {% endblock %} 51 | 52 | {% block javascript %} 53 | 58 | {% endblock %} 59 | -------------------------------------------------------------------------------- /loveapp/logic/notification_request.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import hashlib 3 | import hmac 4 | import json 5 | import logging 6 | 7 | import urllib3 8 | 9 | import loveapp.config as config 10 | 11 | 12 | CONTENT_TYPE_JSON = 'application/json' 13 | 14 | 15 | pool = urllib3.PoolManager() 16 | 17 | 18 | class NotificationRequest(object): 19 | 20 | def __init__(self, subscription, payload): 21 | self.url = subscription.request_url 22 | self.method = subscription.request_method 23 | self.format = subscription.request_format 24 | self.secret = subscription.secret 25 | self.event = subscription.event 26 | self.content = self._content(self.format, payload) 27 | 28 | def send(self): 29 | try: 30 | return pool.urlopen( 31 | self.method, 32 | self.url, 33 | body=self.content.payload, 34 | headers=self.headers, 35 | ) 36 | except Exception: 37 | logging.info('Could not send request to %s', self.url) 38 | return False 39 | 40 | @property 41 | def headers(self): 42 | # hmac does not like unicode 43 | digest = hmac.new(str(self.secret), str(self.content.payload), hashlib.sha1) 44 | return { 45 | 'Content-type': self.content.content_type, 46 | 'X-YelpLove-Event': self.event, 47 | 'User-Agent': '{} ({})'.format(config.APP_NAME, config.APP_BASE_URL), 48 | 'X-Hub-Signature': digest.hexdigest(), 49 | } 50 | 51 | def _content(self, format, payload): 52 | if CONTENT_TYPE_JSON == format: 53 | return JSONContent(payload) 54 | else: 55 | raise RuntimeError('Unsupported format %s' % format) 56 | 57 | 58 | class JSONContent(object): 59 | 60 | def __init__(self, payload): 61 | self.content_type = CONTENT_TYPE_JSON 62 | self.payload = json.dumps(payload) 63 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/love_link.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | {% import theme("parts/facetile.html") as facetile %} 3 | 4 | {% block title %}Send Love{% endblock %} 5 | 6 | {% block body %} 7 |
8 |
9 |

Send your lovin'

10 | 11 |
12 | {% for lovee in loved %} 13 | {{ facetile.face_icon(lovee) }} 14 | {% endfor %} 15 |
16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 |
25 | 26 |
27 | 30 |
31 | 32 |
33 |
34 | 35 |
36 |

Who made me laugh in my darkest hour?

37 |

Who helped me out?

38 |

Who's my hero this week?

39 |
40 |
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/me.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/love_message.html") as love_message %} 4 | 5 | {% block title %}My Love{% endblock %} 6 | 7 | {% block body %} 8 |
9 |
10 |

Love Received

11 | {% if received_loves %} 12 |
13 | {% for love in received_loves %} 14 | {% set sender = love.sender_key.get() %} 15 | {% set recipient = love.recipient_key.get() %} 16 | {{ love_message.love( 17 | sender.full_name, 18 | sender.username, 19 | 'you', 20 | '', 21 | sender, 22 | love.message, 23 | love.seconds_since_epoch, 24 | love.secret) 25 | }} 26 | {% endfor %} 27 |
28 | {% else %} 29 |
30 | Oh no! You haven't received any love yet!
31 | Give and ye shall receive! 32 |
33 | {% endif %} 34 |
35 | 36 |
37 |

Love Sent

38 | {% if sent_loves %} 39 |
40 | {% for love in sent_loves %} 41 | {% set sender = love.sender_key.get() %} 42 | {% set recipient = love.recipient_key.get() %} 43 | {{ love_message.love( 44 | 'You', 45 | '', 46 | recipient.full_name, 47 | recipient.username, 48 | recipient, 49 | love.message, 50 | love.seconds_since_epoch, 51 | love.secret) 52 | }} 53 | {% endfor %} 54 |
55 | {% else %} 56 |
57 | Oh no! You haven't sent any love yet. Get on it! 58 |
59 | {% endif %} 60 |
61 |
62 | {% endblock %} 63 | 64 | {% block javascript %} 65 | 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /loveapp/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import flask_themes2 3 | from flask import Blueprint 4 | from flask import current_app 5 | from flask import Flask 6 | from flask_themes2 import Themes 7 | from flask_themes2 import ThemeTemplateLoader 8 | from google.appengine.api import wrap_wsgi_app 9 | 10 | import loveapp.config as config 11 | from loveapp import views 12 | from loveapp.util.auth import is_admin 13 | from loveapp.util.company_values import linkify_company_values 14 | from loveapp.util.converter import RegexConverter 15 | from loveapp.util.csrf import generate_csrf_token 16 | 17 | 18 | def create_app(theme_loaders=()): 19 | if current_app: 20 | return current_app 21 | app = Flask(__name__.split('.')[0]) 22 | app.wsgi_app = wrap_wsgi_app(app.wsgi_app) 23 | 24 | app.secret_key = config.SECRET_KEY 25 | app.url_map.converters['regex'] = RegexConverter 26 | app.jinja_env.globals['config'] = config 27 | app.jinja_env.globals['csrf_token'] = generate_csrf_token 28 | app.jinja_env.globals['is_admin'] = is_admin 29 | app.jinja_env.filters['linkify_company_values'] = linkify_company_values 30 | 31 | app.register_blueprint(views.web.web_app) 32 | app.register_blueprint(views.api.api_app) 33 | app.register_blueprint(views.tasks.tasks_app) 34 | 35 | # flask_themes2 is storing themes_blueprint at the global level 36 | # https://github.com/sysr-q/flask-themes2/blob/master/flask_themes2/__init__.py#L280C1-L281C58 37 | # which means on some parametrized test runs, we run into errors re-adding urls on the blueprint. 38 | # Forcing the reset of themes_blueprint here seems to work 39 | flask_themes2.themes_blueprint = Blueprint('_themes', __name__) 40 | flask_themes2.themes_blueprint.jinja_loader = ThemeTemplateLoader(True) 41 | Themes(app, app_identifier='yelplove', loaders=theme_loaders) 42 | 43 | # if debug property is present, let's use it 44 | try: 45 | app.debug = config.DEBUG 46 | except AttributeError: 47 | app.debug = False 48 | 49 | return app 50 | -------------------------------------------------------------------------------- /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: Love 14 | properties: 15 | - name: company_values 16 | - name: secret 17 | - name: timestamp 18 | direction: desc 19 | 20 | - kind: Love 21 | properties: 22 | - name: recipient_key 23 | - name: secret 24 | - name: sender_key 25 | - name: timestamp 26 | direction: desc 27 | 28 | - kind: Love 29 | properties: 30 | - name: recipient_key 31 | - name: secret 32 | - name: timestamp 33 | direction: desc 34 | 35 | - kind: Love 36 | properties: 37 | - name: recipient_key 38 | - name: timestamp 39 | direction: desc 40 | 41 | - kind: Love 42 | properties: 43 | - name: secret 44 | - name: sender_key 45 | - name: timestamp 46 | direction: desc 47 | 48 | - kind: Love 49 | properties: 50 | - name: sender_key 51 | - name: timestamp 52 | direction: desc 53 | 54 | - kind: LoveCount 55 | properties: 56 | - name: department 57 | - name: office 58 | - name: week_start 59 | - name: sent_count 60 | direction: desc 61 | 62 | - kind: LoveCount 63 | properties: 64 | - name: department 65 | - name: week_start 66 | - name: sent_count 67 | direction: desc 68 | 69 | - kind: LoveCount 70 | properties: 71 | - name: meta_department 72 | - name: week_start 73 | - name: sent_count 74 | direction: desc 75 | 76 | - kind: LoveCount 77 | properties: 78 | - name: office 79 | - name: week_start 80 | - name: sent_count 81 | direction: desc 82 | 83 | - kind: LoveCount 84 | properties: 85 | - name: week_start 86 | - name: sent_count 87 | direction: desc 88 | -------------------------------------------------------------------------------- /loveapp/logic/email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.api.mail import EmailMessage 3 | 4 | import loveapp.config as config 5 | import loveapp.logic.secret 6 | from loveapp.util.email import get_name_and_email 7 | 8 | if config.EMAIL_BACKEND == 'sendgrid': 9 | # a bit of a hack here so that we can avoid adding dependencies unless 10 | # the user wants them 11 | import sendgrid 12 | 13 | 14 | def send_appengine_email(sender, recipient, subject, body_html, body_text): 15 | email = EmailMessage() 16 | email.sender = sender 17 | email.to = recipient 18 | email.subject = subject 19 | email.body = body_text 20 | email.html = body_html 21 | email.send() 22 | 23 | 24 | def send_sendgrid_email(sender, recipient, subject, body_html, body_text): 25 | key = loveapp.logic.secret.get_secret('SENDGRID_API_KEY') 26 | sg = sendgrid.SendGridAPIClient(apikey=key) 27 | 28 | from_ = sendgrid.helpers.mail.Email(*get_name_and_email(sender)) 29 | to = sendgrid.helpers.mail.Email(*get_name_and_email(recipient)) 30 | content_html = sendgrid.helpers.mail.Content('text/html', body_html) 31 | content_text = sendgrid.helpers.mail.Content('text/plain', body_text) 32 | # text/plain needs to be before text/html or sendgrid gets mad 33 | message = sendgrid.helpers.mail.Mail(from_, subject, to, content_text) 34 | message.add_content(content_html) 35 | 36 | sg.client.mail.send.post(request_body=message.get()) 37 | 38 | 39 | EMAIL_BACKENDS = { 40 | 'appengine': send_appengine_email, 41 | 'sendgrid': send_sendgrid_email, 42 | } 43 | 44 | 45 | def send_email(sender, recipient, subject, body_html, body_text): 46 | """Send an email using whatever configured backend there is. 47 | sender, recipient - email address, can be bare, or in the 48 | Name format 49 | subject - string 50 | body_html - string 51 | body_text - string 52 | """ 53 | backend = EMAIL_BACKENDS[config.EMAIL_BACKEND] 54 | backend(sender, recipient, subject, body_html, body_text) 55 | -------------------------------------------------------------------------------- /loveapp/util/pagination.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import namedtuple 3 | 4 | from google.appengine.datastore.datastore_query import Cursor 5 | 6 | 7 | PaginationResult = namedtuple( 8 | 'PaginationResult', 9 | [ 10 | 'collection', 11 | 'prev_cursor', 12 | 'next_cursor', 13 | 'prev', 14 | 'next', 15 | ], 16 | ) 17 | 18 | 19 | class Pagination(object): 20 | ITEMS_PER_PAGE = 20 21 | 22 | @classmethod 23 | def paginate(cls, order_by, prev_cursor_str, next_cursor_str): 24 | if not prev_cursor_str and not next_cursor_str: 25 | objects, next_cursor, more = cls.query().order(order_by).fetch_page(cls.ITEMS_PER_PAGE) 26 | prev_cursor_str = '' 27 | if next_cursor: 28 | next_cursor_str = next_cursor.urlsafe() 29 | else: 30 | next_cursor_str = '' 31 | next_ = True if more else False 32 | prev = False 33 | elif next_cursor_str: 34 | cursor = Cursor(urlsafe=next_cursor_str) 35 | objects, next_cursor, more = cls.query().order(order_by).fetch_page( 36 | cls.ITEMS_PER_PAGE, 37 | start_cursor=cursor, 38 | ) 39 | prev_cursor_str = next_cursor_str 40 | next_cursor_str = next_cursor.urlsafe() 41 | prev = True 42 | next_ = True if more else False 43 | elif prev_cursor_str: 44 | cursor = Cursor(urlsafe=prev_cursor_str) 45 | objects, next_cursor, more = cls.query().order(-order_by).fetch_page( 46 | cls.ITEMS_PER_PAGE, 47 | start_cursor=cursor, 48 | ) 49 | objects.reverse() 50 | next_cursor_str = prev_cursor_str 51 | prev_cursor_str = next_cursor.urlsafe() 52 | prev = True if more else False 53 | next_ = True 54 | 55 | return PaginationResult( 56 | collection=objects, 57 | prev_cursor=prev_cursor_str, 58 | next_cursor=next_cursor_str, 59 | prev=prev, 60 | next=next_, 61 | ) 62 | -------------------------------------------------------------------------------- /loveapp/logic/love_link.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | import logging 4 | import random 5 | import string 6 | 7 | from google.appengine.ext import ndb 8 | 9 | import loveapp.logic.alias 10 | from errors import NoSuchLoveLink 11 | from loveapp.models import Employee 12 | from loveapp.models import LoveLink 13 | 14 | 15 | def get_love_link(hash_key): 16 | loveLink = LoveLink.query(LoveLink.hash_key == hash_key).get() 17 | if (loveLink is None): 18 | raise NoSuchLoveLink("Couldn't Love Link with id {}".format(hash_key)) 19 | return loveLink 20 | 21 | 22 | def generate_link_id(): 23 | link_id = ''.join(random.choice(string.ascii_letters) for _ in range(5)) 24 | return link_id 25 | 26 | 27 | def create_love_link(recipients, message): 28 | logging.info('Creating love link') 29 | link_id = generate_link_id() 30 | new_love_link = LoveLink( 31 | hash_key=link_id, 32 | recipient_list=recipients, 33 | message=message, 34 | ) 35 | logging.info(new_love_link) 36 | new_love_link.put() 37 | 38 | return new_love_link 39 | 40 | 41 | def add_recipient(hash_key, recipient): 42 | loveLink = LoveLink.query(LoveLink.hash_key == hash_key).get() 43 | if (loveLink is None): 44 | raise NoSuchLoveLink("Couldn't Love Link with id {}".format(hash_key)) 45 | 46 | # check that user exists, get_key_for_username throws an exception if not 47 | recipient_username = loveapp.logic.alias.name_for_alias(recipient) 48 | Employee.get_key_for_username(recipient_username) 49 | 50 | loveLink.recipient_list += ', ' + recipient 51 | loveLink.put() 52 | 53 | 54 | def love_links_cleanup(): 55 | """ 56 | Deletes love links that are more than a month (30 days) old. 57 | """ 58 | earliest = datetime.datetime.now() - datetime.timedelta(days=30) 59 | love_links_keys = LoveLink.query(LoveLink.timestamp <= earliest).fetch(keys_only=True) 60 | logging.info('Preparing to delete love links older than {}.'.format(str(earliest))) 61 | ndb.delete_multi(love_links_keys) 62 | logging.info('Love links older than {} were deleted.'.format(str(earliest))) 63 | 64 | return 65 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/sent.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | {% import theme("parts/facetile.html") as facetile %} 3 | 4 | {% block title %}Love Sent{% endblock %} 5 | 6 | {% block body %} 7 |
8 |
9 |

Love sent!

10 | 11 |
12 | {% for lovee in loved %} 13 | {{ facetile.face_icon(lovee) }} 14 | {% endfor %} 15 |
16 | 17 | 18 |
19 | {{ message|linkify_company_values }} 20 |
21 | 22 |

Share the love

23 |

You can use the link below to have others join this lovefest!

24 |
25 | {{ url }} 26 | 27 |
28 | 29 |
30 |

Who made me laugh in my darkest hour?

31 |

Who helped me out?

32 |

Who's my hero this week?

33 |
34 |
35 |
36 | {% endblock %} 37 | 38 | {% block javascript %} 39 | 60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/values.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/love_message.html") as love_message %} 4 | 5 | {% block title %}Love with company values{% endblock %} 6 | 7 | {% block body %} 8 |
9 |
10 | {% if company_value_string %} 11 |

Most recent Loves for "{{ company_value_string }}"

12 | {% else %} 13 |

Who's repping the Values?

14 | {% endif %} 15 |
16 | {% for name, link in values %} 17 | {{name}} 18 | {{ " | " if not loop.last else "" }} 19 | {% endfor %} 20 |
21 |
22 | 23 | {% if loves_first_list %} 24 |
25 |
26 | {% for love in loves_first_list %} 27 | {% set sender = love.sender_key.get() %} 28 | {% set recipient = love.recipient_key.get() %} 29 | {{ love_message.love( 30 | sender.full_name, 31 | sender.username, 32 | recipient.first_name, 33 | recipient.username, 34 | sender, 35 | love.message, 36 | love.seconds_since_epoch) 37 | }} 38 | {% endfor %} 39 |
40 |
41 |
42 | {% for love in loves_second_list %} 43 | {% set sender = love.sender_key.get() %} 44 | {% set recipient = love.recipient_key.get() %} 45 | {{ love_message.love( 46 | sender.full_name, 47 | sender.username, 48 | recipient.first_name, 49 | recipient.username, 50 | sender, 51 | love.message, 52 | love.seconds_since_epoch) 53 | }} 54 | {% endfor %} 55 |
56 |
57 | {% else %} 58 |
59 | Oh no! Nobody has posted any love for this core value yet!
60 | Give and ye shall receive! 61 |
62 | {% endif %} 63 |
64 | {% endblock %} 65 | 66 | {% block javascript %} 67 | 70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /tests/logic/love_link_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | import unittest 4 | 5 | import pytest 6 | 7 | import loveapp.logic.love 8 | import loveapp.logic.love_link 9 | from errors import NoSuchLoveLink 10 | from testing.factories import create_employee 11 | from testing.factories import create_love_link 12 | 13 | 14 | @pytest.mark.usefixtures('gae_testbed') 15 | class LoveLinkTest(unittest.TestCase): 16 | 17 | def setUp(self): 18 | self.link = create_love_link(hash_key='HeLLo', recipient_list='johndoe,janedoe', message='well hello there') 19 | self.princessbubblegum = create_employee(username='princessbubblegum') 20 | 21 | def test_get_love_link(self): 22 | link = loveapp.logic.love_link.get_love_link('HeLLo') 23 | self.assertEqual(link.hash_key, 'HeLLo') 24 | self.assertEqual(link.recipient_list, 'johndoe,janedoe') 25 | self.assertEqual(link.message, 'well hello there') 26 | 27 | def test_create_love_link(self): 28 | link = loveapp.logic.love_link.create_love_link('jake', "it's adventure time!") 29 | self.assertEqual(link.recipient_list, 'jake') 30 | self.assertEqual(link.message, "it's adventure time!") 31 | 32 | def test_add_recipient(self): 33 | link = loveapp.logic.love_link.create_love_link('finn', 'Mathematical!') 34 | 35 | loveapp.logic.love_link.add_recipient(link.hash_key, 'princessbubblegum') 36 | new_link = loveapp.logic.love_link.get_love_link(link.hash_key) 37 | 38 | self.assertEqual(new_link.recipient_list, 'finn, princessbubblegum') 39 | 40 | def test_love_links_cleanup(self): 41 | new_love = loveapp.logic.love_link.create_love_link('jake', "I'm new love!") 42 | old_love = loveapp.logic.love_link.create_love_link('finn', "I'm old love :(") 43 | old_love.timestamp = datetime.datetime.now() - datetime.timedelta(days=31) 44 | old_love.put() 45 | 46 | loveapp.logic.love_link.love_links_cleanup() 47 | db_love = loveapp.logic.love_link.get_love_link(new_love.hash_key) 48 | 49 | self.assertEqual(db_love.hash_key, new_love.hash_key) 50 | 51 | with self.assertRaises(NoSuchLoveLink): 52 | loveapp.logic.love_link.get_love_link(old_love.hash_key) 53 | -------------------------------------------------------------------------------- /tests/views/task_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | 4 | from testing.factories.employee import create_employee 5 | from testing.factories.love import create_love 6 | from testing.util import LoggedInAdminBaseTest 7 | 8 | 9 | class EmailLoveTestCase(LoggedInAdminBaseTest): 10 | 11 | def setUp(self): 12 | self.sender = create_employee(username='john') 13 | self.recipient = create_employee(username='jenny') 14 | self.love = create_love( 15 | sender_key=self.sender.key, 16 | recipient_key=self.recipient.key, 17 | message='Love!' 18 | ) 19 | 20 | def tearDown(self): 21 | self.love.key.delete() 22 | self.recipient.key.delete() 23 | self.sender.key.delete() 24 | 25 | @mock.patch('logic.love.send_love_email', autospec=True) 26 | def test_send_love_email(self, mock_send_email): # noqa 27 | response = self.app.post( 28 | '/tasks/love/email', 29 | {'id': self.love.key.id()}, 30 | ) 31 | 32 | self.assertEqual(response.status_int, 200) 33 | mock_send_email.assert_called_once_with(self.love) 34 | 35 | 36 | class LoadEmployeesTestCase(LoggedInAdminBaseTest): 37 | 38 | @mock.patch('google.appengine.api.taskqueue.add', autospec=True) 39 | @mock.patch('logic.employee.load_employees', autospec=True) 40 | def test_load_employees_from_s3(self, mock_load_employees, mock_taskqueue_add): # noqa 41 | response = self.app.get('/tasks/employees/load/s3') 42 | 43 | self.assertEqual(response.status_int, 200) 44 | self.assertEqual(mock_load_employees.call_count, 1) 45 | mock_taskqueue_add.assert_called_once_with(url='/tasks/love_count/rebuild') 46 | 47 | @mock.patch('google.appengine.api.taskqueue.add', autospec=True) 48 | @mock.patch('logic.employee.load_employees_from_csv', autospec=True) 49 | def test_load_employees_from_csv(self, mock_load_employees_from_csv, mock_taskqueue_add): # noqa 50 | response = self.app.post('/tasks/employees/load/csv') 51 | 52 | self.assertEqual(response.status_int, 200) 53 | self.assertEqual(mock_load_employees_from_csv.call_count, 1) 54 | mock_taskqueue_add.assert_called_once_with(url='/tasks/love_count/rebuild') 55 | -------------------------------------------------------------------------------- /loveapp/config-example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copy this file to config.py and change the settings. Don't forget to specify your own SECRET_KEY. 3 | from collections import namedtuple 4 | 5 | # The app name will be used in several places. 6 | APP_NAME = 'Yelp Love' 7 | 8 | APP_BASE_URL = 'https://PROJECT_ID.appspot.com/' 9 | 10 | LOVE_SENDER_EMAIL = 'Yelp Love ' 11 | 12 | # We can use the 'appengine' email API or the 'sendgrid' API. Pick one here. 13 | EMAIL_BACKEND = 'appengine' 14 | # If you have EMAIL_BACKEND = 'sendgrid', you'll need to set the SENDGRID_API_KEY 15 | # secret using the Secret Model. This is documented in the README in the discussion 16 | # on "JSON via Amazon S3". You'll also need to add the sendgrid module to your 17 | # requirements.txt. Note that you don't need it in your requirements if you don't 18 | # have it chosen! 19 | 20 | # Flask's secret key, used to encrypt the session cookie. 21 | # Set this to any random string and make sure not to share this! 22 | SECRET_KEY = 'YOUR_SECRET_HERE' 23 | 24 | # Use default theme 25 | THEME = 'default' 26 | 27 | # Set to True if you'd like to see Tracebacks on localhost 28 | DEBUG = True 29 | 30 | # Every employee needs a reference to a Google Account. This reference is based on the users 31 | # Google Account email address and created when employee data is imported: we take the *username* 32 | # and this DOMAIN 33 | DOMAIN = 'example.com' 34 | 35 | # Name of the S3 bucket used to import employee data from a file named employees.json 36 | # Check out /import/employees.json.example to see how this file should look like. 37 | S3_BUCKET = 'employees' 38 | 39 | # When do we use Gravatar? Options are: 40 | # * 'always' - prefers Gravatar over the Employee.photo_url 41 | # * 'backup' - use Gravatar when photo_url is empty 42 | # * anything else - disabled 43 | GRAVATAR = 'backup' 44 | 45 | ORG_TITLE = 'All Company' 46 | 47 | TEAMS_TITLE = 'All Teams' 48 | 49 | OFFICES_TITLE = 'All Offices' 50 | 51 | CompanyValue = namedtuple('CompanyValue', ['id', 'display_string', 'hashtags']) 52 | COMPANY_VALUES = [ 53 | CompanyValue('BE_EXCELLENT', 'Be excellent to each other', ('excellent', 'BeExcellent', 'WyldStallyns')), 54 | CompanyValue('DUST_IN_THE_WIND', 'All we are is dust in the wind, dude.', ('woah', 'whoa', 'DustInTheWind')) 55 | ] 56 | -------------------------------------------------------------------------------- /testing/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | import pytest 4 | from bs4 import BeautifulSoup 5 | 6 | from testing.factories import create_employee 7 | 8 | 9 | class YelpLoveTestCase(): 10 | 11 | def assertRequiresLogin(self, response): 12 | assert response.status_code == 302 13 | assert response.headers['Location'].startswith('https://www.google.com/accounts/Login'), \ 14 | 'Unexpected Location header: {0}'.format(response.headers['Location']) 15 | 16 | def assertRequiresAdmin(self, response): 17 | assert response.status_code == 401 18 | 19 | def assertHasCsrf(self, response, form_class, session): 20 | """Make sure the response form contains the correct CSRF token. 21 | 22 | :param form: a form entry from response.forms 23 | :param session: response.session 24 | """ 25 | soup = BeautifulSoup(response.data, 'html.parser') 26 | csrf_token = soup.find('form', class_=form_class).\ 27 | find('input', attrs={'name': '_csrf_token'}).\ 28 | get('value') 29 | assert csrf_token is not None 30 | assert csrf_token == session['_csrf_token'] 31 | 32 | def addCsrfTokenToSession(self, client): 33 | csrf_token = 'MY_TOKEN' 34 | with client.session_transaction() as session: 35 | session['_csrf_token'] = csrf_token 36 | return csrf_token 37 | 38 | 39 | class LoggedInUserBaseTest(YelpLoveTestCase): 40 | 41 | @pytest.fixture(autouse=True) 42 | def logged_in_user(self, gae_testbed): 43 | self.logged_in_employee = create_employee(username='johndoe') 44 | with mock.patch('loveapp.util.decorators.users.get_current_user') as mock_get_current_user: 45 | mock_get_current_user.return_value = self.logged_in_employee.user 46 | yield self.logged_in_employee 47 | self.logged_in_employee.key.delete() 48 | 49 | 50 | class LoggedInAdminBaseTest(LoggedInUserBaseTest): 51 | @pytest.fixture(autouse=True) 52 | def logged_in_admin(self, gae_testbed): 53 | self.logged_in_employee = create_employee(username='johndoe') 54 | with mock.patch('loveapp.util.decorators.users.is_current_user_admin') as mock_is_current_user_admin: 55 | mock_is_current_user_admin.return_value = True 56 | yield self.logged_in_employee 57 | self.logged_in_employee.key.delete() 58 | -------------------------------------------------------------------------------- /loveapp/util/company_values.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import itertools 3 | import re 4 | 5 | import markupsafe 6 | 7 | import loveapp.config as config 8 | 9 | 10 | def get_company_value(value_id): 11 | return dict((value.id, value) for value in config.COMPANY_VALUES).get(value_id) 12 | 13 | 14 | def get_company_value_link_pairs(): 15 | value_link_pairs = [ 16 | (value.display_string, '/value/' + value.id.lower()) 17 | for value in config.COMPANY_VALUES 18 | ] 19 | return sorted(value_link_pairs) 20 | 21 | 22 | def supported_hashtags(): 23 | # Returns all supported hashtags 24 | return list(map( 25 | lambda x: '#' + x, 26 | itertools.chain(*[value.hashtags for value in config.COMPANY_VALUES]) 27 | )) 28 | 29 | 30 | def get_hashtag_value_mapping(): 31 | hashtag_value_mapping = {} 32 | for value in config.COMPANY_VALUES: 33 | for hashtag in value.hashtags: 34 | hashtag_value_mapping['#' + hashtag.lower()] = value.id 35 | 36 | return hashtag_value_mapping 37 | 38 | 39 | def linkify_company_values(love): 40 | # escape the input before we add our own safe links 41 | escaped_love = str(markupsafe.escape(love)) 42 | hashtag_value_mapping = get_hashtag_value_mapping() 43 | 44 | # find all the hashtags. 45 | ht_regex = '#[a-zA-Z0-9]+' 46 | present_hashtags = re.findall(ht_regex, escaped_love) 47 | 48 | # find all the ones we care about 49 | valid_hashtags = set() 50 | for hashtag in present_hashtags: 51 | if hashtag.lower() in hashtag_value_mapping: 52 | valid_hashtags.add(hashtag) 53 | 54 | # replace the hashtags with urls 55 | for hashtag in valid_hashtags: 56 | value_anchor = '{word}'.format( 57 | value_url='/value/' + hashtag_value_mapping[hashtag.lower()].lower(), 58 | word=hashtag 59 | ) 60 | escaped_love = escaped_love.replace(hashtag, value_anchor) 61 | 62 | return markupsafe.Markup(escaped_love) 63 | 64 | 65 | def values_matching_prefix(prefix): 66 | if prefix is None: 67 | return supported_hashtags() 68 | 69 | lower_prefix = prefix.lower() 70 | matching_hashtags = [] 71 | for hashtag in supported_hashtags(): 72 | if hashtag.lower().startswith(lower_prefix): 73 | matching_hashtags.append(hashtag) 74 | return matching_hashtags 75 | -------------------------------------------------------------------------------- /tests/logic/office_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import mock 5 | import pytest 6 | 7 | from loveapp.logic.office import get_all_offices 8 | from loveapp.logic.office import OfficeParser 9 | from loveapp.logic.office import REMOTE_OFFICE 10 | from testing.factories import create_employee 11 | 12 | OFFICES = { 13 | 'Germany', 14 | 'USA' 15 | } 16 | 17 | 18 | class TestOffice(unittest.TestCase): 19 | 20 | def setUp(self): 21 | self.employee_dicts = [ 22 | {'username': 'foo1-hamburg', 'department': 'bar-team', 'office': 'Germany: Hamburg Office'}, 23 | {'username': 'foo2-hamburg', 'department': 'bar-team', 'office': 'Germany: Remote'}, 24 | {'username': 'foo3-hamburg', 'department': 'bar-team', 'office': 'Sweden: Remote'}, 25 | ] 26 | 27 | def _create_employees(self): 28 | for office in OFFICES: 29 | create_employee(office=office, username='{}-{}'.format('username', office)) 30 | 31 | @pytest.mark.usefixtures('gae_testbed') 32 | def test_get_all_offices(self): 33 | self._create_employees() 34 | assert OFFICES == set(get_all_offices()) 35 | 36 | @mock.patch('loveapp.logic.office.yaml.safe_load', return_value=OFFICES) 37 | def test_employee_parser_no_team_match(self, mock_offices): 38 | office_parser = OfficeParser() 39 | self.assertEqual( 40 | office_parser.get_office_name( 41 | self.employee_dicts[0]['office'], 42 | ), 43 | 'Germany', 44 | ) 45 | self.assertEqual( 46 | office_parser.get_office_name( 47 | self.employee_dicts[1]['office'], 48 | ), 49 | 'Germany', 50 | ) 51 | self.assertEqual( 52 | office_parser.get_office_name( 53 | self.employee_dicts[2]['office'], 54 | ), 55 | REMOTE_OFFICE, 56 | ) 57 | mock_offices.assert_called_once() 58 | 59 | @mock.patch('loveapp.logic.office.yaml.safe_load', return_value=OFFICES) 60 | def test_employee_parser_with_team_match(self, mock_offices): 61 | office_parser = OfficeParser(self.employee_dicts) 62 | for employee in self.employee_dicts: 63 | self.assertEqual( 64 | office_parser.get_office_name( 65 | employee['office'], 66 | employee_department=employee['department'], 67 | ), 68 | 'Germany', 69 | ) 70 | mock_offices.assert_called_once() 71 | -------------------------------------------------------------------------------- /tests/models/employee_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | import pytest 4 | from google.appengine.api import users 5 | 6 | from errors import NoSuchEmployee 7 | from loveapp.models.employee import Employee 8 | from testing.factories import create_employee 9 | 10 | 11 | def test_create_from_dict(mock_config, gae_testbed): 12 | mock_config.DOMAIN = 'foo.io' 13 | 14 | employee_dict = dict( 15 | username='john.d', 16 | first_name='John', 17 | last_name='Doe', 18 | department='Accounting', 19 | office='USA CA SF New Montgomery', 20 | photos=[], 21 | ) 22 | employee = Employee.create_from_dict(employee_dict) 23 | 24 | assert employee is not None 25 | assert employee.user is not None 26 | assert 'john.d@foo.io' == employee.user.email() 27 | 28 | 29 | @mock.patch('loveapp.models.employee.users.get_current_user') 30 | def test_get_current_employee(mock_get_current_user, gae_testbed): 31 | employee = create_employee(username='john.d') 32 | mock_get_current_user.return_value = employee.user 33 | current_employee = Employee.get_current_employee() 34 | 35 | assert current_employee is not None 36 | assert 'john.d' == current_employee.username 37 | 38 | 39 | @mock.patch('loveapp.models.employee.users.get_current_user') 40 | def test_get_current_employee_raises(mock_get_current_user, gae_testbed): 41 | mock_get_current_user.return_value = users.User('foo@bar.io') 42 | 43 | with pytest.raises(NoSuchEmployee): 44 | Employee.get_current_employee() 45 | 46 | 47 | def test_full_name(gae_testbed): 48 | employee = create_employee(first_name='Foo', last_name='Bar') 49 | assert 'Foo Bar' == employee.full_name 50 | 51 | 52 | def test_gravatar_backup(mock_config, gae_testbed): 53 | mock_config.GRAVATAR = 'backup' 54 | employee = create_employee(photo_url='') 55 | assert employee.get_gravatar() == employee.get_photo_url() 56 | employee = create_employee(photo_url='http://example.com/example.jpg') 57 | assert employee.photo_url == employee.get_photo_url() 58 | 59 | 60 | def test_gravatar_always(mock_config, gae_testbed): 61 | mock_config.GRAVATAR = 'always' 62 | employee = create_employee(photo_url='') 63 | assert employee.get_gravatar() == employee.get_photo_url() 64 | employee = create_employee(photo_url='http://example.com/example.jpg') 65 | assert employee.get_gravatar() == employee.get_photo_url() 66 | 67 | 68 | def test_gravatar_disabled(mock_config, gae_testbed): 69 | mock_config.GRAVATAR = 'disabled' 70 | employee = create_employee(photo_url='') 71 | assert employee.photo_url == employee.get_photo_url() 72 | employee = create_employee(photo_url='http://example.com/example.jpg') 73 | assert employee.photo_url == employee.get_photo_url() 74 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/love_form.html: -------------------------------------------------------------------------------- 1 | {% import theme("parts/flash.html") as flash %} 2 | 3 |
4 |
5 |

Send your lovin'

6 |
7 | 8 |
{% if message %}{{ message }}{% endif %}
9 | {{ flash.display_flash_messages() }} 10 | 11 |
12 | Type a username 13 | 14 | 15 |
16 |
17 | 18 | Keep it simple: don't disclose confidential or sensitive information. 19 | 140 chars max 20 |
21 |
22 | 23 |
24 | 25 |
26 | 29 |
30 |
31 |
32 | 33 | {% if url %} 34 | 38 | {% endif %} 39 |
40 |
41 | 42 |
43 |

Who made me laugh in my darkest hour?

44 |

Who helped me out?

45 |

Who's my hero this week?

46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /loveapp/logic/love_count.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | import logging 4 | 5 | from google.appengine.api.runtime import memory_usage 6 | from google.appengine.ext import ndb 7 | 8 | from loveapp.logic import utc_week_limits 9 | from loveapp.logic.toggle import set_toggle_state 10 | from loveapp.models import Employee 11 | from loveapp.models import Love 12 | from loveapp.models import LoveCount 13 | from loveapp.models.toggle import LOVE_SENDING_ENABLED 14 | 15 | 16 | def top_lovers_and_lovees(utc_week_start, dept=None, office=None, limit=20): 17 | """Synchronously return a list of (employee key, sent love count) and a list of 18 | (employee key, received love count), each sorted in descending order of love sent 19 | or received. 20 | """ 21 | sent_query = LoveCount.query(LoveCount.week_start == utc_week_start) 22 | if dept: 23 | sent_query = sent_query.filter(LoveCount.department == dept) 24 | 25 | if office: 26 | sent_query = sent_query.filter(LoveCount.office == office) 27 | 28 | sent = sent_query.order(-LoveCount.sent_count).fetch() 29 | lovers = [] 30 | for c in sent: 31 | if len(lovers) == limit: 32 | break 33 | if c.sent_count == 0: 34 | continue 35 | employee_key = c.key.parent() 36 | 37 | lovers.append((employee_key, c.sent_count)) 38 | 39 | received = sorted(sent, key=lambda c: c.received_count, reverse=True) 40 | lovees = [] 41 | for c in received: 42 | if len(lovees) == limit: 43 | break 44 | if c.received_count == 0: 45 | continue 46 | employee_key = c.key.parent() 47 | 48 | lovees.append((employee_key, c.received_count)) 49 | 50 | return (lovers, lovees) 51 | 52 | 53 | def rebuild_love_count(): 54 | utc_dt = datetime.datetime.utcnow() - datetime.timedelta(days=7) # rebuild last week and this week 55 | week_start, _ = utc_week_limits(utc_dt) 56 | 57 | set_toggle_state(LOVE_SENDING_ENABLED, False) 58 | 59 | logging.info('Deleting LoveCount table... {}MB'.format(memory_usage().current)) 60 | ndb.delete_multi(LoveCount.query(LoveCount.week_start >= week_start).fetch(keys_only=True)) 61 | employee_dict = { 62 | employee.key: employee 63 | for employee in Employee.query() 64 | } 65 | logging.info('Rebuilding LoveCount table... {}MB'.format(memory_usage().current)) 66 | cursor = None 67 | count = 0 68 | while True: 69 | loves, cursor, has_more = Love.query(Love.timestamp >= week_start).fetch_page(500, start_cursor=cursor) 70 | for love in loves: 71 | LoveCount.update(love, employee_dict=employee_dict) 72 | count += len(loves) 73 | logging.info('Processed {} loves, {}MB'.format(count, memory_usage().current)) 74 | if not has_more: 75 | break 76 | logging.info('Done. {}MB'.format(memory_usage().current)) 77 | 78 | set_toggle_state(LOVE_SENDING_ENABLED, True) 79 | -------------------------------------------------------------------------------- /loveapp/models/love_count.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from google.appengine.ext import ndb 3 | 4 | from loveapp.logic import utc_week_limits 5 | 6 | 7 | class LoveCount(ndb.Model): 8 | received_count = ndb.IntegerProperty(default=0) 9 | sent_count = ndb.IntegerProperty(default=0) 10 | week_start = ndb.DateTimeProperty() 11 | department = ndb.StringProperty() 12 | office = ndb.StringProperty() 13 | 14 | @classmethod 15 | def update(cls, love, employee_dict=None): 16 | utc_week_start, _ = utc_week_limits(love.timestamp) 17 | 18 | sender_count = cls.query( 19 | ancestor=love.sender_key, 20 | filters=(cls.week_start == utc_week_start) 21 | ).get() 22 | if sender_count is not None: 23 | sender_count.sent_count += 1 24 | else: 25 | employee = employee_dict[love.sender_key] if employee_dict else love.sender_key.get() 26 | sender_count = cls( 27 | parent=love.sender_key, 28 | sent_count=1, 29 | week_start=utc_week_start, 30 | department=employee.department, 31 | office=employee.office, 32 | ) 33 | sender_count.put() 34 | 35 | recipient_count = cls.query( 36 | ancestor=love.recipient_key, 37 | filters=(cls.week_start == utc_week_start) 38 | ).get() 39 | if recipient_count is not None: 40 | recipient_count.received_count += 1 41 | else: 42 | employee = employee_dict[love.recipient_key] if employee_dict else love.recipient_key.get() 43 | recipient_count = cls( 44 | parent=love.recipient_key, 45 | received_count=1, 46 | week_start=utc_week_start, 47 | department=employee.department, 48 | office=employee.office, 49 | ) 50 | recipient_count.put() 51 | 52 | @classmethod 53 | def remove(cls, love): 54 | utc_week_start, _ = utc_week_limits(love.timestamp) 55 | 56 | sender_count = cls.query( 57 | ancestor=love.sender_key, 58 | filters=(cls.week_start == utc_week_start) 59 | ).get() 60 | if sender_count is not None and sender_count.sent_count > 0: 61 | sender_count.sent_count -= 1 62 | if sender_count.sent_count == 0 and sender_count.received_count == 0: 63 | sender_count.key.delete() 64 | else: 65 | sender_count.put() 66 | 67 | recipient_count = cls.query( 68 | ancestor=love.recipient_key, 69 | filters=(cls.week_start == utc_week_start) 70 | ).get() 71 | if recipient_count is not None and recipient_count.received_count > 0: 72 | recipient_count.received_count -= 1 73 | if recipient_count.sent_count == 0 and recipient_count.received_count == 0: 74 | recipient_count.key.delete() 75 | else: 76 | recipient_count.put() 77 | -------------------------------------------------------------------------------- /loveapp/views/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import Blueprint 3 | from flask import request 4 | from flask import Response 5 | from google.appengine.api import taskqueue 6 | from google.appengine.ext import ndb 7 | 8 | import loveapp.logic.employee 9 | import loveapp.logic.love 10 | import loveapp.logic.love_count 11 | import loveapp.logic.love_link 12 | import loveapp.logic.notifier 13 | from loveapp.models import Love 14 | 15 | tasks_app = Blueprint('tasks_app', __name__) 16 | 17 | # All tasks that are to be executed by cron need to use HTTP GET 18 | # see https://cloud.google.com/appengine/docs/python/config/cron 19 | 20 | 21 | @tasks_app.route('/tasks/employees/load/s3', methods=['GET']) 22 | def load_employees_from_s3(): 23 | loveapp.logic.employee.load_employees() 24 | # we need to rebuild the love count index as the departments may have changed. 25 | taskqueue.add(url='/tasks/love_count/rebuild') 26 | return Response(status=200) 27 | 28 | 29 | # This task has a web UI to trigger it, so let's use POST 30 | @tasks_app.route('/tasks/employees/load/csv', methods=['POST']) 31 | def load_employees_from_csv(): 32 | loveapp.logic.employee.load_employees_from_csv() 33 | # we need to rebuild the love count index as the departments may have changed. 34 | taskqueue.add(url='/tasks/love_count/rebuild') 35 | return Response(status=200) 36 | 37 | 38 | # One-off tasks are much easier to trigger using GET 39 | @tasks_app.route('/tasks/employees/combine', methods=['GET']) 40 | def combine_employees(): 41 | old_username, new_username = request.args['old'], request.args['new'] 42 | if not old_username: 43 | return Response(response='{} is not a valid username'.format(old_username), status=400) 44 | elif not new_username: 45 | return Response(response='{} is not a valid username'.format(new_username), status=400) 46 | 47 | loveapp.logic.employee.combine_employees(old_username, new_username) 48 | return Response(status=200) 49 | 50 | 51 | @tasks_app.route('/tasks/index/rebuild', methods=['GET']) 52 | def rebuild_index(): 53 | loveapp.logic.employee.rebuild_index() 54 | return Response(status=200) 55 | 56 | 57 | @tasks_app.route('/tasks/love/email', methods=['POST']) 58 | def email_love(): 59 | love_id = int(request.form['id']) 60 | love = ndb.Key(Love, love_id).get() 61 | loveapp.logic.love.send_love_email(love) 62 | return Response(status=200) 63 | 64 | 65 | @tasks_app.route('/tasks/love_count/rebuild', methods=['GET']) 66 | def rebuild_love_count(): 67 | loveapp.logic.love_count.rebuild_love_count() 68 | return Response(status=200) 69 | 70 | 71 | @tasks_app.route('/tasks/subscribers/notify', methods=['POST']) 72 | def notify_subscribers(): 73 | notifier = loveapp.logic.notifier.notifier_for_event(request.json['event'])(**request.json['options']) 74 | notifier.notify() 75 | return Response(status=200) 76 | 77 | 78 | @tasks_app.route('/tasks/lovelinks/cleanup', methods=['GET']) 79 | def lovelinks_cleanup(): 80 | loveapp.logic.love_link.love_links_cleanup() 81 | return Response(status=200) 82 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/subscriptions.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/flash.html") as flash %} 4 | 5 | {% block title %}Subscriptions{% endblock %} 6 | 7 | {% block body %} 8 | {{ flash.display_flash_messages() }} 9 |

All Subscriptions

10 | {% if subscriptions %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for s in subscriptions %} 24 | 25 | 32 | 33 | 34 | 35 | 36 | 42 | 43 | {% endfor %} 44 | 45 |
ActiveEventOwnerURLSecret 
26 | {% if s.active %} 27 | Yes 28 | {% else %} 29 | No 30 | {% endif %} 31 | {{ s.event }}{{ s.owner_key.get().username }}{{ s.request_url }}{{ s.secret }} 37 |
38 | 39 | 40 |
41 |
46 | {% else %} 47 |

Nobody is using webhooks yet. :[

48 | {% endif %} 49 |
50 | {% include theme("subscription_form.html") %} 51 | {% endblock %} 52 | 53 | {% block javascript %} 54 | 103 | {% endblock %} 104 | -------------------------------------------------------------------------------- /loveapp/themes/default/static/js/linkify.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Linkify - v1.1.3 3 | * Find URLs in plain text and return HTML for discovered links. 4 | * https://github.com/HitSend/jQuery-linkify/ 5 | * 6 | * Made by SoapBox Innovations, Inc. 7 | * Under MIT License 8 | */ 9 | !function(a,b,c,d){"use strict";function e(a,b){this._defaults=f,this.element=a,this.setOptions(b),this.init()}var f={tagName:"a",newLine:"\n",target:"_blank",linkClass:null,linkClasses:[],linkAttributes:null};e.prototype={constructor:e,init:function(){1===this.element.nodeType?e.linkifyNode.call(this,this.element):this.element=e.linkify.call(this,this.element.toString())},setOptions:function(a){this.settings=e.extendSettings(a,this.settings)},toString:function(){return this.element.toString()}},e.extendSettings=function(a,b){var c;b=b||{};for(c in f)b[c]||(b[c]=f[c]);for(c in a)b[c]=a[c];return b},e.linkMatch=new RegExp(["(",'\\s|[^a-zA-Z0-9.\\+_\\/"\\>\\-]|^',")(?:","(","[a-zA-Z0-9\\+_\\-]+","(?:","\\.[a-zA-Z0-9\\+_\\-]+",")*@",")?(","http:\\/\\/|https:\\/\\/|ftp:\\/\\/",")?(","(?:(?:[a-z0-9][a-z0-9_%\\-_+]*\\.)+)",")(","(?:com|ca|co|edu|gov|net|org|dev|biz|cat|int|pro|tel|mil|aero|asia|coop|info|jobs|mobi|museum|name|post|travel|local|[a-z]{2})",")(","(?:","[\\/|\\?]","(?:","[\\-a-zA-Z0-9_%#*&+=~!?,;:.\\/]*",")*",")","[\\-\\/a-zA-Z0-9_%#*&+=~]","|","\\/?",")?",")(",'[^a-zA-Z0-9\\+_\\/"\\<\\-]|$',")"].join(""),"g"),e.emailLinkMatch=/(<[a-z]+ href=\")(http:\/\/)([a-zA-Z0-9\+_\-]+(?:\.[a-zA-Z0-9\+_\-]+)*@)/g,e.linkify=function(a,b){var c,d,f,g=[];this.constructor===e&&this.settings?(d=this.settings,b&&(d=e.extendSettings(b,d))):d=e.extendSettings(b),f=d.linkClass?d.linkClass.split(/\s+/):[],f.push.apply(f,d.linkClasses),a=a.replace(/$2$3$4$5$6$7"),a=a.replace(e.linkMatch,g.join(" ")),a=a.replace(e.emailLinkMatch,"$1mailto:$3"),a=a.replace(/(\s){2}/g,"$1"),a=a.replace(/\n/g,d.newLine)},e.linkifyNode=function(a){var b,d,f,g,h;if(a&&"object"==typeof a&&1===a.nodeType&&"a"!==a.tagName.toLowerCase()&&!/[^\s]linkified[\s$]/.test(a.className)){for(b=[],g=e._dummyElement||c.createElement("div"),d=a.firstChild,f=a.childElementCount;d;){if(3===d.nodeType){for(;g.firstChild;)g.removeChild(g.firstChild);for(g.innerHTML=e.linkify.call(this,d.textContent||d.innerText),b.push.apply(b,g.childNodes);g.firstChild;)g.removeChild(g.firstChild)}else 1===d.nodeType?b.push(e.linkifyNode(d)):b.push(d);d=d.nextSibling}for(;a.firstChild;)a.removeChild(a.firstChild);for(h=0;h 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}{% endblock %} | {{ config.APP_NAME }} 10 | 11 | 12 | 51 |
52 | {% block body %}{% endblock %} 53 |
54 | 57 |
58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | {% block javascript %}{% endblock %} 66 | 67 | 68 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/explore.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/love_message.html") as love_message %} 4 | {% import theme("parts/photobox.html") as photobox %} 5 | {% import theme("parts/flash.html") as flash %} 6 | 7 | {% block title %}Explore{% endblock %} 8 | 9 | {% block body %} 10 |
11 |
12 |

Explore the lovin'

13 |
14 | {{ flash.display_flash_messages() }} 15 |
16 | 17 | 18 |
19 |
20 | 21 |
22 |
23 |
24 |
25 | {% if user %} 26 |
27 | {{ photobox.user_icon(user) }} 28 |

{{ user.full_name }}

29 |

{{ user.department }}

30 |
31 | {% endif %} 32 |
33 |
34 | 35 | {% if user %} 36 |
37 |
38 |

Love Sent

39 | {% if sent_loves %} 40 |
41 | {% for love in sent_loves %} 42 | {% set sender = love.sender_key.get() %} 43 | {% set recipient = love.recipient_key.get() %} 44 | {{ love_message.love( 45 | sender.first_name, 46 | sender.username, 47 | recipient.full_name, 48 | recipient.username, 49 | recipient, 50 | love.message, 51 | love.seconds_since_epoch) 52 | }} 53 | {% endfor %} 54 |
55 | {% else %} 56 |
57 | Oh no! {{ user.username }} hasn't sent any love yet. 58 |
59 | {% endif %} 60 |
61 |
62 |

Love Received

63 | {% if received_loves %} 64 |
65 | {% for love in received_loves %} 66 | {% set sender = love.sender_key.get() %} 67 | {% set recipient = love.recipient_key.get() %} 68 | {{ love_message.love( 69 | sender.full_name, 70 | sender.username, 71 | recipient.first_name, 72 | recipient.username, 73 | sender, 74 | love.message, 75 | love.seconds_since_epoch) 76 | }} 77 | {% endfor %} 78 |
79 | {% else %} 80 |
81 | Oh no! {{ user.username }} hasn't received any love yet.
82 | Hook them up. 83 |
84 | {% endif %} 85 |
86 |
87 | {% endif %} 88 | 89 | {% endblock %} 90 | {% block javascript %} 91 | 106 | {% endblock %} 107 | -------------------------------------------------------------------------------- /tests/util/company_values_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import mock 5 | 6 | import loveapp.util.company_values 7 | from loveapp.config import CompanyValue 8 | 9 | 10 | class CompanyValuesUtilTest(unittest.TestCase): 11 | 12 | COMPANY_VALUE_ONE = CompanyValue('FAKE_VALUE_ONE', 'Fake Value 1', ['fakevalue1']) 13 | COMPANY_VALUE_TWO = CompanyValue('FAKE_VALUE_TWO', 'Fake Value 2', ['fakevalue2', 'otherhashtag']) 14 | 15 | @mock.patch('loveapp.util.company_values.config') 16 | def test_get_company_value(self, mock_config): 17 | mock_config.COMPANY_VALUES = [ 18 | self.COMPANY_VALUE_ONE, 19 | self.COMPANY_VALUE_TWO 20 | ] 21 | company_value = loveapp.util.company_values.get_company_value(self.COMPANY_VALUE_ONE.id) 22 | self.assertEqual(company_value, self.COMPANY_VALUE_ONE) 23 | 24 | probably_None = loveapp.util.company_values.get_company_value('fake_value') 25 | self.assertEqual(None, probably_None) 26 | 27 | @mock.patch('loveapp.util.company_values.config') 28 | def test_supported_hashtags(self, mock_config): 29 | mock_config.COMPANY_VALUES = [] 30 | supported_hashtags = loveapp.util.company_values.supported_hashtags() 31 | self.assertEqual(supported_hashtags, []) 32 | 33 | mock_config.COMPANY_VALUES = [self.COMPANY_VALUE_TWO] 34 | supported_hashtags = loveapp.util.company_values.supported_hashtags() 35 | self.assertEqual(supported_hashtags, ['#fakevalue2', '#otherhashtag']) 36 | 37 | @mock.patch('loveapp.util.company_values.config') 38 | def test_get_hashtag_value_mapping(self, mock_config): 39 | mock_config.COMPANY_VALUES = [] 40 | hashtag_mapping = loveapp.util.company_values.get_hashtag_value_mapping() 41 | self.assertEqual({}, hashtag_mapping) 42 | 43 | mock_config.COMPANY_VALUES = [ 44 | self.COMPANY_VALUE_ONE, 45 | self.COMPANY_VALUE_TWO 46 | ] 47 | 48 | expected_mapping = { 49 | '#' + self.COMPANY_VALUE_ONE.hashtags[0]: self.COMPANY_VALUE_ONE.id, 50 | '#' + self.COMPANY_VALUE_TWO.hashtags[0]: self.COMPANY_VALUE_TWO.id, 51 | '#' + self.COMPANY_VALUE_TWO.hashtags[1]: self.COMPANY_VALUE_TWO.id, 52 | } 53 | hashtag_mapping = loveapp.util.company_values.get_hashtag_value_mapping() 54 | self.assertEqual(expected_mapping, hashtag_mapping) 55 | 56 | @mock.patch('loveapp.util.company_values.config') 57 | def test_linkify_company_values(self, mock_config): 58 | mock_config.COMPANY_VALUES = [] 59 | love_text = u'who wants to #liveForever? 😭' 60 | linkified_value = loveapp.util.company_values.linkify_company_values(love_text) 61 | # should be the same, because there's no hashtags. 62 | self.assertEqual(love_text, linkified_value) 63 | 64 | mock_config.COMPANY_VALUES = [ 65 | CompanyValue('FREDDIE', 'Mercury', ('liveForever',)) 66 | ] 67 | love_text = 'who wants to #liveForever?' 68 | linkified_value = loveapp.util.company_values.linkify_company_values(love_text) 69 | # there should be a link in here now 70 | self.assertIn('href', linkified_value) 71 | 72 | @mock.patch('loveapp.util.company_values.config') 73 | def test_values_matching_prefix(self, mock_config): 74 | mock_config.COMPANY_VALUES = [ 75 | CompanyValue('TEST', 'test', ['abseil', 'absolute', 'abrasion']) 76 | ] 77 | 78 | self.assertEqual( 79 | set(['#abseil', '#absolute', '#abrasion']), 80 | set(loveapp.util.company_values.values_matching_prefix('#a')) 81 | ) 82 | 83 | self.assertEqual( 84 | set(['#abseil', '#absolute']), 85 | set(loveapp.util.company_values.values_matching_prefix('#abs')) 86 | ) 87 | -------------------------------------------------------------------------------- /loveapp/logic/office.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import Counter 3 | from collections import defaultdict 4 | 5 | import yaml 6 | 7 | REMOTE_OFFICE = 'Remote' 8 | OTHER_OFFICE = 'Other' 9 | 10 | 11 | def get_all_offices(): 12 | from loveapp.models import Employee 13 | 14 | """ 15 | Retrieve all the offices in the database 16 | Returns: List[str] 17 | """ 18 | return [ 19 | row.office 20 | for row in Employee.query(projection=['office'], distinct=True).fetch() if row.office 21 | ] 22 | 23 | 24 | class OfficeParser: 25 | def __init__(self, employee_dicts=None): 26 | """ 27 | 28 | Args: 29 | employee_dicts ([Dict], optional): Defaults to None. 30 | If this dict exists the location of the employee will be determined from 31 | the team location in case we don't have a location assigned to the employee 32 | """ 33 | self.offices = yaml.safe_load(open('offices.yaml')) 34 | self.__team_country_map = None 35 | if employee_dicts: 36 | self.__team_country_map = self.__create_team_country_map(employee_dicts) 37 | 38 | def __create_team_country_map(self, employee_dicts): 39 | """ 40 | 41 | Args: 42 | employee_dicts ([Dict]): employee info 43 | 44 | Returns: 45 | [DICT]: Map of each team and the country assigned to it. 46 | The team country is the country with more employees. 47 | """ 48 | teams_locations = defaultdict(lambda: Counter()) 49 | for employee in employee_dicts: 50 | teams_locations[employee['department']][ 51 | self.__get_office_name_match(employee['office']) 52 | ] += 1 53 | 54 | team_country_map = {} 55 | for team, countries in teams_locations.items(): 56 | team_country_map[team] = countries.most_common(1)[0][0] 57 | 58 | return team_country_map 59 | 60 | def get_office_name(self, employee_office_location, employee_department=None): 61 | """ 62 | Given the employee office location retrieve the office name 63 | The matching is done by basic string matching 64 | Args: 65 | employee_office_location: str 66 | employee_department: Optional[str]. 67 | This is the team of the employee. If it exists the office will be guessed from the 68 | office location in case we don't have an office for the employee 69 | Returns: str 70 | 71 | Examples in: out 72 | if we have yaml file with the follwong content: {Hamburg office, SF office} 73 | NY Remote: Remote 74 | SF office: SF office 75 | SF office Remote: SF office 76 | Germany Hamburg office: Hamburg office 77 | CA SF New Montgomery Office: Sf office 78 | """ 79 | employee_office_location = employee_office_location.lower() 80 | 81 | matched_office_name = self.__get_office_name_match(employee_office_location) 82 | if matched_office_name != employee_office_location: 83 | return matched_office_name 84 | 85 | if self.__team_country_map and employee_department: 86 | return self.get_office_name(self.__team_country_map[employee_department]) 87 | 88 | if REMOTE_OFFICE.lower() in employee_office_location: 89 | return REMOTE_OFFICE 90 | 91 | return OTHER_OFFICE 92 | 93 | def __get_office_name_match(self, office_name): 94 | """ 95 | Get the office name in the saved yaml file that matches this office name 96 | Args: 97 | office_name [str] 98 | 99 | Returns: 100 | [str]: The matched office name if exists otherwise the same name in the input 101 | """ 102 | for office in self.offices: 103 | if office.lower() in office_name.lower(): 104 | return office 105 | return office_name 106 | -------------------------------------------------------------------------------- /loveapp/models/employee.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import base64 3 | import functools 4 | import hashlib 5 | 6 | from google.appengine.api import users 7 | from google.appengine.ext import ndb 8 | 9 | import loveapp.config 10 | from errors import NoSuchEmployee 11 | from loveapp.util.pagination import Pagination 12 | 13 | 14 | def memoized(func): 15 | results = {} 16 | 17 | @functools.wraps(func) 18 | def _memoization_wrapper(*args): 19 | if args not in results: 20 | results[args] = func(*args) 21 | return results[args] 22 | 23 | def _forget_results(): 24 | results.clear() 25 | 26 | _memoization_wrapper.forget_results = _forget_results 27 | return _memoization_wrapper 28 | 29 | 30 | class Employee(ndb.Model, Pagination): 31 | """Models an Employee.""" 32 | department = ndb.StringProperty(indexed=True) 33 | first_name = ndb.StringProperty(indexed=False) 34 | last_name = ndb.StringProperty(indexed=False) 35 | photo_url = ndb.TextProperty() 36 | terminated = ndb.BooleanProperty(default=False) 37 | timestamp = ndb.DateTimeProperty(auto_now_add=True) 38 | user = ndb.UserProperty() 39 | username = ndb.StringProperty() 40 | office = ndb.StringProperty(indexed=True) 41 | 42 | @classmethod 43 | def get_current_employee(cls): 44 | user = users.get_current_user() 45 | user_email = user.email() 46 | employee = cls.query(cls.user == user, cls.terminated == False).get() # noqa 47 | if employee is None: 48 | raise NoSuchEmployee('Couldn\'t find a Google Apps user with email {}'.format(user_email)) 49 | return employee 50 | 51 | @classmethod 52 | def create_from_dict(cls, d, persist=True): 53 | new_employee = cls() 54 | new_employee.username = d['username'] 55 | new_employee.user = users.User( 56 | '{user}@{domain}'.format(user=new_employee.username, domain=loveapp.config.DOMAIN) 57 | ) 58 | new_employee.update_from_dict(d) 59 | 60 | if persist is True: 61 | new_employee.put() 62 | 63 | return new_employee 64 | 65 | @classmethod 66 | def key_to_username(cls, key): 67 | return cls.query(cls.key == key).get().username 68 | 69 | @classmethod 70 | @memoized 71 | def get_key_for_username(cls, username): 72 | key = cls.query(cls.username == username, cls.terminated == False).get(keys_only=True) # noqa 73 | if key is None: 74 | raise NoSuchEmployee("Couldn't find a Google Apps user with username {}".format(username)) 75 | return key 76 | 77 | def update_from_dict(self, d): 78 | self.first_name = d['first_name'] 79 | self.last_name = d['last_name'] 80 | self.photo_url = d.get('photo_url') 81 | if d.get('photos'): 82 | self.photo_url = d['photos']['ms'].replace('http://', 'https://', 1) 83 | self.department = d.get('department') 84 | self.office = d['office'] 85 | 86 | def get_gravatar(self): 87 | """Creates gravatar URL from email address.""" 88 | m = hashlib.md5() 89 | m.update(self.user.email().encode()) 90 | encoded_hash = base64.b16encode(m.digest()).lower() 91 | return 'https://gravatar.com/avatar/{}?s=200'.format(encoded_hash) 92 | 93 | def get_photo_url(self): 94 | """Return an avatar photo URL (depending on Gravatar config). This still could 95 | be empty, in which case the theme needs to provide an alternate photo. 96 | """ 97 | if loveapp.config.GRAVATAR == 'always': 98 | return self.get_gravatar() 99 | elif loveapp.config.GRAVATAR == 'backup' and not self.photo_url: 100 | return self.get_gravatar() 101 | else: 102 | return self.photo_url 103 | 104 | @property 105 | def full_name(self): 106 | """Return user's full name (first name + ' ' + last name).""" 107 | return u'{} {}'.format(self.first_name, self.last_name) 108 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/leaderboard.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% import theme("parts/photobox.html") as photobox %} 4 | 5 | {% block title %}Leaderboard{% endblock %} 6 | 7 | {% block body %} 8 |
9 |
10 |

Lovers of the Week

11 |
12 |
13 |
14 |
15 | 16 | 20 |
21 |
22 | 23 | 29 |
30 |
31 | 32 | 38 |
39 |
40 |
41 |
42 | 43 |
44 |
45 |

Most Love Received

46 | {% for lovee_dict in top_loved %} 47 | {% set lovee = lovee_dict['employee'] %} 48 | {% set num_received = lovee_dict['num_received'] %} 49 |
50 | {{ photobox.user_icon(lovee) }} 51 |
52 |
53 | {{ num_received }} received 54 |
55 |

{{ loop.index }}. {{ lovee.full_name }}

56 |
{{ lovee.department }}
57 |
58 |
59 | {% else %} 60 |
61 | No one in {{ selected_dept }} has received any love {% if selected_timespan == "this_week" %}this{% else %}last{% endif %} week! 62 | Get lovin'! 63 |
64 | {% endfor %} 65 |
66 |
67 |

Most Love Sent

68 | {% for lover_dict in top_lovers %} 69 | {% set lover = lover_dict['employee'] %} 70 | {% set num_sent = lover_dict['num_sent'] %} 71 |
72 | {{ photobox.user_icon(lover) }} 73 |
74 |
75 | {{ num_sent }} sent 76 |
77 |

{{ loop.index }}. {{ lover.full_name }}

78 |
{{ lover.department }}
79 |
80 |
81 | {% else %} 82 |
83 | No one in {{ selected_dept }} has sent any love {% if selected_timespan == "this_week" %}this{% else %}last{% endif %} week! Oh, the humanity! 84 |
85 | {% endfor %} 86 |
87 |
88 | {% endblock %} 89 | 90 | {% block javascript %} 91 | 99 | {% endblock %} 100 | -------------------------------------------------------------------------------- /loveapp/views/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import Blueprint 3 | from flask import make_response 4 | from flask import request 5 | 6 | from errors import TaintedLove 7 | from loveapp.logic import TIMESPAN_THIS_WEEK 8 | from loveapp.logic.leaderboard import get_leaderboard_data 9 | from loveapp.logic.love import get_love 10 | from loveapp.logic.love import send_loves 11 | from loveapp.logic.love_link import create_love_link 12 | from loveapp.models import Employee 13 | from loveapp.util.decorators import api_key_required 14 | from loveapp.util.recipient import sanitize_recipients 15 | from loveapp.util.render import make_json_response 16 | from loveapp.views import common 17 | 18 | 19 | LOVE_CREATED_STATUS_CODE = 201 # Created 20 | LOVE_FAILED_STATUS_CODE = 418 # I'm a Teapot 21 | LOVE_BAD_PARAMS_STATUS_CODE = 422 # Unprocessable Entity 22 | LOVE_NOT_FOUND_STATUS_CODE = 404 # Not Found 23 | 24 | api_app = Blueprint('api_app', __name__) 25 | 26 | # GET /api/love 27 | 28 | 29 | @api_app.route('/api/love', methods=['GET']) 30 | @api_key_required 31 | def api_get_love(): 32 | sender = request.args.get('sender') 33 | recipient = request.args.get('recipient') 34 | 35 | limit = request.args.get('limit') 36 | if limit: 37 | try: 38 | limit = int(limit) 39 | except ValueError: 40 | return make_response( 41 | 'Invalid value for "limit": {0!r}'.format(limit), 42 | LOVE_BAD_PARAMS_STATUS_CODE) 43 | 44 | if not (sender or recipient): 45 | return make_response( 46 | 'You must provide either a sender or a recipient.', 47 | LOVE_BAD_PARAMS_STATUS_CODE) 48 | 49 | love_found = get_love( 50 | sender_username=sender, 51 | recipient_username=recipient, 52 | limit=limit, 53 | ).get_result() 54 | 55 | return make_json_response([ 56 | { 57 | 'sender': Employee.key_to_username(love.sender_key), 58 | 'recipient': Employee.key_to_username(love.recipient_key), 59 | 'message': love.message, 60 | 'timestamp': love.timestamp.isoformat(), 61 | } 62 | for love in love_found 63 | ]) 64 | 65 | # POST /api/love 66 | 67 | 68 | @api_app.route('/api/love', methods=['POST']) 69 | @api_key_required 70 | def api_send_loves(): 71 | sender = request.form.get('sender') 72 | recipients = sanitize_recipients(request.form.get('recipient')) 73 | message = request.form.get('message') 74 | 75 | try: 76 | recipients = send_loves(recipients, message, sender_username=sender) 77 | recipients_display_str = ', '.join(recipients) 78 | link_url = create_love_link(recipients_display_str, message).url 79 | return make_response( 80 | u'Love sent to {}! Share: {}'.format(recipients_display_str, link_url), 81 | LOVE_CREATED_STATUS_CODE, 82 | {} 83 | ) 84 | except TaintedLove as exc: 85 | return make_response( 86 | exc.user_message, 87 | LOVE_FAILED_STATUS_CODE if exc.is_error else LOVE_CREATED_STATUS_CODE, 88 | {} 89 | ) 90 | 91 | # GET /api/leaderboard 92 | 93 | 94 | @api_app.route('/api/leaderboard', methods=['GET']) 95 | @api_key_required 96 | def api_get_leaderboard(): 97 | department = request.args.get('department', None) 98 | timespan = request.args.get('timespan', TIMESPAN_THIS_WEEK) 99 | office = request.args.get('office', None) 100 | 101 | (top_lover_dicts, top_loved_dicts) = get_leaderboard_data(timespan, department, office) 102 | 103 | top_lover = [ 104 | { 105 | 'full_name': lover['employee'].full_name, 106 | 'username': lover['employee'].username, 107 | 'department': lover['employee'].department, 108 | 'love_count': lover['num_sent'], 109 | 'photo_url': lover['employee'].photo_url, 110 | } 111 | for lover in top_lover_dicts 112 | ] 113 | 114 | top_loved = [ 115 | { 116 | 'full_name': loved['employee'].full_name, 117 | 'username': loved['employee'].username, 118 | 'department': loved['employee'].department, 119 | 'love_count': loved['num_received'], 120 | 'photo_url': loved['employee'].photo_url, 121 | } 122 | for loved in top_loved_dicts 123 | ] 124 | final_result = {'top_loved': top_loved, 'top_lover': top_lover} 125 | return make_json_response(final_result) 126 | 127 | 128 | @api_app.route('/api/autocomplete', methods=['GET']) 129 | @api_key_required 130 | def autocomplete(): 131 | return common.autocomplete(request) 132 | -------------------------------------------------------------------------------- /tests/logic/love_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | import mock 5 | import pytest 6 | 7 | import loveapp.logic.love 8 | from errors import TaintedLove 9 | from loveapp.config import CompanyValue 10 | from testing.factories import create_alias_with_employee_username 11 | from testing.factories import create_employee 12 | 13 | 14 | @pytest.mark.usefixtures('gae_testbed') 15 | class TestSendLoves(unittest.TestCase): 16 | 17 | def setUp(self): 18 | self.alice = create_employee(username='alice') 19 | self.bob = create_employee(username='bob') 20 | self.carol = create_employee(username='carol') 21 | self.message = 'hallo' 22 | 23 | @mock.patch('google.appengine.api.taskqueue.add', autospec=True) 24 | def test_send_loves(self, mock_taskqueue_add): 25 | loveapp.logic.love.send_loves( 26 | set(['bob', 'carol']), 27 | self.message, 28 | sender_username='alice', 29 | ) 30 | 31 | loves_for_bob = loveapp.logic.love.get_love(None, 'bob').get_result() 32 | self.assertEqual(len(loves_for_bob), 1) 33 | self.assertEqual(loves_for_bob[0].sender_key, self.alice.key) 34 | self.assertEqual(loves_for_bob[0].message, self.message) 35 | 36 | loves_for_carol = loveapp.logic.love.get_love(None, 'carol').get_result() 37 | self.assertEqual(len(loves_for_carol), 1) 38 | self.assertEqual(loves_for_carol[0].sender_key, self.alice.key) 39 | self.assertEqual(loves_for_carol[0].message, self.message) 40 | 41 | def test_invalid_sender(self): 42 | with self.assertRaises(TaintedLove): 43 | loveapp.logic.love.send_loves( 44 | set(['alice']), 45 | 'hallo', 46 | sender_username='wwu', 47 | ) 48 | 49 | @mock.patch('google.appengine.api.taskqueue.add', autospec=True) 50 | def test_sender_is_a_recipient(self, mock_taskqueue_add): 51 | loveapp.logic.love.send_loves( 52 | set(['bob', 'alice']), 53 | self.message, 54 | sender_username='alice', 55 | ) 56 | 57 | loves_for_bob = loveapp.logic.love.get_love('alice', 'bob').get_result() 58 | self.assertEqual(len(loves_for_bob), 1) 59 | self.assertEqual(loves_for_bob[0].message, self.message) 60 | 61 | loves_for_alice = loveapp.logic.love.get_love(None, 'alice').get_result() 62 | self.assertEqual(loves_for_alice, []) 63 | 64 | def test_sender_is_only_recipient(self): 65 | with self.assertRaises(TaintedLove): 66 | loveapp.logic.love.send_loves( 67 | set(['alice']), 68 | self.message, 69 | sender_username='alice', 70 | ) 71 | 72 | def test_invalid_recipient(self): 73 | with self.assertRaises(TaintedLove): 74 | loveapp.logic.love.send_loves( 75 | set(['bob', 'dean']), 76 | 'hallo', 77 | sender_username='alice', 78 | ) 79 | 80 | loves_for_bob = loveapp.logic.love.get_love('alice', 'bob').get_result() 81 | self.assertEqual(loves_for_bob, []) 82 | 83 | @mock.patch('google.appengine.api.taskqueue.add', autospec=True) 84 | def test_send_loves_with_alias(self, mock_taskqueue_add): 85 | message = 'Loving your alias' 86 | create_alias_with_employee_username(name='bobby', username=self.bob.username) 87 | 88 | loveapp.logic.love.send_loves(['bobby'], message, sender_username=self.carol.username) 89 | 90 | loves_for_bob = loveapp.logic.love.get_love('carol', 'bob').get_result() 91 | self.assertEqual(len(loves_for_bob), 1) 92 | self.assertEqual(loves_for_bob[0].sender_key, self.carol.key) 93 | self.assertEqual(loves_for_bob[0].message, message) 94 | 95 | def test_send_loves_with_alias_and_username_for_same_user(self): 96 | create_alias_with_employee_username(name='bobby', username=self.bob.username) 97 | 98 | with self.assertRaises(TaintedLove): 99 | loveapp.logic.love.send_loves(['bob', 'bobby'], 'hallo', sender_username='alice') 100 | 101 | loves_for_bob = loveapp.logic.love.get_love('alice', 'bob').get_result() 102 | self.assertEqual(loves_for_bob, []) 103 | 104 | @mock.patch('loveapp.util.company_values.config') 105 | @mock.patch('google.appengine.api.taskqueue.add', autospec=True) 106 | def test_send_love_with_value_hashtag(self, mock_taskqueue_add, mock_config): 107 | mock_config.COMPANY_VALUES = [ 108 | CompanyValue('AWESOME', 'awesome', ['awesome']) 109 | ] 110 | message = 'Loving your alias #Awesome' 111 | create_alias_with_employee_username(name='bobby', username=self.bob.username) 112 | loveapp.logic.love.send_loves(['bobby'], message, sender_username=self.carol.username) 113 | 114 | loves_for_bob = loveapp.logic.love.get_love('carol', 'bob').get_result() 115 | self.assertEqual(len(loves_for_bob), 1) 116 | self.assertEqual(loves_for_bob[0].company_values, ['AWESOME']) 117 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends theme("layout.html") %} 2 | 3 | {% block title %}Send Love{% endblock %} 4 | 5 | {% block body %} 6 | {% include theme("love_form.html") %} 7 | {% endblock %} 8 | 9 | {% block javascript %} 10 | 144 | {% endblock %} 145 | -------------------------------------------------------------------------------- /tests/views/api_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | from __future__ import unicode_literals 4 | 5 | import mock 6 | import pytest 7 | 8 | import loveapp.logic.employee 9 | import loveapp.logic.love 10 | from loveapp.models import AccessKey 11 | from loveapp.views.api import LOVE_FAILED_STATUS_CODE 12 | from testing.factories import create_alias_with_employee_username 13 | from testing.factories import create_employee 14 | 15 | 16 | @pytest.fixture 17 | def api_key(gae_testbed): 18 | return AccessKey.create('autocomplete key').access_key 19 | 20 | 21 | class _ApiKeyRequiredTestCase(): 22 | successful_response_code = 200 23 | 24 | def do_request(self, api_key): 25 | raise NotImplementedError('Implement this method with behavior which' 26 | ' requires an API key and returns the response') 27 | 28 | def test_with_api_key(self, gae_testbed, client, api_key): 29 | response = self.do_request(client, api_key) 30 | assert response.status_code == self.successful_response_code 31 | 32 | def test_without_api_key(self, gae_testbed, client): 33 | bad_api_key = AccessKey.generate_uuid() 34 | response = self.do_request(client, bad_api_key) 35 | assert response.status == '401 UNAUTHORIZED' 36 | 37 | 38 | class TestAutocomplete(_ApiKeyRequiredTestCase): 39 | @pytest.fixture(autouse=True) 40 | def create_employees(self, gae_testbed): 41 | create_employee(username='alice') 42 | create_employee(username='alex') 43 | create_employee(username='bob') 44 | create_employee(username='carol') 45 | with mock.patch('loveapp.logic.employee.memory_usage', autospec=True): 46 | loveapp.logic.employee.rebuild_index() 47 | 48 | def do_request(self, client, api_key): 49 | return client.get( 50 | 'api/autocomplete', 51 | query_string={'term': ''}, 52 | data={'api_key': api_key} 53 | ) 54 | 55 | @pytest.mark.parametrize('prefix, expected_values', [ 56 | ('a', ['alice', 'alex']), 57 | ('b', ['bob']), 58 | ('c', ['carol']), 59 | ('', []), 60 | ('stupidprefix', []), 61 | ]) 62 | def test_autocomplete(gae_testbed, client, api_key, prefix, expected_values): 63 | api_key = AccessKey.create('autocomplete key').access_key 64 | response = client.get('/api/autocomplete', query_string={'term': prefix}, data={'api_key': api_key}) 65 | received_values = set(item['value'] for item in response.json) 66 | assert set(expected_values) == received_values 67 | 68 | 69 | class TestGetLove(_ApiKeyRequiredTestCase): 70 | @pytest.fixture(autouse=True) 71 | def create_employees(self, gae_testbed): 72 | create_employee(username='alice') 73 | create_employee(username='bob') 74 | 75 | def do_request(self, client, api_key): 76 | query_params = { 77 | 'sender': 'alice', 78 | 'recipient': 'bob', 79 | 'limit': 1 80 | } 81 | return client.get( 82 | '/api/love', 83 | query_string=query_params, 84 | data={'api_key': api_key} 85 | ) 86 | 87 | def test_get_love(self, gae_testbed, client, api_key): 88 | with mock.patch('loveapp.logic.event.add_event'): 89 | loveapp.logic.love.send_loves(['bob', ], 'Care Bear Stare!', 'alice') 90 | response = self.do_request(client, api_key) 91 | response_data = response.json 92 | assert len(response_data) == 1 93 | assert response_data[0]['sender'] == 'alice' 94 | assert response_data[0]['recipient'] == 'bob' 95 | 96 | 97 | class TestSendLove(_ApiKeyRequiredTestCase): 98 | successful_response_code = 201 99 | 100 | @pytest.fixture(autouse=True) 101 | def create_employees(self, gae_testbed): 102 | create_employee(username='alice') 103 | create_employee(username='bob') 104 | 105 | def do_request(self, client, api_key): 106 | form_values = { 107 | 'sender': 'alice', 108 | 'recipient': 'bob', 109 | 'message': 'Care Bear Stare!', 110 | 'api_key': api_key, 111 | } 112 | with mock.patch('loveapp.logic.event.add_event'): 113 | response = client.post('/api/love', data=form_values) 114 | return response 115 | 116 | def test_send_love(self, gae_testbed, client, api_key): 117 | response = self.do_request(client, api_key) 118 | assert 'Love sent to bob! Share:' in response.data.decode() 119 | 120 | def test_send_loves_with_alias_and_username_for_same_user(self, gae_testbed, client, api_key): 121 | create_alias_with_employee_username(name='bobby', username='bob') 122 | form_values = { 123 | 'sender': 'alice', 124 | 'recipient': 'bob,bobby', 125 | 'message': 'Alias', 126 | 'api_key': api_key, 127 | } 128 | 129 | response = client.post('/api/love', data=form_values) 130 | assert response.status_code == LOVE_FAILED_STATUS_CODE 131 | assert 'send love to a user multiple times' in response.data.decode() 132 | 133 | 134 | class TestGetLeaderboard(_ApiKeyRequiredTestCase): 135 | @pytest.fixture(autouse=True) 136 | def create_employees(self, gae_testbed): 137 | create_employee(username='alice') 138 | create_employee(username='bob') 139 | with mock.patch('loveapp.logic.event.add_event'): 140 | loveapp.logic.love.send_loves(['bob', ], 'Care Bear Stare!', 'alice') 141 | 142 | def do_request(self, client, api_key): 143 | query_params = { 144 | 'department': 'Engineering', 145 | } 146 | return client.get( 147 | '/api/leaderboard', 148 | query_string=query_params, 149 | data={'api_key': api_key} 150 | ) 151 | 152 | def test_get_leaderboard(self, gae_testbed, client, api_key): 153 | response_data = self.do_request(client, api_key).json 154 | top_loved = response_data.get('top_loved') 155 | top_lover = response_data.get('top_lover') 156 | assert len(response_data) == 2 157 | assert len(top_loved) == 1 158 | assert len(top_lover) == 1 159 | assert top_loved[0].get('username') == 'bob' 160 | assert top_lover[0].get('username') == 'alice' 161 | -------------------------------------------------------------------------------- /loveapp/themes/default/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | } 4 | 5 | body,h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6,.navbar,.btn { 6 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 7 | } 8 | 9 | h2 { 10 | font-weight: bold; 11 | color: #777; 12 | } 13 | 14 | .form-control { 15 | padding: 8px 10px 4px; 16 | height: 40px; 17 | } 18 | 19 | .active { 20 | font-weight: bold; 21 | } 22 | 23 | .content-container { 24 | margin-top: 20px; 25 | } 26 | 27 | #send-love-form .checkbox { 28 | display: inline-block; 29 | margin-left: 20px; 30 | } 31 | 32 | .love-container { 33 | padding: 84px 15px 60px; 34 | } 35 | 36 | .love-header { 37 | background-image: url('/_themes/default/img/star_header_bg.png'); 38 | height: 60px; 39 | padding-top: 8px; 40 | } 41 | 42 | .love-title { 43 | margin-top: 0px; 44 | margin-bottom: 0px; 45 | } 46 | 47 | .love-message-container { 48 | margin-bottom: 20px; 49 | clear: left; 50 | } 51 | 52 | .love-message { 53 | margin-left: 60px; 54 | } 55 | 56 | .love-byline { 57 | margin-left: 60px; 58 | color: #999; 59 | font-size: 16px; 60 | } 61 | 62 | .love-byline a { 63 | color: #999; 64 | } 65 | 66 | .love-photos { 67 | margin-right: 20px; 68 | } 69 | 70 | .send-love-form { 71 | margin-top: 12px; 72 | } 73 | 74 | .love-link { 75 | padding: 8px 12px; 76 | font-size: 13px; 77 | font-weight: normal; 78 | line-height: 1; 79 | color: #333; 80 | text-align: center; 81 | background-color: #eee; 82 | border: 1px solid #ccc; 83 | border-radius: 4px; 84 | } 85 | 86 | .navbar-brand { 87 | padding: 0; 88 | padding-left: 15px; 89 | margin-right: 10px; 90 | margin-top: 4px; 91 | } 92 | 93 | .navbar-right { 94 | font-size: 80%; 95 | } 96 | 97 | .navbar-collapse.collapse.in { 98 | margin-top: 4px; 99 | background-color: #333; 100 | opacity: 0.8; 101 | } 102 | 103 | .label-default { 104 | padding-top: 8px; 105 | background-color: #969696; 106 | } 107 | 108 | .help-block-right { 109 | float: right; 110 | margin: 6px 0 0; 111 | font-size: 70%; 112 | } 113 | 114 | .help-block-left { 115 | float: left; 116 | margin: 6px 0 0; 117 | font-size: 70%; 118 | } 119 | 120 | .help-block-below { 121 | margin-top: 6px; 122 | margin-bottom: 12px; 123 | } 124 | 125 | h4 { 126 | margin-top: 0px; 127 | margin-bottom: 2px; 128 | } 129 | 130 | #send-love-form input, 131 | #send-love-form textarea { 132 | font-size: 24px; 133 | } 134 | 135 | #suggestions-container { 136 | height: 200px; 137 | text-align: right; 138 | margin-top: 80px; 139 | background-image: url("/_themes/default/img/rocket.png"); 140 | background-repeat: no-repeat; 141 | background-size: 140px; 142 | background-position: 0 0; 143 | } 144 | 145 | .btn { 146 | padding: 8px 16px 6px; 147 | background: linear-gradient(#ea050b, #c41200); 148 | text-shadow: 0 -1px 1px #b80806; 149 | border: 1px solid #a50508; 150 | color: #fff; 151 | } 152 | 153 | .btn-lg { 154 | font-size: 24px; 155 | } 156 | 157 | #love-message-filter { 158 | margin-top: 20px; 159 | } 160 | 161 | #explore-messages { 162 | margin-top: 30px; 163 | } 164 | 165 | h4 { 166 | font-size: 20px; 167 | } 168 | 169 | .form-explore input { 170 | font-size: 24px; 171 | } 172 | 173 | .form-explore button { 174 | font-size: 20px; 175 | padding: 2px 12px; 176 | } 177 | 178 | .explore-user { 179 | margin-top: 20px; 180 | } 181 | 182 | .explore-user h1, 183 | .explore-user p { 184 | margin-left: 60px; 185 | } 186 | 187 | .dept { 188 | font-size: 16px; 189 | color: gray; 190 | } 191 | 192 | .num-loves { 193 | float:right; 194 | } 195 | 196 | .num-loved { 197 | float:right; 198 | } 199 | 200 | .leaderboard-filter { 201 | text-align: right; 202 | margin-top: 16px; 203 | } 204 | 205 | .leaderboard-filter #department { 206 | width: 200px; 207 | } 208 | 209 | .footer { 210 | margin-top: 50px; 211 | text-align: center; 212 | font-size: 16px; 213 | padding-top: 30px; 214 | border-top: 1px solid #ddd; 215 | } 216 | 217 | .ui-autocomplete { 218 | position: absolute; 219 | top: 100%; 220 | left: 0; 221 | z-index: 1000; 222 | float: left; 223 | display: none; 224 | min-width: 160px; 225 | _width: 160px; 226 | padding: 4px 0; 227 | margin: 2px 0 0 0; 228 | list-style: none; 229 | background-color: #ffffff; 230 | border-color: #ccc; 231 | border-color: rgba(0, 0, 0, 0.2); 232 | border-style: solid; 233 | border-width: 1px; 234 | -webkit-border-radius: 5px; 235 | -moz-border-radius: 5px; 236 | border-radius: 5px; 237 | -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 238 | -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 239 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 240 | -webkit-background-clip: padding-box; 241 | -moz-background-clip: padding; 242 | background-clip: padding-box; 243 | *border-right-width: 2px; 244 | *border-bottom-width: 2px; 245 | } 246 | 247 | .ui-autocomplete .ui-menu-item > a.ui-corner-all { 248 | display: block; 249 | padding: 3px 15px; 250 | clear: both; 251 | font-weight: normal; 252 | line-height: 18px; 253 | color: #555555; 254 | white-space: nowrap; 255 | } 256 | 257 | .ui-autocomplete .ui-menu-item > a.ui-corner-all &.ui-state-hover, &.ui-state-active { 258 | color: #ffffff; 259 | text-decoration: none; 260 | background-color: #0088cc; 261 | border-radius: 0px; 262 | -webkit-border-radius: 0px; 263 | -moz-border-radius: 0px; 264 | background-image: none; 265 | } 266 | 267 | .avatar-autocomplete { 268 | height: 35px; 269 | cursor: pointer; 270 | } 271 | 272 | .avatar-autocomplete-img { 273 | margin: 2px 10px 2px 5px; 274 | } 275 | 276 | .avatar-autocomplete .ui-state-focus { 277 | background-color: #ebebeb; 278 | } 279 | 280 | .ui-state-focus { 281 | background-color: #fac0ba; 282 | } 283 | 284 | .ui-helper-hidden-accessible { 285 | display: none; 286 | } 287 | 288 | .subscription-form input, 289 | .subscription-form select, 290 | .subscription-form textarea { 291 | font-size: 24px; 292 | } 293 | 294 | .subscription-form .form-group .checkbox { 295 | margin-top: 0; 296 | } 297 | 298 | .radio-group label { 299 | margin-right: 30px; 300 | font-weight: normal; 301 | } 302 | 303 | .alias-form input { 304 | font-size: 24px; 305 | } 306 | 307 | .pagination-result { 308 | text-align: center; 309 | } 310 | 311 | .facetile { 312 | display: inline-block; 313 | position: relative; 314 | } 315 | 316 | .facetile .name { 317 | position: absolute; 318 | bottom: 0; 319 | left: 0; 320 | padding-left: 5px; 321 | width: 100%; 322 | color: #fff; 323 | font-size: 18px; 324 | text-overflow: ellipsis; 325 | white-space: nowrap; 326 | overflow: hidden; 327 | text-shadow: 328 | /* first layer at 1px */ 329 | -1px -1px 0px #000, 330 | 0px -1px 0px #000, 331 | 1px -1px 0px #000, 332 | -1px 0px 0px #000, 333 | 1px 0px 0px #000, 334 | -1px 1px 0px #000, 335 | 0px 1px 0px #000, 336 | 1px 1px 0px #000, 337 | /* second layer at 2px */ 338 | -2px -2px 0px #000, 339 | -1px -2px 0px #000, 340 | 0px -2px 0px #000, 341 | 1px -2px 0px #000, 342 | 2px -2px 0px #000, 343 | 2px -1px 0px #000, 344 | 2px 0px 0px #000, 345 | 2px 1px 0px #000, 346 | 2px 2px 0px #000, 347 | 1px 2px 0px #000, 348 | 0px 2px 0px #000, 349 | -1px 2px 0px #000, 350 | -2px 2px 0px #000, 351 | -2px 1px 0px #000, 352 | -2px 0px 0px #000, 353 | -2px -1px 0px #000; 354 | } 355 | -------------------------------------------------------------------------------- /loveapp/themes/default/templates/email.html: -------------------------------------------------------------------------------- 1 | {% import theme("parts/avatar.html") as avatar %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 34 | 35 | 36 |
19 | 20 | 21 |
22 | 23 | 24 | 29 | 30 |
25 | 26 | Yelp Love 27 | 28 |
31 |
32 | 33 |
37 | 38 | 39 | 40 | 41 | 42 | 84 | 85 | 86 |
43 | 44 | 45 |
46 | 47 | 48 | 57 | 58 |
49 |

{{ sender.full_name }}

50 |
{{ sender.department }}
51 |

{{ love.message|linkify_company_values }}

52 | {% if love.secret %} 53 |

Shh... sent secretly!

54 | {% endif %} 55 | Send Love Back 56 |
59 | 60 |
61 | 62 | 63 | 64 |
Your Recent Love
65 | 66 | {% for l, lover in recent_love_and_lovers %} 67 | 68 | 69 |
70 | 71 | 72 | 78 | 79 |
73 |

{{ lover.full_name }}

74 |
{{ lover.department }}
75 |

{{ l.message }}

76 |

{{ l.timestamp.strftime('%d %b %Y') }}

77 |
80 | 81 | {% endfor %} 82 | 83 |
87 | 88 | 89 | 90 | 91 | 92 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Yelp/love.svg?branch=master)](https://travis-ci.org/Yelp/love) 2 | 3 | ![Yelp Love](yelplove-medium.png) 4 | 5 | # Yelp Love 6 | 7 | Yelp Love lets you show others your appreciation for something they've done. 8 | Did they make you laugh in your darkest hour? Did they save your ass? Did they 9 | help fix that bug? Send them love! 10 | 11 | ## Features 12 | 13 | * Send love to one or more recipients (publicly or privately) 14 | * Email notifications when users receive love 15 | * Viewing the most recent 20 loves sent or received by any user 16 | * Leaderboard with the top 20 users who sent and received love 17 | * [API](#api) that allows external applications to send and retrieve love data 18 | * [Manual or automated synchronization](#import-employee-data) between Yelp Love and your employee data 19 | * Admin section to manage aliases and API keys 20 | 21 | To get an idea what Yelp Love looks like go and check out the [screenshots](/screenshots). 22 | 23 | ## Installation 24 | 25 | Yelp Love runs on [Google App Engine](https://appengine.google.com/). 26 | In order to install Yelp Love you will need a Google account and the 27 | [Google App Engine SDK for Python](https://cloud.google.com/appengine/docs/standard/python/download). 28 | 29 | ### Create a new project 30 | 31 | [Follow the instructions](https://cloud.google.com/appengine/docs/standard/python/getting-started/python-standard-env) 32 | on creating a project and initializing your App Engine app - you'll also need 33 | to set up billing and give Cloud Build permission to deploy your app. 34 | 35 | ### Prepare for deployment 36 | 37 | Copy the [example config](config-example.py) file to config.py and change the 38 | settings. Don't forget to specify your own SECRET_KEY. 39 | 40 | ### Initial deployment 41 | 42 | Finally, run 43 | ``` 44 | $ make deploy 45 | ``` 46 | This will open a browser window for you to authenticate yourself with your 47 | Google account and will upload your local application code to Google App Engine. 48 | 49 | If the initial deployment fails with a Cloud Build error, try running 50 | ``` 51 | $ gcloud app deploy 52 | ``` 53 | manually. After that, `make deploy` should do the job, and will make sure 54 | everything is uploaded properly - including the worker service, database indexes 55 | and so on. 56 | 57 | Once the deployment succeeds open your browser and navigate to your application 58 | URL, normally [https://project_id.appspot.com](https://project_id.appspot.com). 59 | 60 | ## Import employee data 61 | 62 | ### CSV 63 | 64 | Create a file employees.csv in the import directory, add all your employee data, 65 | and deploy it. We‘ve put an example csv file in that directory so you can get an 66 | idea of which fields Yelp Love requires for an employee. 67 | 68 | Once the CSV file is deployed point your browser to 69 | [https://project_id.appspot.com/employees/import](https://project_id.appspot.com/employees/import). 70 | and follow the instructions. 71 | 72 | ### JSON via Amazon S3 73 | 74 | Create a file employees.json, add all your employee data, and save it in an S3 bucket. 75 | We‘ve put an example JSON file in the import directory so you can get an idea of which 76 | fields Yelp Love requires for an employee. 77 | 78 | The S3 bucket name must be configured in config.py. 79 | 80 | In order to access the S3 bucket you have to save AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY 81 | using the [Secret](models/secret.py) model. Locally, you can temporarily add an endpoint inside loveapp/views/web.py and then navigate to it in your browser (e.g., http://localhost:8080/create_secrets): 82 | 83 | ```python 84 | @web_app.route('/create_secrets') 85 | def create_secrets(): 86 | from loveapp.models import Secret 87 | Secret(id='AWS_ACCESS_KEY_ID', value='change-me').put() 88 | Secret(id='AWS_SECRET_ACCESS_KEY', value='change-me').put() 89 | return "please delete me now" 90 | ``` 91 | 92 | In production you can use the [Datastore UI](https://console.cloud.google.com/datastore/entities/). 93 | 94 | To kick off the final import you have to run: 95 | 96 | ```python 97 | from loveapp.logic import employee 98 | employee.load_employees() 99 | ``` 100 | 101 | You can also setup a cronjob to keep employee data up to date. 102 | Checkout [cron.yaml](cron.yaml) for further details. 103 | 104 | ## Development 105 | 106 | ### Prerequisites 107 | 108 | Before you can run Yelp Love on your local machine please install the [Google App Engine SDK for Python](https://cloud.google.com/appengine/downloads). You can get it from Google directly or use 109 | your favorite packet manager. 110 | 111 | ### Running the application locally 112 | 113 | * Check out the application code: git clone git@github.com:Yelp/love.git 114 | * Follow the [Prepare for deployment](#prepare-for-deployment) section 115 | * Run the app: make run-dev will start both [Yelp Love](http://localhost:8080) as well as the [Admin server](http://localhost:8000) 116 | * Follow the [CSV import](#csv) section to locally import user data 117 | * Make your changes 118 | 119 | ## Deployment 120 | 121 | When you bumped versions in the appropriate files you can deploy your changes by running 122 | make deploy. 123 | 124 | If you are seeing the following error: 125 | ``` 126 | Error 404: --- begin server output --- 127 | This application does not exist (project_id=u'PROJECT-ID'). To create an App Engine application in this project, run "gcloud app create" in your console. 128 | ``` 129 | 130 | This is because GAE is no longer automatically initialized, you must run `gcloud app create` using the **Google Cloud Shell** (not your terminal...I know...confusing) before deploying on App Engine for the first time. See the screenshot below: 131 | ![GAE cloud shell](https://i.stack.imgur.com/TcUrm.png) 132 | 133 | Once your code has been uploaded to Google, you must activate the newly deployed version 134 | in the [Developer Console](https://console.developers.google.com/). Then you're done! 135 | 136 | ## API 137 | 138 | Yelp Love also ships with an API which will be available under [https://project_id.appspot.com/api](https://project_id.appspot.com/api). 139 | All data of successful GET requests is sent as JSON. 140 | 141 | ### Authentication 142 | 143 | Successful requests to the API require an API key. These can be created in the Admin section of the 144 | application. Authenticating with an invalid API key will return 401 Unauthorized. 145 | 146 | ### Endpoints 147 | 148 | All names, e.g. sender or recipient in the following examples refer to employee usernames. 149 | 150 | #### Retrieve love 151 | 152 | ``` 153 | GET /love?sender=foo&recipient=bar&limit=20 154 | ``` 155 | 156 | You must provide either a sender or a recipient. The limit parameter is optional - no limiting will 157 | be applied if omitted. 158 | 159 | ##### Example 160 | 161 | ``` 162 | curl "https://project_id.appspot.com/api/love?sender=hammy&api_key=secret" 163 | ``` 164 | ```javascript 165 | [ 166 | { 167 | "timestamp": "2017-02-10T18:10:08.552636", 168 | "message": "New Barking Release! <3", 169 | "sender": "hammy", 170 | "recipient": "darwin" 171 | } 172 | ] 173 | ``` 174 | 175 | #### Send love 176 | 177 | ``` 178 | POST /love 179 | ``` 180 | 181 | Sending love requires 3 parameters: sender, recipient, and message. The recipient parameter may contain 182 | multiple comma-separated usernames. 183 | 184 | ##### Example 185 | 186 | ``` 187 | curl -X POST -F "sender=hammy" -F "recipient=john,jane" -F "message=YOLO" -F "api_key=secret" https://project_id.appspot.com/api/love 188 | ``` 189 | ``` 190 | Love sent to john, jane! 191 | ``` 192 | 193 | #### Autocomplete usernames 194 | 195 | ``` 196 | GET /autocomplete?term=ham 197 | ``` 198 | 199 | The autocomplete endpoint will return all employees which first_name, last_name, or username match the given term. 200 | 201 | ##### Example 202 | 203 | ``` 204 | curl "https://project_id.appspot.com/api/autocomplete?term=ha&api_key=secret" 205 | ``` 206 | ```javascript 207 | [ 208 | { 209 | "label": "Hammy Yo (hammy)", 210 | "value": "hammy" 211 | }, 212 | { 213 | "label": "Johnny Hamburger (jham)", 214 | "value": "jham" 215 | } 216 | ] 217 | ``` 218 | 219 | ## Original Authors and Contributors 220 | 221 | * [adamrothman](https://github.com/adamrothman) 222 | * [amartinezfonts](https://github.com/amartinezfonts) 223 | * [benasher44](https://github.com/benasher44) 224 | * [jetze](https://github.com/jetze) 225 | * [KurtisFreedland](https://github.com/KurtisFreedland) 226 | * [mesozoic](https://github.com/mesozoic) 227 | * [michalczapko](https://github.com/michalczapko) 228 | * [wuhuwei](https://github.com/wuhuwei) 229 | 230 | For more info check out the [Authors](AUTHORS.md) file. 231 | 232 | ## License 233 | 234 | Yelp Love is licensed under the [MIT license](LICENSE). 235 | 236 | ## Contributing 237 | 238 | Everyone is encouraged to contribute to Yelp Love by forking the 239 | [Github repository](http://github.com/Yelp/love) and making a pull request or 240 | opening an issue. 241 | -------------------------------------------------------------------------------- /loveapp/logic/love.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | from google.appengine.api import taskqueue 5 | 6 | import loveapp.config as config 7 | import loveapp.logic.alias 8 | import loveapp.logic.email 9 | import loveapp.logic.event 10 | from errors import TaintedLove 11 | from loveapp.logic.toggle import get_toggle_state 12 | from loveapp.models import Employee 13 | from loveapp.models import Love 14 | from loveapp.models import LoveCount 15 | from loveapp.models.toggle import LOVE_SENDING_ENABLED 16 | from loveapp.util.company_values import get_hashtag_value_mapping 17 | from loveapp.util.render import render_template 18 | 19 | 20 | def _love_query(start_dt, end_dt, include_secret): 21 | query = Love.query().order(-Love.timestamp) 22 | if type(start_dt) is datetime: 23 | query = query.filter(Love.timestamp >= start_dt) 24 | if type(end_dt) is datetime: 25 | query = query.filter(Love.timestamp <= end_dt) 26 | if type(include_secret) is bool and include_secret is False: 27 | query = query.filter(Love.secret == False) # noqa 28 | return query 29 | 30 | 31 | def _sent_love_query(employee_key, start_dt, end_dt, include_secret): 32 | return _love_query(start_dt, end_dt, include_secret).filter(Love.sender_key == employee_key) 33 | 34 | 35 | def _received_love_query(employee_key, start_dt, end_dt, include_secret): 36 | return _love_query(start_dt, end_dt, include_secret).filter(Love.recipient_key == employee_key) 37 | 38 | 39 | def recent_sent_love(employee_key, start_dt=None, end_dt=None, include_secret=True, limit=None): 40 | query = _sent_love_query(employee_key, start_dt, end_dt, include_secret) 41 | return query.fetch_async(limit) if type(limit) is int else query.fetch_async() 42 | 43 | 44 | def recent_received_love(employee_key, start_dt=None, end_dt=None, include_secret=True, limit=None): 45 | query = _received_love_query(employee_key, start_dt, end_dt, include_secret) 46 | return query.fetch_async(limit) if type(limit) is int else query.fetch_async() 47 | 48 | 49 | def _love_query_by_company_value(employee_key, company_value, start_dt, end_dt, include_secret): 50 | return _love_query(start_dt, end_dt, include_secret).filter(Love.company_values == company_value) 51 | 52 | 53 | def _love_query_with_any_company_value(employee_key, start_dt, end_dt, include_secret): 54 | company_values = [value.id for value in config.COMPANY_VALUES] 55 | return _love_query(start_dt, end_dt, include_secret).filter(Love.company_values.IN(company_values)) 56 | 57 | 58 | def recent_loves_by_company_value(employee_key, company_value, start_dt=None, end_dt=None, 59 | include_secret=False, limit=None): 60 | query = _love_query_by_company_value(employee_key, company_value, start_dt, end_dt, include_secret) 61 | return query.fetch_async(limit) if type(limit) is int else query.fetch_async() 62 | 63 | 64 | def recent_loves_with_any_company_value(employee_key, start_dt=None, end_dt=None, 65 | include_secret=False, limit=None): 66 | query = _love_query_with_any_company_value(employee_key, start_dt, end_dt, include_secret) 67 | return query.fetch_async(limit) if type(limit) is int else query.fetch_async() 68 | 69 | 70 | def send_love_email(l): # noqa 71 | """Send an email notifying the recipient of l about their love.""" 72 | sender_future = l.sender_key.get_async() 73 | recipient_future = l.recipient_key.get_async() 74 | 75 | # Remove this love from recent_love if present (datastore is funny sometimes) 76 | recent_love = recent_received_love(l.recipient_key, limit=4).get_result() 77 | index_to_remove = None 78 | for i, love in enumerate(recent_love): 79 | if l.sender_key == love.sender_key and l.recipient_key == love.recipient_key and l.message == love.message: 80 | index_to_remove = i 81 | break 82 | if index_to_remove is not None: 83 | del recent_love[index_to_remove] 84 | 85 | sender = sender_future.get_result() 86 | recipient = recipient_future.get_result() 87 | 88 | from_ = config.LOVE_SENDER_EMAIL 89 | to = recipient.user.email() 90 | subject = u'Love from {}'.format(sender.full_name) 91 | 92 | body_text = u'"{}"\n\n{}'.format( 93 | l.message, 94 | '(Sent secretly)' if l.secret else '' 95 | ) 96 | 97 | body_html = render_template( 98 | 'email.html', 99 | love=l, 100 | sender=sender, 101 | recipient=recipient, 102 | recent_love_and_lovers=[(love, love.sender_key.get()) for love in recent_love[:3]] 103 | ) 104 | 105 | loveapp.logic.email.send_email(from_, to, subject, body_html, body_text) 106 | 107 | 108 | def get_love(sender_username=None, recipient_username=None, limit=None): 109 | """Get all love from a particular sender or to a particular recipient. 110 | 111 | :param sender_username: If present, only return love sent from a particular user. 112 | :param recipient_username: If present, only return love sent to a particular user. 113 | :param limit: If present, only return this many items. 114 | """ 115 | sender_username = loveapp.logic.alias.name_for_alias(sender_username) 116 | recipient_username = loveapp.logic.alias.name_for_alias(recipient_username) 117 | 118 | if not (sender_username or recipient_username): 119 | raise TaintedLove('Not gonna give you all the love in the world. Sorry.') 120 | 121 | if sender_username == recipient_username: 122 | raise TaintedLove('Who sends love to themselves? Honestly?') 123 | 124 | love_query = ( 125 | Love.query() 126 | .filter(Love.secret == False) # noqa 127 | .order(-Love.timestamp) 128 | ) 129 | 130 | if sender_username: 131 | sender_key = Employee.get_key_for_username(sender_username) 132 | love_query = love_query.filter(Love.sender_key == sender_key) 133 | 134 | if recipient_username: 135 | recipient_key = Employee.get_key_for_username(recipient_username) 136 | love_query = love_query.filter(Love.recipient_key == recipient_key) 137 | 138 | if limit: 139 | return love_query.fetch_async(limit) 140 | else: 141 | return love_query.fetch_async() 142 | 143 | 144 | def send_loves(recipients, message, sender_username=None, secret=False): 145 | if get_toggle_state(LOVE_SENDING_ENABLED) is False: 146 | raise TaintedLove('Sorry, sending love is temporarily disabled. Please try again in a few minutes.') 147 | 148 | recipient_keys, unique_recipients = validate_love_recipients(recipients) 149 | 150 | if sender_username is None: 151 | sender_username = Employee.get_current_employee().username 152 | 153 | sender_username = loveapp.logic.alias.name_for_alias(sender_username) 154 | sender_key = Employee.query( 155 | Employee.username == sender_username, 156 | Employee.terminated == False, # noqa 157 | ).get(keys_only=True) # noqa 158 | 159 | if sender_key is None: 160 | raise TaintedLove(u'Sorry, {} is not a valid user.'.format(sender_username)) 161 | 162 | # Only raise an error if the only recipient is the sender. 163 | if sender_key in recipient_keys: 164 | recipient_keys.remove(sender_key) 165 | unique_recipients.remove(sender_username) 166 | if len(recipient_keys) == 0: 167 | raise TaintedLove(u'You can love yourself, but not on {}!'.format( 168 | config.APP_NAME 169 | )) 170 | 171 | for recipient_key in recipient_keys: 172 | _send_love(recipient_key, message, sender_key, secret) 173 | 174 | return unique_recipients 175 | 176 | 177 | def validate_love_recipients(recipients): 178 | unique_recipients = set([loveapp.logic.alias.name_for_alias(name) for name in recipients]) 179 | 180 | if len(recipients) != len(unique_recipients): 181 | raise TaintedLove(u'Sorry, you are trying to send love to a user multiple times.') 182 | 183 | # validate all recipients before carrying out any Love transactions 184 | recipient_keys = [] 185 | for recipient_username in unique_recipients: 186 | recipient_key = Employee.query( 187 | Employee.username == recipient_username, 188 | Employee.terminated == False # noqa 189 | ).get(keys_only=True) # noqa 190 | 191 | if recipient_key is None: 192 | raise TaintedLove(u'Sorry, {} is not a valid user.'.format(recipient_username)) 193 | else: 194 | recipient_keys += [recipient_key] 195 | 196 | return recipient_keys, unique_recipients 197 | 198 | 199 | def _send_love(recipient_key, message, sender_key, secret): 200 | """Send love and do associated bookkeeping.""" 201 | new_love = Love( 202 | sender_key=sender_key, 203 | recipient_key=recipient_key, 204 | message=message, 205 | secret=(secret is True), 206 | ) 207 | new_love.company_values = _get_company_values(new_love, message) 208 | new_love.put() 209 | LoveCount.update(new_love) 210 | 211 | # Send email asynchronously 212 | taskqueue.add( 213 | url='/tasks/love/email', 214 | params={ 215 | 'id': new_love.key.id() 216 | } 217 | ) 218 | 219 | if not secret: 220 | loveapp.logic.event.add_event( 221 | loveapp.logic.event.LOVESENT, 222 | {'love_id': new_love.key.id()}, 223 | ) 224 | 225 | 226 | def _get_company_values(new_love, message): 227 | # Handle hashtags. 228 | hashtag_value_mapping = get_hashtag_value_mapping() 229 | 230 | matched_categories = set() 231 | for hashtag, category in hashtag_value_mapping.items(): 232 | if hashtag in message.lower(): 233 | matched_categories.add(category) 234 | 235 | company_values = [] 236 | for value in matched_categories: 237 | company_values.append(value) 238 | 239 | return company_values 240 | -------------------------------------------------------------------------------- /loveapp/logic/employee.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import csv 3 | import json 4 | import logging 5 | import os.path 6 | 7 | import boto3 8 | from google.appengine.api import search 9 | from google.appengine.api.runtime import memory_usage 10 | from google.appengine.ext import ndb 11 | 12 | import loveapp.config as config 13 | from errors import NoSuchEmployee 14 | from loveapp.logic import chunk 15 | from loveapp.logic.office import OfficeParser 16 | from loveapp.logic.secret import get_secret 17 | from loveapp.logic.toggle import set_toggle_state 18 | from loveapp.models import Employee 19 | from loveapp.models import Love 20 | from loveapp.models import LoveCount 21 | from loveapp.models.toggle import LOVE_SENDING_ENABLED 22 | 23 | 24 | INDEX_NAME = 'employees' 25 | 26 | 27 | def csv_import_file(): 28 | return os.path.join( 29 | os.path.dirname(os.path.abspath(__file__)), 30 | '../import/employees.csv' 31 | ) 32 | 33 | 34 | def load_employees_from_csv(): 35 | employee_dicts = _get_employee_info_from_csv() 36 | set_toggle_state(LOVE_SENDING_ENABLED, False) 37 | _update_employees(employee_dicts) 38 | set_toggle_state(LOVE_SENDING_ENABLED, True) 39 | rebuild_index() 40 | 41 | 42 | def _get_employee_info_from_csv(): 43 | logging.info('Reading employees from csv file...') 44 | employees = csv.DictReader(open(csv_import_file())) 45 | logging.info('Done reading employees from csv file.') 46 | return employees 47 | 48 | 49 | def _clear_index(): 50 | logging.info('Clearing index... {}MB'.format(memory_usage().current)) 51 | index = search.Index(name=INDEX_NAME) 52 | last_id = None 53 | while True: 54 | # We can batch up to 200 doc_ids in the delete call, and 55 | # batching is better according to the docs. Because we're deleting 56 | # async, we need to keep track of where we left off each time 57 | # we do get_range 58 | use_start_object = False 59 | if last_id is None: 60 | use_start_object = True 61 | doc_ids = [ 62 | doc.doc_id for doc in index.get_range( 63 | ids_only=True, 64 | limit=200, 65 | start_id=last_id, 66 | include_start_object=use_start_object, 67 | ) 68 | ] 69 | if not doc_ids: 70 | break 71 | last_id = doc_ids[-1] 72 | index.delete(doc_ids) 73 | 74 | logging.info('Done clearing index. {}MB'.format(memory_usage().current)) 75 | 76 | 77 | def _generate_substrings(string): 78 | """Given a string, return a string of all its substrings (not including the original) 79 | anchored at the first character, concatenated with spaces. 80 | 81 | Example: 82 | _concatenate_substrings('arothman') => 'a ar aro arot aroth arothm arothma' 83 | """ 84 | return ' '.join([string[:i] for i in range(1, len(string))]) 85 | 86 | 87 | def _get_employee_info_from_s3(): 88 | logging.info('Reading employees file from S3... {}MB'.format(memory_usage().current)) 89 | session = boto3.Session( 90 | aws_access_key_id=get_secret('AWS_ACCESS_KEY_ID'), 91 | aws_secret_access_key=get_secret('AWS_SECRET_ACCESS_KEY'), 92 | ) 93 | s3 = session.resource('s3') 94 | bucket = s3.Bucket(config.S3_BUCKET) 95 | obj = bucket.Object('employees_appeng.json') 96 | employee_dicts = json.loads(obj.get()['Body'].read().decode('utf-8')) 97 | logging.info('Done reading employees file from S3. {}MB'.format(memory_usage().current)) 98 | return employee_dicts 99 | 100 | 101 | def _index_employees(employees): 102 | logging.info('Indexing employees... {}MB'.format(memory_usage().current)) 103 | index = search.Index(name=INDEX_NAME) 104 | # According to appengine, put can handle a maximum of 200 documents, 105 | # and apparently batching is more efficient 106 | for chunk_of_200 in chunk(employees, 200): 107 | documents = [] 108 | for employee in chunk_of_200: 109 | if employee is not None: 110 | # Gross hack to support prefix matching, see documentation for _generate_substrings 111 | substrings = u' '.join([ 112 | _generate_substrings(employee.first_name), 113 | _generate_substrings(employee.last_name), 114 | _generate_substrings(employee.username), 115 | ]) 116 | doc = search.Document(fields=[ 117 | # Full name is already unicode 118 | search.TextField(name='full_name', value=employee.full_name), 119 | search.TextField(name='username', value=employee.username), 120 | search.TextField(name='substrings', value=substrings), 121 | ]) 122 | documents.append(doc) 123 | index.put(documents) 124 | logging.info('Done indexing employees. {}MB'.format(memory_usage().current)) 125 | 126 | 127 | def _update_employees(employee_dicts): 128 | """Given a JSON string in the format "[{employee info 1}, {employee info 2}, ...]", 129 | create new employee records and update existing records as necessary. 130 | 131 | Then determine whether any employees have been terminated since the last update, 132 | and mark these employees as such. 133 | """ 134 | employee_dicts = list(employee_dicts) 135 | 136 | logging.info('Updating employees... {}MB'.format(memory_usage().current)) 137 | 138 | db_employee_dict = { 139 | employee.username: employee 140 | for employee in Employee.query() 141 | } 142 | 143 | all_employees, new_employees = [], [] 144 | current_usernames = set() 145 | office_parser = OfficeParser(employee_dicts) 146 | 147 | for d in employee_dicts: 148 | d['office'] = office_parser.get_office_name( 149 | employee_office_location=d['office'], 150 | employee_department=d.get('department'), 151 | ) if d.get('office') else None 152 | existing_employee = db_employee_dict.get(d['username']) 153 | 154 | if existing_employee is None: 155 | new_employee = Employee.create_from_dict(d, persist=False) 156 | all_employees.append(new_employee) 157 | new_employees.append(new_employee) 158 | else: 159 | existing_employee.update_from_dict(d) 160 | # If the user is in the S3 dump, then the user is no longer 161 | # terminated. 162 | existing_employee.terminated = False 163 | all_employees.append(existing_employee) 164 | 165 | current_usernames.add(d['username']) 166 | if len(all_employees) % 200 == 0: 167 | logging.info('Processed {} employees, {}MB'.format(len(all_employees), memory_usage().current)) 168 | ndb.put_multi(all_employees) 169 | 170 | # Figure out if there are any employees in the DB that aren't in the S3 171 | # dump. These are terminated employees, and we need to mark them as such. 172 | db_usernames = set(db_employee_dict.keys()) 173 | 174 | terminated_usernames = db_usernames - current_usernames 175 | terminated_employees = [] 176 | for username in terminated_usernames: 177 | employee = db_employee_dict[username] 178 | employee.terminated = True 179 | terminated_employees.append(employee) 180 | ndb.put_multi(terminated_employees) 181 | 182 | logging.info('Done updating employees. {}MB'.format(memory_usage().current)) 183 | 184 | 185 | def combine_employees(old_username, new_username): 186 | set_toggle_state(LOVE_SENDING_ENABLED, False) 187 | 188 | old_employee_key = Employee.query(Employee.username == old_username).get(keys_only=True) 189 | new_employee_key = Employee.query(Employee.username == new_username).get(keys_only=True) 190 | if not old_employee_key: 191 | raise NoSuchEmployee(old_username) 192 | elif not new_employee_key: 193 | raise NoSuchEmployee(new_username) 194 | 195 | # First, we need to update the actual instances of Love sent to/from the old employee 196 | logging.info('Reassigning {}\'s love to {}...'.format(old_username, new_username)) 197 | 198 | love_to_save = [] 199 | 200 | # Reassign all love sent FROM old_username 201 | for sent_love in Love.query(Love.sender_key == old_employee_key).iter(): 202 | sent_love.sender_key = new_employee_key 203 | love_to_save.append(sent_love) 204 | 205 | # Reassign all love sent TO old_username 206 | for received_love in Love.query(Love.recipient_key == old_employee_key).iter(): 207 | received_love.recipient_key = new_employee_key 208 | love_to_save.append(received_love) 209 | 210 | ndb.put_multi(love_to_save) 211 | logging.info('Done reassigning love.') 212 | 213 | # Second, we need to update the LoveCount table 214 | logging.info('Updating LoveCount table...') 215 | 216 | love_counts_to_delete, love_counts_to_save = [], [] 217 | 218 | for old_love_count in LoveCount.query(ancestor=old_employee_key).iter(): 219 | # Try to find a corresponding row for the new employee 220 | new_love_count = LoveCount.query( 221 | ancestor=new_employee_key, 222 | filters=(LoveCount.week_start == old_love_count.week_start) 223 | ).get() 224 | 225 | if new_love_count is None: 226 | # If there's no corresponding row for the new user, create one 227 | new_love_count = LoveCount( 228 | parent=new_employee_key, 229 | received_count=old_love_count.received_count, 230 | sent_count=old_love_count.sent_count, 231 | week_start=old_love_count.week_start 232 | ) 233 | else: 234 | # Otherwise, combine the two rows 235 | new_love_count.received_count += old_love_count.received_count 236 | new_love_count.sent_count += old_love_count.sent_count 237 | 238 | # You `delete` keys but you `put` entities... Google's APIs are weird 239 | love_counts_to_delete.append(old_love_count.key) 240 | love_counts_to_save.append(new_love_count) 241 | 242 | ndb.delete_multi(love_counts_to_delete) 243 | ndb.put_multi(love_counts_to_save) 244 | logging.info('Done updating LoveCount table.') 245 | 246 | # Now we can delete the old employee 247 | logging.info('Deleting employee {}...'.format(old_username)) 248 | old_employee_key.delete() 249 | logging.info('Done deleting employee.') 250 | 251 | # ... Which means we need to rebuild the index 252 | rebuild_index() 253 | 254 | set_toggle_state(LOVE_SENDING_ENABLED, True) 255 | 256 | 257 | def employees_matching_prefix(prefix): 258 | """Returns a list of (full name, username) tuples for users that match the given prefix.""" 259 | if not prefix: 260 | return [] 261 | 262 | user_tuples = set() 263 | 264 | search_query = search.Query( 265 | query_string=prefix, 266 | options=search.QueryOptions( 267 | limit=15)) 268 | results = search.Index(name=INDEX_NAME).search(search_query) 269 | for r in results: 270 | username, full_name = None, None 271 | for f in r.fields: 272 | if f.name == 'full_name': 273 | full_name = f.value 274 | elif f.name == 'username': 275 | username = f.value 276 | else: 277 | continue 278 | if username is not None and full_name is not None: 279 | photo_url = Employee.query(Employee.username == username).get().get_photo_url() 280 | user_tuples.add((full_name, username, photo_url)) 281 | 282 | user_tuples = list(user_tuples) 283 | user_tuples.sort() 284 | return user_tuples 285 | 286 | 287 | def load_employees(): 288 | employee_dicts = _get_employee_info_from_s3() 289 | set_toggle_state(LOVE_SENDING_ENABLED, False) 290 | _update_employees(employee_dicts) 291 | set_toggle_state(LOVE_SENDING_ENABLED, True) 292 | rebuild_index() 293 | 294 | 295 | def rebuild_index(): 296 | active_employees_future = Employee.query(Employee.terminated == False).fetch_async() # noqa 297 | _clear_index() 298 | _index_employees(active_employees_future.get_result()) 299 | --------------------------------------------------------------------------------