├── 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 |
16 | Slack IDs for users that interact with the bot
17 | GitHub ID for users that register for the GitHub Summary feature
18 |
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 |
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 |
24 |
25 |
26 |
27 | {% if logo %}
28 | Current Logo
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | remove
37 |
38 |
39 | {% else %}
40 |
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 | "{label} ".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 |
--------------------------------------------------------------------------------