├── 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 |
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 |
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 |
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 |
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 |
13 | {% set display_sender_link = (icon_user.username == sender_username) %}
14 | {% set display_recipient_link = (icon_user.username == recipient_username) %}
15 |
{% if display_sender_link %}{% endif %}{{ sender_name }}
16 | loved
17 |
{% if display_recipient_link %}{% endif %}{{ recipient_name }}
18 |
{{ ts * 1000 }}
19 |
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 | Alias
15 | Owner
16 |
17 |
18 |
19 |
20 | {% for a in aliases %}
21 |
22 | {{ a.name }}
23 | {{ a.owner_key.get().username }}
24 |
25 |
29 |
30 |
31 | {% endfor %}
32 |
33 |
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 |
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 |
30 | {% else %}
31 | No API keys have been created yet. :[
32 | {% endif %}
33 |
34 | Add an API Key
35 |
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 | Photo
16 | Username
17 | Name
18 | Department
19 | Terminated
20 |
21 |
22 |
23 | {% for employee in pagination_result.collection %}
24 |
25 | {{ photobox.user_icon(employee) }}
26 | {{ employee.username }}
27 | {{ employee.full_name }}
28 | {{ employee.department }}
29 | {{ employee.terminated }}
30 |
31 | {% endfor %}
32 |
33 |
34 |
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 |
Recipients:
11 |
12 | {% for lovee in loved %}
13 | {{ facetile.face_icon(lovee) }}
14 | {% endfor %}
15 |
16 |
17 |
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 |
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 |
Recipients:
11 |
12 | {% for lovee in loved %}
13 | {{ facetile.face_icon(lovee) }}
14 | {% endfor %}
15 |
16 |
17 |
Message:
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 |
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 | Send love to
14 |
15 |
16 |
22 |
23 |
31 |
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 |
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(/0?" "+f.join(" "):"")+'"'),d.target&&g.push('target="'+d.target+'"');for(c in d.linkAttributes)g.push([c,'="',d.linkAttributes[c].replace(/\"/g,""").replace(/\$/g,"$"),'"'].join(""));return g.push(">$2$3$4$5$6"+d.tagName+">$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 | Username
17 |
18 |
19 |
20 | Explore
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 | Time period
16 |
17 | This week
18 | Last week
19 |
20 |
21 |
22 | Department
23 |
24 | {{ teams_title }}
25 | {% for department in departments|sort %}
26 | {{ department }}
27 | {% endfor %}
28 |
29 |
30 |
31 | Office
32 |
33 | {{ offices_title }}
34 | {% for office in offices|sort %}
35 | {{ office }}
36 | {% endfor %}
37 |
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 |
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 |
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 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {{ sender.department }}
51 | {{ love.message|linkify_company_values }}
52 | {% if love.secret %}
53 | Shh... sent secretly!
54 | {% endif %}
55 | Send Love Back
56 |
57 |
58 |
59 |
60 |
61 |
62 | Your Recent Love
63 |
64 |
65 |
66 | {% for l, lover in recent_love_and_lovers %}
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | {{ lover.department }}
75 | {{ l.message }}
76 | {{ l.timestamp.strftime('%d %b %Y') }}
77 |
78 |
79 |
80 |
81 | {% endfor %}
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/Yelp/love)
2 |
3 | 
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 | 
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 |
--------------------------------------------------------------------------------