├── tests ├── __init__.py ├── apps │ ├── __init__.py │ ├── debug │ │ ├── __init__.py │ │ └── test_healthcheck.py │ ├── web │ │ ├── __init__.py │ │ └── cassettes │ │ │ ├── TestUpcomingEventsViews.test_upcoming_events_add_new_group__does_not_exist.yaml │ │ │ └── TestUpcomingEventsViews.test_upcoming_events_add_new_group_does_not_exist.yaml │ ├── upcoming_events │ │ ├── __init__.py │ │ ├── test_models.py │ │ ├── test_cards.py │ │ └── test_workflow.py │ ├── call_for_proposals │ │ ├── __init__.py │ │ └── test_cli.py │ ├── github_integration │ │ ├── __init__.py │ │ ├── api │ │ │ └── __init__.py │ │ ├── functional │ │ │ └── __init__.py │ │ ├── summary │ │ │ └── __init__.py │ │ └── webhook │ │ │ └── __init__.py │ └── slack_integration │ │ ├── __init__.py │ │ ├── api │ │ └── __init__.py │ │ └── functional │ │ └── __init__.py ├── smoke │ ├── __init__.py │ ├── test_redis.py │ └── test_marshmallow.py ├── common │ ├── __init__.py │ ├── wrappers │ │ ├── __init__.py │ │ ├── test_requests_client.py │ │ ├── test_youtube.py │ │ ├── test_aws_s3.py │ │ └── cassettes │ │ │ ├── test_get_urlname__group_does_not_exist.yaml │ │ │ └── test_slack_user_does_not_exist.yaml │ └── test_middleware.py ├── models │ ├── __init__.py │ └── test_base.py ├── toolbox │ ├── __init__.py │ ├── test_helpers.py │ ├── test_rq.py │ ├── test_handlers.py │ └── test_event_emitter.py ├── _utilities │ ├── fixtures │ │ ├── __init__.py │ │ ├── vcr.py │ │ ├── github.py │ │ ├── aws_s3.py │ │ ├── database.py │ │ ├── flask.py │ │ ├── toolbox.py │ │ └── slack.py │ ├── factories │ │ ├── __init__.py │ │ ├── README.md │ │ ├── event_details.py │ │ ├── cfps.py │ │ ├── manager.py │ │ ├── slack.py │ │ └── github_summary_user.py │ ├── __init__.py │ ├── README.md │ └── fakes.py ├── README.md └── conftest.py ├── busy_beaver ├── apps │ ├── __init__.py │ ├── web │ │ ├── __init__.py │ │ └── blueprint.py │ ├── debug │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ └── healthcheck.py │ │ └── workflows.py │ ├── call_for_proposals │ │ ├── __init__.py │ │ ├── blueprint.py │ │ ├── workflows.py │ │ └── forms.py │ ├── github_integration │ │ ├── __init__.py │ │ ├── cards.py │ │ ├── oauth │ │ │ ├── __init__.py │ │ │ └── oauth_flow.py │ │ ├── summary │ │ │ ├── __init__.py │ │ │ ├── blocks.py │ │ │ └── summary.py │ │ ├── webhook │ │ │ ├── __init__.py │ │ │ ├── workflow.py │ │ │ └── event_subscription.py │ │ ├── blueprint.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── oauth.py │ │ │ ├── event_subscription.py │ │ │ └── decorators.py │ │ └── cli.py │ ├── slack_integration │ │ ├── __init__.py │ │ ├── oauth │ │ │ └── __init__.py │ │ ├── api │ │ │ ├── README.md │ │ │ ├── slash_command.py │ │ │ ├── event_subscription.py │ │ │ ├── __init__.py │ │ │ ├── oauth.py │ │ │ └── decorators.py │ │ ├── README.md │ │ ├── blocks.py │ │ └── interactors.py │ ├── upcoming_events │ │ ├── __init__.py │ │ ├── blueprint.py │ │ ├── README.md │ │ ├── cards.py │ │ └── upcoming_events.py │ ├── youtube_integration │ │ ├── __init__.py │ │ └── models.py │ └── README.md ├── common │ ├── __init__.py │ ├── wrappers │ │ ├── __init__.py │ │ ├── README.md │ │ ├── youtube.py │ │ └── requests_client.py │ ├── middleware.py │ ├── oauth.py │ └── datetime_utilities.py ├── templates │ ├── login.html │ ├── index.html │ ├── privacy.html │ ├── upcoming_events_add_new_group.html │ ├── settings.html │ └── organization_settings.html ├── static │ └── images │ │ └── logo.png ├── toolbox │ ├── __init__.py │ ├── slack_block_kit │ │ ├── __init__.py │ │ └── elements.py │ ├── helpers.py │ ├── event_emitter.py │ └── rq.py ├── README.md ├── extensions.py ├── blueprints.py ├── models.py ├── __init__.py ├── exceptions.py └── clients.py ├── migrations ├── README ├── versions │ ├── 20190623_13-57-42__rename_github_summary_user_table.py │ ├── 20200729_02-10-07__add_time_to_post_column.py │ ├── 20190309_12-12-25__only_allow_unique_keys.py │ ├── 20200123_23-52-54__save_auth_response_json.py │ ├── 20190303_14-10-03__add_roles_to_api_user.py │ ├── 20200712_19-59-28__make_group_id_non_nullable_after_data_.py │ ├── 20200813_18-09-49__add_organization_name_field.py │ ├── 20200726_15-26-34__remove_slack_installation_state.py │ ├── 20190121_21-59-34__add_key_vaue_table_store.py │ ├── 20200729_00-15-35__add_workspace_logo_url_field.py │ ├── 20201001_18-36-02__remove_user_token_column.py │ ├── 20200706_04-20-47__remove_slack_oauth_state_column.py │ ├── 20190401_19-28-36__create_twitter_poller_task_table.py │ ├── 20190526_13-04-37__create_update_event_database_task_table.py │ ├── 20200917_20-10-08__make_channel_field_nullable.py │ ├── 20190302_15-02-04__remove_simplekv_store.py │ ├── 20200705_23-23-00__add_app_home_opened_count_to_slack_user.py │ ├── 20190622_04-53-46__remove_state_column.py │ ├── 20181216_23-02-26__create_api_user_table.py │ ├── 20190302_15-45-13__add_key_value_store_for_slack_workspaces.py │ ├── 20200727_23-16-15__add_cascade_delete.py │ ├── 20190512_22-47-44__adding_youtube_video.py │ ├── 20200727_14-24-44__add_index_and_unique_constraint.py │ ├── 20200706_19-19-01__add_fields_for_time_and_timezone.py │ ├── 20181216_03-58-02__make_user_table.py │ ├── 20200707_02-57-28__remove_redundant_columns.py │ ├── 20200711_14-59-22__add_enabled_field_to_github_summary_.py │ ├── 20200813_18-47-41__add_post_cron_enabled_field.py │ ├── 20200712_15-43-40__create_upcoming_events_config_table.py │ ├── 20190324_22-05-57__create_base_task_table.py │ ├── 20190609_20-09-41__rename_sync_event_task_table_and_update_.py │ ├── 20190526_22-47-39__change_column_name_and_add_end_epoch_in_.py │ ├── 20200925_14-59-25__create_call_for_proposals_onfig_table.py │ ├── 20190526_01-41-37__create_events_table.py │ ├── 20200922_19-54-52__remove_key_value_table.py │ ├── 20190401_19-03-55__revert_task_table_creation.py │ ├── 20200712_16-16-34__add_meetup_groups_middle_layer_between_.py │ ├── 20200705_16-09-04__add_slack_user_table.py │ ├── 20200419_04-49-50__remove_token_auth_table.py │ ├── 20200125_02-16-15__create_slack_app_home_opened_counter.py │ └── 20200708_21-59-03__set_up_task_model_for_current_workflow.py ├── script.py.mako └── alembic.ini ├── .coveragerc ├── docker ├── redis │ └── redis.conf ├── dev │ └── Dockerfile └── prod │ └── Dockerfile ├── scripts ├── database │ ├── drop_all_tables.sql │ └── README.md ├── README.md ├── check_worker_box.sh ├── start_rq_scheduler.py ├── dev │ └── create_s3_bucket.sh ├── start_async_worker.py └── entrypoint.sh ├── .gitattributes ├── .flake8 ├── requirements_dev.txt ├── pytest.ini ├── assets ├── logo.jpg └── logo.png ├── .codecov.yaml ├── docs ├── diagrams │ ├── docker-windows-expose.png │ └── docker-windows-shared.png ├── development-create-slack-bot │ └── images │ │ ├── add-bot-user-1.png │ │ ├── add-bot-user-2.png │ │ ├── init-slack-app-1.png │ │ ├── init-slack-app-2.png │ │ ├── init-slack-app-4.png │ │ ├── oauth-tokens-1.png │ │ ├── event-subscriptions-1.png │ │ ├── event-subscriptions-2.png │ │ ├── init-slack-app-3.png │ │ ├── signing-secret-1.png │ │ ├── slash-commands-1.png │ │ ├── slash-commands-2.png │ │ ├── event-subscriptions-3.png │ │ ├── event-subscriptions-4.png │ │ ├── install-app-to-workspace-1.png │ │ ├── install-app-to-workspace-2.png │ │ └── setup-app permission-scopes-1.png ├── README.md └── deployment │ ├── slack_integration.md │ ├── digitalocean_deployment.md │ └── README.md ├── helm ├── values │ ├── redis.yaml │ ├── README.md │ ├── bb_production.yaml │ └── bb_staging.yaml ├── charts │ └── busybeaver │ │ ├── templates │ │ ├── NOTES.txt │ │ ├── service--redis.yaml │ │ ├── service--web.yaml │ │ ├── job--migrate-db.yaml │ │ ├── cronjob--post-upcoming-cfps.yaml │ │ ├── cronjob--sync-events-database.yaml │ │ ├── cronjob--queue-github-summary.yaml │ │ ├── cronjob--post-upcoming-events.yaml │ │ ├── ingress.yaml │ │ ├── deployment--scheduler.yaml │ │ ├── deployment--worker.yaml │ │ └── deployment--web.yaml │ │ ├── .helmignore │ │ ├── values.yaml │ │ └── Chart.yaml └── README.md ├── pyproject.toml ├── .dockerignore ├── .pre-commit-config.yaml ├── .isort.cfg ├── .github ├── pull_request_template.md └── workflows │ ├── build_tag_and_push_image.yaml │ └── deploy_app_to_kubernetes.yaml ├── .env.template ├── LICENSE ├── requirements.in └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/smoke/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/debug/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/toolbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/common/wrappers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/debug/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/debug/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/upcoming_events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/call_for_proposals/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/github_integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/slack_integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/call_for_proposals/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/cards.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/upcoming_events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/youtube_integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/github_integration/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/slack_integration/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/github_integration/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/github_integration/summary/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/github_integration/webhook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/slack_integration/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/summary/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/webhook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *tests*, venv*, migrations* 3 | -------------------------------------------------------------------------------- /docker/redis/redis.conf: -------------------------------------------------------------------------------- 1 | save 60 1000 2 | appendonly yes 3 | -------------------------------------------------------------------------------- /tests/_utilities/factories/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import FactoryManager # noqa 2 | -------------------------------------------------------------------------------- /scripts/database/drop_all_tables.sql: -------------------------------------------------------------------------------- 1 | drop schema public cascade; 2 | create schema public; 3 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Scripts 2 | 3 | Collection of scripts to help develop and deploy Busy Beaver. 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.png filter=lfs diff=lfs merge=lfs -text 2 | *.jpg filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=.git,__pycache__,docs/source/conf.py,old,build,dist,*venv*,migrations 3 | max-line-length=99 4 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | black==21.7b0 3 | flake8 4 | isort 5 | pdbpp 6 | pip-tools 7 | pre-commit==2.14.0 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | unit 4 | integration 5 | end2end 6 | smoke 7 | freeze_time 8 | current 9 | -------------------------------------------------------------------------------- /tests/_utilities/__init__.py: -------------------------------------------------------------------------------- 1 | from .factories import FactoryManager # noqa 2 | from .fakes import FakeMeetupAdapter, FakeSlackClient # noqa 3 | -------------------------------------------------------------------------------- /tests/apps/debug/test_healthcheck.py: -------------------------------------------------------------------------------- 1 | def test_healthcheck(client): 2 | rv = client.get("/healthcheck") 3 | assert rv.status_code == 200 4 | -------------------------------------------------------------------------------- /assets/logo.jpg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b1dfb4f74a2366ac826223159e9f5c125a8025fd96ab68b0129d74395d595440 3 | size 233277 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f54804b5ed20824c585c4682cdd8a9449da6004dc530103e8e3d05d50dd43d2c 3 | size 122231 4 | -------------------------------------------------------------------------------- /busy_beaver/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 | Sign in with Slack 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /.codecov.yaml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 80% 6 | threshold: 5% 7 | base: auto 8 | patch: off 9 | -------------------------------------------------------------------------------- /busy_beaver/apps/web/blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints 2 | 3 | web_bp = blueprints.Blueprint("web", __name__) 4 | 5 | from . import views # noqa isort:skip 6 | -------------------------------------------------------------------------------- /tests/models/test_base.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.models import BaseModel 2 | 3 | 4 | def test_basemodel_tablename(): 5 | assert BaseModel.__tablename__ == "basemodel" 6 | -------------------------------------------------------------------------------- /busy_beaver/static/images/logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f54804b5ed20824c585c4682cdd8a9449da6004dc530103e8e3d05d50dd43d2c 3 | size 122231 4 | -------------------------------------------------------------------------------- /docs/diagrams/docker-windows-expose.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5f5647bace2ca80b2acea04687019caf6c619fd16ac2155544d6cd6cdadfe22b 3 | size 113745 4 | -------------------------------------------------------------------------------- /docs/diagrams/docker-windows-shared.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:e7fad398895de27a0d8da14df7bc167dc40e4c423f6e241ee17a0959101e2939 3 | size 63048 4 | -------------------------------------------------------------------------------- /busy_beaver/apps/call_for_proposals/blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints 2 | 3 | cfps_bp = blueprints.Blueprint("cfps", __name__) 4 | 5 | from . import cli # noqa isort:skip 6 | -------------------------------------------------------------------------------- /busy_beaver/apps/upcoming_events/blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints 2 | 3 | events_bp = blueprints.Blueprint("events", __name__) 4 | 5 | from . import cli # noqa isort:skip 6 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Busy Beaver Tests 2 | 3 | Folder structure corresponds to the tree of `busy_beaver/` 4 | 5 | ## Test Ideas 6 | 7 | - [ ] test what Flask CLI commands are active 8 | -------------------------------------------------------------------------------- /scripts/check_worker_box.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check to see if worker box is still active 4 | # Used for Kubernetes probes 5 | rq info -u ${REDIS_URI} --only-workers | grep -q ${HOSTNAME} 6 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/add-bot-user-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b77f298274964edbc9aafa237ce7f74972e2446e86c3314d968f97bce842243a 3 | size 51028 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/add-bot-user-2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:394165a9f4537d4a9cb7a9c6d63cb6ea77d2b7f9a3ab5b462720a54516628044 3 | size 90221 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/init-slack-app-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a6f46a52b08060682f68ad0e1eccb23e3a47e61e5e9a03ce2d997a3368ca0dd6 3 | size 83872 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/init-slack-app-2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8ba4593968c75ca7101134d36a42e5adbd2adb461bf552209839bf3bede26ac4 3 | size 83397 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/init-slack-app-4.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4624f97511219a678c0d3ea8f4590ed0bf25148eb8f3ecf797fd0a5128c59730 3 | size 95453 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/oauth-tokens-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0728ab5616ec5f5996c6232d054955abef14b758c720dfcdc126ddd977818ff2 3 | size 107308 4 | -------------------------------------------------------------------------------- /tests/_utilities/README.md: -------------------------------------------------------------------------------- 1 | # Test Utilities 2 | 3 | This folder contains tools and utilities 4 | that makes testing easy and carefree. 5 | Folder is named `_utilities` to avoid namespacing issues. 6 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/event-subscriptions-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:65062d709738d6354fd72a60b06d47b2db7dd8994adf832aa17bc30a4b0d2a6d 3 | size 34452 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/event-subscriptions-2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:41ecfea96f44f74dc86d1732687a881b51bb2200e8ece861bf6f0a6fa6daba2a 3 | size 64293 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/init-slack-app-3.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ca8a1fa9f3ed40ea4db8b9c7c467c7d227af35eeead8a4c14211c9cef697706f 3 | size 138648 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/signing-secret-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c53f73228a535176759bad6ae74fe20f6d07f5b7d057143b166e167b9875c671 3 | size 190183 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/slash-commands-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1dc733c3e1b63bd2c768893d60d7ee7f529ed1d5ed19f7b3956ca560f70be808 3 | size 138780 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/slash-commands-2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c8dc58f395f0853c3eb11391380a94de726e58a2098d223a6eb428e05dc66db3 3 | size 158091 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/event-subscriptions-3.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0f31f61d809b497b8286d1ea0c6d171f2f1ce2b54af6046e969038c25fc8edea 3 | size 103630 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/event-subscriptions-4.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:db360c7133ffa485e5e1c3cd7c4da2955d644e5fc78e32ea32267090558a6684 3 | size 101841 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/install-app-to-workspace-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:08e28bda30831afdf8b7e9a0fe4136c13e7f9f8eaabd1f2ad5cf6aec9e8ff743 3 | size 62431 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/install-app-to-workspace-2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3de5746519cb264d4e496012237e0ba0ecc19aa3c78d617ea6ca860884d42f52 3 | size 36083 4 | -------------------------------------------------------------------------------- /docs/development-create-slack-bot/images/setup-app permission-scopes-1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5533a6ba41224b944a907c1b29ab81158f1b9dba6dd5fa9450eb0d8d8d09e264 3 | size 124965 4 | -------------------------------------------------------------------------------- /busy_beaver/toolbox/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_emitter import EventEmitter # noqa 2 | from .helpers import generate_range_utc_now_minus, make_response, utc_now_minus # noqa 3 | from .rq import set_task_progress # noqa 4 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints 2 | 3 | github_bp = blueprints.Blueprint("github", __name__) 4 | 5 | from . import api # noqa isort:skip 6 | from . import cli # noqa isort:skip 7 | -------------------------------------------------------------------------------- /busy_beaver/toolbox/slack_block_kit/__init__.py: -------------------------------------------------------------------------------- 1 | from .blocks import ( # noqa 2 | Action, 3 | Context, 4 | ContextMarkdown, 5 | Divider, 6 | File, 7 | Image, 8 | Input, 9 | Section, 10 | ) 11 | -------------------------------------------------------------------------------- /helm/values/redis.yaml: -------------------------------------------------------------------------------- 1 | usePassword: false 2 | 3 | cluster: 4 | enabled: false 5 | 6 | master: 7 | persistence: 8 | enabled: true 9 | size: 1Gi 10 | 11 | configmap: |- 12 | save 60 1000 13 | appendonly yes 14 | -------------------------------------------------------------------------------- /busy_beaver/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |


5 | 6 |
7 | 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | This folder container project documentation. 4 | 5 | ## Table of Contents 6 | 7 | - [Deployment](deployment/README.md) 8 | - [Creating a Slack Bot for Development](development-create-slack-bot/README.md) 9 | -------------------------------------------------------------------------------- /scripts/start_rq_scheduler.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.app import create_app 2 | from busy_beaver.extensions import rq 3 | 4 | app = create_app() 5 | ctx = app.app_context() 6 | ctx.push() 7 | 8 | scheduler = rq.get_scheduler(interval=10) 9 | scheduler.run() 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | py36 = true 4 | exclude = ''' 5 | /( 6 | \.eggs 7 | | \.git 8 | | \.hg 9 | | \.mypy_cache 10 | | \.pytest_cache 11 | | \.vscode 12 | | dist 13 | | migrations 14 | | venv 15 | )/ 16 | ''' 17 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | Thank you for installing {{ .Chart.Name }}. 2 | 3 | Your release is named {{ .Release.Name }}. 4 | 5 | To learn more about the release, try: 6 | 7 | $ helm status {{ .Release.Name }} 8 | $ helm get all {{ .Release.Name }} 9 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/service--redis.yaml: -------------------------------------------------------------------------------- 1 | kind: Service 2 | apiVersion: v1 3 | metadata: 4 | name: {{ .Values.queue.name }} 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | spec: 7 | type: ExternalName 8 | externalName: {{ .Values.queue.dns }} 9 | -------------------------------------------------------------------------------- /tests/_utilities/factories/README.md: -------------------------------------------------------------------------------- 1 | # Factories 2 | 3 | We are using [`factoryboy`](https://factoryboy.readthedocs.io) 4 | to produce test data. 5 | 6 | As we are leveraging the adapter pattern to wrap APIs, 7 | we can simplify our testing by 8 | creating factories to produce fixtures. 9 | -------------------------------------------------------------------------------- /busy_beaver/README.md: -------------------------------------------------------------------------------- 1 | # Busy Beaver Project Layout 2 | 3 | ## Apps 4 | 5 | All business logic specific code. 6 | HTTP endpoints are located in `api/` subfolders. 7 | 8 | ## Models 9 | 10 | SQLAlchemy models. 11 | 12 | ## Toolbox 13 | 14 | Internal tools used for this library. 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | 4 | Dockerfile 5 | docker-compose.yml 6 | docker-compose-prod.yml 7 | .travis.yml 8 | 9 | .env.template 10 | .env 11 | .envrc 12 | creds.txt 13 | 14 | venv/ 15 | htmlcov/ 16 | .coverage 17 | 18 | Makefile 19 | busy_beaver.db 20 | tests/ 21 | data_dump.sql 22 | 23 | *.pyc 24 | -------------------------------------------------------------------------------- /busy_beaver/common/wrappers/__init__.py: -------------------------------------------------------------------------------- 1 | from .aws_s3 import S3Client # noqa 2 | from .github import AsyncGitHubClient, GitHubClient # noqa 3 | from .meetup import MeetupClient # noqa 4 | from .requests_client import RequestsClient # noqa 5 | from .slack import SlackClient # noqa 6 | from .youtube import YouTubeClient # noqa 7 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/vcr.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope="session") 5 | def vcr_config(): 6 | """Overwrite headers and query parameters where key can be leaked""" 7 | return { 8 | "filter_headers": [("authorization", "DUMMY")], 9 | "filter_query_parameters": ["key"], 10 | } 11 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/api/README.md: -------------------------------------------------------------------------------- 1 | # Slack 2 | 3 | Installation of the application into other Slack workspaces 4 | is enabled via [OAuth2](https://api.slack.com/docs/oauth). 5 | 6 | Point the Redirect URL to: https://app.busybeaverbot.com/slack/installation-callback` 7 | 8 | We are using the Slack Event Subscription API and Slash Commands. 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 21.7b0 4 | hooks: 5 | - id: black 6 | - repo: https://gitlab.com/pycqa/flake8 7 | rev: fadedefae2df9fa6dff624fab733d717aea5ca0e 8 | hooks: 9 | - id: flake8 10 | - repo: https://github.com/timothycrosley/isort 11 | rev: 4.3.21-2 12 | hooks: 13 | - id: isort 14 | -------------------------------------------------------------------------------- /busy_beaver/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_bootstrap import Bootstrap 2 | from flask_login import LoginManager 3 | from flask_migrate import Migrate 4 | from flask_rq2 import RQ 5 | from flask_sqlalchemy import SQLAlchemy 6 | 7 | bootstrap = Bootstrap() 8 | db = SQLAlchemy() 9 | migrate = Migrate() 10 | rq = RQ() 11 | 12 | login_manager = LoginManager() 13 | login_manager.login_view = "web.login" 14 | -------------------------------------------------------------------------------- /tests/toolbox/test_helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | 5 | from busy_beaver.toolbox import utc_now_minus 6 | 7 | 8 | @pytest.mark.freeze_time("2019-05-12") 9 | def test_utc_now_minus(): 10 | today = datetime.utcnow() 11 | 12 | result = utc_now_minus(timedelta(days=1)) 13 | 14 | assert result.replace(tzinfo=None) == today - timedelta(days=1) 15 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=88 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | multi_line_output=3 6 | use_parentheses=True 7 | force_sort_within_sections=True 8 | known_first_party=busy_beaver,tests 9 | default_section=THIRDPARTY 10 | sections=FUTURE,STDLIB,THIRDPARTY,LOCALFOLDER,FIRSTPARTY 11 | no_lines_before=FIRSTPARTY 12 | skip=scripts/start_async_worker.py,busy_beaver/__init__.py 13 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/service--web.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}-web" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 80 10 | targetPort: {{ .Values.app.port }} 11 | selector: 12 | type: web 13 | {{- include "busybeaver.selectorLabels" . | nindent 4 }} 14 | -------------------------------------------------------------------------------- /scripts/dev/create_s3_bucket.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set +e 4 | 5 | aws --endpoint-url=http://localhost:4566 --region us-east-1 s3api head-bucket --bucket ${BUCKET_NAME} 6 | 7 | if [ $? -eq 0 ] 8 | then 9 | exit 0 10 | else 11 | echo "creating bucket" 12 | aws --endpoint-url=http://localhost:4566 --region us-east-1 \ 13 | s3api create-bucket --bucket ${BUCKET_NAME} --acl public-read 14 | exit 0 15 | fi 16 | -------------------------------------------------------------------------------- /busy_beaver/blueprints.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.apps.call_for_proposals.blueprint import cfps_bp # noqa 2 | from busy_beaver.apps.debug.api.healthcheck import healthcheck_bp # noqa 3 | from busy_beaver.apps.github_integration.blueprint import github_bp # noqa 4 | from busy_beaver.apps.slack_integration.api import slack_bp # noqa 5 | from busy_beaver.apps.upcoming_events.blueprint import events_bp # noqa 6 | from busy_beaver.apps.web.blueprint import web_bp # noqa 7 | -------------------------------------------------------------------------------- /helm/values/README.md: -------------------------------------------------------------------------------- 1 | # Helm Values Files 2 | 3 | [Values files](https://helm.sh/docs/chart_template_guide/values_files/) allow us to cusomtize Helm charts. 4 | 5 | ## Contents 6 | 7 | |Filename|Description| 8 | |---|---| 9 | |`bb_production.yaml`|Production file for Busy Beaver chart| 10 | |`bb_staging.yaml`|Staging file for Busy Beaver chart| 11 | |`redis.yaml`|Values for [bitnmai/redis](https://github.com/bitnami/charts/tree/master/bitnami/redis)| 12 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/values/bb_production.yaml: -------------------------------------------------------------------------------- 1 | # Default values for busybeaver. 2 | 3 | environment: production 4 | namespace: busybeaver-production 5 | serviceAccount: busybeaver-production-sa 6 | secretName: bb-prd-terraform-secrets 7 | workspaceId: T093FC1RC 8 | 9 | app: 10 | port: 5000 11 | webReplicaCount: 1 12 | workerReplicaCount: 1 13 | 14 | ingress: 15 | host: app.busybeaverbot.com 16 | 17 | queue: 18 | name: bb-queue-production 19 | dns: bb-queue-production-redis-master.busybeaver-production.svc.cluster.local 20 | -------------------------------------------------------------------------------- /busy_beaver/apps/debug/api/healthcheck.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import blueprints, jsonify 4 | 5 | logger = logging.getLogger(__name__) 6 | healthcheck_bp = blueprints.Blueprint("healthcheck", __name__) 7 | 8 | 9 | @healthcheck_bp.route("/healthcheck", methods=["GET"]) 10 | def health_check(): 11 | return jsonify({"ping": "pong"}) 12 | 13 | 14 | @healthcheck_bp.route("/healthcheck-logged", methods=["GET"]) 15 | def health_check_logged(): 16 | logger.info("Hit healthcheck") 17 | return jsonify({"ping": "pong"}) 18 | -------------------------------------------------------------------------------- /busy_beaver/apps/debug/workflows.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.extensions import rq 2 | 3 | 4 | @rq.job 5 | def add(x, y): 6 | return x + y 7 | 8 | 9 | """ 10 | # How to debug 11 | 12 | make flaskshell 13 | 14 | from datetime import timedelta 15 | from busy_beaver.apps.debug.workflows import add 16 | 17 | 18 | job = add.queue(3, 4) 19 | job.result 20 | 21 | job = add.schedule(timedelta(seconds=5), 1, 2) 22 | job.result 23 | """ 24 | # app.config['RQ_SCHEDULER_QUEUE'] = 'scheduled' 25 | # app.config['RQ_SCHEDULER_INTERVAL'] = 1 default is 60 26 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/api/__init__.py: -------------------------------------------------------------------------------- 1 | from ..blueprint import github_bp 2 | from .event_subscription import GitHubEventSubscriptionResource 3 | from .oauth import GitHubIdentityVerificationCallbackResource 4 | 5 | github_bp.add_url_rule( 6 | "/event-subscription", 7 | view_func=GitHubEventSubscriptionResource.as_view("github_event_subscription"), 8 | ) 9 | 10 | github_bp.add_url_rule( 11 | "/installation-callback", 12 | view_func=GitHubIdentityVerificationCallbackResource.as_view("github_verification"), 13 | ) 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Closes # (if there is a related issue) 2 | 3 | ### What does this do 4 | 5 | [high-level description of what this PR does] 6 | 7 | ### Why are we doing this 8 | 9 | [description of why] 10 | 11 | ### How should this be tested 12 | 13 | [what tests have been written, any tests to perform after merging and deploying?] 14 | 15 | ### Migrations 16 | 17 | [post SQL here] 18 | 19 | ### Dependencies 20 | 21 | [Merge dependencies? Deployment dependencies?] 22 | 23 | ### Callouts 24 | 25 | [Anything other should be made aware of?] 26 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Project wide pytest plugins / fixtures 2 | 3 | References: 4 | - http://alexmic.net/flask-sqlalchemy-pytest/ 5 | - http://flask.pocoo.org/docs/1.0/testing/ 6 | """ 7 | 8 | import pytest 9 | 10 | from busy_beaver.extensions import rq as _rq 11 | 12 | pytest_plugins = ( 13 | "tests._utilities.fixtures.database", 14 | "tests._utilities.fixtures.flask", 15 | "tests._utilities.fixtures.toolbox", 16 | "tests._utilities.fixtures.vcr", 17 | ) 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | def rq(app): 22 | yield _rq 23 | -------------------------------------------------------------------------------- /busy_beaver/common/middleware.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from werkzeug.wrappers import Request 4 | 5 | 6 | class RequestUuidMiddleware: 7 | """Add uuid to each request""" 8 | 9 | def __init__(self, app): 10 | self.app = app 11 | 12 | def __call__(self, environ, start_response): 13 | request = Request(environ) 14 | request_id = request.headers.get("X-Request-ID") 15 | if not request_id: 16 | request_id = str(uuid.uuid4()) 17 | environ["request_id"] = request_id 18 | return self.app(environ, start_response) 19 | -------------------------------------------------------------------------------- /tests/common/wrappers/test_requests_client.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.common.wrappers.requests_client import RequestsClient 2 | 3 | 4 | def test_adding_headers(): 5 | requests = RequestsClient(headers={"Accept": "application/json/test"}) 6 | assert requests.headers["Accept"] == "application/json/test" 7 | 8 | 9 | def test_add_header_after_initailizing_without_headers(): 10 | requests_client_no_header = RequestsClient() 11 | requests = RequestsClient(headers={"Accept": "application/json/test"}) 12 | 13 | assert len(requests.headers) >= len(requests_client_no_header.headers) 14 | -------------------------------------------------------------------------------- /migrations/versions/20190623_13-57-42__rename_github_summary_user_table.py: -------------------------------------------------------------------------------- 1 | """rename github summary user table 2 | 3 | Revision ID: dee7af02d018 4 | Revises: 05fd1af68aa4 5 | Create Date: 2019-06-23 13:57:42.112629 6 | 7 | """ 8 | from alembic import op 9 | 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "dee7af02d018" 13 | down_revision = "05fd1af68aa4" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.rename_table("user", "github_summary_user") 20 | 21 | 22 | def downgrade(): 23 | op.rename_table("github_summary_user", "user") 24 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for busybeaver. 2 | 3 | environment: staging 4 | namespace: busybeaver-staging 5 | serviceAccount: busybeaver-staging-sa 6 | secretName: bb-stg-terraform-secrets 7 | workspaceId: TKT910ZU0 8 | 9 | app: 10 | port: 5000 11 | webReplicaCount: 1 12 | workerReplicaCount: 1 13 | 14 | image: 15 | repository: alysivji/busy-beaver 16 | version: 1.4.7 17 | pullPolicy: Always 18 | 19 | ingress: 20 | host: staging.busybeaverbot.com 21 | 22 | queue: 23 | name: bb-queue-staging 24 | dns: bb-queue-staging-redis-master.busybeaver-staging.svc.cluster.local 25 | -------------------------------------------------------------------------------- /helm/values/bb_staging.yaml: -------------------------------------------------------------------------------- 1 | # Default values for busybeaver. 2 | 3 | environment: staging 4 | namespace: busybeaver-staging 5 | serviceAccount: busybeaver-staging-sa 6 | secretName: bb-stg-terraform-secrets 7 | workspaceId: TKT910ZU0 8 | 9 | app: 10 | port: 5000 11 | webReplicaCount: 1 12 | workerReplicaCount: 1 13 | 14 | image: 15 | repository: alysivji/busy-beaver 16 | version: clean-up-repo 17 | pullPolicy: Always 18 | 19 | ingress: 20 | host: staging.busybeaverbot.com 21 | 22 | queue: 23 | name: bb-queue-staging 24 | dns: bb-queue-staging-redis-master.busybeaver-staging.svc.cluster.local 25 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /busy_beaver/templates/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |

Privacy Policy

5 | 6 |

7 | The Busy Beaver team understands that your personal data 8 | is a big deal. We make an effort to limit the amount of data that we collect 9 | and store. 10 |

11 | 12 |

13 | Our database contains the following 14 | personally identifiable information: 15 |

19 |

20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/github.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def generate_event_subscription_request(): 6 | def _generate_data( 7 | *, action="", issue_html_url="", pr_html_url="", release_html_url="" 8 | ): 9 | request_json = {"action": action} 10 | if issue_html_url: 11 | request_json["issue"] = {"html_url": issue_html_url} 12 | if pr_html_url: 13 | request_json["pull_request"] = {"html_url": pr_html_url} 14 | if release_html_url: 15 | request_json["release"] = {"html_url": release_html_url} 16 | return request_json 17 | 18 | return _generate_data 19 | -------------------------------------------------------------------------------- /tests/common/wrappers/test_youtube.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from busy_beaver.common.wrappers.youtube import YouTubeClient 4 | from busy_beaver.config import YOUTUBE_API_KEY, YOUTUBE_CHANNEL 5 | 6 | 7 | @pytest.fixture 8 | def client(): 9 | yield YouTubeClient(api_key=YOUTUBE_API_KEY) 10 | 11 | 12 | @pytest.mark.vcr() 13 | def test_get_latest_videos_from_channel(client): 14 | response = client.get_latest_videos_from_channel(channel_id=YOUTUBE_CHANNEL) 15 | assert response[0][0] == "https://www.youtube.com/watch?v=J3XYnnHrumM" 16 | assert response[0][1] == "chipy org sprint sponsored by quicket solutions" 17 | assert response[0][2] == "2019-04-16T15:05:28.000Z" 18 | -------------------------------------------------------------------------------- /busy_beaver/apps/call_for_proposals/workflows.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from busy_beaver.extensions import db 4 | from busy_beaver.models import CallForProposalsConfiguration 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def create_or_update_call_for_proposals_configuration( 10 | installation, channel, internal_cfps 11 | ): 12 | config = installation.cfp_config 13 | if config is None: 14 | config = CallForProposalsConfiguration() 15 | config.slack_installation = installation 16 | config.enabled = True 17 | 18 | config.internal_cfps = internal_cfps 19 | config.channel = channel 20 | db.session.add(config) 21 | db.session.commit() 22 | -------------------------------------------------------------------------------- /busy_beaver/models.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.common.models import BaseModel, Task # noqa isort:skip 2 | 3 | from busy_beaver.apps.call_for_proposals.models import ( # noqa 4 | CallForProposalsConfiguration, 5 | ) 6 | from busy_beaver.apps.github_integration.models import ( # noqa 7 | GitHubSummaryConfiguration, 8 | GitHubSummaryUser, 9 | ) 10 | from busy_beaver.apps.slack_integration.models import ( # noqa 11 | SlackInstallation, 12 | SlackUser, 13 | ) 14 | from busy_beaver.apps.upcoming_events.models import ( # noqa 15 | Event, 16 | UpcomingEventsConfiguration, 17 | UpcomingEventsGroup, 18 | ) 19 | from busy_beaver.apps.youtube_integration.models import YouTubeVideo # noqa 20 | -------------------------------------------------------------------------------- /busy_beaver/apps/upcoming_events/README.md: -------------------------------------------------------------------------------- 1 | # Upcoming Events 2 | 3 | Display upcoming events from Meetup in Slack. 4 | 5 | ## Background 6 | 7 | - Users can query the database using the 8 | [Slack slash commands](https://api.slack.com/slash-commands) 9 | - `/busybeaver next` 10 | - `/busybeaver events` 11 | - The contents of `/busybeaver events` will be posted a specified channel 12 | when an endpoint gets hit with a POST request; 13 | this is currently triggered with a CRON job 14 | 15 | ### Events Database 16 | 17 | This feature polls Meetup once a day and 18 | adds syncs database with fetched events. 19 | 20 | - new event get created 21 | - removed events get deleted 22 | - existing events get updated 23 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/api/slash_command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import jsonify, request 4 | from flask.views import MethodView 5 | 6 | from .decorators import slack_verification_required 7 | from busy_beaver.apps.slack_integration.slash_command import process_slash_command 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SlackSlashCommandDispatchResource(MethodView): 13 | """Dealing with slash commands""" 14 | 15 | decorators = [slack_verification_required] 16 | 17 | def post(self): 18 | data = dict(request.form) 19 | logger.info("Received Slack slash command", extra={"form_data": data}) 20 | return jsonify(process_slash_command(data)) 21 | -------------------------------------------------------------------------------- /tests/_utilities/factories/event_details.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import factory 4 | 5 | from busy_beaver.common.wrappers.meetup import EventDetails as model 6 | 7 | 8 | def EventDetails(session): 9 | """Meetup Event Details Factory""" 10 | 11 | class _EventDetailsFactory(factory.Factory): 12 | class Meta: 13 | model = model 14 | 15 | id = factory.Faker("uuid4") 16 | name = "ChiPy" 17 | url = "http://meetup.com/_ChiPy_/event/blah" 18 | start_epoch = int((datetime.now() + timedelta(days=1)).timestamp()) 19 | end_epoch = start_epoch + 60 * 60 * 2 20 | venue = "Braintree" 21 | 22 | return _EventDetailsFactory 23 | -------------------------------------------------------------------------------- /busy_beaver/apps/README.md: -------------------------------------------------------------------------------- 1 | # Applications 2 | 3 | Each package inside of this folder contains Busy Beaver features. 4 | All web framework specific code is located in this directory. 5 | Levarege [Flask Blueprints pattern](http://flask.pocoo.org/docs/1.0/blueprints/) 6 | quite extensively. 7 | Flask code is in `api` subfolders. 8 | 9 | ## App Directory 10 | 11 | |Application|Description 12 | |---|---| 13 | |call_for_proposals|Fetch and post upcoming Call For Proposals (CFPs)| 14 | |debug|Tools to help development and debug| 15 | |events|Find and post upcoming events| 16 | |github_integration|GitHub related-integration logic| 17 | |slack_integration|Slack-related integration logic| 18 | |youtube_integration|YouTube related-itnegration logic (WIP)| 19 | -------------------------------------------------------------------------------- /tests/toolbox/test_rq.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rq import get_current_job 3 | 4 | from busy_beaver.extensions import db, rq 5 | from busy_beaver.models import Task 6 | from busy_beaver.toolbox import set_task_progress 7 | 8 | 9 | @rq.job 10 | def add(x, y): 11 | # TODO update this 12 | job = get_current_job() 13 | task = Task(job_id=job.id, name="Test Task", data={"x": x, "y": y}) 14 | db.session.add(task) 15 | db.session.commit() 16 | 17 | set_task_progress(100) 18 | return x + y 19 | 20 | 21 | @pytest.mark.unit 22 | def test_set_task_progress(session): 23 | job = add.queue(1, 2) 24 | 25 | curr_task = Task.query.filter_by(job_id=job.id).first() 26 | assert curr_task.task_state == Task.TaskState.COMPLETED 27 | -------------------------------------------------------------------------------- /docker/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.0-buster 2 | 3 | LABEL maintainer="Aly Sivji " \ 4 | description="BusyBeaver -- Development image" 5 | 6 | ENV PYTHONUNBUFFERED 1 7 | ENV PYTHONASYNCIODEBUG 1 8 | ENV PYTHONTRACEMALLOC 1 9 | 10 | WORKDIR /app 11 | 12 | COPY requirements.txt requirements_dev.txt /tmp/ 13 | RUN pip install --no-cache-dir -r /tmp/requirements_dev.txt 14 | 15 | EXPOSE 5000 16 | 17 | COPY ./ /app 18 | 19 | # Switch from root user for security 20 | RUN groupadd -g 901 -r busybeaverdev \ 21 | && useradd -g busybeaverdev -r -u 901 busybeaver_user \ 22 | && mkdir /home/busybeaver_user \ 23 | && chmod -R 755 /home/busybeaver_user 24 | USER busybeaver_user 25 | 26 | ENTRYPOINT [ "scripts/entrypoint.sh" ] 27 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/aws_s3.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from busy_beaver.common.wrappers import S3Client 4 | from busy_beaver.config import ( 5 | DIGITALOCEAN_SPACES_BUCKET_NAME, 6 | DIGITALOCEAN_SPACES_KEY, 7 | DIGITALOCEAN_SPACES_SECRET, 8 | ) 9 | 10 | bucket = DIGITALOCEAN_SPACES_BUCKET_NAME 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def s3(): 15 | """Create bucket for tests if it does not already exist""" 16 | s3 = S3Client(DIGITALOCEAN_SPACES_KEY, DIGITALOCEAN_SPACES_SECRET) 17 | 18 | created_bucket = False 19 | if not s3.find_bucket(bucket): 20 | s3.create_bucket(bucket) 21 | created_bucket = True 22 | 23 | yield s3 24 | 25 | if created_bucket: 26 | s3.delete_bucket(bucket) 27 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: busybeaver 3 | description: Community engagement Slackbot 4 | home: https://github.com/busy-beaver-dev/busy-beaver 5 | type: application 6 | 7 | # This is the chart version. This version number should be incremented each time you make changes 8 | # to the chart and its templates, including the app version. 9 | version: 0.5.1 10 | 11 | # This is the version number of the application being deployed. This version number should be 12 | # incremented each time you make changes to the application. 13 | appVersion: 2.1.1 14 | 15 | keywords: 16 | - community-engagement 17 | - slackbot 18 | 19 | maintainers: 20 | - name: Aly Sivji 21 | email: alysivji@gmail.com 22 | url: https://alysivji.github.io 23 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/webhook/workflow.py: -------------------------------------------------------------------------------- 1 | def generate_new_issue_message(data): 2 | action = data.get("action", None) 3 | if action != "opened": 4 | return 5 | 6 | html_url = data["issue"]["html_url"] 7 | return f"*New Issue*: {html_url}" 8 | 9 | 10 | def generate_new_pull_request_message(data): 11 | action = data.get("action", None) 12 | if action != "opened": 13 | return 14 | 15 | html_url = data["pull_request"]["html_url"] 16 | return f"*New Pull Request*: {html_url}" 17 | 18 | 19 | def generate_new_release_message(data): 20 | action = data.get("action", None) 21 | if action != "published": 22 | return 23 | 24 | html_url = data["release"]["html_url"] 25 | return f"*New Release*: {html_url}" 26 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/api/event_subscription.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import jsonify, request 4 | from flask.views import MethodView 5 | 6 | from .decorators import slack_verification_required 7 | from busy_beaver.apps.slack_integration.event_subscription import ( 8 | process_event_subscription_callback, 9 | ) 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class SlackEventSubscriptionResource(MethodView): 15 | """Callback endpoint for Slack event subscriptions""" 16 | 17 | decorators = [slack_verification_required] 18 | 19 | def post(self): 20 | data = request.json 21 | logger.info("Received event from Slack", extra={"request_json": data}) 22 | return jsonify(process_event_subscription_callback(data)) 23 | -------------------------------------------------------------------------------- /docker/prod/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.0-buster 2 | 3 | LABEL maintainer="Aly Sivji " \ 4 | description="BusyBeaver -- Production image" 5 | 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | WORKDIR /app 9 | 10 | COPY requirements.txt /tmp/ 11 | RUN pip install --no-cache-dir -r /tmp/requirements.txt 12 | 13 | EXPOSE 5000 14 | 15 | COPY ./ /app 16 | 17 | # Switch from root user for security 18 | RUN groupadd -g 901 -r busybeaverdev \ 19 | && useradd -g busybeaverdev -r -u 901 busybeaver_user \ 20 | && mkdir /home/busybeaver_user \ 21 | && chmod -R 755 /home/busybeaver_user 22 | # GH-358 -- https://github.com/busy-beaver-dev/busy-beaver/issues/358 23 | # find out why this needs to be commented out in CRI 24 | # USER busybeaver_user 25 | 26 | ENTRYPOINT [ "scripts/entrypoint.sh" ] 27 | -------------------------------------------------------------------------------- /migrations/versions/20200729_02-10-07__add_time_to_post_column.py: -------------------------------------------------------------------------------- 1 | """add time_to_post column 2 | 3 | Revision ID: 12a693d95f87 4 | Revises: 102c4e5f4066 5 | Create Date: 2020-07-29 02:10:07.847585 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "12a693d95f87" 13 | down_revision = "102c4e5f4066" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column("task", sa.Column("time_to_post", sa.Time(), nullable=True)) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column("task", "time_to_post") 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=development 2 | 3 | SLACK_CLIENT_ID= 4 | SLACK_CLIENT_SECRET= 5 | SLACK_BOTUSER_OAUTH_TOKEN=Bot_User_OAuth_Access_Token-goes_here 6 | SLACK_SIGNING_SECRET=signing_secret 7 | SLACK_DEV_WORKSPACE_ID= 8 | SLACK_DEV_WORKSPACE_CFP_CHANNEL= 9 | SLACK_DEV_WORKSPACE_GITHUB_SUMMARY_CHANNEL= 10 | SLACK_DEV_WORKSPACE_UPCOMING_EVENTS_CHANNEL= 11 | 12 | GITHUB_OAUTH_TOKEN= 13 | GITHUB_APP_CLIENT_ID= 14 | GITHUB_APP_CLIENT_SECRET= 15 | GITHUB_SIGNING_SECRET= 16 | 17 | YOUTUBE_API_KEY= 18 | 19 | MEETUP_API_KEY= 20 | 21 | DIGITALOCEAN_SPACES_KEY=foo 22 | DIGITALOCEAN_SPACES_SECRET=foo 23 | DIGITALOCEAN_SPACES_ENDPOINT_URL=http://localstack:4566 24 | DIGITALOCEAN_SPACES_REGION_NAME="us-east-1" 25 | DIGITALOCEAN_SPACES_BUCKET_NAME="cdn" 26 | DIGITALOCEAN_SPACES_BASE_URL="http://0.0.0.0:4566" 27 | LOGOS_FOLDER=busybeaver-workspace-logos 28 | -------------------------------------------------------------------------------- /busy_beaver/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import dictConfig as load_dict_config 3 | 4 | import sentry_sdk 5 | from sentry_sdk.integrations.flask import FlaskIntegration 6 | from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration 7 | 8 | from .config import ENVIRONMENT, LOGGING_CONFIG, SENTRY_DSN 9 | 10 | # observability 11 | load_dict_config(LOGGING_CONFIG) 12 | logger = logging.getLogger(__name__) 13 | if SENTRY_DSN: 14 | sentry_sdk.init( 15 | dsn=SENTRY_DSN, 16 | environment=ENVIRONMENT, 17 | integrations=[FlaskIntegration(), SqlalchemyIntegration()], 18 | ) 19 | 20 | logger.info("Configure Integrations") 21 | from . import clients # noqa isort:skip 22 | 23 | logger.info("Starting Server") 24 | from .app import create_app # noqa isort:skip 25 | from . import models # noqa isort:skip 26 | -------------------------------------------------------------------------------- /migrations/versions/20190309_12-12-25__only_allow_unique_keys.py: -------------------------------------------------------------------------------- 1 | """only allow unique keys in key-value store 2 | 3 | Revision ID: 73b592804bfa 4 | Revises: c2aead9ff6d9 5 | Create Date: 2019-03-09 12:12:25.914048 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '73b592804bfa' 14 | down_revision = 'c2aead9ff6d9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_unique_constraint(None, 'key_value_store', ['key']) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_constraint(None, 'key_value_store', type_='unique') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /scripts/start_async_worker.py: -------------------------------------------------------------------------------- 1 | from logging.config import dictConfig as load_dict_config 2 | 3 | import sentry_sdk 4 | from sentry_sdk.integrations.rq import RqIntegration 5 | from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration 6 | 7 | from busy_beaver.app import create_app 8 | from busy_beaver.config import ENVIRONMENT, LOGGING_CONFIG, SENTRY_DSN 9 | from busy_beaver.extensions import rq 10 | from busy_beaver.toolbox.rq import retry_failed_job 11 | 12 | # observability 13 | load_dict_config(LOGGING_CONFIG) 14 | if SENTRY_DSN: 15 | sentry_sdk.init( 16 | SENTRY_DSN, 17 | environment=ENVIRONMENT, 18 | integrations=[RqIntegration(), SqlalchemyIntegration()], 19 | ) 20 | 21 | app = create_app() 22 | ctx = app.app_context() 23 | ctx.push() 24 | 25 | w = rq.get_worker("default", "failed") 26 | w.push_exc_handler(retry_failed_job) 27 | w.work() 28 | -------------------------------------------------------------------------------- /tests/_utilities/factories/cfps.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .slack import SlackInstallation 4 | from busy_beaver.models import CallForProposalsConfiguration as cfp_config_model 5 | 6 | 7 | def CallForProposalsConfiguration(session): 8 | class _CFPConfigFactory(factory.alchemy.SQLAlchemyModelFactory): 9 | class Meta: 10 | model = cfp_config_model 11 | sqlalchemy_session_persistence = "commit" 12 | sqlalchemy_session = session 13 | 14 | enabled = True 15 | slack_installation = factory.SubFactory(SlackInstallation(session)) 16 | channel = "call-for-proposals" 17 | internal_cfps = [ 18 | {"event": "`__main__` Meeting", "url": "http://bit.ly/chipy-cfp"}, 19 | {"event": "Special Interest Groups", "url": "http://bit.ly/chipy-sig-cfp"}, 20 | ] 21 | 22 | return _CFPConfigFactory 23 | -------------------------------------------------------------------------------- /tests/smoke/test_redis.py: -------------------------------------------------------------------------------- 1 | """Redis & python-rq related tests""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.smoke 7 | def test_redis_smoke_test(rq): 8 | default_queue = rq.get_queue("default") 9 | default_queue.get_jobs() 10 | assert True 11 | 12 | 13 | def add(x, y): 14 | return x + y 15 | 16 | 17 | @pytest.mark.smoke 18 | def test_async_task_called_like_regular_function(app, rq): 19 | rq.job(add) 20 | assert add(5, 2) == 7 21 | 22 | 23 | @pytest.mark.smoke 24 | def test_run_async_task(app, rq): 25 | rq.job(add) 26 | job = add.queue(5, 2) 27 | assert job.result == 7 28 | 29 | 30 | def exception_function(): 31 | raise ValueError 32 | 33 | 34 | @pytest.mark.smoke 35 | def test_run_async_task_raising_exception(app, rq): 36 | rq.job(exception_function) 37 | 38 | with pytest.raises(ValueError): 39 | exception_function.queue() 40 | -------------------------------------------------------------------------------- /migrations/versions/20200123_23-52-54__save_auth_response_json.py: -------------------------------------------------------------------------------- 1 | """save_auth_response_json 2 | 3 | Revision ID: 8c5ac2860989 4 | Revises: 655741e212e9 5 | Create Date: 2020-01-23 23:52:54.452643 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "8c5ac2860989" 14 | down_revision = "655741e212e9" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "slack_installation", sa.Column("auth_response", sa.JSON(), nullable=True) 23 | ) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column("slack_installation", "auth_response") 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/20190303_14-10-03__add_roles_to_api_user.py: -------------------------------------------------------------------------------- 1 | """add roles to api user 2 | 3 | Revision ID: c2aead9ff6d9 4 | Revises: 500e1baf1bef 5 | Create Date: 2019-03-03 14:10:03.776587 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "c2aead9ff6d9" 13 | down_revision = "500e1baf1bef" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "api_user", 22 | sa.Column("role", sa.String(length=255), nullable=False, server_default="user"), 23 | ) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column("api_user", "role") 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /tests/apps/upcoming_events/test_models.py: -------------------------------------------------------------------------------- 1 | from finite_state_machine.exceptions import ConditionNotMet 2 | import pytest 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | from busy_beaver.apps.upcoming_events.models import PostCRONEnabledStateMachine 6 | 7 | 8 | def test_meetup_group_configuration_unique_constraint(factory): 9 | group = factory.UpcomingEventsGroup(meetup_urlname="_ChiPy_") 10 | 11 | with pytest.raises(IntegrityError): 12 | factory.UpcomingEventsGroup( 13 | meetup_urlname="_ChiPy_", configuration=group.configuration 14 | ) 15 | 16 | 17 | def test_cron_job_field_cannot_be_enabled_if_no_channel_is_selected(factory): 18 | config = factory.UpcomingEventsConfiguration(channel=None, post_cron_enabled=False) 19 | 20 | machine = PostCRONEnabledStateMachine(config) 21 | 22 | with pytest.raises(ConditionNotMet): 23 | machine.toggle() 24 | -------------------------------------------------------------------------------- /busy_beaver/toolbox/slack_block_kit/elements.py: -------------------------------------------------------------------------------- 1 | """ 2 | Slack Block Kit: Block Elements 3 | 4 | Block elements can be used inside of section, context, and actions 5 | layout blocks. Inputs can only be used inside of input blocks. 6 | 7 | https://api.slack.com/reference/block-kit/block-elements 8 | """ 9 | 10 | 11 | class Element: 12 | type = None 13 | 14 | def __init__(self): 15 | self.output = {} 16 | self.output["type"] = self.type 17 | 18 | def __repr__(self): # pragma: no cover 19 | cls_name = self.__class__.__name__ 20 | return f"<{cls_name}>" 21 | 22 | def to_dict(self) -> dict: 23 | return self.output 24 | 25 | 26 | class Image(Element): 27 | type = "image" 28 | 29 | def __init__(self, image_url, alt_text): 30 | super().__init__() 31 | self.output["image_url"] = image_url 32 | self.output["alt_text"] = alt_text 33 | -------------------------------------------------------------------------------- /migrations/versions/20200712_19-59-28__make_group_id_non_nullable_after_data_.py: -------------------------------------------------------------------------------- 1 | """make group_id non-nullable after data migration 2 | 3 | Revision ID: 68ff4349ae47 4 | Revises: f29c0fd74c68 5 | Create Date: 2020-07-12 19:59:28.800930 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "68ff4349ae47" 13 | down_revision = "f29c0fd74c68" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column("event", "group_id", existing_type=sa.INTEGER(), nullable=False) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.alter_column("event", "group_id", existing_type=sa.INTEGER(), nullable=True) 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /tests/_utilities/factories/manager.py: -------------------------------------------------------------------------------- 1 | from .cfps import CallForProposalsConfiguration 2 | from .event import Event, UpcomingEventsConfiguration, UpcomingEventsGroup 3 | from .event_details import EventDetails 4 | from .github_summary_user import GitHubSummaryConfiguration, GitHubSummaryUser 5 | from .slack import SlackInstallation, SlackUser 6 | 7 | 8 | class FactoryManager: 9 | known_factories = [ 10 | CallForProposalsConfiguration, 11 | Event, 12 | EventDetails, 13 | GitHubSummaryConfiguration, 14 | GitHubSummaryUser, 15 | SlackInstallation, 16 | SlackUser, 17 | UpcomingEventsConfiguration, 18 | UpcomingEventsGroup, 19 | ] 20 | 21 | def __init__(self, session): 22 | self.session = session 23 | 24 | for factory_func in self.known_factories: 25 | setattr(self, factory_func.__name__, factory_func(self.session)) 26 | -------------------------------------------------------------------------------- /tests/common/test_middleware.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request 2 | import pytest 3 | 4 | from busy_beaver.common.middleware import RequestUuidMiddleware 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def app(): 9 | app = Flask(__name__) 10 | app.wsgi_app = RequestUuidMiddleware(app.wsgi_app) 11 | 12 | @app.route("/") 13 | def hello_world(): 14 | return request.environ.get("request_id") 15 | 16 | ctx = app.app_context() 17 | ctx.push() 18 | yield app 19 | 20 | ctx.pop() 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def client(app): 25 | client = app.test_client() 26 | yield client 27 | 28 | 29 | def test_middleware(client): 30 | result = client.get("/") 31 | assert len(result.data) > 0 32 | 33 | 34 | def test_middleware_with_request_id(client): 35 | result = client.get("/", headers={"X-Request-ID": "abc"}) 36 | assert result.data == b"abc" 37 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/job--migrate-db.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}--db-migrate" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | annotations: 7 | "helm.sh/hook": pre-install,pre-upgrade 8 | "helm.sh/hook-weight": "1" 9 | "helm.sh/hook-delete-policy": hook-succeeded 10 | spec: 11 | activeDeadlineSeconds: 120 12 | template: 13 | spec: 14 | restartPolicy: Never 15 | containers: 16 | - name: db-migrate 17 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 18 | imagePullPolicy: {{ .Values.image.pullPolicy }} 19 | command: ["flask"] 20 | args: ["db", "upgrade"] 21 | env: {{- include "busybeaver.env_vars" . | indent 10 }} 22 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 23 | automountServiceAccountToken: false 24 | -------------------------------------------------------------------------------- /migrations/versions/20200813_18-09-49__add_organization_name_field.py: -------------------------------------------------------------------------------- 1 | """add organization name field 2 | 3 | Revision ID: caf810949fa5 4 | Revises: 12a693d95f87 5 | Create Date: 2020-08-13 18:09:49.751741 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "caf810949fa5" 13 | down_revision = "12a693d95f87" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "slack_installation", 22 | sa.Column("organization_name", sa.String(length=255), nullable=True), 23 | ) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column("slack_installation", "organization_name") 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/20200726_15-26-34__remove_slack_installation_state.py: -------------------------------------------------------------------------------- 1 | """remove slack installation state 2 | 3 | Revision ID: 7959c628791e 4 | Revises: 68ff4349ae47 5 | Create Date: 2020-07-26 15:26:34.040967 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "7959c628791e" 13 | down_revision = "68ff4349ae47" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.drop_column("slack_installation", "state") 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.add_column( 27 | "slack_installation", 28 | sa.Column("state", sa.VARCHAR(length=20), autoincrement=False, nullable=False), 29 | ) 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /busy_beaver/apps/youtube_integration/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from busy_beaver.common.models import BaseModel 4 | from busy_beaver.extensions import db 5 | 6 | 7 | class YouTubeVideo(BaseModel): 8 | """Store all YouTube videos that have been posted""" 9 | 10 | __tablename__ = "youtube_video" 11 | 12 | def __repr__(self): # pragma: no cover 13 | return f"" 14 | 15 | # Attributes 16 | youtube_id = db.Column(db.String(300), nullable=False) 17 | 18 | title = db.Column(db.String(300), nullable=False) 19 | published_at = db.Column(db.DateTime, nullable=True) 20 | description = db.Column(db.String(1000), nullable=False) 21 | 22 | @staticmethod 23 | def date_str_to_datetime(date_str: str) -> datetime: 24 | """For converting string 'publishedAt'.""" 25 | date_format = "%Y-%m-%dT%H:%M:%S" 26 | return datetime.strptime(date_str.split(".")[0], date_format) 27 | -------------------------------------------------------------------------------- /busy_beaver/toolbox/helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import json as _json 3 | 4 | from flask import Response 5 | import pytz 6 | 7 | 8 | def make_response( 9 | status_code: int = 200, 10 | *, 11 | headers: dict = None, 12 | json: dict = None, 13 | error: dict = None, 14 | ): 15 | """Build and send response""" 16 | resp = {"data": {}, "error": {}} 17 | if json: 18 | resp["data"] = json 19 | if error: 20 | resp["error"] = error 21 | 22 | return Response( 23 | status=status_code, 24 | headers=headers if headers else {}, 25 | content_type="application/json", 26 | response=_json.dumps(resp), 27 | ) 28 | 29 | 30 | def utc_now_minus(period: timedelta): 31 | return pytz.utc.localize(datetime.utcnow()) - period 32 | 33 | 34 | def generate_range_utc_now_minus(period: timedelta): 35 | now = pytz.utc.localize(datetime.utcnow()) 36 | return (now - period, now) 37 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints 2 | 3 | from .event_subscription import SlackEventSubscriptionResource 4 | from .oauth import SlackAppInstallationCallbackResource, SlackSignInCallbackResource 5 | from .slash_command import SlackSlashCommandDispatchResource 6 | 7 | slack_bp = blueprints.Blueprint("slack", __name__) 8 | 9 | slack_bp.add_url_rule( 10 | "/event-subscription", 11 | view_func=SlackEventSubscriptionResource.as_view("slack_event_subscription"), 12 | ) 13 | 14 | slack_bp.add_url_rule( 15 | "/slash-command", 16 | view_func=SlackSlashCommandDispatchResource.as_view("slash_command_dispatcher"), 17 | ) 18 | 19 | slack_bp.add_url_rule( 20 | "/installation-callback", 21 | view_func=SlackAppInstallationCallbackResource.as_view("slack_install_callback"), 22 | ) 23 | 24 | slack_bp.add_url_rule( 25 | "/sign-in-callback", 26 | view_func=SlackSignInCallbackResource.as_view("slack_signin_callback"), 27 | ) 28 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/database.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from busy_beaver.extensions import db as db 4 | from tests._utilities import FactoryManager 5 | 6 | 7 | @pytest.fixture(name="db", scope="module") 8 | def database(app): 9 | """Test database.""" 10 | db.app = app 11 | db.create_all() 12 | yield db 13 | 14 | db.drop_all() 15 | 16 | 17 | @pytest.fixture(name="session", scope="function") 18 | def create_sqlalchemy_scoped_session(db): 19 | """Creates a new database session for each test.""" 20 | connection = db.engine.connect() 21 | transaction = connection.begin() 22 | options = dict(bind=connection, binds={}) 23 | 24 | session = db.create_scoped_session(options=options) 25 | db.session = session 26 | yield session 27 | 28 | transaction.rollback() 29 | connection.close() 30 | session.remove() 31 | 32 | 33 | @pytest.fixture(name="factory") 34 | def factory_manager(session): 35 | yield FactoryManager(session) 36 | -------------------------------------------------------------------------------- /docs/deployment/slack_integration.md: -------------------------------------------------------------------------------- 1 | # Slack Integration 2 | 3 | Details related to Busy Beaver's Slack integration. 4 | 5 | ## Integration Checklist 6 | 7 | For both staging and production apps 8 | 9 | - [ ] Update URL in App Home Screen 10 | - [ ] Name Bot: `Busy Beaver` with the username `@busybeaver` 11 | - [ ] Slash Command: Enable `/busybeaver` and set up URL 12 | - [ ] Update app permission 13 | - [ ] NEED TO DOCUMENT ALL OF THIS SOMEWHERE 14 | - [ ] Update Auth Callback URL for installation 15 | - [ ] Set up event subscriptions and put them to the URL 16 | - [ ] WHAT EVENT SUBSCRIPTIONS DO WE NEED TO ENABLE 17 | 18 | ### App Details 19 | 20 | |Environment|Name|Workspace|workspace_id 21 | |---|---|---|---| 22 | |Production|Busy Beaver|[BusyBeaverDev](https://busybeaverdev.slack.com/)|`TPDB2AV4K` 23 | |Staging|Busy Beaver Staging|[Busy Beaver Staging](https://busybeaverbot.slack.com/)|`TKT910ZU0` 24 | |Development|Busy Beaver Development|[SivBots](https://sivbots.slack.com/)|`T5G0FCMNW` 25 | -------------------------------------------------------------------------------- /migrations/versions/20190121_21-59-34__add_key_vaue_table_store.py: -------------------------------------------------------------------------------- 1 | """add key-vaue table store (using simplekv) 2 | 3 | Revision ID: 78514b173380 4 | Revises: 312e762dfa85 5 | Create Date: 2019-01-21 21:59:34.979693 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "78514b173380" 13 | down_revision = "312e762dfa85" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "kv_store", 22 | sa.Column("key", sa.String(length=250), nullable=False), 23 | sa.Column("value", sa.LargeBinary(), nullable=False), 24 | sa.PrimaryKeyConstraint("key"), 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_table("kv_store") 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/20200729_00-15-35__add_workspace_logo_url_field.py: -------------------------------------------------------------------------------- 1 | """add workspace logo url field 2 | 3 | Revision ID: 102c4e5f4066 4 | Revises: c9971d11c82f 5 | Create Date: 2020-07-29 00:15:35.565444 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlalchemy_utils 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "102c4e5f4066" 14 | down_revision = "c9971d11c82f" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "slack_installation", 23 | sa.Column( 24 | "workspace_logo_url", sqlalchemy_utils.types.url.URLType(), nullable=True 25 | ), 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_column("slack_installation", "workspace_logo_url") 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/api/oauth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import jsonify, request 4 | from flask.views import MethodView 5 | 6 | from busy_beaver.apps.github_integration.oauth.workflow import ( 7 | process_github_oauth_callback, 8 | ) 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class GitHubIdentityVerificationCallbackResource(MethodView): 14 | """Callback endpoint to verify GitHub username 15 | 16 | In order to link Slack IDs to GitHub usernames, we have to create a GitHub OAuth App 17 | with a callback URL that GitHub can send verification messages to once user connect 18 | their account. 19 | """ 20 | 21 | def get(self): 22 | logger.info("GitHub OAuth Callback") 23 | params = request.args 24 | code = params.get("code") 25 | state = params.get("state") 26 | callback_url = request.url 27 | 28 | result = process_github_oauth_callback(callback_url, state, code) 29 | return jsonify(result) 30 | -------------------------------------------------------------------------------- /migrations/versions/20201001_18-36-02__remove_user_token_column.py: -------------------------------------------------------------------------------- 1 | """remove user token column 2 | 3 | Revision ID: 1fe0aa535040 4 | Revises: e51108c88034 5 | Create Date: 2020-10-01 18:36:02.685882 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "1fe0aa535040" 14 | down_revision = "e51108c88034" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_column("slack_installation", "access_token") 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column( 28 | "slack_installation", 29 | sa.Column( 30 | "access_token", postgresql.BYTEA(), autoincrement=False, nullable=True 31 | ), 32 | ) 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/cronjob--post-upcoming-cfps.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1beta1 2 | kind: CronJob 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}--post-upcoming-cfps" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: sync-events-database 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | spec: 10 | schedule: "0 12 * * 1" 11 | jobTemplate: 12 | spec: 13 | template: 14 | spec: 15 | restartPolicy: OnFailure 16 | containers: 17 | - name: busybeaver--post-upcoming-cfps 18 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 19 | imagePullPolicy: {{ .Values.image.pullPolicy }} 20 | command: ["flask"] 21 | args: 22 | - "post_upcoming_cfps" 23 | env: {{- include "busybeaver.env_vars" . | indent 12 }} 24 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 25 | automountServiceAccountToken: false 26 | -------------------------------------------------------------------------------- /migrations/versions/20200706_04-20-47__remove_slack_oauth_state_column.py: -------------------------------------------------------------------------------- 1 | """remove slack_oauth_state column 2 | 3 | Revision ID: 3f137bef24e4 4 | Revises: 21e8f6f7a837 5 | Create Date: 2020-07-06 04:20:47.952399 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "3f137bef24e4" 13 | down_revision = "21e8f6f7a837" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.drop_column("slack_user", "slack_oauth_state") 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.add_column( 27 | "slack_user", 28 | sa.Column( 29 | "slack_oauth_state", 30 | sa.VARCHAR(length=36), 31 | autoincrement=False, 32 | nullable=True, 33 | ), 34 | ) 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/cronjob--sync-events-database.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1beta1 2 | kind: CronJob 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}--sync-events-database" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: sync-events-database 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | spec: 10 | schedule: "0 0 * * *" 11 | jobTemplate: 12 | spec: 13 | template: 14 | spec: 15 | restartPolicy: OnFailure 16 | containers: 17 | - name: busybeaver--sync-events-database 18 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 19 | imagePullPolicy: {{ .Values.image.pullPolicy }} 20 | command: ["flask"] 21 | args: 22 | - "sync_events_database" 23 | env: {{- include "busybeaver.env_vars" . | indent 12 }} 24 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 25 | automountServiceAccountToken: false 26 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d-%%(minute).2d-%%(second).2d__%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/cronjob--queue-github-summary.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1beta1 2 | kind: CronJob 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}--cron--queue-github-summary" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: post-github-summary 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | spec: 10 | schedule: "50 23 * * *" 11 | jobTemplate: 12 | spec: 13 | template: 14 | spec: 15 | restartPolicy: OnFailure 16 | containers: 17 | - name: busybeaver--queue-github-summary-jobs 18 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 19 | imagePullPolicy: {{ .Values.image.pullPolicy }} 20 | command: ["flask"] 21 | args: 22 | - "queue_github_summary_jobs" 23 | env: {{- include "busybeaver.env_vars" . | indent 12 }} 24 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 25 | automountServiceAccountToken: false 26 | -------------------------------------------------------------------------------- /migrations/versions/20190401_19-28-36__create_twitter_poller_task_table.py: -------------------------------------------------------------------------------- 1 | """create twitter poller task table 2 | 3 | Revision ID: b5db0d063898 4 | Revises: 8e309a02eb44 5 | Create Date: 2019-04-01 19:28:36.048202 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "b5db0d063898" 14 | down_revision = "8e309a02eb44" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "post_tweet_task", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("data", sa.JSON(), nullable=True), 25 | sa.ForeignKeyConstraint(["id"], ["task.id"]), 26 | sa.PrimaryKeyConstraint("id"), 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table("post_tweet_task") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/cronjob--post-upcoming-events.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1beta1 2 | kind: CronJob 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}--cron--queue-upcoming-events" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: post-upcoming-events 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | spec: 10 | schedule: "55 23 * * *" 11 | jobTemplate: 12 | spec: 13 | template: 14 | spec: 15 | restartPolicy: OnFailure 16 | containers: 17 | - name: busybeaver--queue-upcoming-events-jobs 18 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 19 | imagePullPolicy: {{ .Values.image.pullPolicy }} 20 | command: ["flask"] 21 | args: 22 | - "queue_post_upcoming_events_jobs" 23 | env: {{- include "busybeaver.env_vars" . | indent 12 }} 24 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 25 | automountServiceAccountToken: false 26 | -------------------------------------------------------------------------------- /scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Set strict mode options 3 | set -euo pipefail 4 | 5 | # Set default value for the server 6 | DEFAULT="webserver" 7 | SERVER=${1:-$DEFAULT} 8 | 9 | # Set a default value for production status 10 | PRODUCTION=${IN_PRODUCTION:-0} 11 | 12 | if [ "$SERVER" = "webserver" ]; then 13 | echo "Starting Flask server" 14 | if [ "$PRODUCTION" = 1 ]; then 15 | exec gunicorn "busy_beaver:create_app()" -b 0.0.0.0:5000 16 | elif [ "$PRODUCTION" = 0 ]; then 17 | exec gunicorn "busy_beaver:create_app()" -b 0.0.0.0:5000 --reload --timeout 100000 18 | else 19 | echo "Unrecognized option for variable IN_PRODUCTION: '$PRODUCTION'" 20 | exit 1 21 | fi 22 | elif [ "$SERVER" = "worker" ]; then 23 | echo "Starting RQ worker" 24 | exec python scripts/start_async_worker.py 25 | elif [ "$SERVER" = "scheduler" ]; then 26 | echo "Starting RQ scehduler" 27 | exec python scripts/start_rq_scheduler.py 28 | else 29 | echo "Unrecognized option for server: '$SERVER'" 30 | exit 1 31 | fi 32 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/api/event_subscription.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import jsonify, request 4 | from flask.views import MethodView 5 | 6 | from .decorators import github_verification_required 7 | from busy_beaver.apps.github_integration.webhook.event_subscription import ( 8 | process_github_event_subscription, 9 | ) 10 | from busy_beaver.exceptions import UnverifiedWebhookRequest 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class GitHubEventSubscriptionResource(MethodView): 16 | """Callback endpoint for GitHub event subscriptions""" 17 | 18 | decorators = [github_verification_required] 19 | 20 | def post(self): 21 | data = request.json 22 | logger.info("Received GitHub event", extra={"request_json": data}) 23 | 24 | event_type = request.headers.get("X-GitHub-Event", None) 25 | if not event_type: 26 | raise UnverifiedWebhookRequest("Missing GitHub event type") 27 | 28 | result = process_github_event_subscription(event_type, data) 29 | return jsonify(result) 30 | -------------------------------------------------------------------------------- /migrations/versions/20190526_13-04-37__create_update_event_database_task_table.py: -------------------------------------------------------------------------------- 1 | """create update event database task table 2 | 3 | Revision ID: 958548ca6d07 4 | Revises: ad009ed08b4f 5 | Create Date: 2019-05-26 13:04:37.762104 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "958548ca6d07" 14 | down_revision = "ad009ed08b4f" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "fetch_new_events_task", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("data", sa.JSON(), nullable=True), 25 | sa.ForeignKeyConstraint(["id"], ["task.id"]), 26 | sa.PrimaryKeyConstraint("id"), 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table("fetch_new_events_task") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/20200917_20-10-08__make_channel_field_nullable.py: -------------------------------------------------------------------------------- 1 | """make channel field nullable 2 | 3 | Revision ID: 636be46da1fe 4 | Revises: 9e34ca8cefc0 5 | Create Date: 2020-09-17 20:10:08.370810 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "636be46da1fe" 13 | down_revision = "9e34ca8cefc0" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column( 21 | "upcoming_events_configuration", 22 | "channel", 23 | existing_type=sa.VARCHAR(length=20), 24 | nullable=True, 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.alter_column( 32 | "upcoming_events_configuration", 33 | "channel", 34 | existing_type=sa.VARCHAR(length=20), 35 | nullable=False, 36 | ) 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /.github/workflows/build_tag_and_push_image.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build-and-push-image: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | IMAGE_NAME: alysivji/busy-beaver 11 | RELEASE_TAG: ${{ github.event.release.tag_name }} 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | lfs: true 16 | - name: Login to DockerHub 17 | run: echo "$DOCKERHUB_ACCESS_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin 18 | env: 19 | DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} 20 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 21 | - name: Pull latest image 22 | run: docker pull $IMAGE_NAME || true 23 | - name: Build image 24 | run: docker build -f docker/prod/Dockerfile --pull --cache-from $IMAGE_NAME --tag $IMAGE_NAME --tag $IMAGE_NAME:$RELEASE_TAG . 25 | - name: Push image 26 | run: | 27 | docker push $IMAGE_NAME 28 | docker push $IMAGE_NAME:$RELEASE_TAG 29 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/README.md: -------------------------------------------------------------------------------- 1 | # Slack Integration 2 | 3 | All code related to integrating with Slack. 4 | We are currently using: 5 | 6 | - [Event Subscription API](https://api.slack.com/events-api) 7 | - [Slash Commands](https://api.slack.com/slash-commands) 8 | - commands are dispatched to the command handlers using `EventEmitter` 9 | 10 | ## Commands 11 | 12 | Users can use the following commands: 13 | 14 | - `/busybeaver connect` to create a new account, 15 | - `/busybeaver reconnect` to link Slack ID to different GitHub account 16 | - `/busybeaver disconnect` to delete user account. 17 | 18 | TODO update this 19 | 20 | ## Installing in Slack Workspace 21 | 22 | Distrbituion URL: 23 | 24 | https://slack.com/oauth/v2/authorize?client_id=795376369155.506256439575&scope=app_mentions:read,channels:history,channels:join,channels:read,chat:write,commands,emoji:read,groups:read,im:history,im:read,im:write,mpim:history,mpim:read,mpim:write,reactions:read,reactions:write,team:read,usergroups:read,users.profile:read,users:read,users:write&user_scope=channels:read,groups:read 25 | -------------------------------------------------------------------------------- /busy_beaver/templates/upcoming_events_add_new_group.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "bootstrap/form.html" import render_field, render_form_row %} 3 | 4 | {% block container %} 5 |

Upcoming Events -- Add / Remove Group

6 | 7 |
8 | 9 |
My Groups
10 | 11 |
    12 | {% for group in groups %} 13 |
  • {{ group.meetup_urlname }} (remove)
  • 14 | {% else %} 15 | No Active Groups 16 | {% endfor %} 17 |
18 | 19 |
20 | 21 |

22 | To add a new group, you will need your Meetup group's unique URL identifer. 23 |

24 | 25 |

26 | If your Meetup group URL is https://www.meetup.com/_ChiPy_/, 27 | enter _ChiPy_. 28 |

29 | 30 |
31 | {{ form.csrf_token }} 32 | 33 | {{ render_form_row([form.meetup_urlname], col_map={'meetup_urlname': 'col-md-6'}) }} 34 | 35 | 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /migrations/versions/20190302_15-02-04__remove_simplekv_store.py: -------------------------------------------------------------------------------- 1 | """remove key-value store (stop using simplekv... we will do our own thing) 2 | 3 | Revision ID: a5915c5a78eb 4 | Revises: 78514b173380 5 | Create Date: 2019-03-02 15:02:04.315615 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a5915c5a78eb' 14 | down_revision = '78514b173380' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_table('kv_store') 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.create_table('kv_store', 28 | sa.Column('key', sa.VARCHAR(length=250), autoincrement=False, nullable=False), 29 | sa.Column('value', postgresql.BYTEA(), autoincrement=False, nullable=False), 30 | sa.PrimaryKeyConstraint('key', name='kv_store_pkey') 31 | ) 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/20200705_23-23-00__add_app_home_opened_count_to_slack_user.py: -------------------------------------------------------------------------------- 1 | """add app_home_opened_count to slack_user 2 | 3 | Revision ID: 44515a4bff4d 4 | Revises: eddd9fbf0db6 5 | Create Date: 2020-07-05 23:23:00.963727 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "44515a4bff4d" 13 | down_revision = "eddd9fbf0db6" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # Step 1 -- Add nullable column 20 | op.add_column( 21 | "slack_user", sa.Column("app_home_opened_count", sa.Integer(), nullable=True) 22 | ) 23 | 24 | # Step 2 -- Fill in column with default values 25 | op.execute("UPDATE slack_user SET app_home_opened_count = 0") 26 | 27 | # Step 3 -- Set column to be non-nullable 28 | op.alter_column("slack_user", "app_home_opened_count", nullable=False) 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_column("slack_user", "app_home_opened_count") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/20190622_04-53-46__remove_state_column.py: -------------------------------------------------------------------------------- 1 | """remove state column 2 | 3 | Revision ID: 05fd1af68aa4 4 | Revises: a6df75102d6e 5 | Create Date: 2019-06-22 04:53:46.341696 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "05fd1af68aa4" 14 | down_revision = "a6df75102d6e" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_index("ix_slack_installation_state", table_name="slack_installation") 22 | op.drop_column("slack_installation", "state") 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.add_column( 29 | "slack_installation", 30 | sa.Column("state", sa.VARCHAR(length=36), autoincrement=False, nullable=True), 31 | ) 32 | op.create_index( 33 | "ix_slack_installation_state", "slack_installation", ["state"], unique=False 34 | ) 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aly Sivji 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /busy_beaver/toolbox/event_emitter.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.exceptions import ( 2 | EventEmitterEventAlreadyRegistered, 3 | EventEmitterEventNotRegistered, 4 | ) 5 | 6 | 7 | class EventEmitter: 8 | def __init__(self): 9 | self.registered_events = {} 10 | 11 | def on(self, event, func=None): 12 | """Pass in a function or use as a decorator""" 13 | if event in self.registered_events: 14 | raise EventEmitterEventAlreadyRegistered(f"{event} already registered") 15 | 16 | def _on(f): 17 | self.registered_events[event] = f 18 | return f 19 | 20 | if func is None: 21 | return _on 22 | else: 23 | return _on(func) 24 | 25 | def emit(self, _event, *args, default=None, **kwargs): 26 | if _event not in self.registered_events: 27 | if not default: 28 | raise EventEmitterEventNotRegistered( 29 | f"{_event} has not been registered" 30 | ) 31 | _event = default 32 | 33 | func = self.registered_events[_event] 34 | return func(*args, **kwargs) 35 | -------------------------------------------------------------------------------- /migrations/versions/20181216_23-02-26__create_api_user_table.py: -------------------------------------------------------------------------------- 1 | """create api user table 2 | 3 | Revision ID: 312e762dfa85 4 | Revises: 3f5a7657912d 5 | Create Date: 2018-12-16 23:02:26.309850 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '312e762dfa85' 14 | down_revision = '3f5a7657912d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('api_user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date_created', sa.DateTime(), nullable=True), 24 | sa.Column('date_modified', sa.DateTime(), nullable=True), 25 | sa.Column('username', sa.String(length=255), nullable=False), 26 | sa.Column('token', sa.String(length=255), nullable=False), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table('api_user') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /busy_beaver/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |

Busy Beaver Settings

5 | 6 |

7 | Busy Beaver has many different features that can help organizers 8 | better engage their communities. 9 |

10 | 11 |
    12 |
  • Workspace Configuration
  • 13 |

    Organization information (name, logo, etc).

    14 | 15 |
  • Upcoming Events Settings
  • 16 |

    Integrate your organization's event calendar into Busy Beaver.

    17 | 18 |
  • GitHub Summary Settings
  • 19 |

    20 | Tech-focused organizations can use Busy Beaver to share daily summaries 21 | of public GitHub activity for registered users. 22 |

    23 | 24 |
  • Call For Proposals Settings
  • 25 |

    26 | Tech-focused organizations can use Busy Beaver to share upcoming 27 | deadlines for internal and external Call For Proposals (CFP). 28 |

    29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | alembic==1.3.2 2 | bootstrap-flask==1.4 3 | boto3==1.15.0 4 | click==7.1.2 # required for black (dev dependency) 5 | cryptography==3.2 6 | finite-state-machine==0.2.0 7 | flask-login==0.5.0 8 | Flask-Migrate==2.5.2 9 | Flask-RQ2==18.3 10 | flask-shell-ipython==0.4.1 11 | Flask-SQLAlchemy==2.4.1 12 | Flask-WTF==0.14.3 13 | Flask==1.1.1 14 | gunicorn==20.0.4 15 | httpx[http2]==0.19.0 16 | inflect==2.1.0 17 | ipython==7.18.1 18 | marshmallow==3.3.0 19 | psycopg2-binary==2.8.4 20 | python-dateutil==2.8.1 21 | python-json-logger==0.1.11 22 | pytz==2020.1 23 | redis==3.4.1 24 | requests-oauthlib==1.3.0 25 | requests==2.22.0 26 | secure==0.2.1 27 | sentry-sdk[flask]==0.14.0 28 | slackclient==2.5.0 29 | sqlalchemy-utils==0.36.1 30 | SQLAlchemy==1.3.12 31 | werkzeug==0.16.0 32 | whitenoise==5.0.1 # serving static files quickly; use until we need a CDN 33 | wtforms==2.3.1 34 | WTForms-Components==0.10.4 35 | 36 | # testing dependencies 37 | factory_boy==3.1.0 38 | pytest-cov==2.10.1 39 | pytest-freezegun==0.4.2 40 | pytest-mock==3.3.1 41 | pytest-sugar==0.9.4 42 | pytest-vcr==1.0.2 43 | pytest==6.1.2 44 | responses==0.12.0 45 | vcrpy==4.1.1 46 | -------------------------------------------------------------------------------- /migrations/versions/20190302_15-45-13__add_key_value_store_for_slack_workspaces.py: -------------------------------------------------------------------------------- 1 | """add key value store for slack workspaces 2 | 3 | Revision ID: 500e1baf1bef 4 | Revises: a5915c5a78eb 5 | Create Date: 2019-03-02 15:45:13.600162 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '500e1baf1bef' 14 | down_revision = 'a5915c5a78eb' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('key_value_store', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date_created', sa.DateTime(), nullable=True), 24 | sa.Column('date_modified', sa.DateTime(), nullable=True), 25 | sa.Column('key', sa.String(length=255), nullable=False), 26 | sa.Column('value', sa.LargeBinary(), nullable=False), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table('key_value_store') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /tests/apps/upcoming_events/test_cards.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.apps.upcoming_events.cards import UpcomingEvent, UpcomingEventList 2 | from busy_beaver.toolbox.slack_block_kit import Context, Divider, Image, Section 3 | 4 | 5 | def test_upcoming_event(factory): 6 | event = factory.EventDetails() 7 | 8 | result = UpcomingEvent(event) 9 | 10 | assert len(result) == 3 # sections: 3 in the header, each block is 3 11 | assert isinstance(result[0], Section) 12 | assert isinstance(result[1], Context) 13 | assert isinstance(result[2], Divider) 14 | 15 | 16 | def test_upcoming_event_to_dict(factory): 17 | event = factory.EventDetails() 18 | 19 | result = UpcomingEvent(event).to_dict() 20 | 21 | assert len(result) == 3 # sections: 3 in the header, each block is 3 22 | 23 | 24 | def test_upcoming_event_list(factory): 25 | events = factory.EventDetails.create_batch(size=5) 26 | 27 | result = UpcomingEventList(events, image_url="url") 28 | 29 | assert len(result) == 3 + 3 * 5 # sections: 3 in the header, each block is 3 30 | assert isinstance(result[0], Image) 31 | assert isinstance(result[1], Section) 32 | assert isinstance(result[2], Divider) 33 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/flask.py: -------------------------------------------------------------------------------- 1 | from flask_login.test_client import FlaskLoginClient 2 | import pytest 3 | 4 | from busy_beaver.app import create_app 5 | 6 | 7 | @pytest.fixture(scope="module") 8 | def app(): 9 | """Session-wide test `Flask` application. 10 | 11 | Establish an application context before running the tests. 12 | """ 13 | app = create_app(testing=True) 14 | app.test_client_class = FlaskLoginClient 15 | ctx = app.app_context() 16 | ctx.push() 17 | yield app 18 | 19 | ctx.pop() 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def client(app): 24 | """Create Flask test client where we can trigger test requests to app""" 25 | client = app.test_client() 26 | yield client 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def login_client(app): 31 | """Create Flask test client where we can trigger test requests to app""" 32 | 33 | def _wrapper(user): 34 | client = app.test_client(user=user) 35 | return client 36 | 37 | yield _wrapper 38 | 39 | 40 | @pytest.fixture(scope="module") 41 | def runner(app): 42 | """Create Flask CliRunner that can be used to invoke commands""" 43 | runner = app.test_cli_runner() 44 | yield runner 45 | -------------------------------------------------------------------------------- /busy_beaver/common/oauth.py: -------------------------------------------------------------------------------- 1 | """OAuth Integrations 2 | 3 | This module ontains logic for integration with third-party APIs. 4 | We provide a nice wrapper around `requests-oauthlib` to 5 | simplify the OAuth process for the user.. 6 | """ 7 | 8 | import abc 9 | from typing import NamedTuple 10 | 11 | 12 | class OAuthError(Exception): 13 | status_code = 403 14 | 15 | def __init__(self, error): 16 | super().__init__() 17 | self.message = error 18 | 19 | 20 | class ExternalOAuthDetails(NamedTuple): 21 | url: str 22 | state: str 23 | 24 | 25 | class OAuthFlow(abc.ABC): 26 | """Defining a common API to add consistency to software design process""" 27 | 28 | @abc.abstractmethod 29 | def __init__(self, client_id, client_secret): # pragma: no cover 30 | pass 31 | 32 | @abc.abstractmethod 33 | def generate_authentication_tuple(self) -> ExternalOAuthDetails: # pragma: no cover 34 | """Creates a (URL, state) tuple used to authenticate users""" 35 | pass 36 | 37 | @abc.abstractmethod 38 | def process_callback(self, authorization_response_url): # pragma: no cover 39 | """Handles callback made by authentication service servers to verify users""" 40 | pass 41 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: {{ include "busybeaver.fullname" . }} 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | annotations: 7 | kubernetes.io/ingress.class: nginx 8 | cert-manager.io/cluster-issuer: letsencrypt-prod 9 | spec: 10 | tls: 11 | - hosts: 12 | - {{ .Values.ingress.host }} 13 | {{ if eq .Values.environment "production"}} 14 | - busybeaverbot.com 15 | - www.busybeaverbot.com 16 | {{ end }} 17 | secretName: {{ include "busybeaver.fullname" . }}-tls 18 | rules: 19 | - host: {{ .Values.ingress.host }} 20 | http: 21 | paths: 22 | - backend: 23 | serviceName: {{ include "busybeaver.fullname" . }}-web 24 | servicePort: 80 25 | {{ if eq .Values.environment "production"}} 26 | - host: busybeaverbot.com 27 | http: 28 | paths: 29 | - backend: 30 | serviceName: {{ include "busybeaver.fullname" . }}-web 31 | servicePort: 80 32 | - host: www.busybeaverbot.com 33 | http: 34 | paths: 35 | - backend: 36 | serviceName: {{ include "busybeaver.fullname" . }}-web 37 | servicePort: 80 38 | {{ end }} 39 | -------------------------------------------------------------------------------- /migrations/versions/20200727_23-16-15__add_cascade_delete.py: -------------------------------------------------------------------------------- 1 | """add_cascade_delete 2 | 3 | Revision ID: c9971d11c82f 4 | Revises: 31f9b92e97db 5 | Create Date: 2020-07-27 23:16:15.227491 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = "c9971d11c82f" 12 | down_revision = "31f9b92e97db" 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade(): 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.drop_constraint("fk_upcoming_events_group_id", "event", type_="foreignkey") 20 | op.create_foreign_key( 21 | "fk_upcoming_events_group_id", 22 | "event", 23 | "upcoming_events_group", 24 | ["group_id"], 25 | ["id"], 26 | ondelete="cascade", 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_constraint("fk_upcoming_events_group_id", "event", type_="foreignkey") 34 | op.create_foreign_key( 35 | "fk_upcoming_events_group_id", 36 | "event", 37 | "upcoming_events_group", 38 | ["group_id"], 39 | ["id"], 40 | ) 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /helm/README.md: -------------------------------------------------------------------------------- 1 | # Helm 2 | 3 | Helm configurations to deploy and support the Busy Beaver app. 4 | 5 | ## Directory Contents 6 | 7 | |Item|Description 8 | |---|---| 9 | |`charts`|Contains custom Helm charts used to deploy Busy Beaver| 10 | |`values`|[Values files](https://helm.sh/docs/chart_template_guide/values_files/) used by Helm| 11 | 12 | ## Commands 13 | 14 | ### Redis 15 | 16 | We will need to install Redis and copy the service DNS into the `bb_[environment].yaml` values file. 17 | 18 | ```console 19 | helm repo add bitnami https://charts.bitnami.com/bitnami 20 | 21 | helm upgrade --install bb-queue-staging bitnami/redis -f ./helm/values/redis.yaml --namespace busybeaver-staging 22 | 23 | helm upgrade --install bb-queue-production bitnami/redis -f ./helm/values/redis.yaml --namespace busybeaver-production 24 | ``` 25 | 26 | ### Busy Beaver App 27 | 28 | The staging environment is brought up as needed. 29 | 30 | ```console 31 | helm upgrade --install busybeaver-staging ./helm/charts/busybeaver/ -f ./helm/values/bb_staging.yaml --namespace busybeaver-staging --set image.version=[version] 32 | 33 | helm upgrade --install busybeaver-production ./helm/charts/busybeaver/ -f ./helm/values/bb_production.yaml --namespace busybeaver-production --set image.version=[version] 34 | ``` 35 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/deployment--scheduler.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}-scheduler" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: scheduler-deploy 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | annotations: 10 | timestamp: "{{ date "20200907150405" .Release.Time }}" 11 | spec: 12 | replicas: 1 13 | selector: 14 | matchLabels: 15 | type: scheduler 16 | {{- include "busybeaver.selectorLabels" . | nindent 6 }} 17 | template: 18 | metadata: 19 | labels: 20 | type: scheduler 21 | {{- include "busybeaver.selectorLabels" . | nindent 8 }} 22 | spec: 23 | containers: 24 | - name: webapp-scheduler 25 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 26 | imagePullPolicy: {{ .Values.image.pullPolicy }} 27 | args: ["scheduler"] 28 | # resources: 29 | # limits: 30 | # memory: "256Mi" 31 | # cpu: "200m" 32 | env: 33 | {{- include "busybeaver.env_vars" . | indent 10 }} 34 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 35 | automountServiceAccountToken: false 36 | -------------------------------------------------------------------------------- /tests/smoke/test_marshmallow.py: -------------------------------------------------------------------------------- 1 | """Ensure Marshmallow is working the way we intended""" 2 | 3 | from datetime import date 4 | 5 | from marshmallow import Schema, fields 6 | from marshmallow.exceptions import ValidationError 7 | import pytest 8 | 9 | 10 | class ArtistSchema(Schema): 11 | name = fields.Str() 12 | 13 | 14 | class AlbumSchema(Schema): 15 | title = fields.Str(required=True) 16 | release_date = fields.Date() 17 | artist = fields.Nested(ArtistSchema()) 18 | 19 | 20 | @pytest.mark.smoke 21 | def test_marshmallow_successful_validation(): 22 | # Arrange 23 | bowie = dict(name="David Bowie") 24 | album = dict(artist=bowie, title="Hunky Dory", release_date=date(1971, 12, 17)) 25 | schema = AlbumSchema() 26 | 27 | # Act 28 | result = schema.dump(album) 29 | 30 | # Assert 31 | assert result["title"] == "Hunky Dory" 32 | assert result["release_date"] == "1971-12-17" 33 | assert result["artist"]["name"] == "David Bowie" 34 | 35 | 36 | @pytest.mark.smoke 37 | def test_marshmallow_failed_validation(): 38 | # Arrange 39 | album = {"release_date": "1971-12-17", "artist": {"name": "David Bowie"}} 40 | schema = AlbumSchema() 41 | 42 | # Act 43 | with pytest.raises(ValidationError): 44 | schema.load(album) 45 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/api/oauth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import jsonify, redirect, request, url_for 4 | from flask.views import MethodView 5 | from flask_login import login_user 6 | 7 | from busy_beaver.apps.slack_integration.oauth.workflow import ( 8 | process_slack_installation_callback, 9 | process_slack_sign_in_callback, 10 | send_welcome_message, 11 | ) 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class SlackAppInstallationCallbackResource(MethodView): 17 | """Callback endpoint for installing app into Slack workspace""" 18 | 19 | def get(self): 20 | logger.info("Slack Workspace Installation") 21 | callback_url = request.url 22 | installation = process_slack_installation_callback(callback_url) 23 | send_welcome_message(installation) 24 | # TODO take them an actual page 25 | return jsonify({"Installation": "successful"}) 26 | 27 | 28 | class SlackSignInCallbackResource(MethodView): 29 | """Callback endpoint for Sign In with Slack workflows""" 30 | 31 | def get(self): 32 | logger.info("Slack Signing OAuth Callback") 33 | user = process_slack_sign_in_callback(request.url) 34 | login_user(user) 35 | return redirect(url_for("web.settings_view")) 36 | -------------------------------------------------------------------------------- /migrations/versions/20190512_22-47-44__adding_youtube_video.py: -------------------------------------------------------------------------------- 1 | """Adding youtube video 2 | 3 | Revision ID: cf9e672a4c63 4 | Revises: b5db0d063898 5 | Create Date: 2019-05-12 22:47:44.886876 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cf9e672a4c63' 14 | down_revision = 'b5db0d063898' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('youtube_video', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date_created', sa.DateTime(), nullable=True), 24 | sa.Column('date_modified', sa.DateTime(), nullable=True), 25 | sa.Column('youtube_id', sa.String(length=300), nullable=False), 26 | sa.Column('title', sa.String(length=300), nullable=False), 27 | sa.Column('published_at', sa.DateTime(), nullable=True), 28 | sa.Column('description', sa.String(length=1000), nullable=False), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('youtube_video') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/deployment--worker.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}-workers" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: worker-deploy 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | annotations: 10 | timestamp: "{{ date "20200907150405" .Release.Time }}" 11 | spec: 12 | replicas: {{ .Values.app.workerReplicaCount }} 13 | selector: 14 | matchLabels: 15 | type: worker 16 | {{- include "busybeaver.selectorLabels" . | nindent 6 }} 17 | template: 18 | metadata: 19 | labels: 20 | type: worker 21 | {{- include "busybeaver.selectorLabels" . | nindent 8 }} 22 | spec: 23 | containers: 24 | - name: webapp-worker 25 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 26 | imagePullPolicy: {{ .Values.image.pullPolicy }} 27 | args: ["worker"] 28 | # resources: 29 | # limits: 30 | # memory: "512Mi" 31 | # cpu: "500m" 32 | env: 33 | {{- include "busybeaver.env_vars" . | indent 10 }} 34 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 35 | automountServiceAccountToken: false 36 | -------------------------------------------------------------------------------- /migrations/versions/20200727_14-24-44__add_index_and_unique_constraint.py: -------------------------------------------------------------------------------- 1 | """add index and unique constraint 2 | 3 | Revision ID: 31f9b92e97db 4 | Revises: 50cefad49d98 5 | Create Date: 2020-07-27 14:24:44.542662 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = "31f9b92e97db" 12 | down_revision = "50cefad49d98" 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade(): 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.create_index( 20 | op.f("ix_upcoming_events_group_meetup_urlname"), 21 | "upcoming_events_group", 22 | ["meetup_urlname"], 23 | unique=False, 24 | ) 25 | op.create_unique_constraint( 26 | "unique_group_per_config", 27 | "upcoming_events_group", 28 | ["config_id", "meetup_urlname"], 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_constraint( 36 | "unique_group_per_config", "upcoming_events_group", type_="unique" 37 | ) 38 | op.drop_index( 39 | op.f("ix_upcoming_events_group_meetup_urlname"), 40 | table_name="upcoming_events_group", 41 | ) 42 | # ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /migrations/versions/20200706_19-19-01__add_fields_for_time_and_timezone.py: -------------------------------------------------------------------------------- 1 | """add fields for time and timezone 2 | 3 | Revision ID: a97292c2b635 4 | Revises: 3f137bef24e4 5 | Create Date: 2020-07-06 19:19:01.413325 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlalchemy_utils 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "a97292c2b635" 14 | down_revision = "3f137bef24e4" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "github_summary_configuration", 23 | sa.Column("summary_post_time", sa.Time(), nullable=True), 24 | ) 25 | op.add_column( 26 | "github_summary_configuration", 27 | sa.Column( 28 | "summary_post_timezone", 29 | sqlalchemy_utils.types.timezone.TimezoneType(backend="pytz"), 30 | nullable=True, 31 | ), 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_column("github_summary_configuration", "summary_post_timezone") 39 | op.drop_column("github_summary_configuration", "summary_post_time") 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /migrations/versions/20181216_03-58-02__make_user_table.py: -------------------------------------------------------------------------------- 1 | """make user table 2 | 3 | Revision ID: 3f5a7657912d 4 | Revises: 5 | Create Date: 2018-12-16 03:58:02.045980 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '3f5a7657912d' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date_created', sa.DateTime(), nullable=True), 24 | sa.Column('date_modified', sa.DateTime(), nullable=True), 25 | sa.Column('slack_id', sa.String(length=300), nullable=False), 26 | sa.Column('github_id', sa.String(length=300), nullable=True), 27 | sa.Column('github_username', sa.String(length=300), nullable=True), 28 | sa.Column('github_state', sa.String(length=36), nullable=True), 29 | sa.Column('github_access_token', sa.String(length=100), nullable=True), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('user') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /tests/common/wrappers/test_aws_s3.py: -------------------------------------------------------------------------------- 1 | import io 2 | import uuid 3 | 4 | import pytest 5 | import requests 6 | 7 | from busy_beaver.config import ( 8 | DIGITALOCEAN_SPACES_BASE_URL, 9 | DIGITALOCEAN_SPACES_ENDPOINT_URL, 10 | ) 11 | 12 | 13 | @pytest.mark.unit 14 | def test_find_bucket_that_does_not_exist(s3): 15 | random_bucket = str(uuid.uuid4()) 16 | assert s3.find_bucket(random_bucket) is False 17 | 18 | 19 | @pytest.mark.unit 20 | def test_create_bucket_and_then_delete_it(s3): 21 | random_bucket = str(uuid.uuid4()) 22 | assert s3.create_bucket(random_bucket) is True 23 | assert s3.delete_bucket(random_bucket) is True 24 | 25 | 26 | @pytest.mark.unit 27 | def test_upload_logo_to_blob_store(s3): 28 | # Arrange 29 | logo_bytes = b"abcdefghijklmnopqrstuvwxyz" 30 | logo_file = io.BytesIO(logo_bytes) 31 | logo_file.filename = "testfile.txt" 32 | 33 | # Act 34 | url = s3.upload_logo(logo_file) 35 | 36 | # Assert -- fetch file to make sure it is what we expect 37 | modified_url = url.replace( 38 | DIGITALOCEAN_SPACES_BASE_URL, DIGITALOCEAN_SPACES_ENDPOINT_URL 39 | ) 40 | resp = requests.get(modified_url) 41 | assert resp.text == logo_bytes.decode("utf-8") 42 | 43 | # TODO should we clean up? 44 | # it's in a container; not really worried about it at this stage 45 | -------------------------------------------------------------------------------- /busy_beaver/templates/organization_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "bootstrap/form.html" import render_field, render_form_row %} 3 | 4 | {% block container %} 5 |

Organization Settings

6 | 7 |

8 | Customize Busy Beaver for your organization. 9 |

10 | 11 |

12 | You can update your organization name and add your logo 13 | to brand messages from Busy Beaver. 14 |

15 | 16 |
17 | 18 |
19 | {{ name_form.csrf_token }} 20 | {{ render_form_row([name_form.organization_name], col_map={'organization_name': 'col-md-6'}) }} 21 | 22 | 23 |
24 | 25 |
26 | 27 | {% if logo %} 28 |
Current Logo
29 | 30 |
31 | 32 |

33 | 34 |
35 | 36 | remove 37 | 38 |

39 | {% else %} 40 |
41 | {{ logo_form.csrf_token }} 42 | {{ render_field(logo_form.logo) }} 43 | 44 | 45 |
46 | {% endif %} 47 | 48 |

49 | 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /migrations/versions/20200707_02-57-28__remove_redundant_columns.py: -------------------------------------------------------------------------------- 1 | """remove redundant columns 2 | 3 | Revision ID: 337517ec92c5 4 | Revises: a97292c2b635 5 | Create Date: 2020-07-07 02:57:28.121225 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "337517ec92c5" 14 | down_revision = "a97292c2b635" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_column("github_summary_configuration", "timezone_info") 22 | op.drop_column("github_summary_configuration", "time_to_post") 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.add_column( 29 | "github_summary_configuration", 30 | sa.Column( 31 | "time_to_post", sa.VARCHAR(length=20), autoincrement=False, nullable=True 32 | ), 33 | ) 34 | op.add_column( 35 | "github_summary_configuration", 36 | sa.Column( 37 | "timezone_info", 38 | postgresql.JSON(astext_type=sa.Text()), 39 | autoincrement=False, 40 | nullable=True, 41 | ), 42 | ) 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /migrations/versions/20200711_14-59-22__add_enabled_field_to_github_summary_.py: -------------------------------------------------------------------------------- 1 | """add enabled field to github_summary_config 2 | 3 | Revision ID: f1adfb5b4d39 4 | Revises: 9bc99f240f5f 5 | Create Date: 2020-07-11 14:59:22.112438 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "f1adfb5b4d39" 13 | down_revision = "9bc99f240f5f" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # Step 1: Schema migration 20 | # Add enabled field that is nullable 21 | op.add_column( 22 | "github_summary_configuration", 23 | sa.Column("enabled", sa.Boolean(), nullable=True), 24 | ) 25 | 26 | # Step 2: Data migration 27 | # Set enabled=False 28 | engine = op.get_bind() 29 | meta = sa.MetaData(bind=engine) 30 | event = sa.Table("github_summary_configuration", meta, autoload=True) 31 | stmt = event.update().values(enabled=False) 32 | engine.execute(stmt) 33 | 34 | # Step 3: Data migration 35 | # enabled field cannot be nullable 36 | op.alter_column("github_summary_configuration", "enabled", nullable=False) 37 | 38 | 39 | def downgrade(): 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | op.drop_column("github_summary_configuration", "enabled") 42 | # ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /migrations/versions/20200813_18-47-41__add_post_cron_enabled_field.py: -------------------------------------------------------------------------------- 1 | """add post_cron_enabled field 2 | 3 | Revision ID: 9e34ca8cefc0 4 | Revises: caf810949fa5 5 | Create Date: 2020-08-13 18:47:41.965149 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "9e34ca8cefc0" 13 | down_revision = "caf810949fa5" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # Step 1: Schema migration 20 | op.add_column( 21 | "upcoming_events_configuration", 22 | sa.Column("post_cron_enabled", sa.Boolean(), nullable=True), 23 | ) 24 | 25 | # Step 2: Data migration 26 | engine = op.get_bind() 27 | meta = sa.MetaData(bind=engine) 28 | upcoming_events_configuration = sa.Table( 29 | "upcoming_events_configuration", meta, autoload=True 30 | ) 31 | stmt = upcoming_events_configuration.update().values(post_cron_enabled=False) 32 | engine.execute(stmt) 33 | 34 | # Step 3: Schema migration 35 | op.alter_column( 36 | "upcoming_events_configuration", "post_cron_enabled", nullable=False 37 | ) 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_column("upcoming_events_configuration", "post_cron_enabled") 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/summary/blocks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | 4 | from busy_beaver.toolbox.slack_block_kit import Divider, Section 5 | from busy_beaver.toolbox.slack_block_kit.elements import Image 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class GitHubSummaryPost: 11 | def __init__(self, all_user_events): 12 | self.all_user_events = all_user_events 13 | 14 | def __repr__(self): # pragma: no cover 15 | return "" 16 | 17 | def as_blocks(self): 18 | output = [ 19 | Section(f"*Daily GitHub Summary -- {datetime.now():%B %d, %Y}*"), 20 | Divider(), 21 | ] 22 | 23 | if not self.all_user_events: 24 | output.append(Section(text="No activity to report.")) 25 | output.append(Divider()) 26 | return [block.to_dict() for block in output] 27 | 28 | for user, events in self.all_user_events: 29 | profile_pic = ( 30 | f"https://avatars.githubusercontent.com/u/{user.github_id}?size=75" 31 | ) 32 | img = Image(image_url=profile_pic, alt_text=f"{user.github_username}") 33 | output.append(Section(text=events.generate_summary_text(), accessory=img)) 34 | output.append(Divider()) 35 | return [block.to_dict() for block in output] 36 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/webhook/event_subscription.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.apps.github_integration.webhook.workflow import ( 2 | generate_new_issue_message, 3 | generate_new_pull_request_message, 4 | generate_new_release_message, 5 | ) 6 | from busy_beaver.clients import chipy_slack 7 | from busy_beaver.toolbox import EventEmitter 8 | 9 | github_event_dispatcher = EventEmitter() 10 | 11 | 12 | def process_github_event_subscription(event_type, data): 13 | return github_event_dispatcher.emit(event_type, default="not_found", data=data) 14 | 15 | 16 | @github_event_dispatcher.on("not_found") 17 | @github_event_dispatcher.on("ping") 18 | def do_nothing(data): 19 | pass 20 | 21 | 22 | @github_event_dispatcher.on("issues") 23 | def handle_issue(data): 24 | message = generate_new_issue_message(data) 25 | if message: 26 | _post_to_slack(message) 27 | 28 | 29 | @github_event_dispatcher.on("pull_request") 30 | def handle_pr(data): 31 | message = generate_new_pull_request_message(data) 32 | if message: 33 | _post_to_slack(message) 34 | 35 | 36 | @github_event_dispatcher.on("release") 37 | def handle_release(data): 38 | message = generate_new_release_message(data) 39 | if message: 40 | _post_to_slack(message) 41 | 42 | 43 | def _post_to_slack(message): 44 | chipy_slack.post_message(message=message, channel="busy-beaver-meta") 45 | -------------------------------------------------------------------------------- /busy_beaver/exceptions.py: -------------------------------------------------------------------------------- 1 | class BusyBeaverException(Exception): 2 | pass 3 | 4 | 5 | class AsyncException(BusyBeaverException): 6 | pass 7 | 8 | 9 | class EventEmitterException(BusyBeaverException): 10 | pass 11 | 12 | 13 | class EventEmitterEventAlreadyRegistered(EventEmitterException): 14 | pass 15 | 16 | 17 | class EventEmitterEventNotRegistered(EventEmitterException): 18 | pass 19 | 20 | 21 | class NotFound(BusyBeaverException): 22 | status_code = 404 23 | 24 | def __init__(self, object_type): 25 | super().__init__() 26 | self.message = f"{object_type} not found" 27 | 28 | 29 | class NotAuthorized(BusyBeaverException): 30 | status_code = 401 31 | 32 | def __init__(self, error): 33 | super().__init__() 34 | self.message = error 35 | 36 | 37 | class SlackTooManyBlocks(BusyBeaverException): 38 | pass 39 | 40 | 41 | class StateMachineError(BusyBeaverException): 42 | status_code = 500 43 | 44 | def __init__(self, error): 45 | super().__init__() 46 | self.message = error 47 | 48 | 49 | class UnverifiedWebhookRequest(NotAuthorized): 50 | pass 51 | 52 | 53 | class UnexpectedStatusCode(BusyBeaverException): 54 | pass 55 | 56 | 57 | class ValidationError(BusyBeaverException): 58 | status_code = 422 59 | 60 | def __init__(self, error): 61 | super().__init__() 62 | self.message = error 63 | -------------------------------------------------------------------------------- /tests/_utilities/factories/slack.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from busy_beaver.models import SlackInstallation as slack_installation_model 4 | from busy_beaver.models import SlackUser as slack_user_model 5 | 6 | 7 | def SlackInstallation(session): 8 | class _SlackInstallationFactory(factory.alchemy.SQLAlchemyModelFactory): 9 | class Meta: 10 | model = slack_installation_model 11 | sqlalchemy_session_persistence = "commit" 12 | sqlalchemy_session = session 13 | 14 | authorizing_user_id = "abc" 15 | 16 | bot_access_token = factory.Faker("uuid4") 17 | bot_user_id = "def" 18 | 19 | scope = "identity chat:message:write" 20 | workspace_id = "SC234sdfsde" 21 | workspace_name = "ChiPy" 22 | 23 | organization_name = "Chicago Python" 24 | workspace_logo_url = ( 25 | "https://www.chipy.org/static/img/chipmunk.1927e65c68a7.png" 26 | ) 27 | 28 | return _SlackInstallationFactory 29 | 30 | 31 | def SlackUser(session): 32 | class _SlackUserFactory(factory.alchemy.SQLAlchemyModelFactory): 33 | class Meta: 34 | model = slack_user_model 35 | sqlalchemy_session_persistence = "commit" 36 | sqlalchemy_session = session 37 | 38 | installation = factory.SubFactory(SlackInstallation(session)) 39 | slack_id = "user_id" 40 | 41 | return _SlackUserFactory 42 | -------------------------------------------------------------------------------- /tests/toolbox/test_handlers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import uuid 3 | 4 | import pytest 5 | 6 | from busy_beaver.config import TASK_QUEUE_MAX_RETRIES 7 | from busy_beaver.exceptions import AsyncException 8 | from busy_beaver.models import Task 9 | from busy_beaver.toolbox.rq import retry_failed_job 10 | 11 | 12 | @pytest.fixture 13 | def generate_exc_info(): 14 | def _wrapper(exc_type): 15 | try: 16 | raise exc_type 17 | except exc_type: 18 | return sys.exc_info() 19 | 20 | return _wrapper 21 | 22 | 23 | def add(x, y): 24 | return x + y 25 | 26 | 27 | @pytest.mark.unit 28 | def test_retry_failed_job_handler_max_failures(app, rq, session, generate_exc_info): 29 | # create record in db 30 | job_id = str(uuid.uuid4()) 31 | task = Task(job_id=job_id, name="Add") 32 | session.add(task) 33 | session.commit() 34 | 35 | # queue up job 36 | rq.job(add) 37 | job = add.queue(5, 2, job_id=job_id) 38 | 39 | # fail job max number of times - 1 40 | exc_info = generate_exc_info(ValueError) 41 | for _ in range(TASK_QUEUE_MAX_RETRIES): 42 | retry_failed_job(job, exc_info) 43 | 44 | with pytest.raises(AsyncException): 45 | retry_failed_job(job, exc_info) 46 | 47 | task = Task.query.filter_by(job_id=job_id).first() 48 | assert task.task_state.value == Task.TaskState.FAILED 49 | assert job.meta["failures"] == TASK_QUEUE_MAX_RETRIES + 1 50 | -------------------------------------------------------------------------------- /migrations/versions/20200712_15-43-40__create_upcoming_events_config_table.py: -------------------------------------------------------------------------------- 1 | """create upcoming events config table 2 | 3 | Revision ID: 67025a818f50 4 | Revises: f1adfb5b4d39 5 | Create Date: 2020-07-12 15:43:40.023276 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "67025a818f50" 13 | down_revision = "f1adfb5b4d39" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "upcoming_events_configuration", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("date_created", sa.DateTime(), nullable=True), 24 | sa.Column("date_modified", sa.DateTime(), nullable=True), 25 | sa.Column("enabled", sa.Boolean(), nullable=False), 26 | sa.Column("installation_id", sa.Integer(), nullable=False), 27 | sa.Column("channel", sa.String(length=20), nullable=True), 28 | sa.ForeignKeyConstraint( 29 | ["installation_id"], ["slack_installation.id"], name="fk_installation_id" 30 | ), 31 | sa.PrimaryKeyConstraint("id"), 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table("upcoming_events_configuration") 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /busy_beaver/toolbox/rq.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rq import get_current_job 4 | 5 | from busy_beaver.config import TASK_QUEUE_MAX_RETRIES 6 | from busy_beaver.exceptions import AsyncException 7 | from busy_beaver.extensions import db 8 | from busy_beaver.models import Task 9 | 10 | logger = logging.getLogger(__name__) 11 | MAX_FAILURES = TASK_QUEUE_MAX_RETRIES + 1 12 | 13 | 14 | def set_task_progress(progress): 15 | job = get_current_job() 16 | if job: 17 | job.meta["progress"] = progress 18 | job.save_meta() 19 | 20 | if progress >= 100: 21 | task = Task.query.filter_by(job_id=job.get_id()).first() 22 | if task: 23 | task.task_state = Task.TaskState.COMPLETED 24 | db.session.commit() 25 | 26 | 27 | def retry_failed_job(job, *exc_info): 28 | job.meta.setdefault("failures", 0) 29 | job.meta["failures"] += 1 30 | job.save() 31 | 32 | # TODO save additional information about the job here 33 | # maybe put into a separate queue for offline viewing? 34 | # if a result needs to be save, make sure we save it 35 | 36 | num_failures = job.meta["failures"] 37 | if num_failures >= MAX_FAILURES: 38 | task = Task.query.filter_by(job_id=job.id).first() 39 | task.task_state = Task.TaskState.FAILED 40 | db.session.add(task) 41 | db.session.commit() 42 | raise AsyncException(f"Job failed {num_failures} times") 43 | -------------------------------------------------------------------------------- /migrations/versions/20190324_22-05-57__create_base_task_table.py: -------------------------------------------------------------------------------- 1 | """create base task table 2 | 3 | Revision ID: eaec612dbc5f 4 | Revises: 73b592804bfa 5 | Create Date: 2019-03-24 22:05:57.734554 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "eaec612dbc5f" 14 | down_revision = "73b592804bfa" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "task", 23 | sa.Column("date_created", sa.DateTime(), nullable=True), 24 | sa.Column("date_modified", sa.DateTime(), nullable=True), 25 | sa.Column("id", sa.String(length=36), nullable=False), 26 | sa.Column("name", sa.String(length=128), nullable=True), 27 | sa.Column("description", sa.String(length=128), nullable=True), 28 | sa.Column("failed", sa.Boolean(), nullable=True), 29 | sa.Column("complete", sa.Boolean(), nullable=True), 30 | sa.PrimaryKeyConstraint("id"), 31 | ) 32 | op.create_index(op.f("ix_task_name"), "task", ["name"], unique=False) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_index(op.f("ix_task_name"), table_name="task") 39 | op.drop_table("task") 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /migrations/versions/20190609_20-09-41__rename_sync_event_task_table_and_update_.py: -------------------------------------------------------------------------------- 1 | """rename sync event task table and update values 2 | 3 | Revision ID: 383bbb33f257 4 | Revises: ad8d0445e832 5 | Create Date: 2019-06-09 20:09:41.764048 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "383bbb33f257" 14 | down_revision = "ad8d0445e832" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # Step 1: Schema migration 21 | # rename table 22 | op.rename_table("fetch_new_events_task", "sync_event_database_task") 23 | 24 | # Step 2: Data migration 25 | # Change task type to new sync_task_name 26 | engine = op.get_bind() 27 | meta = sa.MetaData(bind=engine) 28 | task = sa.Table("task", meta, autoload=True) 29 | stmt = ( 30 | task.update() 31 | .where(task.c.type == "fetch_new_events") 32 | .values(type="sync_event_database") 33 | ) 34 | engine.execute(stmt) 35 | 36 | 37 | def downgrade(): 38 | op.rename_table("sync_event_database_task", "fetch_new_events_task") 39 | 40 | engine = op.get_bind() 41 | meta = sa.MetaData(bind=engine) 42 | task = sa.Table("task", meta, autoload=True) 43 | stmt = ( 44 | task.update() 45 | .where(task.c.type == "sync_event_database") 46 | .values(type="fetch_new_events") 47 | ) 48 | engine.execute(stmt) 49 | -------------------------------------------------------------------------------- /busy_beaver/common/datetime_utilities.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, time, timedelta 2 | 3 | import pytz 4 | 5 | 6 | def add_gmt_offset_to_timezone(timezone_tuple_set): 7 | """For forms. 8 | 9 | Currently timezone choices items show up like this: 10 | 'America/New_York' 11 | But this function formats the choices to display in this format: 12 | GMT-05:00 America/New_York 13 | :return: 14 | A list of tuples in this format: 15 | (, ) 16 | 17 | Copied from: 18 | https://github.com/mfogel/django-timezone-field/blob/master/timezone_field/utils.py 19 | """ 20 | gmt_timezone = pytz.timezone("Greenwich") 21 | time_ref = datetime(2000, 1, 1) 22 | time_zero = gmt_timezone.localize(time_ref) 23 | _choices = [] 24 | for tz, tz_str in timezone_tuple_set: 25 | delta = (time_zero - tz.localize(time_ref)).total_seconds() 26 | h = (datetime.min + timedelta(seconds=delta.__abs__())).hour 27 | gmt_diff = time(h).strftime("%H:%M") 28 | pair_one = tz 29 | pair_two = "GMT{sign}{gmt_diff} {timezone}".format( 30 | sign="-" if delta < 0 else "+", 31 | gmt_diff=gmt_diff, 32 | timezone=tz_str.replace("_", " "), 33 | ) 34 | _choices.append((delta, pair_one, pair_two, tz_str)) 35 | 36 | _choices.sort(key=lambda x: x[0]) 37 | choices = [(tz_str, two) for zero, one, two, tz_str in _choices] 38 | return choices 39 | -------------------------------------------------------------------------------- /migrations/versions/20190526_22-47-39__change_column_name_and_add_end_epoch_in_.py: -------------------------------------------------------------------------------- 1 | """change column name and add end_epoch in event table 2 | 3 | Revision ID: ad8d0445e832 4 | Revises: 958548ca6d07 5 | Create Date: 2019-05-26 22:47:39.821526 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "ad8d0445e832" 14 | down_revision = "958548ca6d07" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # Step 1: Schema migration 21 | # Change column name (to start_epoch) and add end_epoch column 22 | op.alter_column( 23 | "event", "utc_epoch", new_column_name="start_epoch", server_default=None 24 | ) 25 | op.add_column("event", sa.Column("end_epoch", sa.Integer(), nullable=True)) 26 | 27 | # Step 2: Data migration 28 | # Set end_epoch col to start_epoch col 29 | engine = op.get_bind() 30 | meta = sa.MetaData(bind=engine) 31 | event = sa.Table("event", meta, autoload=True) 32 | stmt = event.update().values(end_epoch=event.c.start_epoch) 33 | engine.execute(stmt) 34 | 35 | # Step 3: Schema migration 36 | # end_epoch column can't be empty 37 | op.alter_column("event", "end_epoch", nullable=False) 38 | 39 | 40 | def downgrade(): 41 | op.alter_column( 42 | "event", "start_epoch", new_column_name="utc_epoch", server_default=None 43 | ) 44 | op.drop_column("event", "end_epoch") 45 | -------------------------------------------------------------------------------- /docs/deployment/digitalocean_deployment.md: -------------------------------------------------------------------------------- 1 | # DigitalOcean Deployment 2 | 3 | Details about infrastructure set up on DigitalOcean. 4 | 5 | #### Table of Contents 6 | 7 | 8 | 9 | - [Kubernetes](#kubernetes) 10 | - [Prerequestites](#prerequestites) 11 | - [Setting up Staging Environment](#setting-up-staging-environment) 12 | - [Setting up Production Environment](#setting-up-production-environment) 13 | - [Secrets](#secrets) 14 | 15 | 16 | 17 | ## Kubernetes 18 | 19 | Busy Beaver is deployed to Kubernets as a Helm chart. 20 | 21 | ### Prerequestites 22 | 23 | The code for this is in my private Cloud Configuration repo. 24 | 25 | - Install Helm 26 | - Use Helm to set up `nginx`, `cert-manager`, `redis` (staging and prod), `fluent-bit` 27 | - Add `busybeaver-staging` Secret to cluster 28 | - Add `busybeaver-production` Secret to cluster 29 | 30 | ### Setting up Staging Environment 31 | 32 | ```console 33 | helm install busybeaver-staging ./busybeaver/ -f values/staging.yaml 34 | helm upgrade busybeaver-staging ./busybeaver/ -f values/staging.yaml --set image.version=[version] 35 | ``` 36 | 37 | ### Setting up Production Environment 38 | 39 | ```console 40 | helm install busybeaver-production ./busybeaver/ -f values/production.yaml 41 | helm upgrade busybeaver-production ./busybeaver/ -f values/production.yaml --set image.version=[version] 42 | ``` 43 | 44 | ### Secrets 45 | 46 | Secrets are loaded from AWS Secrets Manager into Kuberenetes using Terraform. 47 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/api/decorators.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | import hmac 4 | 5 | from flask import request 6 | 7 | from busy_beaver.config import GITHUB_SIGNING_SECRET 8 | from busy_beaver.exceptions import UnverifiedWebhookRequest 9 | 10 | 11 | def verify_github_signature(signing_secret: str): 12 | 13 | if not isinstance(signing_secret, str): 14 | raise ValueError 15 | 16 | def verification_decorator(func): 17 | @functools.wraps(func) 18 | def _wrapper(*args, **kwargs): 19 | github_signature = request.headers.get("X-Hub-Signature", None) 20 | if not github_signature: 21 | raise UnverifiedWebhookRequest("Missing header") 22 | 23 | sig = calculate_signature(signing_secret, request.data) 24 | if signatures_unequal(sig, github_signature): 25 | raise UnverifiedWebhookRequest("Invalid") 26 | 27 | return func(*args, **kwargs) 28 | 29 | return _wrapper 30 | 31 | return verification_decorator 32 | 33 | 34 | def calculate_signature(signing_secret, request_data): 35 | return ( 36 | "sha1=" 37 | + hmac.new(str.encode(signing_secret), request_data, hashlib.sha1).hexdigest() 38 | ) 39 | 40 | 41 | def signatures_unequal(request_hash, github_signature): 42 | return not hmac.compare_digest(request_hash, github_signature) 43 | 44 | 45 | github_verification_required = verify_github_signature(GITHUB_SIGNING_SECRET) 46 | -------------------------------------------------------------------------------- /.github/workflows/deploy_app_to_kubernetes.yaml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: [deployment] 3 | jobs: 4 | helm-upgrade: 5 | runs-on: ubuntu-latest 6 | 7 | env: 8 | CHART_LOCATION: ./helm/charts/busybeaver 9 | VALUES_FILE: ./helm/values/bb_production.yaml 10 | RELEASE_NAME: busybeaver-production 11 | RELEASE_TAG: ${{ github.event.deployment.ref }} 12 | NAMESPACE: busybeaver-production 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Generate k8s config for DO 16 | uses: matootie/dokube@v1.3.4 17 | with: 18 | personalAccessToken: ${{ secrets.DIGITALOCEAN_TOKEN }} 19 | clusterName: trapper 20 | version: 1.6.6 21 | expirationTime: 300 # seconds 22 | - name: Use helm to deploy 23 | run: helm upgrade --install $RELEASE_NAME $CHART_LOCATION -f $VALUES_FILE --namespace $NAMESPACE --set image.version=$RELEASE_TAG 24 | 25 | - name: Update deployment status (success) 26 | if: success() 27 | uses: chrnorm/deployment-status@releases/v1 28 | with: 29 | token: "${{ github.token }}" 30 | state: "success" 31 | deployment_id: ${{ github.event.deployment.id }} 32 | 33 | - name: Update deployment status (failure) 34 | if: ${{ ! success() }} 35 | uses: chrnorm/deployment-status@releases/v1 36 | with: 37 | token: "${{ github.token }}" 38 | state: "failure" 39 | deployment_id: ${{ github.event.deployment.id }} 40 | -------------------------------------------------------------------------------- /busy_beaver/clients.py: -------------------------------------------------------------------------------- 1 | """Third-party integrations 2 | 3 | This module contains logic to configure third-party 4 | integrations. The integrations in this module are 5 | global across the application. 6 | 7 | Variables are assigned Singleton instances of each 8 | integration. 9 | """ 10 | 11 | from .common.wrappers import ( 12 | AsyncGitHubClient, 13 | GitHubClient, 14 | MeetupClient, 15 | S3Client, 16 | SlackClient, 17 | ) 18 | from .config import ( 19 | DIGITALOCEAN_SPACES_KEY, 20 | DIGITALOCEAN_SPACES_SECRET, 21 | GITHUB_CLIENT_ID, 22 | GITHUB_CLIENT_SECRET, 23 | GITHUB_OAUTH_TOKEN, 24 | MEETUP_API_KEY, 25 | SLACK_CLIENT_ID, 26 | SLACK_CLIENT_SECRET, 27 | SLACK_TOKEN, 28 | ) 29 | from busy_beaver.apps.github_integration.oauth.oauth_flow import GitHubOAuthFlow 30 | from busy_beaver.apps.slack_integration.oauth.oauth_flow import ( 31 | SlackInstallationOAuthFlow, 32 | SlackSignInOAuthFlow, 33 | ) 34 | 35 | chipy_slack = SlackClient(SLACK_TOKEN) # Default Workspace -- this is being phased out 36 | github = GitHubClient(GITHUB_OAUTH_TOKEN) 37 | github_async = AsyncGitHubClient(GITHUB_OAUTH_TOKEN) 38 | meetup = MeetupClient(MEETUP_API_KEY) 39 | 40 | slack_install_oauth = SlackInstallationOAuthFlow(SLACK_CLIENT_ID, SLACK_CLIENT_SECRET) 41 | slack_signin_oauth = SlackSignInOAuthFlow(SLACK_CLIENT_ID, SLACK_CLIENT_SECRET) 42 | github_oauth = GitHubOAuthFlow(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET) 43 | 44 | s3 = S3Client(DIGITALOCEAN_SPACES_KEY, DIGITALOCEAN_SPACES_SECRET) 45 | -------------------------------------------------------------------------------- /migrations/versions/20200925_14-59-25__create_call_for_proposals_onfig_table.py: -------------------------------------------------------------------------------- 1 | """create call_for_proposals_onfig table 2 | 3 | Revision ID: e51108c88034 4 | Revises: abcab0310efc 5 | Create Date: 2020-09-25 14:59:25.268465 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "e51108c88034" 13 | down_revision = "abcab0310efc" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "call_for_proposals_configuration", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("date_created", sa.DateTime(), nullable=True), 24 | sa.Column("date_modified", sa.DateTime(), nullable=True), 25 | sa.Column("enabled", sa.Boolean(), nullable=False), 26 | sa.Column("installation_id", sa.Integer(), nullable=False), 27 | sa.Column("channel", sa.String(length=20), nullable=False), 28 | sa.Column("internal_cfps", sa.JSON(), nullable=True), 29 | sa.ForeignKeyConstraint( 30 | ["installation_id"], ["slack_installation.id"], name="fk_installation_id" 31 | ), 32 | sa.PrimaryKeyConstraint("id"), 33 | ) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_table("call_for_proposals_configuration") 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /migrations/versions/20190526_01-41-37__create_events_table.py: -------------------------------------------------------------------------------- 1 | """create events table 2 | 3 | Revision ID: ad009ed08b4f 4 | Revises: cf9e672a4c63 5 | Create Date: 2019-05-26 01:41:37.886228 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "ad009ed08b4f" 14 | down_revision = "cf9e672a4c63" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "event", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("date_created", sa.DateTime(), nullable=True), 25 | sa.Column("date_modified", sa.DateTime(), nullable=True), 26 | sa.Column("remote_id", sa.String(length=255), nullable=False), 27 | sa.Column("name", sa.String(length=255), nullable=False), 28 | sa.Column("url", sa.String(length=500), nullable=False), 29 | sa.Column("venue", sa.String(length=255), nullable=False), 30 | sa.Column("utc_epoch", sa.Integer(), nullable=False), 31 | sa.PrimaryKeyConstraint("id"), 32 | ) 33 | op.create_index(op.f("ix_event_remote_id"), "event", ["remote_id"], unique=False) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_index(op.f("ix_event_remote_id"), table_name="event") 40 | op.drop_table("event") 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /helm/charts/busybeaver/templates/deployment--web.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: "{{ include "busybeaver.fullname" . }}-web" 5 | namespace: "{{ include "busybeaver.namespace" . }}" 6 | labels: 7 | type: web-deploy 8 | {{- include "busybeaver.labels" . | nindent 4 }} 9 | annotations: 10 | timestamp: "{{ date "20200907150405" .Release.Time }}" 11 | spec: 12 | replicas: {{ .Values.app.webReplicaCount }} 13 | selector: 14 | matchLabels: 15 | type: web 16 | {{- include "busybeaver.selectorLabels" . | nindent 6 }} 17 | template: 18 | metadata: 19 | labels: 20 | type: web 21 | {{- include "busybeaver.selectorLabels" . | nindent 8 }} 22 | spec: 23 | containers: 24 | - name: webapp 25 | image: {{ .Values.image.repository }}:{{ .Values.image.version }} 26 | imagePullPolicy: {{ .Values.image.pullPolicy }} 27 | args: ["webserver"] 28 | # resources: 29 | # limits: 30 | # memory: "512Mi" 31 | # cpu: "500m" 32 | env: 33 | {{- include "busybeaver.env_vars" . | indent 10 }} 34 | ports: 35 | - containerPort: {{ .Values.app.port }} 36 | readinessProbe: 37 | httpGet: 38 | path: /healthcheck 39 | port: {{ .Values.app.port }} 40 | initialDelaySeconds: 10 41 | periodSeconds: 5 42 | serviceAccountName: {{ include "busybeaver.serviceAccountName" . }} 43 | automountServiceAccountToken: false 44 | -------------------------------------------------------------------------------- /busy_beaver/common/wrappers/README.md: -------------------------------------------------------------------------------- 1 | # Wrappers 2 | 3 | Wrappers around third-party integrations. 4 | 5 | 6 | 7 | - [GitHub Integration](#github-integration) 8 | - [Meetup Integration](#meetup-integration) 9 | - [YouTube Adapter](#youtube-adapter) 10 | - [Where to get your channel id](#where-to-get-your-channel-id) 11 | - [How to create an api key](#how-to-create-an-api-key) 12 | - [Example](#example) 13 | 14 | 15 | 16 | ## GitHub Integration 17 | 18 | Create a [GitHub OAuth App](https://github.com/settings/developers). The sole function of this app is to provide a means for the Slack user to validate their GitHub account. 19 | 20 | You will also need need to create a [Personal Access Token](https://github.com/settings/tokens) that can be used to access the GitHub API. 21 | 22 | ## Meetup Integration 23 | 24 | Go to https://secure.meetup.com/meetup_api/key/ 25 | to get an API key 26 | 27 | ## YouTube Adapter 28 | 29 | ### Where to get your channel id 30 | 31 | Login to youtube and go to https://www.youtube.com/account_advanced, your 32 | channel id will be displayed. 33 | 34 | ### How to create an api key 35 | 36 | 1. Go to https://console.developers.google.com and create a project. 37 | 2. Visit https://developers.google.com/youtube/registering_an_application#Create_API_Keys 38 | for instructions to generate the api key 39 | 40 | ### Example 41 | 42 | ```python 43 | api_key = "..." 44 | channel = "..." 45 | youtube = YouTubeAdapter(api_key=api_key) 46 | data = youtube.get_latest_videos_from_channel(channel) 47 | ``` 48 | -------------------------------------------------------------------------------- /tests/_utilities/factories/github_summary_user.py: -------------------------------------------------------------------------------- 1 | from datetime import time 2 | 3 | import factory 4 | 5 | from .slack import SlackInstallation 6 | from busy_beaver.models import ( 7 | GitHubSummaryConfiguration as github_summary_configuration_model, 8 | ) 9 | from busy_beaver.models import GitHubSummaryUser as github_summary_user_model 10 | 11 | 12 | def GitHubSummaryUser(session): 13 | class _GitHubSummaryUserFactory(factory.alchemy.SQLAlchemyModelFactory): 14 | class Meta: 15 | model = github_summary_user_model 16 | sqlalchemy_session_persistence = "commit" 17 | sqlalchemy_session = session 18 | 19 | slack_id = "slack_user" 20 | github_id = "13242345435" 21 | github_username = "github_user" 22 | github_state = "" 23 | github_access_token = factory.Faker("uuid4") 24 | configuration = factory.SubFactory(GitHubSummaryConfiguration(session)) 25 | 26 | return _GitHubSummaryUserFactory 27 | 28 | 29 | def GitHubSummaryConfiguration(session): 30 | class _GitHubSummaryConfiguration(factory.alchemy.SQLAlchemyModelFactory): 31 | class Meta: 32 | model = github_summary_configuration_model 33 | sqlalchemy_session_persistence = "commit" 34 | sqlalchemy_session = session 35 | 36 | enabled = True 37 | channel = "busy-beaver" 38 | summary_post_time = time(14, 00) 39 | summary_post_timezone = "America/Chicago" 40 | slack_installation = factory.SubFactory(SlackInstallation(session)) 41 | 42 | return _GitHubSummaryConfiguration 43 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/api/decorators.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | import hmac 4 | 5 | from flask import request 6 | 7 | from busy_beaver.config import SLACK_SIGNING_SECRET 8 | from busy_beaver.exceptions import UnverifiedWebhookRequest 9 | 10 | 11 | def verify_slack_signature(signing_secret: str): 12 | 13 | if not isinstance(signing_secret, str): 14 | raise ValueError 15 | 16 | def verification_decorator(func): 17 | @functools.wraps(func) 18 | def _wrapper(*args, **kwargs): 19 | timestamp = request.headers.get("X-Slack-Request-Timestamp", None) 20 | slack_signature = request.headers.get("X-Slack-Signature", None) 21 | if not timestamp or not slack_signature: 22 | raise UnverifiedWebhookRequest("Invalid") 23 | 24 | sig = calculate_signature(signing_secret, timestamp, request.get_data()) 25 | if signatures_unequal(sig, slack_signature): 26 | raise UnverifiedWebhookRequest("Invalid") 27 | 28 | return func(*args, **kwargs) 29 | 30 | return _wrapper 31 | 32 | return verification_decorator 33 | 34 | 35 | slack_verification_required = verify_slack_signature(SLACK_SIGNING_SECRET) 36 | 37 | 38 | def calculate_signature(signing_secret, timestamp, data): 39 | req = str.encode("v0:" + str(timestamp) + ":") + data 40 | return "v0=" + hmac.new(str.encode(signing_secret), req, hashlib.sha256).hexdigest() 41 | 42 | 43 | def signatures_unequal(request_hash, slack_signature): 44 | return not hmac.compare_digest(request_hash, slack_signature) 45 | -------------------------------------------------------------------------------- /busy_beaver/apps/call_for_proposals/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms.fields import FieldList, FormField, SelectField, StringField 3 | from wtforms.fields.html5 import URLField 4 | from wtforms.validators import URL, DataRequired 5 | from wtforms.widgets import HTMLString, html_params 6 | 7 | 8 | class RemoveButtonWidget(object): 9 | input_type = "submit" 10 | 11 | html_params = staticmethod(html_params) 12 | 13 | def __call__(self, field, **kwargs): 14 | kwargs.setdefault("id", field.id) 15 | kwargs.setdefault("type", "button") 16 | if "value" not in kwargs: 17 | kwargs["value"] = field._value() 18 | 19 | kwargs["class"] = "remove form-control" 20 | return HTMLString( 21 | "".format( 22 | params=self.html_params(name=field.name, **kwargs), label="X" 23 | ) 24 | ) 25 | 26 | 27 | class RemoveButtonField(StringField): 28 | widget = RemoveButtonWidget() 29 | 30 | 31 | class InternalCFPItemForm(FlaskForm): 32 | class Meta: 33 | csrf = False # this form is only used as a subform 34 | 35 | event = StringField(validators=[DataRequired()]) 36 | url = URLField("CFP URL", validators=[URL()]) 37 | remove = RemoveButtonField("remove") 38 | 39 | 40 | class CFPSettingsForm(FlaskForm): 41 | channel = SelectField(label="Channel") 42 | internal_cfps = FieldList( 43 | FormField(InternalCFPItemForm), min_entries=0, max_entries=10 44 | ) 45 | 46 | 47 | class TemplateInternalCFPForm(FlaskForm): 48 | internal_cfps = FieldList( 49 | FormField(InternalCFPItemForm), min_entries=1, max_entries=1 50 | ) 51 | -------------------------------------------------------------------------------- /migrations/versions/20200922_19-54-52__remove_key_value_table.py: -------------------------------------------------------------------------------- 1 | """remove key value table 2 | 3 | Revision ID: abcab0310efc 4 | Revises: 636be46da1fe 5 | Create Date: 2020-09-22 19:54:52.311677 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "abcab0310efc" 14 | down_revision = "636be46da1fe" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_table("key_value_store") 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.create_table( 28 | "key_value_store", 29 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 30 | sa.Column( 31 | "date_created", postgresql.TIMESTAMP(), autoincrement=False, nullable=True 32 | ), 33 | sa.Column( 34 | "date_modified", postgresql.TIMESTAMP(), autoincrement=False, nullable=True 35 | ), 36 | sa.Column("key", sa.VARCHAR(length=255), autoincrement=False, nullable=False), 37 | sa.Column("value", postgresql.BYTEA(), autoincrement=False, nullable=False), 38 | sa.Column("installation_id", sa.INTEGER(), autoincrement=False, nullable=False), 39 | sa.ForeignKeyConstraint( 40 | ["installation_id"], ["slack_installation.id"], name="fk_installation_id" 41 | ), 42 | sa.PrimaryKeyConstraint("id", name="key_value_store_pkey"), 43 | sa.UniqueConstraint("key", name="key_value_store_key_key"), 44 | ) 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /migrations/versions/20190401_19-03-55__revert_task_table_creation.py: -------------------------------------------------------------------------------- 1 | """revert task table creation 2 | 3 | Revision ID: 67b2d33c452e 4 | Revises: eaec612dbc5f 5 | Create Date: 2019-04-01 19:03:55.166780 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "67b2d33c452e" 14 | down_revision = "eaec612dbc5f" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_index("ix_task_name", table_name="task") 22 | op.drop_table("task") 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.create_table( 29 | "task", 30 | sa.Column( 31 | "date_created", postgresql.TIMESTAMP(), autoincrement=False, nullable=True 32 | ), 33 | sa.Column( 34 | "date_modified", postgresql.TIMESTAMP(), autoincrement=False, nullable=True 35 | ), 36 | sa.Column("id", sa.VARCHAR(length=36), autoincrement=False, nullable=False), 37 | sa.Column("name", sa.VARCHAR(length=128), autoincrement=False, nullable=True), 38 | sa.Column( 39 | "description", sa.VARCHAR(length=128), autoincrement=False, nullable=True 40 | ), 41 | sa.Column("failed", sa.BOOLEAN(), autoincrement=False, nullable=True), 42 | sa.Column("complete", sa.BOOLEAN(), autoincrement=False, nullable=True), 43 | sa.PrimaryKeyConstraint("id", name="task_pkey"), 44 | ) 45 | op.create_index("ix_task_name", "task", ["name"], unique=False) 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/blocks.py: -------------------------------------------------------------------------------- 1 | from busy_beaver.apps.upcoming_events.upcoming_events import ( 2 | generate_upcoming_events_message, 3 | ) 4 | from busy_beaver.toolbox.slack_block_kit import Divider, Section 5 | from busy_beaver.toolbox.slack_block_kit.blocks import Block 6 | 7 | APP_HOME_HEADER_INSTALLED = ( 8 | "*Welcome!* Busy Beaver is a community engagement bot.\n\n" 9 | "Join <#{channel}> to see daily GitHub summaries for registered users. " 10 | "Wanna join the fun? `/busybeaver connect` to link your GitHub account!" 11 | ) 12 | APP_HOME_HEADER = ( 13 | "*Welcome!* Busy Beaver is a community engagement bot.\n\n" 14 | "Please contact the Slack workspace admin to complete installation." 15 | ) 16 | 17 | 18 | class AppHome: 19 | def __init__(self, *, github_summary_channel=None, upcoming_events_config=None): 20 | if github_summary_channel: 21 | header = APP_HOME_HEADER_INSTALLED.format(channel=github_summary_channel) 22 | else: 23 | header = APP_HOME_HEADER 24 | blocks = [Section(header)] 25 | 26 | if upcoming_events_config: 27 | blocks.extend([Divider(), Section("\n\n\n\n")]) 28 | blocks.extend(generate_upcoming_events_message(upcoming_events_config)) 29 | 30 | self.blocks = blocks 31 | 32 | def __repr__(self): # pragma: no cover 33 | return "" 34 | 35 | def __len__(self): 36 | return len(self.blocks) 37 | 38 | def __getitem__(self, i): 39 | return self.blocks[i] 40 | 41 | def to_dict(self) -> dict: 42 | blocks = [ 43 | block.to_dict() if isinstance(block, Block) else block 44 | for block in self.blocks 45 | ] 46 | return {"type": "home", "blocks": blocks} 47 | -------------------------------------------------------------------------------- /busy_beaver/common/wrappers/youtube.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from datetime import datetime 3 | from typing import Dict 4 | 5 | from .requests_client import RequestsClient, Response 6 | from busy_beaver.models import YouTubeVideo 7 | 8 | 9 | def sort_by_published(video_json: Dict) -> datetime: 10 | publish_at = video_json["snippet"]["publishedAt"].split(".")[0] 11 | return YouTubeVideo.date_str_to_datetime(publish_at) 12 | 13 | 14 | class YouTubeClient: 15 | def __init__(self, *, api_key: str) -> None: 16 | self.base_url = "https://www.googleapis.com/youtube/v3" 17 | self.api_key = api_key 18 | self.client = RequestsClient() 19 | 20 | def __repr__(self) -> str: # pragma: no cover 21 | return "YouTubeAdapter" 22 | 23 | def get_latest_videos_from_channel(self, channel_id: str) -> Response: 24 | params = { 25 | "channelId": channel_id, 26 | "key": self.api_key, 27 | "part": "snippet,id", 28 | "order": "date", 29 | "maxResults": 50, 30 | "type": "video", 31 | } 32 | url = f"{self.base_url}/search" 33 | response = self.client.get(url, params=params) 34 | 35 | # Unpack and label items in response 36 | labels = namedtuple("YoutubeVideo", ["url", "name", "date"]) 37 | 38 | results = [ 39 | labels( 40 | *[ 41 | "https://www.youtube.com/watch?v=" + _["id"]["videoId"], 42 | _["snippet"]["title"], 43 | _["snippet"]["publishedAt"], 44 | ] 45 | ) 46 | for _ in response.json["items"] 47 | if _["id"]["kind"] == "youtube#video" 48 | ] 49 | 50 | return results 51 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/summary/summary.py: -------------------------------------------------------------------------------- 1 | from .event_list import ( 2 | CommitsList, 3 | CreatedReposList, 4 | ForkedReposList, 5 | IssuesOpenedList, 6 | PublicizedReposList, 7 | PullRequestsList, 8 | ReleasesPublishedList, 9 | StarredReposList, 10 | ) 11 | from busy_beaver.models import GitHubSummaryUser 12 | 13 | 14 | class GitHubUserEvents: 15 | def __init__(self, user: GitHubSummaryUser, classified_events: list): 16 | self.user = user 17 | self.classified_events = classified_events 18 | 19 | @classmethod 20 | def classify_events_by_type(cls, user: GitHubSummaryUser, events: list): 21 | tracked_event_types = [ # this is the order of summary output 22 | ReleasesPublishedList(), 23 | CreatedReposList(), 24 | PublicizedReposList(), 25 | ForkedReposList(), 26 | PullRequestsList(), 27 | IssuesOpenedList(), 28 | CommitsList(), 29 | StarredReposList(), 30 | ] 31 | 32 | for event in events: 33 | for event_list in tracked_event_types: 34 | if event_list.matches_event(event): 35 | event_list.append(event) 36 | 37 | return cls(user, tracked_event_types) 38 | 39 | def generate_summary_text(self): 40 | summary = "" 41 | for event_list in self.classified_events: 42 | summary += event_list.generate_summary_text() 43 | 44 | if not summary: 45 | return "" 46 | 47 | user_info = "<@{slack_id}> as \n" 48 | params = { 49 | "slack_id": self.user.slack_id, 50 | "github_id": self.user.github_username, 51 | } 52 | return user_info.format(**params) + summary + "\n" 53 | -------------------------------------------------------------------------------- /migrations/versions/20200712_16-16-34__add_meetup_groups_middle_layer_between_.py: -------------------------------------------------------------------------------- 1 | """add meetup groups middle layer between events 2 | 3 | Revision ID: f29c0fd74c68 4 | Revises: 67025a818f50 5 | Create Date: 2020-07-12 16:16:34.770629 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "f29c0fd74c68" 13 | down_revision = "67025a818f50" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "upcoming_events_group", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("date_created", sa.DateTime(), nullable=True), 24 | sa.Column("date_modified", sa.DateTime(), nullable=True), 25 | sa.Column("config_id", sa.Integer(), nullable=False), 26 | sa.Column("meetup_urlname", sa.String(length=100), nullable=False), 27 | sa.ForeignKeyConstraint( 28 | ["config_id"], 29 | ["upcoming_events_configuration.id"], 30 | name="fk_upcoming_events_configuration_id", 31 | ), 32 | sa.PrimaryKeyConstraint("id"), 33 | ) 34 | op.add_column("event", sa.Column("group_id", sa.Integer(), nullable=True)) 35 | op.create_foreign_key( 36 | "fk_upcoming_events_group_id", 37 | "event", 38 | "upcoming_events_group", 39 | ["group_id"], 40 | ["id"], 41 | ) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_constraint("fk_upcoming_events_group_id", "event", type_="foreignkey") 48 | op.drop_column("event", "group_id") 49 | op.drop_table("upcoming_events_group") 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /migrations/versions/20200705_16-09-04__add_slack_user_table.py: -------------------------------------------------------------------------------- 1 | """add slack_user table 2 | 3 | Revision ID: eddd9fbf0db6 4 | Revises: 9151e4b0a560 5 | Create Date: 2020-07-05 16:09:04.051330 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "eddd9fbf0db6" 13 | down_revision = "9151e4b0a560" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "slack_user", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("date_created", sa.DateTime(), nullable=True), 24 | sa.Column("date_modified", sa.DateTime(), nullable=True), 25 | sa.Column("installation_id", sa.Integer(), nullable=False), 26 | sa.Column("slack_id", sa.String(length=30), nullable=False), 27 | sa.Column("slack_oauth_state", sa.String(length=36), nullable=True), 28 | sa.ForeignKeyConstraint( 29 | ["installation_id"], ["slack_installation.id"], name="fk_installation_id" 30 | ), 31 | sa.PrimaryKeyConstraint("id"), 32 | ) 33 | op.create_index( 34 | op.f("ix_slack_user_installation_id"), 35 | "slack_user", 36 | ["installation_id"], 37 | unique=False, 38 | ) 39 | op.create_index( 40 | op.f("ix_slack_user_slack_id"), "slack_user", ["slack_id"], unique=False 41 | ) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_index(op.f("ix_slack_user_slack_id"), table_name="slack_user") 48 | op.drop_index(op.f("ix_slack_user_installation_id"), table_name="slack_user") 49 | op.drop_table("slack_user") 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /tests/_utilities/fakes.py: -------------------------------------------------------------------------------- 1 | """Testing helpers that make life easy""" 2 | 3 | from unittest import mock 4 | 5 | 6 | class FakeMeetupAdapter: 7 | def __init__(self, events): 8 | self.mock = mock.MagicMock() 9 | self.events = events 10 | 11 | def get_events(self, *args, **kwargs): 12 | self.mock(*args, **kwargs) 13 | return self.events 14 | 15 | 16 | class FakeSlackClient: 17 | def __init__(self, *, is_admin=None, details=None, members=None): 18 | self.mock = mock.MagicMock() 19 | if is_admin is not None: 20 | self._is_admin = is_admin 21 | if details: 22 | self.details = details 23 | if members: 24 | self.members = members 25 | 26 | def __call__(self, *args, **kwargs): 27 | self.mock(*args, **kwargs) 28 | return self 29 | 30 | def dm(self, *args, **kwargs): 31 | self.mock(*args, **kwargs) 32 | return 33 | 34 | def channel_details(self, *args, **kwargs): 35 | self.mock(*args, **kwargs) 36 | return self.details 37 | 38 | def get_channel_members(self, *args, **kwargs): 39 | self.mock(*args, **kwargs) 40 | return self.members 41 | 42 | def get_bot_channels(self, *args, **kwargs): 43 | self.mock(*args, **kwargs) 44 | return [] 45 | 46 | def is_admin(self, *args, **kwargs): 47 | self.mock(*args, **kwargs) 48 | return self._is_admin 49 | 50 | def post_ephemeral_message(self, *args, **kwargs): 51 | self.mock(*args, **kwargs) 52 | return 53 | 54 | def post_message(self, *args, **kwargs): 55 | self.mock(*args, **kwargs) 56 | return 57 | 58 | def display_app_home(self, *args, **kwargs): 59 | self.mock(*args, **kwargs) 60 | return 61 | 62 | def __repr__(self): 63 | return "" 64 | -------------------------------------------------------------------------------- /tests/common/wrappers/cassettes/test_get_urlname__group_does_not_exist.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - BusyBeaver 15 | authorization: 16 | - DUMMY 17 | method: GET 18 | uri: https://api.meetup.com/lasdfjzxm 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA6tWSi0qyi8qVrKKrlZKzk9JVbJSSi/KLy2IB4sr6SjlphYXJ6aDxD3zyhJzMlMU 23 | wPIKpUU5eYm5qQo5icUpaVlVFblKtbG1AAdZdZ5PAAAA 24 | headers: 25 | Accept-Ranges: 26 | - bytes 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Length: 32 | - '90' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Mon, 27 Jul 2020 21:39:49 GMT 37 | ETag: 38 | - '"70c67dbdcf8c8b4d3acdab60d8acda4f-gzip"' 39 | Server: 40 | - Apache/2.4.39 (Unix) OpenSSL/1.1.1c 41 | Vary: 42 | - Accept-Encoding,User-Agent,Accept-Language 43 | Via: 44 | - 1.1 varnish 45 | X-Accepted-OAuth-Scopes: 46 | - ageless, basic 47 | X-Cache: 48 | - MISS 49 | X-Cache-Hits: 50 | - '0' 51 | X-Meetup-Request-ID: 52 | - d28f1048-80e7-455f-8a64-5b54fcc2b10b 53 | X-Meetup-server: 54 | - ip-10-192-13-244 55 | X-OAuth-Scopes: 56 | - basic 57 | X-RateLimit-Limit: 58 | - '30' 59 | X-RateLimit-Remaining: 60 | - '28' 61 | X-RateLimit-Reset: 62 | - '10' 63 | X-Served-By: 64 | - cache-chi21134-CHI 65 | X-Timer: 66 | - S1595885989.346367,VS0,VE119 67 | status: 68 | code: 404 69 | message: Not Found 70 | version: 1 71 | -------------------------------------------------------------------------------- /docs/deployment/README.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | Material related to deploying Busy Beaver. 4 | 5 | #### Table of Contents 6 | 7 | 8 | 9 | - [Stack](#stack) 10 | - [Deploying App](#deploying-app) 11 | - [Deployment Notes](#deployment-notes) 12 | - [Integration Notes](#integration-notes) 13 | - [GitHub](#github) 14 | - [Meetup](#meetup) 15 | - [Sentry](#sentry) 16 | 17 | 18 | 19 | ## Stack 20 | 21 | - Deployment has been packaged up as a Helm chart 22 | - Deployed out to DigitalOcean-managed Kubernete 23 | - Database is DO-managed postgres 24 | - see [DO deployment](digitalocean_deployment.md) for more details 25 | 26 | ### Deploying App 27 | 28 | There is a GitHub workflow that can be triggered to deploy the BusyBeaver using `helm upgrade`. Hit https://api.github.com/repos/busy-beaver-dev/busy-beaver/deployments with a POST request: 29 | 30 | - body: `{"ref": "VERSION"}` 31 | - headers 32 | - `Authorization`: `Token {}` 33 | - `Accept`: `application/vnd.github.v3+json` 34 | 35 | ### Deployment Notes 36 | 37 | - Production URL: `https://app.busybeaverbot.com` 38 | - Staging URL: `https://staging.busybeaverbot.com` 39 | - if staging database gets deleted and we have to start again 40 | - will need to set up app for distribution and install it via OAuth 41 | - Need to find a place to store information about accounts and credentials 42 | - current in my personal account 43 | - KMS? LastPass? 44 | 45 | ## Integration Notes 46 | 47 | - [Slack](notes/slack_integration.md) 48 | 49 | ### GitHub 50 | 51 | For both staging and production apps in the `busy-beaver-dev` organization 52 | 53 | - [ ] update the callback URL 54 | 55 | ### Meetup 56 | 57 | Currently using an API token generated 58 | by an application in my personal account 59 | for both staging and production. 60 | 61 | ### Sentry 62 | 63 | Have a `busybeaverbot` Project with 2 environments: 64 | 65 | - `staging` 66 | - `production` 67 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/toolbox.py: -------------------------------------------------------------------------------- 1 | """pytest Fixutres that make living my test life easy""" 2 | 3 | from collections import namedtuple 4 | import uuid 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture 10 | def patcher(monkeypatch): 11 | """Helper to patch in the correct spot""" 12 | 13 | def _patcher(module_to_test, *, namespace, replacement): 14 | namespace_to_patch = f"{module_to_test}.{namespace}" 15 | monkeypatch.setattr(namespace_to_patch, replacement) 16 | return replacement 17 | 18 | yield _patcher 19 | 20 | 21 | @pytest.fixture 22 | def create_fake_background_task(mocker): 23 | """This fixture creates fake background jobs. 24 | 25 | When `.queue` is called on a function decorated with `@rq.job`, it creates a 26 | background job managed by python-rq and returns an object with job details. 27 | 28 | This fixture creates a Fake that looks like an object created by python-rq. Used 29 | this fixture to replace functions decorated with `@rq.job` so that we can unit test 30 | "trigger" functions. 31 | 32 | Unit test trigger functions by ensuring data that should be saved to database 33 | actually is. 34 | """ 35 | JobDetails = namedtuple("JobDetails", ["id"]) 36 | 37 | class FakeBackgroundTask: 38 | def __init__(self): 39 | self.mock = mocker.MagicMock() 40 | self.id = str(uuid.uuid4()) 41 | 42 | def __repr__(self): 43 | return f"" 44 | 45 | def queue(self, *args, **kwargs): 46 | self.mock("queue", *args, **kwargs) 47 | return JobDetails(self.id) 48 | 49 | def schedule(self, *args, **kwargs): 50 | self.mock("schedule", *args, **kwargs) 51 | return JobDetails(self.id) 52 | 53 | def _create_fake_background_task(): 54 | return FakeBackgroundTask() 55 | 56 | yield _create_fake_background_task 57 | -------------------------------------------------------------------------------- /tests/apps/web/cassettes/TestUpcomingEventsViews.test_upcoming_events_add_new_group__does_not_exist.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - BusyBeaver 15 | authorization: 16 | - DUMMY 17 | method: GET 18 | uri: https://api.meetup.com/adsfasdfeum3n4x 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA6tWSi0qyi8qVrKKrlZKzk9JVbJSSi/KLy2IB4sr6SjlphYXJ6aDxD3zyhJzMlMU 23 | wPIKpUU5eYm5qQqJKcVpicUpaamlucZ5JhVKtbG1ALQjkmFVAAAA 24 | headers: 25 | Accept-Ranges: 26 | - bytes 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Length: 32 | - '96' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Tue, 28 Jul 2020 23:45:01 GMT 37 | ETag: 38 | - '"c951d57d0685d272415e9e9c7d52eca5-gzip"' 39 | Server: 40 | - Apache/2.4.39 (Unix) OpenSSL/1.1.1c 41 | Vary: 42 | - Accept-Encoding,User-Agent,Accept-Language 43 | Via: 44 | - 1.1 varnish 45 | X-Accepted-OAuth-Scopes: 46 | - ageless, basic 47 | X-Cache: 48 | - MISS 49 | X-Cache-Hits: 50 | - '0' 51 | X-Meetup-Request-ID: 52 | - 18309b36-81e3-4ab8-bfae-de9345cef9e1 53 | X-Meetup-server: 54 | - ip-10-192-22-218 55 | X-OAuth-Scopes: 56 | - basic 57 | X-RateLimit-Limit: 58 | - '30' 59 | X-RateLimit-Remaining: 60 | - '28' 61 | X-RateLimit-Reset: 62 | - '10' 63 | X-Served-By: 64 | - cache-mdw17368-MDW 65 | X-Timer: 66 | - S1595979901.957166,VS0,VE51 67 | status: 68 | code: 404 69 | message: Not Found 70 | version: 1 71 | -------------------------------------------------------------------------------- /tests/apps/web/cassettes/TestUpcomingEventsViews.test_upcoming_events_add_new_group_does_not_exist.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - BusyBeaver 15 | authorization: 16 | - DUMMY 17 | method: GET 18 | uri: https://api.meetup.com/adsfasdfeum3n4x 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA6tWSi0qyi8qVrKKrlZKzk9JVbJSSi/KLy2IB4sr6SjlphYXJ6aDxD3zyhJzMlMU 23 | wPIKpUU5eYm5qQqJKcVpicUpaamlucZ5JhVKtbG1ALQjkmFVAAAA 24 | headers: 25 | Accept-Ranges: 26 | - bytes 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Length: 32 | - '96' 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Tue, 28 Jul 2020 23:39:27 GMT 37 | ETag: 38 | - '"c951d57d0685d272415e9e9c7d52eca5-gzip"' 39 | Server: 40 | - Apache/2.4.39 (Unix) OpenSSL/1.1.1c 41 | Vary: 42 | - Accept-Encoding,User-Agent,Accept-Language 43 | Via: 44 | - 1.1 varnish 45 | X-Accepted-OAuth-Scopes: 46 | - ageless, basic 47 | X-Cache: 48 | - MISS 49 | X-Cache-Hits: 50 | - '0' 51 | X-Meetup-Request-ID: 52 | - 10cb7b75-aa77-4cb6-9ee1-1585ceafbc5a 53 | X-Meetup-server: 54 | - ip-10-192-3-67 55 | X-OAuth-Scopes: 56 | - basic 57 | X-RateLimit-Limit: 58 | - '30' 59 | X-RateLimit-Remaining: 60 | - '29' 61 | X-RateLimit-Reset: 62 | - '10' 63 | X-Served-By: 64 | - cache-pwk4975-PWK 65 | X-Timer: 66 | - S1595979568.631772,VS0,VE61 67 | status: 68 | code: 404 69 | message: Not Found 70 | version: 1 71 | -------------------------------------------------------------------------------- /tests/common/wrappers/cassettes/test_slack_user_does_not_exist.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Content-Type: 6 | - application/x-www-form-urlencoded;charset=utf-8 7 | User-Agent: 8 | - Python/3.8.1 slackclient/2.5.0 Linux/4.19.76-linuxkit 9 | authorization: 10 | - DUMMY 11 | method: GET 12 | uri: https://www.slack.com/api/users.info?user=not-real-id 13 | response: 14 | body: 15 | string: '{"ok":false,"error":"user_not_found"}' 16 | headers: 17 | Access-Control-Allow-Headers: slack-route, x-slack-version-ts, x-b3-traceid, 18 | x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags 19 | Access-Control-Allow-Origin: '*' 20 | Access-Control-Expose-Headers: x-slack-req-id, retry-after 21 | Cache-Control: private, no-cache, no-store, must-revalidate 22 | Content-Encoding: gzip 23 | Content-Length: '57' 24 | Content-Type: application/json; charset=utf-8 25 | Date: Sun, 05 Jul 2020 19:04:38 GMT 26 | Expires: Mon, 26 Jul 1997 05:00:00 GMT 27 | Pragma: no-cache 28 | Server: Apache 29 | Vary: Accept-Encoding 30 | referrer-policy: no-referrer 31 | strict-transport-security: max-age=31536000; includeSubDomains; preload 32 | x-accepted-oauth-scopes: users:read 33 | x-content-type-options: nosniff 34 | x-oauth-scopes: app_mentions:read,channels:history,channels:join,channels:read,chat:write,commands,emoji:read,groups:read,im:history,im:read,im:write,mpim:history,mpim:read,mpim:write,reactions:read,reactions:write,team:read,usergroups:read,users.profile:read,users:read,users:write 35 | x-slack-backend: r 36 | x-slack-req-id: 48da14f97825feca3bd07207807fc2b4 37 | x-via: haproxy-www-75m7,haproxy-edge-iad-qqch 38 | x-xss-protection: '0' 39 | status: 40 | code: 200 41 | message: OK 42 | url: https://www.slack.com/api/users.info?user=not-real-id 43 | version: 1 44 | -------------------------------------------------------------------------------- /busy_beaver/apps/upcoming_events/cards.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from busy_beaver.common.wrappers.meetup import EventDetails 4 | from busy_beaver.toolbox.slack_block_kit import Context, Divider, Image, Section 5 | 6 | 7 | class UpcomingEventList: 8 | def __init__(self, events: List[EventDetails], image_url: str): 9 | output = [] 10 | if image_url: 11 | output.append(Image(image_url=image_url, alt_text="Logo")) 12 | output.append(Section("*Upcoming Events*")) 13 | output.append(Divider()) 14 | 15 | if len(events) == 0: 16 | output.append(Section("No events scheduled")) 17 | 18 | for event_details in events: 19 | output.extend(UpcomingEvent(event_details)) 20 | 21 | self.output = output 22 | 23 | def __repr__(self): # pragma: no cover 24 | return "" 25 | 26 | def __len__(self): 27 | return len(self.output) 28 | 29 | def __getitem__(self, i): 30 | return self.output[i] 31 | 32 | def to_dict(self) -> dict: 33 | return [block.to_dict() for block in self.output] 34 | 35 | 36 | class UpcomingEvent: 37 | def __init__(self, event: EventDetails): 38 | event_information_string = ( 39 | f"*<{event.url}|{event.name}>*\n" 40 | f"" 41 | ) 42 | event_location_string = f":round_pushpin: Location: {event.venue}" 43 | self.output = [ 44 | Section(text=event_information_string), 45 | Context(text=event_location_string), 46 | Divider(), 47 | ] 48 | 49 | def __repr__(self): # pragma: no cover 50 | return "" 51 | 52 | def __len__(self): 53 | return len(self.output) 54 | 55 | def __getitem__(self, i): 56 | return self.output[i] 57 | 58 | def to_dict(self) -> dict: 59 | return [block.to_dict() for block in self.output] 60 | -------------------------------------------------------------------------------- /busy_beaver/common/wrappers/requests_client.py: -------------------------------------------------------------------------------- 1 | from json import JSONDecodeError 2 | import logging 3 | from typing import Any, Dict, List, NamedTuple, Union 4 | 5 | import requests 6 | 7 | logger = logging.getLogger(__name__) 8 | DEFAULT_HEADERS = { 9 | "Accept": "application/json", 10 | "Content-Type": "application/json", 11 | "User-Agent": "BusyBeaver", 12 | } 13 | 14 | 15 | class Response(NamedTuple): 16 | status_code: int 17 | headers: Dict[str, str] 18 | json: Union[List[Dict[str, Any]], Dict[str, Any]] = None 19 | 20 | 21 | class RequestsClient: 22 | """Wrapper around requests to simplify interaction with JSON REST APIs""" 23 | 24 | def __init__(self, headers: dict = None, raise_for_status: bool = True): 25 | if headers is None: 26 | headers = {} 27 | 28 | self.session = requests.Session() 29 | self.headers = DEFAULT_HEADERS | headers 30 | self.raise_for_status = raise_for_status 31 | 32 | def __repr__(self): # pragma: no cover 33 | return "RequestsClient" 34 | 35 | def get(self, url: str, **kwargs) -> Response: 36 | return self._request("get", url, **kwargs) 37 | 38 | def head(self, url: str, **kwargs) -> Response: 39 | return self._request("head", url, **kwargs) 40 | 41 | def post(self, url: str, **kwargs) -> Response: 42 | return self._request("post", url, **kwargs) 43 | 44 | def _request(self, method: str, url: str, **kwargs) -> Response: 45 | headers_to_add = kwargs.pop("headers", {}) 46 | req_headers = self.headers | headers_to_add 47 | r = self.session.request(method, url, headers=req_headers, **kwargs) 48 | if self.raise_for_status: 49 | r.raise_for_status() 50 | 51 | try: 52 | resp = Response(status_code=r.status_code, headers=r.headers, json=r.json()) 53 | except JSONDecodeError: 54 | resp = Response(status_code=r.status_code, headers=r.headers) 55 | return resp 56 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/cli.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta 2 | import logging 3 | 4 | import pytz 5 | 6 | from .blueprint import github_bp 7 | from .models import GitHubSummaryConfiguration 8 | from .summary.workflow import post_github_summary_message 9 | from busy_beaver.extensions import db 10 | from busy_beaver.models import Task 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @github_bp.cli.command( 16 | "queue_github_summary_jobs", help="Queue GitHub summary jobs for tomorrow" 17 | ) 18 | def queue_github_summary_jobs_for_tomorrow(): 19 | all_active_configs = GitHubSummaryConfiguration.query.filter_by(enabled=True) 20 | 21 | for config in all_active_configs: 22 | workspace_id = config.slack_installation.workspace_id 23 | time_to_post = _get_time_to_post(config) 24 | if not time_to_post: 25 | continue 26 | 27 | job = post_github_summary_message.schedule( 28 | time_to_post, workspace_id=workspace_id 29 | ) 30 | task = Task( 31 | job_id=job.id, 32 | name="post_github_summary_message", 33 | task_state=Task.TaskState.SCHEDULED, 34 | data={ 35 | "workspace_id": workspace_id, 36 | "time_to_post": time_to_post.isoformat(), 37 | }, 38 | ) 39 | db.session.add(task) 40 | db.session.commit() 41 | 42 | 43 | def _get_time_to_post(config): 44 | # TODO state machine can remove this 45 | if not config.summary_post_time or not config.summary_post_timezone: 46 | extra = {"workspace_id": config.slack_installation.workspace_id} 47 | logger.error("No time to post configuration", extra=extra) 48 | return None 49 | tomorrow = date.today() + timedelta(days=1) 50 | dt_to_post = datetime.combine(tomorrow, config.summary_post_time) 51 | localized_dt = config.summary_post_timezone.localize(dt_to_post) 52 | return localized_dt.astimezone(pytz.utc) 53 | -------------------------------------------------------------------------------- /busy_beaver/apps/github_integration/oauth/oauth_flow.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | from requests_oauthlib import OAuth2Session 4 | 5 | from busy_beaver.common.oauth import ExternalOAuthDetails, OAuthFlow 6 | from busy_beaver.common.wrappers import GitHubClient 7 | 8 | 9 | class GitHubOAuthInfo(NamedTuple): 10 | access_token: str 11 | github_id: str 12 | github_username: str 13 | 14 | 15 | class GitHubOAuthFlow(OAuthFlow): 16 | AUTHORIZATION_BASE_URL = "https://github.com/login/oauth/authorize" 17 | TOKEN_URL = "https://github.com/login/oauth/access_token" 18 | 19 | def __init__(self, client_id, client_secret): 20 | self.session = OAuth2Session(client_id) 21 | self.client_secret = client_secret 22 | 23 | def generate_authentication_tuple(self) -> ExternalOAuthDetails: 24 | url = self.AUTHORIZATION_BASE_URL 25 | authorization_url, state = self.session.authorization_url(url) 26 | return ExternalOAuthDetails(url=authorization_url, state=state) 27 | 28 | def process_callback(self, authorization_response_url) -> GitHubOAuthInfo: 29 | access_token = self._fetch_token(authorization_response_url) 30 | github_id, github_username = self._fetch_github_account_details(access_token) 31 | return GitHubOAuthInfo(access_token, github_id, github_username) 32 | 33 | def _fetch_token(self, authorization_response_url): 34 | user_credentials = self.session.fetch_token( 35 | self.TOKEN_URL, 36 | authorization_response=authorization_response_url, 37 | client_secret=self.client_secret, 38 | ) 39 | return user_credentials["access_token"] 40 | 41 | @staticmethod 42 | def _fetch_github_account_details(access_token): 43 | github = GitHubClient(access_token) 44 | user_details = github.user_details() 45 | 46 | github_id = user_details["id"] 47 | github_username = user_details["login"] 48 | return (github_id, github_username) 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | .pytest_cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | .envrc 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | myvenv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # misc 107 | .DS_Store 108 | .vscode 109 | 110 | # local development files 111 | busy_beaver*db 112 | creds.txt 113 | .pdbrc.py 114 | docker-compose.override.yml 115 | *.retry 116 | tags 117 | .idea 118 | data_dump.sql 119 | 120 | # Vim 121 | *.sw* 122 | -------------------------------------------------------------------------------- /scripts/database/README.md: -------------------------------------------------------------------------------- 1 | # Database 2 | 3 | Before we moved to a managed database service, 4 | we used SQLite and then Postgres inside of a container. 5 | 6 | This directory contains database tools that are not needed anymore 7 | 8 | ## Copying Production Database to Local 9 | 10 | [Postgres Docs](https://www.postgresql.org/docs/8.1/backup.html#BACKUP-DUMP-RESTORE) 11 | 12 | 1. Start up a postgres container in Kubernets 13 | 14 | ```console 15 | kubectl run --generator=run-pod/v1 busybeaver-pg-dump --rm -i --tty --image postgres:11.7 --env DATABASE_URI=$(kubectl get secret busybeaver-production -o jsonpath="{.data.db-uri}" | base64 --decode) -- bash 16 | ``` 17 | 18 | 2. Exec into container and run psql, save results locally 19 | 20 | ```console 21 | kubectl exec -t busybeaver-pg-dump pg_dump $(kubectl get secret busybeaver-production -o jsonpath="{.data.db-uri}" | base64 --decode) > data_dump.sql 22 | ``` 23 | 24 | 3. Load data in local database 25 | 26 | ```console 27 | cat data_dump.sql | docker exec -i `docker-compose ps -q db` psql -U bbdev_user -d busy-beaver 28 | ``` 29 | 30 | ## Migrating from SQLite to Postgres 31 | 32 | When Busy Beaver first started out, it was a SQLite database. As the bot grow in scope, we migrated to Postgres. This was done using [pgloader](https://github.com/dimitri/pgloader), a tool that migrates SQLite databases to Postgres. 33 | 34 | ### Steps 35 | 36 | 1. Install `pgloader` following [instructions on Github](https://github.com/dimitri/pgloader) 37 | 2. Use the [provided template](https://pgloader.readthedocs.io/en/latest/ref/sqlite.html), fill out your database migration requirements. 38 | 3. Run `pgloader sqlite_migration.load` 39 | 40 | ```text 41 | # sqlite_migration.load 42 | 43 | load database 44 | from sqlite:///home/alysivji/busy-beaver/busy_beaver.db 45 | into postgresql://sivdev_user:sivdev_password@0.0.0.0:9432/busy-beaver 46 | 47 | with include drop, create tables, create indexes, reset sequences 48 | 49 | set work_mem to '16MB', maintenance_work_mem to '512 MB'; 50 | ``` 51 | -------------------------------------------------------------------------------- /tests/apps/upcoming_events/test_workflow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from busy_beaver.apps.upcoming_events.workflow import add_new_group_to_configuration 4 | from busy_beaver.models import Event, UpcomingEventsConfiguration, UpcomingEventsGroup 5 | 6 | 7 | class TestUpdateSettings: 8 | @pytest.mark.vcr 9 | def test_add_new_group_to_configuration(self, factory): 10 | """Happy Path -- add group and events to database""" 11 | config = factory.UpcomingEventsConfiguration() 12 | 13 | add_new_group_to_configuration( 14 | installation=config.slack_installation, 15 | upcoming_events_config=config, 16 | meetup_urlname="_ChiPy_", 17 | ) 18 | 19 | groups = UpcomingEventsGroup.query.all() 20 | assert len(groups) == 1 21 | group_added = groups[0] 22 | assert group_added.meetup_urlname == "_ChiPy_" 23 | 24 | all_events = Event.query.all() 25 | assert len(all_events) > 0 26 | 27 | @pytest.mark.vcr 28 | def test_add_new_group_to_configuration_when_config_does_not_exist(self, factory): 29 | """Workflow: 30 | - User tries to add a group when the config doesn't exist 31 | - Program creates config 32 | - Adds group to new config""" 33 | installation = factory.SlackInstallation() 34 | 35 | add_new_group_to_configuration( 36 | installation=installation, 37 | upcoming_events_config=None, 38 | meetup_urlname="_ChiPy_", 39 | ) 40 | 41 | groups = UpcomingEventsGroup.query.all() 42 | assert len(groups) == 1 43 | group_added = groups[0] 44 | assert group_added.meetup_urlname == "_ChiPy_" 45 | 46 | # check group and config are linked 47 | configs = UpcomingEventsConfiguration.query.all() 48 | assert len(configs) == 1 49 | config = configs[0] 50 | assert group_added.configuration is config 51 | 52 | all_events = Event.query.all() 53 | assert len(all_events) > 0 54 | -------------------------------------------------------------------------------- /tests/toolbox/test_event_emitter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from busy_beaver.exceptions import ( 4 | EventEmitterEventAlreadyRegistered, 5 | EventEmitterEventNotRegistered, 6 | ) 7 | from busy_beaver.toolbox import EventEmitter 8 | 9 | 10 | @pytest.fixture 11 | def event_emitter(): 12 | return EventEmitter() 13 | 14 | 15 | @pytest.fixture 16 | def create_function(): 17 | def _wrapper(): 18 | return "test string" 19 | 20 | return _wrapper 21 | 22 | 23 | def test_create_event_emitter(event_emitter): 24 | assert event_emitter is not None 25 | 26 | 27 | def test_register_function_with_event_emitter(event_emitter, create_function): 28 | # Arrange 29 | ee = event_emitter 30 | ee.on("key1", create_function) 31 | 32 | # Act 33 | result = ee.emit("key1") 34 | 35 | # Assert 36 | assert result == "test string" 37 | 38 | 39 | def test_register_function_with_event_emitter_decorator(event_emitter, create_function): 40 | # Arrange 41 | ee = event_emitter 42 | 43 | @ee.on("key1") 44 | def _wrapper(): 45 | return "decorator" 46 | 47 | # Act 48 | result = ee.emit("key1") 49 | 50 | # Assert 51 | assert result == "decorator" 52 | 53 | 54 | def test_event_emitter_with_function_params(event_emitter, create_function): 55 | # Arrange 56 | ee = event_emitter 57 | 58 | @ee.on("key1") 59 | def adder(param1, param2): 60 | return param1 + param2 61 | 62 | # Act 63 | result = ee.emit("key1", 2, param2=3) 64 | 65 | # Assert 66 | assert result == 2 + 3 67 | 68 | 69 | def test_register_same_event_twice_raises_exception(event_emitter, create_function): 70 | # Arrange 71 | ee = event_emitter 72 | ee.on("key1", create_function) 73 | 74 | # Act 75 | with pytest.raises(EventEmitterEventAlreadyRegistered): 76 | ee.on("key1", create_function) 77 | 78 | 79 | def test_emit_for_unregistered_event(event_emitter): 80 | with pytest.raises(EventEmitterEventNotRegistered): 81 | event_emitter.emit("key1") 82 | -------------------------------------------------------------------------------- /migrations/versions/20200419_04-49-50__remove_token_auth_table.py: -------------------------------------------------------------------------------- 1 | """remove token auth table 2 | 3 | Revision ID: 14181eb1a202 4 | Revises: 8906cc54f4cd 5 | Create Date: 2020-04-19 04:49:50.421722 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "14181eb1a202" 14 | down_revision = "8906cc54f4cd" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_constraint("fk_task_user_id", "task", type_="foreignkey") 22 | op.drop_column("task", "user_id") 23 | 24 | op.drop_table("api_user") 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.create_table( 31 | "api_user", 32 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 33 | sa.Column( 34 | "date_created", postgresql.TIMESTAMP(), autoincrement=False, nullable=True 35 | ), 36 | sa.Column( 37 | "date_modified", postgresql.TIMESTAMP(), autoincrement=False, nullable=True 38 | ), 39 | sa.Column( 40 | "username", sa.VARCHAR(length=255), autoincrement=False, nullable=False 41 | ), 42 | sa.Column("token", sa.VARCHAR(length=255), autoincrement=False, nullable=False), 43 | sa.Column( 44 | "role", 45 | sa.VARCHAR(length=255), 46 | server_default=sa.text("'user'::character varying"), 47 | autoincrement=False, 48 | nullable=False, 49 | ), 50 | sa.PrimaryKeyConstraint("id", name="api_user_pkey"), 51 | ) 52 | 53 | op.add_column( 54 | "task", sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=True) 55 | ) 56 | op.create_foreign_key("fk_task_user_id", "task", "api_user", ["user_id"], ["id"]) 57 | # ### end Alembic commands ### 58 | -------------------------------------------------------------------------------- /busy_beaver/apps/slack_integration/interactors.py: -------------------------------------------------------------------------------- 1 | from .models import SlackInstallation 2 | from busy_beaver.common.wrappers import SlackClient 3 | 4 | 5 | def make_slack_response( 6 | response_type="ephemeral", text="", attachments=None, blocks=None 7 | ): 8 | return { 9 | "response_type": response_type, 10 | "text": text, 11 | "attachments": [attachments] if attachments else [], 12 | "blocks": blocks if blocks else [], 13 | } 14 | 15 | 16 | def generate_help_text(installation: SlackInstallation, user_id: str) -> str: 17 | help_text = "Busy Beaver is a Community Engagement bot.\n\n" 18 | github_summary_config = installation.github_summary_config 19 | upcoming_events_config = installation.upcoming_events_config 20 | 21 | if github_summary_config: 22 | if github_summary_config.enabled: 23 | help_text += ( 24 | f"See what projects other members of your community " 25 | f"are working on in <#{github_summary_config.channel}>.\n\n" 26 | ) 27 | 28 | help_text += "Some commands I understand:\n" 29 | 30 | if upcoming_events_config: 31 | if upcoming_events_config.enabled: 32 | help_text += ( 33 | "`/busybeaver next`: Retrieve next event\n" 34 | "`/busybeaver events`: Retrieve list of upcoming events\n" 35 | ) 36 | 37 | if github_summary_config: 38 | if github_summary_config.enabled: 39 | help_text += ( 40 | "`/busybeaver connect`: Connect GitHub Account\n" 41 | "`/busybeaver reconnect`: Connect to different GitHub Account\n" 42 | "`/busybeaver disconnect`: Disconenct GitHub Account\n" 43 | ) 44 | 45 | slack = SlackClient(installation.bot_access_token) 46 | is_admin = slack.is_admin(user_id) 47 | if is_admin: 48 | help_text += "`/busybeaver settings`: View/Modify Busy Beaver settings\n" 49 | 50 | help_text += "`/busybeaver help`: Display help text" 51 | return help_text 52 | -------------------------------------------------------------------------------- /tests/_utilities/fixtures/slack.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import json 3 | from urllib.parse import urlencode 4 | 5 | import pytest 6 | 7 | from busy_beaver.apps.slack_integration.api.decorators import calculate_signature 8 | from busy_beaver.config import SLACK_SIGNING_SECRET 9 | 10 | 11 | @pytest.fixture 12 | def create_slack_headers(): 13 | """Dictionary get sorted when we retrieve the body, account for this""" 14 | 15 | def sort_dict(original_dict): 16 | res = OrderedDict() 17 | for k, v in sorted(original_dict.items()): 18 | if isinstance(v, dict): 19 | res[k] = dict(sort_dict(v)) 20 | else: 21 | res[k] = v 22 | return dict(res) 23 | 24 | def wrapper(timestamp, data, is_json_data=True): 25 | if is_json_data: 26 | request_body = json.dumps(sort_dict(data)).encode("utf-8") 27 | else: 28 | request_body = urlencode(data).encode("utf-8") 29 | sig = calculate_signature(SLACK_SIGNING_SECRET, timestamp, request_body) 30 | return {"X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig} 31 | 32 | return wrapper 33 | 34 | 35 | @pytest.fixture 36 | def generate_slash_command_request(): 37 | def _generate_data( 38 | command, 39 | user_id="U5FRZAD323", 40 | team_id="T5GCMNWAFSDFSDF", 41 | channel_id="CFLDRNBSDFD", 42 | ): 43 | return { 44 | "token": "deprecated", 45 | "team_id": team_id, 46 | "team_domain": "cant-depend-on-this", 47 | "channel_id": channel_id, 48 | "channel_name": "cant-depend-on-this", 49 | "user_id": user_id, 50 | "user_name": "cant-depend-on-this", 51 | "command": "/busybeaver", 52 | "text": command, 53 | "response_url": "https://hooks.slack.com/commands/T5GCMNW/639192748/39", 54 | "trigger_id": "639684516021.186015429778.0a18640db7b29f98749b62f6e824fe30", 55 | } 56 | 57 | return _generate_data 58 | -------------------------------------------------------------------------------- /tests/apps/call_for_proposals/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from busy_beaver.apps.call_for_proposals.cli import OpenCFPPost, post_upcoming_cfps 4 | from tests._utilities import FakeSlackClient 5 | 6 | MODULE_TO_TEST = "busy_beaver.apps.call_for_proposals.cli" 7 | 8 | 9 | @pytest.fixture 10 | def patched_slack(patcher): 11 | obj = FakeSlackClient() 12 | return patcher(MODULE_TO_TEST, namespace="SlackClient", replacement=obj) 13 | 14 | 15 | @pytest.mark.unit 16 | @pytest.mark.vcr 17 | def test_post_upcoming_cfps(mocker, runner, factory, patched_slack): 18 | # Arrange 19 | config = factory.CallForProposalsConfiguration(enabled=True) 20 | 21 | # Act 22 | runner.invoke(post_upcoming_cfps) 23 | 24 | # Assert 25 | assert patched_slack.mock.call_count == 2 26 | 27 | slack_adapter_initalize_args = patched_slack.mock.call_args_list[0] 28 | args, kwargs = slack_adapter_initalize_args 29 | assert config.slack_installation.bot_access_token in args 30 | 31 | post_message_args = patched_slack.mock.call_args_list[-1] 32 | args, kwargs = post_message_args 33 | assert "blocks" in kwargs 34 | assert len(kwargs["blocks"]) == 5 35 | 36 | 37 | @pytest.mark.unit 38 | @pytest.mark.vcr 39 | def test_post_upcoming_cfps_enabled(mocker, runner, factory, patched_slack): 40 | """Only post for configurations that are enabled 41 | 42 | Should have 2 calls: 43 | - when initialized 44 | - when post_message is called 45 | """ 46 | # Arrange 47 | factory.CallForProposalsConfiguration(enabled=False) 48 | factory.CallForProposalsConfiguration(enabled=True) 49 | 50 | # Act 51 | runner.invoke(post_upcoming_cfps) 52 | 53 | # Assert 54 | assert patched_slack.mock.call_count == 2 55 | 56 | 57 | @pytest.mark.unit 58 | @pytest.mark.vcr 59 | def test_post_no_open_cfps_found(): 60 | """When there are no open CFPs, let the user know)""" 61 | # Act 62 | result = OpenCFPPost._generate_conference_text(conference_cfps=[]) 63 | 64 | # Assert 65 | assert "No upcoming CFPs found" in result 66 | -------------------------------------------------------------------------------- /migrations/versions/20200125_02-16-15__create_slack_app_home_opened_counter.py: -------------------------------------------------------------------------------- 1 | """create_slack_app_home_opened_counter 2 | 3 | Revision ID: 4355643c48c3 4 | Revises: 8c5ac2860989 5 | Create Date: 2020-01-25 02:16:15.876968 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "4355643c48c3" 14 | down_revision = "8c5ac2860989" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "slack_app_home_opened", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("date_created", sa.DateTime(), nullable=True), 25 | sa.Column("date_modified", sa.DateTime(), nullable=True), 26 | sa.Column("installation_id", sa.Integer(), nullable=False), 27 | sa.Column("slack_id", sa.String(length=30), nullable=False), 28 | sa.Column("count", sa.Integer(), nullable=False), 29 | sa.ForeignKeyConstraint( 30 | ["installation_id"], ["slack_installation.id"], name="fk_installation_id" 31 | ), 32 | sa.PrimaryKeyConstraint("id"), 33 | ) 34 | op.create_index( 35 | op.f("ix_slack_app_home_opened_installation_id"), 36 | "slack_app_home_opened", 37 | ["installation_id"], 38 | unique=False, 39 | ) 40 | op.create_index( 41 | op.f("ix_slack_app_home_opened_slack_id"), 42 | "slack_app_home_opened", 43 | ["slack_id"], 44 | unique=False, 45 | ) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_index( 52 | op.f("ix_slack_app_home_opened_slack_id"), table_name="slack_app_home_opened" 53 | ) 54 | op.drop_index( 55 | op.f("ix_slack_app_home_opened_installation_id"), 56 | table_name="slack_app_home_opened", 57 | ) 58 | op.drop_table("slack_app_home_opened") 59 | # ### end Alembic commands ### 60 | -------------------------------------------------------------------------------- /migrations/versions/20200708_21-59-03__set_up_task_model_for_current_workflow.py: -------------------------------------------------------------------------------- 1 | """set up task model for current workflow 2 | 3 | Revision ID: 9bc99f240f5f 4 | Revises: 337517ec92c5 5 | Create Date: 2020-07-08 21:59:03.748035 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlalchemy_utils 11 | 12 | from busy_beaver.common.models import Task 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "9bc99f240f5f" 16 | down_revision = "337517ec92c5" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column("task", sa.Column("data", sa.JSON(), nullable=True)) 24 | op.add_column( 25 | "task", 26 | sa.Column( 27 | "task_state", 28 | sqlalchemy_utils.types.choice.ChoiceType(Task.TaskState.STATES), 29 | nullable=True, 30 | ), 31 | ) 32 | op.create_index(op.f("ix_task_task_state"), "task", ["task_state"], unique=False) 33 | op.drop_column("task", "description") 34 | op.drop_column("task", "failed") 35 | op.drop_column("task", "type") 36 | op.drop_column("task", "complete") 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.add_column( 43 | "task", sa.Column("complete", sa.BOOLEAN(), autoincrement=False, nullable=True) 44 | ) 45 | op.add_column( 46 | "task", 47 | sa.Column("type", sa.VARCHAR(length=55), autoincrement=False, nullable=True), 48 | ) 49 | op.add_column( 50 | "task", sa.Column("failed", sa.BOOLEAN(), autoincrement=False, nullable=True) 51 | ) 52 | op.add_column( 53 | "task", 54 | sa.Column( 55 | "description", sa.VARCHAR(length=128), autoincrement=False, nullable=True 56 | ), 57 | ) 58 | op.drop_index(op.f("ix_task_task_state"), table_name="task") 59 | op.drop_column("task", "task_state") 60 | op.drop_column("task", "data") 61 | # ### end Alembic commands ### 62 | -------------------------------------------------------------------------------- /busy_beaver/apps/upcoming_events/upcoming_events.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from busy_beaver.apps.upcoming_events.cards import UpcomingEventList 4 | from busy_beaver.common.wrappers.meetup import EventDetails 5 | from busy_beaver.models import Event, UpcomingEventsConfiguration 6 | 7 | 8 | def generate_upcoming_events_message(config: UpcomingEventsConfiguration): 9 | # if 0 upcoming events 10 | events = _fetch_future_events_from_database(config, count=config.post_num_events) 11 | image_url = config.slack_installation.workspace_logo_url 12 | return UpcomingEventList(events, image_url).to_dict() 13 | 14 | 15 | def generate_next_event_message(config: UpcomingEventsConfiguration): 16 | event_list = _fetch_future_events_from_database(config, count=1) 17 | if event_list: 18 | return _next_event_attachment(event_list[0]) 19 | else: 20 | return { 21 | "mrkdwn_in": ["text", "pretext"], 22 | "pretext": "*Next Event:*", 23 | "text": "No upcoming events scheduled", 24 | "color": "#008952", 25 | } 26 | 27 | 28 | def _fetch_future_events_from_database(config, count): 29 | current_epoch_time = int(time.time()) 30 | groups = [group.id for group in config.groups] 31 | upcoming_events_in_db = ( 32 | Event.query.filter(Event.group_id.in_(groups)) 33 | .filter(Event.start_epoch > current_epoch_time) 34 | .order_by(Event.start_epoch) 35 | .limit(count) 36 | ) 37 | return [EventDetails.from_event_model(model) for model in upcoming_events_in_db] 38 | 39 | 40 | def _next_event_attachment(event: EventDetails) -> dict: 41 | """Make a Slack attachment for the event.""" 42 | text = ( 43 | f"** at {event.venue}" 45 | ) 46 | return { 47 | "mrkdwn_in": ["text", "pretext"], 48 | "pretext": "*Next Event:*", 49 | "title": event.name, 50 | "title_link": event.url, 51 | "fallback": f"{event.name}: {event.url}", 52 | "text": text, 53 | "color": "#008952", 54 | } 55 | --------------------------------------------------------------------------------