├── .dockerignore ├── src ├── api │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── models.py │ ├── views.py │ ├── serializers.py │ └── services.py ├── pyback │ ├── __init__.py │ ├── log.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── tests │ ├── __init__.py │ ├── test_tests.py │ └── conftest.py ├── frontend │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_auto_20181107_1922.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── static │ │ ├── img │ │ │ ├── blue.png │ │ │ ├── slate.png │ │ │ ├── favicon.ico │ │ │ └── favicon-16x16.png │ │ ├── js │ │ │ ├── codeschool.js │ │ │ └── messages.js │ │ └── css │ │ │ └── common.css │ ├── templates │ │ ├── frontend │ │ │ ├── index.html │ │ │ ├── messages.html │ │ │ ├── base.html │ │ │ └── codeschool-form.html │ │ └── registration │ │ │ └── login.html │ ├── urls.py │ ├── forms.py │ └── views.py ├── custom_storages.py └── manage.py ├── .gitignore ├── lib ├── util.sh └── ecs-deploy ├── bin ├── ecs_deploy.sh ├── docker_push.sh └── run.sh ├── Pipfile ├── docker ├── Dockerfile ├── example.env └── docker-compose.yml ├── MAINTAINERS.md ├── LICENSE.MD ├── README.md ├── .travis.yml ├── CODE_OF_CONDUCT.md └── Pipfile.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | *.env -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pyback/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/frontend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/frontend/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.env 3 | 4 | !/docker/example.env 5 | -------------------------------------------------------------------------------- /src/tests/test_tests.py: -------------------------------------------------------------------------------- 1 | def test_context_loads(): 2 | assert True is True 3 | -------------------------------------------------------------------------------- /src/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = 'api' 6 | -------------------------------------------------------------------------------- /src/frontend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StaticFrontendConfig(AppConfig): 5 | name = 'frontend' 6 | -------------------------------------------------------------------------------- /src/frontend/static/img/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pyback/HEAD/src/frontend/static/img/blue.png -------------------------------------------------------------------------------- /src/frontend/static/img/slate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pyback/HEAD/src/frontend/static/img/slate.png -------------------------------------------------------------------------------- /src/frontend/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pyback/HEAD/src/frontend/static/img/favicon.ico -------------------------------------------------------------------------------- /src/frontend/static/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OperationCode/operationcode-pyback/HEAD/src/frontend/static/img/favicon-16x16.png -------------------------------------------------------------------------------- /src/frontend/templates/frontend/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'frontend/base.html' %} 2 | 3 | {% block contente %} 4 | 5 |
Yo
6 | 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /lib/util.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function isDryRun { 4 | if [ "$DRYRUN" = "1" ]; then 5 | return 0 6 | else 7 | return 1 8 | fi 9 | } 10 | 11 | function runCommand { 12 | if isDryRun; then 13 | echo $1; 14 | else 15 | eval $1 16 | return $? 17 | fi 18 | } -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import django 3 | from django.conf import settings 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pyback.settings') 6 | 7 | 8 | def pytest_configure(): 9 | settings.DEBUG = False 10 | settings.RECAPTCHA_DISABLE = True 11 | 12 | django.setup() 13 | -------------------------------------------------------------------------------- /src/frontend/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path('', views.IndexView.as_view(), name='home'), 6 | path('forms/codeschool', views.CodeschoolFormView.as_view(), name='codeschool_form'), 7 | path('bot/messages', views.BotMessagesView.as_view(), name='bot_messages'), 8 | ] 9 | -------------------------------------------------------------------------------- /src/custom_storages.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from storages.backends.s3boto3 import S3Boto3Storage 3 | 4 | 5 | class StaticStorage(S3Boto3Storage): 6 | location = settings.STATICFILES_LOCATION 7 | 8 | 9 | class MediaStorage(S3Boto3Storage): 10 | location = settings.MEDIAFILES_LOCATION 11 | file_overwrite = True 12 | -------------------------------------------------------------------------------- /src/frontend/static/js/codeschool.js: -------------------------------------------------------------------------------- 1 | $("#id_logo").change(function () { 2 | const reader = new FileReader(); 3 | reader.onload = function (e) { 4 | // get loaded data and render thumbnail. 5 | $("#image")[0].src = e.target.result; 6 | }; 7 | 8 | // read the image file as a data URL. 9 | reader.readAsDataURL(this.files[0]); 10 | }); -------------------------------------------------------------------------------- /src/pyback/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from gunicorn import glogging 4 | 5 | 6 | class HealthCheckFilter(logging.Filter): 7 | def filter(self, record): 8 | return 'ELB-HealthChecker' not in record.getMessage() 9 | 10 | 11 | class CustomGunicornLogger(glogging.Logger): 12 | def setup(self, cfg): 13 | super().setup(cfg) 14 | 15 | logger = logging.getLogger('gunicorn.access') 16 | logger.addFilter(HealthCheckFilter()) 17 | -------------------------------------------------------------------------------- /src/pyback/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for src src. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pyback.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /bin/ecs_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | TRAVIS_BRANCH_UPPERCASE=$(echo $TRAVIS_BRANCH | awk '{print toupper($0)}') 4 | 5 | CLUSTER_NAME=ECS_CLUSTER_${TRAVIS_BRANCH_UPPERCASE} 6 | SERVICE_NAME=ECS_SERVICE_${TRAVIS_BRANCH_UPPERCASE} 7 | 8 | echo "Deploying $TRAVIS_BRANCH on service ${!SERVICE_NAME} on cluster ${!CLUSTER_NAME} with image $REMOTE_IMAGE_URL:$TRAVIS_BRANCH" 9 | bash ${SCRIPTDIR}/../lib/ecs-deploy -t 300 -n ${!SERVICE_NAME} -c ${!CLUSTER_NAME} -r $AWS_REGION -i $REMOTE_IMAGE_URL:$TRAVIS_BRANCH -------------------------------------------------------------------------------- /src/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 3 | from django.contrib.auth.models import User 4 | 5 | from .models import Channel, UserInfo 6 | 7 | 8 | class UserInfoInline(admin.StackedInline): 9 | model = UserInfo 10 | can_delete = False 11 | 12 | 13 | class UserAdmin(BaseUserAdmin): 14 | inlines = (UserInfoInline,) 15 | 16 | 17 | admin.site.register(Channel) 18 | admin.site.register(UserInfo) 19 | 20 | admin.site.unregister(User) 21 | admin.site.register(User, UserAdmin) 22 | -------------------------------------------------------------------------------- /src/frontend/migrations/0002_auto_20181107_1922.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-07 19:22 2 | 3 | from django.db import migrations, models 4 | import frontend.forms 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('frontend', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='codeschool', 16 | name='logo', 17 | field=models.ImageField(upload_to='logos', validators=[frontend.forms.image_validator], verbose_name='Logo'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /src/frontend/static/css/common.css: -------------------------------------------------------------------------------- 1 | .invalid, .errorlist { 2 | width: 100%; 3 | margin-top: .25rem; 4 | font-size: 80%; 5 | color: #dc3545; 6 | text-align: left; 7 | } 8 | 9 | .bg-light { 10 | background-color: #f8f9fa !important; 11 | } 12 | 13 | .nav-link { 14 | padding-bottom: 0; 15 | margin-bottom: .2rem; 16 | } 17 | 18 | .auth-card { 19 | max-width: 500px; 20 | margin-left: auto; 21 | margin-right: auto; 22 | } 23 | 24 | footer { 25 | position: fixed; 26 | left: 0; 27 | bottom: 0; 28 | width: 100%; 29 | text-align: center; 30 | } -------------------------------------------------------------------------------- /src/api/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | router = SimpleRouter() 7 | router.register('channels', views.ChannelViewSet) 8 | router.register('users', views.UserViewSet) 9 | router.register('userinfos', views.UserInfoViewSet) 10 | router.register('mods', views.UserModsList) 11 | 12 | urlpatterns = router.urls 13 | 14 | urlpatterns += [ 15 | path('botMessages', views.bot_messages, name='bot_messages'), 16 | path('deleteMessage//', views.delete_message, name='delete_message'), 17 | ] 18 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pyback.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | boto3 = "*" 8 | django = "*" 9 | django-recaptcha2 = "*" 10 | djangorestframework = "*" 11 | django-storages = "*" 12 | django-widget-tweaks = "*" 13 | gunicorn = "*" 14 | Jinja2 = "*" 15 | pillow = "*" 16 | python-decouple = "*" 17 | python-dotenv = "*" 18 | psycopg2 = "*" 19 | requests = "*" 20 | slack-sansio = "*" 21 | "slack-sansio[requests]" = "*" 22 | pyyaml = "*" 23 | websocket = "*" 24 | 25 | [dev-packages] 26 | pytest = "*" 27 | pytest-django = "*" 28 | django-debug-toolbar = "*" 29 | 30 | [requires] 31 | python_version = "3.7" 32 | 33 | [pipenv] 34 | allow_prereleases = true -------------------------------------------------------------------------------- /bin/docker_push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # import util functions 4 | source "${SCRIPTDIR}/../lib/util.sh" 5 | 6 | echo "Logging into ECR..." 7 | AWS_LOGIN=$(runCommand "aws ecr get-login --region $AWS_REGION --no-include-email") 8 | 9 | if [ "$?" = "0" ]; then 10 | eval $AWS_LOGIN || exit $? 11 | echo "Building Docker image..." 12 | runCommand "docker build -t $IMAGE_NAME -f docker/Dockerfile ." || exit $? 13 | echo "Pushing image $IMAGE_NAME:$TRAVIS_BRANCH" 14 | runCommand "docker tag $IMAGE_NAME:latest $REMOTE_IMAGE_URL:$TRAVIS_BRANCH" || exit $? 15 | runCommand "docker push $REMOTE_IMAGE_URL:$TRAVIS_BRANCH" || exit $? 16 | echo "Successfully built and pushed $REMOTE_IMAGE_URL:$TRAVIS_BRANCH" 17 | else 18 | echo "Failed to log in to AWS, exiting" 19 | exit 1 20 | fi -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | ENV PIP_NO_BINARY psycopg2 6 | 7 | WORKDIR /src 8 | RUN mkdir /static 9 | 10 | COPY Pipfile* /src/ 11 | 12 | RUN apt-get update \ 13 | && apt-get install -y libpq-dev python3-websocket gcc \ 14 | && rm -rf /var/lib/apt/lists/* \ 15 | && pip install --upgrade pip \ 16 | && pip install pipenv \ 17 | && pipenv install --system --deploy --dev \ 18 | && apt-get purge -y --auto-remove gcc 19 | 20 | COPY src /src 21 | 22 | EXPOSE 8000 23 | CMD python manage.py collectstatic --no-input;python manage.py makemigrations;\ 24 | python manage.py migrate;\ 25 | gunicorn pyback.wsgi -b 0.0.0.0:8000 -w 3 --access-logfile=- --error-logfile=- --capture-output --logger-class "pyback.log.CustomGunicornLogger" 26 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | This file lists how the Operation Code PyBot project is maintained. When making changes to the system, this file tells you who needs to review your contribution - you need a simple majority of maintainers for the relevant subsystems to provide a 👍 on your pull request. Additionally, you need to not receive a veto from a Lieutenant or the Project Lead. 4 | 5 | Check out [how Operation Code Open Source projects are maintained](https://github.com/OperationCode/START_HERE/blob/61cebc02875ef448679e1130d3a68ef2f855d6c4/open_source_maintenance_policy.md) for details on the process, how to become a maintainer, lieutenant, or the project lead. 6 | 7 | # Project Lead 8 | 9 | * [William Montgomery](http://www.github.com/wimo7083) 10 | 11 | # Lieutenant 12 | 13 | * [Allen Anthes](http://www.github.com/allenanthes) 14 | 15 | # Maintainers 16 | 17 | * [YOU](http://www.github.com/YOU) -------------------------------------------------------------------------------- /docker/example.env: -------------------------------------------------------------------------------- 1 | ### pyback.env ### 2 | 3 | # RECAPTCHA_PUBLIC_KEY= 4 | # RECAPTCHA_PRIVATE_KEY= 5 | 6 | # GITHUB_JWT= 7 | # GITHUB_REPO= 8 | 9 | # SLACK_TOKEN= 10 | # BOT_NAME= 11 | 12 | # DB_ENGINE= 13 | # DB_NAME= 14 | # DB_USER= 15 | # DB_PASSWORD= 16 | # DB_HOST= 17 | # DB_PORT= 18 | 19 | ## Optional Configs ## 20 | #DEBUG=True 21 | #ENVIRONMENT=aws_dev 22 | 23 | # AWS_STORAGE_BUCKET_NAME= 24 | # BUCKET_REGION_NAME= 25 | # AWS_ACCESS_KEY_ID= 26 | # AWS_SECRET_ACCESS_KEY= 27 | 28 | 29 | # --------------------------- # 30 | ### pybot.env ### 31 | 32 | # APP_TOKEN= 33 | # SLACK_TOKEN= 34 | # SLACK_VERIFY= 35 | # SLACK_BOT_ID= 36 | # SLACK_BOT_USER_ID= 37 | 38 | 39 | # PYBACK_PORT= 40 | # PYBACK_HOST= 41 | 42 | # COMMUNITY_CHANNEL= 43 | # MENTORS_CHANNEL= 44 | 45 | # AIRTABLE_API_KEY= 46 | # AIRTABLE_BASE_KEY= 47 | 48 | # PYBACK_TOKEN= 49 | 50 | # --------------------------- # 51 | ### ngrok.env ### 52 | 53 | # NGROK_AUTH= 54 | # NGROK_SUBDOMAIN= -------------------------------------------------------------------------------- /src/frontend/templates/frontend/messages.html: -------------------------------------------------------------------------------- 1 | {% extends "frontend/base.html" %} 2 | {% block styles %} 3 | 4 | {% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
TimestampChannelMessageRemove
19 |
20 |
21 | {% endblock %} 22 | 23 | {% block page-scripts %} 24 | 26 | {% load static %} 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Operation Code 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 | -------------------------------------------------------------------------------- /src/frontend/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "frontend/base.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block content %} 5 |
6 |
8 |

Sign in

9 |
10 | {% csrf_token %} 11 | 12 |
13 | {{ form.username.label_tag }} 14 | {% render_field form.username class='form-control' %} 15 |
16 | 17 | 18 |
19 | {{ form.password.label_tag }} 20 | {% render_field form.password class='form-control' %} 21 |
22 | 23 |
24 | 25 | 26 |
27 |
28 | {% endblock %} 29 | 30 | 31 |
32 | Please choose a username. 33 |
-------------------------------------------------------------------------------- /src/api/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.db.models.signals import post_save 4 | from django.dispatch import receiver 5 | 6 | 7 | class UserInfo(models.Model): 8 | """ 9 | Model used to extend Django's base User model 10 | """ 11 | user = models.OneToOneField(User, on_delete=models.CASCADE) 12 | slack_id = models.CharField(max_length=16) 13 | 14 | def __str__(self): 15 | return f'Username: {self.user} Slack ID: {self.slack_id}' 16 | 17 | 18 | class Channel(models.Model): 19 | name = models.CharField(max_length=255) 20 | channel_id = models.CharField(max_length=32) 21 | mods = models.ManyToManyField(UserInfo) 22 | 23 | def __str__(self): 24 | return f'{self.name} - {self.channel_id}' 25 | 26 | 27 | @receiver(post_save, sender=User) 28 | def create_user_info(sender, instance, created, **kwargs): 29 | """ 30 | Function creates an empty UserInfo attached to the created User if Slack_ID 31 | isn't provided upon User creation 32 | """ 33 | if created: 34 | try: 35 | instance.userinfo 36 | except UserInfo.DoesNotExist: 37 | UserInfo.objects.create(user=instance) 38 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | 4 | pyback: 5 | image: pyback:latest 6 | build: 7 | context: .. 8 | dockerfile: docker/Dockerfile 9 | container_name: pyback01 10 | env_file: 11 | - example.env 12 | - pyback.env 13 | volumes: 14 | - ../src:/src 15 | ports: 16 | - 8000:8000 17 | depends_on: 18 | - db 19 | 20 | 21 | pybot: 22 | image: pybot:latest 23 | container_name: pybot01 24 | env_file: 25 | - example.env 26 | - pybot.env 27 | ports: 28 | - 5000:5000 29 | 30 | 31 | db: 32 | image: postgres:10.1-alpine 33 | container_name: pg01 34 | ports: 35 | - 5434:5432 36 | volumes: 37 | - postgres_data:/var/lib/postgresql/data/ 38 | 39 | 40 | ngrok-pybot: 41 | image: wernight/ngrok:latest 42 | env_file: 43 | - example.env 44 | - ngrok.env 45 | environment: 46 | - NGROK_PORT=pybot:5000 47 | - NGROK_SUBDOMAIN=pybot 48 | ports: 49 | - 4040:4040 50 | 51 | 52 | ngrok-pyback: 53 | image: wernight/ngrok:latest 54 | env_file: 55 | - example.env 56 | - ngrok.env 57 | environment: 58 | - NGROK_PORT=pyback:8000 59 | - NGROK_SUBDOMAIN=pyback 60 | ports: 61 | - 4050:4040 62 | 63 | 64 | volumes: 65 | postgres_data: -------------------------------------------------------------------------------- /src/pyback/urls.py: -------------------------------------------------------------------------------- 1 | """src URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import path, include 20 | 21 | urlpatterns = [ 22 | path('admin/', admin.site.urls), 23 | path('accounts/', include('django.contrib.auth.urls')), 24 | path('api/', include('api.urls')), 25 | path('', include('frontend.urls')), 26 | ] 27 | 28 | # Serves static files locally if DEBUG is True 29 | if settings.DEBUG: 30 | import debug_toolbar 31 | 32 | urlpatterns += [ 33 | *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT), 34 | path('__debug__/', include(debug_toolbar.urls)), 35 | ] 36 | -------------------------------------------------------------------------------- /src/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2018-09-04 22:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Channel', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=255)), 22 | ('channel_id', models.CharField(max_length=32)), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='UserInfo', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('slack_id', models.CharField(max_length=16)), 30 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 31 | ], 32 | ), 33 | migrations.AddField( 34 | model_name='channel', 35 | name='mods', 36 | field=models.ManyToManyField(to='api.UserInfo'), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /src/frontend/static/js/messages.js: -------------------------------------------------------------------------------- 1 | $(document).ready(() => { 2 | const $table = $('#message-table').DataTable({ 3 | order: [[0, 'desc']], 4 | pageLength: 100, 5 | ajax: '/api/botMessages', 6 | columns: [ 7 | {title: 'Timestamp', data: 'ts', render: renderTimestamp}, 8 | {title: 'Channel', data: 'channel'}, 9 | {title: 'Message', data: 'text'}, 10 | {title: 'Remove', data: 'delete_url', render: renderMessage}, 11 | ], 12 | responsive: true, 13 | }); 14 | $('#message-table_wrapper').addClass('bs-select'); 15 | 16 | $(document).on('click', '.delete-btn', deleteMessage); 17 | 18 | async function deleteMessage(e) { 19 | const url = $(e.currentTarget).data('delete'); 20 | const result = await fetch(url); 21 | const json = await result.json(); 22 | 23 | if (json.ok) { 24 | const $row = $(this).parents('tr'); 25 | $row.fadeOut(400, deleteRow) 26 | } 27 | } 28 | 29 | function deleteRow($row) { 30 | $table.row($row) 31 | .remove() 32 | .draw() 33 | } 34 | 35 | function renderTimestamp(data) { 36 | return new Date(data * 1e3).toISOString() 37 | } 38 | 39 | function renderMessage(data) { 40 | return ` 41 | `; 44 | } 45 | }); 46 | 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Operation Code logo 9 | 10 |
11 |
12 |
13 | 14 | # IMPORTANT 15 | This repo has been archived and its functionality merged into the new Python Backend located at https://github.com/OperationCode/back-end 16 | 17 | 18 | [![Build Status](https://travis-ci.org/OperationCode/operationcode-pyback.svg?branch=master)](https://travis-ci.org/OperationCode/operationcode-pyback) 19 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 20 | [![Twitter Follow](https://img.shields.io/twitter/follow/operation_code.svg?style=social&label=Follow&style=social)](https://twitter.com/operation_code) 21 | 22 | 23 | # [OperationCode-Pyback](https://github.com/OperationCode/operationcode-Pyback) 24 | 25 | 26 | 27 | ## Contributing 28 | Bug reports and pull requests are welcome on [Github](https://github.com/OperationCode/operationcode-pybot). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. If you wish to assist, join the [\#new-team-rewrite](https://operation-code.slack.com/messages/C7NJLCCMB/) rewrite to learn how to contribute. 29 | 30 | ## License 31 | This package is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 32 | -------------------------------------------------------------------------------- /bin/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Only process first job in matrix (TRAVIS_JOB_NUMBER ends with ".1") 4 | if [[ ! $TRAVIS_JOB_NUMBER =~ \.1$ ]]; then 5 | echo "Skipping deploy since it's not the first job in matrix" 6 | exit 0 7 | fi 8 | 9 | # Don't process pull requests 10 | # $TRAVIS_PULL_REQUEST will be the PR number or "false" if not a PR 11 | if [[ -n "$TRAVIS_PULL_REQUEST" ]] && [[ "$TRAVIS_PULL_REQUEST" != "false" ]]; then 12 | echo "Skipping deploy because it's a pull request" 13 | exit 0 14 | fi 15 | 16 | # Only process branches listed in DEPLOY_BRANCHES 17 | BRANCHES_TO_DEPLOY=($DEPLOY_BRANCHES) 18 | if [[ ! " ${BRANCHES_TO_DEPLOY[@]} " =~ " ${TRAVIS_BRANCH} " ]]; then 19 | # whatever you want to do when arr contains value 20 | echo "Skipping deploy, not a branch to be deployed" 21 | exit 0 22 | fi 23 | 24 | pip install awscli -q 25 | 26 | if [ $? = 0 ]; then 27 | AWSBIN=$(which aws) 28 | AWSPATH=$(dirname $AWSBIN) 29 | export PATH=$PATH:$AWSPATH 30 | 31 | # Get absolute path of dir where run.sh is located 32 | SOURCE="${BASH_SOURCE[0]}" 33 | while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink 34 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 35 | SOURCE="$(readlink "$SOURCE")" 36 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located 37 | done 38 | export SCRIPTDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 39 | 40 | bash ${SCRIPTDIR}/docker_push.sh && 41 | bash ${SCRIPTDIR}/ecs_deploy.sh 42 | 43 | else 44 | echo "Failed to install AWS CLI" 45 | exit 1 46 | fi -------------------------------------------------------------------------------- /src/api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.http import JsonResponse 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from .services import get_messages, delete 7 | from .serializers import ChannelSerializer, UserSerializer, UserInfoSerializer 8 | from .models import Channel, UserInfo 9 | 10 | 11 | class ChannelViewSet(ReadOnlyModelViewSet): 12 | permission_classes = (IsAuthenticated,) 13 | serializer_class = ChannelSerializer 14 | queryset = Channel.objects.all() 15 | 16 | 17 | class UserViewSet(ReadOnlyModelViewSet): 18 | permission_classes = (IsAuthenticated,) 19 | serializer_class = UserSerializer 20 | queryset = User.objects.all() 21 | 22 | 23 | class UserInfoViewSet(ReadOnlyModelViewSet): 24 | permission_classes = (IsAuthenticated,) 25 | serializer_class = UserInfoSerializer 26 | queryset = UserInfo.objects.all().select_related('user') 27 | 28 | 29 | class UserModsList(ReadOnlyModelViewSet): 30 | permission_classes = (IsAuthenticated,) 31 | serializer_class = ChannelSerializer 32 | queryset = Channel.objects.all() 33 | 34 | def get_queryset(self): 35 | user_slack_id = self.request.query_params.get('slack_id', None) 36 | user_channel_id = self.request.query_params.get('channel_id', None) 37 | if user_slack_id: 38 | return Channel.objects.filter(mods__slack_id=user_slack_id, channel_id=user_channel_id).all() 39 | return self.queryset 40 | 41 | 42 | def bot_messages(request): 43 | return JsonResponse({'data': get_messages()}) 44 | 45 | 46 | def delete_message(request, ts: str, channel: str): 47 | return JsonResponse(delete(ts, channel)) 48 | -------------------------------------------------------------------------------- /src/api/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import serializers 3 | from .models import Channel, UserInfo 4 | 5 | 6 | class UserSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = User 9 | fields = ( 10 | 'id', 11 | 'username', 12 | 'email', 13 | 'is_staff', 14 | 'is_superuser', 15 | 'groups', 16 | 'user_permissions' 17 | ) 18 | 19 | 20 | class ModsSerializer(serializers.ModelSerializer): 21 | class Meta: 22 | model = UserInfo 23 | fields = ( 24 | 'id', 25 | 'slack_id', 26 | ) 27 | 28 | 29 | class ChannelSerializer(serializers.ModelSerializer): 30 | """Serializer to map the Model instance into JSON format.""" 31 | mods = ModsSerializer(many=True) 32 | 33 | class Meta: 34 | """Meta class to map serializer's fields with the model fields.""" 35 | model = Channel 36 | fields = '__all__' 37 | 38 | def create(self, validated_data): 39 | mods_data = validated_data.pop('mods') 40 | channel = Channel.objects.create(**validated_data) 41 | return channel 42 | 43 | 44 | class UserInfoSerializer(serializers.ModelSerializer): 45 | user = UserSerializer() 46 | 47 | class Meta: 48 | model = UserInfo 49 | fields = '__all__' 50 | 51 | def create(self, validated_data): 52 | user_data = {key: value for key, value in validated_data.pop('user').items() if value} 53 | user, created = User.objects.get_or_create(**user_data) 54 | userinfo = UserInfo.objects.create(**validated_data, user=user) 55 | 56 | return userinfo 57 | 58 | def update(self, instance, validated_data): 59 | user_data = validated_data.pop('user') 60 | instance.slack_id = validated_data.get('slack_id', instance.slack_id) 61 | 62 | user = instance.user 63 | user.username = user_data.get('username', user.username) 64 | user.is_staff = user_data.get('is_staff', user.is_staff) 65 | user.email = user_data.get('email', user.email) 66 | 67 | -------------------------------------------------------------------------------- /src/frontend/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-07 17:28 2 | 3 | from django.db import migrations, models 4 | import frontend.forms 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='CodeSchool', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=100, verbose_name='School Name')), 20 | ('url', models.CharField(max_length=100, verbose_name='School website url')), 21 | ('fulltime', models.BooleanField(blank=True, verbose_name='Fulltime available?')), 22 | ('hardware', models.BooleanField(blank=True, verbose_name='Hardware included?')), 23 | ('has_online', models.BooleanField(blank=True, verbose_name='Online Offered?')), 24 | ('only_online', models.BooleanField(blank=True, verbose_name='Online only?')), 25 | ('accredited', models.BooleanField(blank=True, verbose_name='VA Accredited?')), 26 | ('housing', models.BooleanField(blank=True, verbose_name='Housing Included?')), 27 | ('mooc', models.BooleanField(blank=True, verbose_name='MOOC Only?')), 28 | ('rep_name', models.CharField(max_length=100, verbose_name='School Representative')), 29 | ('rep_email', models.CharField(max_length=100, verbose_name='Representative Email')), 30 | ('address1', models.CharField(max_length=100, verbose_name='Address Line 1')), 31 | ('address2', models.CharField(blank=True, max_length=100, verbose_name='Address Line 2')), 32 | ('city', models.CharField(max_length=100, verbose_name='City')), 33 | ('state', models.CharField(max_length=100, verbose_name='State')), 34 | ('zipcode', models.CharField(max_length=100, verbose_name='Zipcode')), 35 | ('country', models.CharField(max_length=100, verbose_name='Country')), 36 | ('logo', models.ImageField(upload_to='logos/', validators=[frontend.forms.image_validator], verbose_name='Logo')), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /src/frontend/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from django.db.models import CharField, BooleanField, ImageField 5 | from snowpenguin.django.recaptcha2.fields import ReCaptchaField 6 | from snowpenguin.django.recaptcha2.widgets import ReCaptchaWidget 7 | from django.conf import settings 8 | 9 | 10 | def image_validator(file): 11 | image = file.file.image 12 | if image.width != 200 or image.height != 200: 13 | raise ValidationError('Image must be 200x200') 14 | 15 | 16 | class CodeSchool(models.Model): 17 | name = CharField(max_length=100, verbose_name='School Name') 18 | url = CharField(max_length=100, verbose_name='School website url') 19 | 20 | fulltime = BooleanField(verbose_name='Fulltime available?', blank=True) 21 | hardware = BooleanField(verbose_name='Hardware included?', blank=True) 22 | has_online = BooleanField(verbose_name='Online Offered?', blank=True) 23 | only_online = BooleanField(verbose_name='Online only?', blank=True) 24 | accredited = BooleanField(verbose_name='VA Accredited?', blank=True) 25 | housing = BooleanField(verbose_name='Housing Included?', blank=True) 26 | mooc = BooleanField(verbose_name='MOOC Only?', blank=True) 27 | 28 | rep_name = CharField(max_length=100, verbose_name='School Representative') 29 | rep_email = CharField(max_length=100, verbose_name='Representative Email') 30 | address1 = CharField(max_length=100, verbose_name='Address Line 1') 31 | address2 = CharField(max_length=100, verbose_name='Address Line 2', blank=True) 32 | city = CharField(max_length=100, verbose_name='City') 33 | state = CharField(max_length=100, verbose_name='State') 34 | zipcode = CharField(max_length=100, verbose_name='Zipcode') 35 | country = CharField(max_length=100, verbose_name='Country') 36 | 37 | logo = ImageField(verbose_name='Logo', validators=[image_validator], upload_to='logos') 38 | 39 | 40 | class RecaptchaForm(forms.Form): 41 | recaptcha = ReCaptchaField(private_key=settings.RECAPTCHA_PRIVATE_KEY, widget=ReCaptchaWidget()) 42 | 43 | 44 | class CodeSchoolModelForm(forms.ModelForm): 45 | class Meta: 46 | model = CodeSchool 47 | fields = '__all__' 48 | 49 | 50 | class CodeSchoolForm(CodeSchoolModelForm, RecaptchaForm): 51 | pass 52 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - IMAGE_NAME=pyback 4 | - DEPLOY_BRANCHES="master staging" 5 | - AWS_REGION=us-east-2 6 | - REMOTE_IMAGE_URL=633607774026.dkr.ecr.us-east-2.amazonaws.com/pyback 7 | - ECS_CLUSTER_MASTER=python-oc-services 8 | - ECS_CLUSTER_STAGING=python-oc-services 9 | - ECS_SERVICE_MASTER=pyback-svc 10 | - ECS_SERVICE_STAGING=pyback-svc-staging 11 | - secure: iJv9zwNPA6EeLgTUhiaTq3YnoSPILqz5Oerl1XoAbL6F5kcXrtY1/mw8BnqdgM7QIr7Q6fi75L1Ts2jBsi0IU/4FtlhRKuaMqlmCBd8z52Mb+KEGP3YJhfPceq5raVqIjc5nHikd2CsGdtSr6gFfNaRITvHqwaUs8owF5eMQX6irxnnWVmz47mKwxZ251vGQGyEukAYQRcIwzjr2dnA94E+tj2pTaCXJO4sBc9BqeDO+rYQCWc1yivhHxx0Zr/JuR/jceNjNL2rZa41GmHMlHenFcnseH84OQ/+/c7AmPLHcYmiTJIL1yY+sMS/PRhLOGzQnxIQvaA1LD8gEAY+YLn3Q6AgHveV28udfU7jzKQmgRqS4OeM1OhykknBcAdLo8k2gbVx73pO32Lgse0JL2S2jmr/yWBpYGoXJ/LeB4x+YG5mookGktRbwtb/RyPI/+W4DE8mgD/d5b0u6zXCwIQ1Xe78Lr4k+uo3mWMnjcvix2gLkPDwlW4zZ8UGe7M9XCkKuZnhxGUV3ijBuecIMBfABi1wGstXQzHLNtiul0ZPWUAeQxDoN+lr94aw+R0U9DcTHWtM2QXY77+olV7D9pnbPZKXB2QRfOpvS2jDQo4nXGCVV/UmCrYgC9sDQQD5u8QXqgJRHTb7Rl+9AKwl1C5u60sBRRpaneQlRBLOjWlo= 12 | - secure: MZlr2GanK4Je41ncRFHwJm8SyJ1yjPI3gKFdXb5xoVMdsKBnYbouz+5PG73Ri9oDnOYsY6c9lv6HrSSo6fO0fc/lyhZeKu34F288rAYPGJdg/Qn0c0xYsKug2qPAn+Oyl0BWjnfR5nSP7cdhRVwca84kVVfOWer6DP+bZZ2tJda5xKqyRFgvZRWZnrUegY/ciXtwjLswnwOX9VE9x0un29acsZvWDCRoGFM0P6SeM/aVYp+o5iJyw6mD8wtmLcw4kI6q3r6S2MbGJ+fefiUW70Of3oUD721aW2DYVkMd5pRgW3/VvW5km+UNo8zD9pffZLGev68hdyEA0Rhrw/qX5DinoS46HnSVlyJWdDUselYELumHAKdIjuWYMaW0sboN89mfNX0H1KaXT2Gc1+eO6nlmJLKwK2KQYULgQXkKoOOhCvvvw+uhMaqxjMp+oLcvwHt6s96KjlRGn7dNDnDePpWH7Pht0L1cYg9xnaeTKJfQm2N6cOQtLKHO/rBHYiEItG6O/kaQytokrU1tLo6Y5o3+8dybvulFha3xgq39oI0Mtp5fVbMM3wZOLQ5UqedYATMaoy4LamEiP0cvThlP5X5/TvyFcQxBu39EYWmaBcSPooncQVPoGyLzVAcfBQp3hOe0XbYKoY0VjHVWYbO2ghVXVax8CDJZ2feE5ghsdMQ= 13 | os: linux 14 | sudo: required 15 | language: python 16 | dist: xenial 17 | python: 3.7 18 | cache: pip 19 | install: 20 | - pip install pipenv 21 | - pipenv install --system --deploy --dev 22 | script: 23 | - pytest 24 | services: 25 | - docker 26 | after_success: 27 | - bash bin/run.sh 28 | notifications: 29 | slack: 30 | rooms: 31 | - secure: RTDdTkBWrw7q1r6ybFFqm2EIDSUkazrUMXPwyImmlktUjIXucadrt7NGgR8TbE/J04WgT0h8/Eb2et+PDCooUu7SZCWfsgutKfX3IFxXjPEBz6XLaPqcHKMPsHeuAJ62ECeNdm8ALlW+7yOe3E4umXNHF2qUp4JZcx/lTCHaV/ew3jhmGHvELSW6uOYkRI8ZFvGZeXzkXDf3c0AyHS+T0CU8xB/OWljT0pU9J4cG22e6UaINEvWu2r9qgjPxLZsxRls2AlNWJKKGg+hzoI76dk6SiiPQ8tr4Eu4iWDcodz484LnXM5KG/jdsizNCGIk7MgdppWGlry1d9EWl47YwbK5aF7l0fCbV7As4tbUaUZVkxpCPoP+GoolH6hmv7gq1bkqUEBseJIXX9eEzE056lgq2rvC40KR5uR+olkCPM57tKITFHH7xEsQX9/Qe8VYFnyjJwjykY2NKVZ22mCEz0l3KLlmnXtlAnBdp7q6mWKJ/oimXdIaGUpWTSruUv9o4/ht4yVrqZFVjbMI3Cuqbk72K42BMEFW0fxojO766yOlO4KHdUYPeBG1Q4NovQVM5diA2pQ+QoVLFRpsQg9XcaTi6XwqiFVIdAAWF9z1YaJ0if9vm0AmTU14ayqJn98hF8/IQFICvKV7m8LEB5NPenV3ob2/wX+EHePWTMAtX+tY= 32 | -------------------------------------------------------------------------------- /src/frontend/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Tuple 4 | 5 | import requests 6 | from django.conf import settings 7 | from django.core.files.uploadedfile import InMemoryUploadedFile 8 | from django.views.generic import FormView, TemplateView 9 | 10 | from .forms import CodeSchoolForm 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class IndexView(TemplateView): 16 | template_name = 'frontend/index.html' 17 | 18 | 19 | class CodeschoolFormView(FormView): 20 | form_class = CodeSchoolForm 21 | template_name = 'frontend/codeschool-form.html' 22 | success_url = f'https://github.com/{settings.GITHUB_REPO}/issues' 23 | 24 | def form_valid(self, form): 25 | form.save() 26 | handle_submission(form.cleaned_data) 27 | return super().form_valid(form) 28 | 29 | def form_invalid(self, form): 30 | return super().form_invalid(form) 31 | 32 | 33 | class BotMessagesView(TemplateView): 34 | template_name = 'frontend/messages.html' 35 | 36 | 37 | def get_logo_and_users(logo: InMemoryUploadedFile) -> Tuple[str, str]: 38 | school_logo = logo.name.replace(' ', '_') 39 | if settings.DEBUG or settings.PRE_PROD: 40 | users = '@wimo7083 @AllenAnthes,' 41 | else: 42 | users = '@wimo7083 @jhampton @kylemh' 43 | 44 | logo_url = f'{settings.MEDIA_URL}logos/{school_logo}' 45 | return logo_url, users 46 | 47 | 48 | def handle_submission(form: dict): 49 | repo_path = settings.GITHUB_REPO 50 | url = f"https://api.github.com/repos/{repo_path}/issues" 51 | headers = {"Authorization": f"Bearer {settings.GITHUB_JWT}"} 52 | 53 | params = make_params(**form) 54 | res = requests.post(url, headers=headers, data=json.dumps(params)) 55 | logger.info(f'response from github API call {res}') 56 | 57 | 58 | def make_params(logo, name, url, address1, city, state, zipcode, country, rep_name, rep_email, recaptcha='', 59 | address2=None, fulltime=False, hardware=False, has_online=False, only_online=False, accredited=False, 60 | housing=False, mooc=False): 61 | logo_url, notify_users = get_logo_and_users(logo) 62 | 63 | return ({ 64 | 'title': f'New Code School Request: {name}', 65 | 'body': ( 66 | f"Name: {name}\n" 67 | f"Website: {url}\n" 68 | f"Full Time: {fulltime}\n" 69 | f"Hardware Included: {hardware}\n" 70 | f"Has Online: {has_online}\n" 71 | f"Only Online: {only_online}\n" 72 | f"VA Accredited: {accredited}\n" 73 | f"Housing Included: {housing}\n" 74 | f"MOOC Only: {mooc}\n" 75 | 76 | f"Address: {address1} {address2}\n" 77 | f"City: {city}\n" 78 | f"State: {state}\n" 79 | f"Country: {country}\n" 80 | f"Zip: {zipcode}\n\n" 81 | f"Representative Name: {rep_name}\n" 82 | f"Representative Email: {rep_email}\n" 83 | 84 | f"logo: ![school-logo]({logo_url})\n" 85 | 86 | 'This code school is ready to be added/updated:\n' 87 | f"{notify_users}\n" 88 | "Please close this issue once you've added/updated the code school." 89 | ) 90 | }) 91 | -------------------------------------------------------------------------------- /src/api/services.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import lru_cache 3 | from typing import Dict, List 4 | 5 | import requests 6 | from django.urls import reverse 7 | from slack import methods 8 | from slack.io.requests import SlackAPI 9 | from django.conf import settings 10 | 11 | SLACK_TOKEN = settings.SLACK_TOKEN 12 | 13 | slack_client = SlackAPI(token=SLACK_TOKEN, session=requests.session()) 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class Message: 18 | def __init__(self, ts, channel, text): 19 | self.ts = ts 20 | self.channel = channel 21 | self.text = text 22 | 23 | @property 24 | def channel_name(self) -> str: 25 | return Message.get_channel_name(self.channel) 26 | 27 | @property 28 | def delete_url(self) -> str: 29 | return reverse('delete_message', kwargs={'ts': self.ts, 'channel': self.channel}) 30 | 31 | @classmethod 32 | @lru_cache(64) 33 | def get_channel_name(cls, channel: str) -> str: 34 | response = slack_client.query(methods.CONVERSATIONS_INFO, data=dict(channel=channel)) 35 | if response['ok']: 36 | channel = response['channel'] 37 | if 'name' in channel: 38 | return response['channel']['name'] 39 | elif channel['is_im']: 40 | return cls.user_name_from_id(channel['user']) 41 | else: 42 | return channel 43 | else: 44 | return channel 45 | 46 | @classmethod 47 | @lru_cache(64) 48 | def user_name_from_id(cls, user_id: str): 49 | response = slack_client.query(methods.USERS_INFO, data=dict(user=user_id)) 50 | try: 51 | if response['user']['real_name']: 52 | return response['user']['real_name'].title() 53 | elif response['user']['name']: 54 | return response['user']['name'].title() 55 | except KeyError as error: 56 | logger.exception(error) 57 | else: 58 | return 'New Member' 59 | 60 | def serialize(self) -> Dict[str, str]: 61 | return { 62 | 'ts': self.ts, 63 | 'channel': self.channel_name, 64 | 'text': self.text, 65 | 'delete_url': self.delete_url, 66 | } 67 | 68 | 69 | def get_messages() -> List[Dict[str, str]]: 70 | bot_name = settings.BOT_NAME 71 | 72 | data = { 73 | 'token': settings.SLACK_TOKEN, 74 | 'query': f'from:{bot_name}', 75 | 'count': 100, 76 | 'sort': 'timestamp' 77 | } 78 | 79 | json_response = _call_slack_api(data) 80 | matches = json_response['messages']['matches'] 81 | messages = [Message(match['ts'], match['channel']['id'], match['text']).serialize() for match in matches] 82 | return messages 83 | 84 | 85 | def _call_slack_api(data): 86 | res = requests.post('https://slack.com/api/search.messages', data=data) 87 | logger.debug(f'API call method: search.messages || Result: {res.status_code}') 88 | res.raise_for_status() 89 | 90 | return res.json() 91 | 92 | 93 | def delete(ts: str, channel: str): 94 | data = { 95 | 'ts': ts, 96 | 'channel': channel, 97 | 'token': settings.SLACK_BOT_TOKEN 98 | } 99 | url, *other = methods.CHAT_DELETE.value 100 | res = requests.get(url, params=data) 101 | return res.json() 102 | -------------------------------------------------------------------------------- /src/frontend/templates/frontend/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}Operation Code Pyback{% endblock %} 5 | {% block header_extra %}{% endblock %} 6 | {% load static %} 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 18 | 19 | {% block styles %}{% endblock %} 20 | 21 | 22 | 23 | 60 | {% block content %}{% endblock %} 61 | 62 |
63 | {% block footer %} 64 | {% block scripts %} 65 | 66 | 67 | 69 | 70 | {% block page-scripts %}{% endblock %} 71 | {% endblock %} 72 | {% endblock %} 73 |
74 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Operation Code: Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /src/frontend/templates/frontend/codeschool-form.html: -------------------------------------------------------------------------------- 1 | {% extends "frontend/base.html" %} 2 | {% load recaptcha2 %} 3 | {% block header_extra %} 4 | {% recaptcha_init %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 | {% load widget_tweaks %} 9 |
10 | 11 |
12 | 13 |
14 | {% csrf_token %} 15 | {% for hidden_field in form.hidden_fields %} 16 | {{ hidden_field }} 17 | {% endfor %} 18 |
19 |
20 | {% for field in form.visible_fields %} 21 | {% if field.name == 'rep_name' %} 22 |
23 |
24 | {% endif %} 25 | {% if field|field_type == 'charfield' %} 26 |
27 | {{ field.label_tag }} 28 | {% render_field field class="form-control" %} 29 | {% if field.help_text %} 30 | {{ field.help_text }} 31 | {% endif %} 32 |
33 | {% endif %} 34 | {% if field|field_type == 'booleanfield' %} 35 | 37 |
38 | 44 |
45 | {% endif %} 46 | {% endfor %} 47 |
48 |
49 | School Logo 50 | 51 |
52 |
53 | {{ form.logo }} 54 |
55 |
56 | 57 | {{ form.logo.errors }} 58 |
59 |
60 |
61 | {{ form.recaptcha }} 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 |
71 | {% endblock %} 72 | {% block page-scripts %} 73 | {% load static %} 74 | 75 | {% endblock %} 76 | -------------------------------------------------------------------------------- /src/pyback/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for pyback. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | from decouple import config 16 | from dotenv import load_dotenv 17 | 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | # loads configs when running locally 21 | url = Path(os.path.dirname(BASE_DIR)) / 'docker' / 'pyback.env' 22 | load_dotenv(dotenv_path=url) 23 | 24 | if 'aws' in config('ENVIRONMENT', 'local'): 25 | AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} 26 | AWS_STORAGE_BUCKET_NAME = config('AWS_STORAGE_BUCKET_NAME') 27 | AWS_S3_REGION_NAME = config('BUCKET_REGION_NAME') # e.g. us-east-2 28 | AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID') 29 | AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY') 30 | AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' 31 | AWS_DEFAULT_ACL = None 32 | AWS_LOCATION = 'static' 33 | STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/' 34 | MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/' 35 | 36 | STATICFILES_LOCATION = 'static' 37 | MEDIAFILES_LOCATION = 'media' 38 | STATICFILES_STORAGE = 'custom_storages.StaticStorage' 39 | DEFAULT_FILE_STORAGE = 'custom_storages.MediaStorage' 40 | 41 | else: 42 | STATIC_URL = '/static/' 43 | MEDIA_URL = '/media/' 44 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 45 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 46 | 47 | # Quick-start development settings - unsuitable for production 48 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 49 | SECRET_KEY = config('SECRET_KEY', '3c!e@=v*#u_#at^atmv@zkg&v%$b-&5($v=j+826+q@o3*xrc%') 50 | 51 | DEBUG = config('DEBUG', False, cast=bool) 52 | PRE_PROD = config('PRE_PROD', False, cast=bool) 53 | 54 | EXTRA_HOSTS = config('EXTRA_HOSTS', 'localhost', cast=lambda v: [s.strip() for s in v.split(',')]) 55 | ALLOWED_HOSTS = ['127.0.0.1', 'localhost', 'pyback.ngrok.io', 'pyback', 56 | 'pyback-lb-197482116.us-east-2.elb.amazonaws.com', 57 | 'pyback-lb-1460262385.us-east-2.elb.amazonaws.com', 'web'] + EXTRA_HOSTS 58 | 59 | # Necessary to allow AWS health check to succeed 60 | try: 61 | import socket 62 | 63 | local_ip = str(socket.gethostbyname(socket.gethostname())) 64 | ALLOWED_HOSTS.append(local_ip) 65 | except Exception as ex: 66 | print(ex) 67 | 68 | INSTALLED_APPS = [ 69 | 'django.contrib.admin', 70 | 'django.contrib.auth', 71 | 'django.contrib.contenttypes', 72 | 'django.contrib.sessions', 73 | 'django.contrib.messages', 74 | 'django.contrib.staticfiles', 75 | 'rest_framework', 76 | 'rest_framework.authtoken', 77 | 'widget_tweaks', 78 | 'snowpenguin.django.recaptcha2', 79 | 'debug_toolbar', 80 | 'storages', 81 | 'frontend', 82 | 'api', 83 | ] 84 | 85 | MIDDLEWARE = [ 86 | 'django.middleware.security.SecurityMiddleware', 87 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 88 | 'django.contrib.sessions.middleware.SessionMiddleware', 89 | 'django.middleware.common.CommonMiddleware', 90 | 'django.middleware.csrf.CsrfViewMiddleware', 91 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 92 | 'django.contrib.messages.middleware.MessageMiddleware', 93 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 94 | ] 95 | 96 | ROOT_URLCONF = 'pyback.urls' 97 | 98 | TEMPLATES = [ 99 | { 100 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 101 | 'DIRS': [os.path.join(BASE_DIR, 'templates')] 102 | , 103 | 'APP_DIRS': True, 104 | 'OPTIONS': { 105 | 'context_processors': [ 106 | 'django.template.context_processors.debug', 107 | 'django.template.context_processors.request', 108 | 'django.contrib.auth.context_processors.auth', 109 | 'django.contrib.messages.context_processors.messages', 110 | ], 111 | }, 112 | }, 113 | ] 114 | 115 | WSGI_APPLICATION = 'pyback.wsgi.application' 116 | 117 | DATABASES = { 118 | 'default': { 119 | 'ENGINE': config('DB_ENGINE', 'django.db.backends.sqlite3'), 120 | 'NAME': config('DB_NAME', os.path.join(BASE_DIR, 'db.sqlite3')), 121 | 'USER': config('DB_USER', ''), 122 | 'PASSWORD': config('DB_PASSWORD', ''), 123 | 'HOST': config('DB_HOST', ''), 124 | 'PORT': config('DB_PORT', ''), 125 | } 126 | } 127 | 128 | # Django REST framework settings 129 | REST_FRAMEWORK = { 130 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 131 | 'rest_framework.authentication.BasicAuthentication', 132 | 'rest_framework.authentication.SessionAuthentication', 133 | 'rest_framework.authentication.TokenAuthentication', 134 | ) 135 | } 136 | 137 | # Password validation 138 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 139 | AUTH_PASSWORD_VALIDATORS = [ 140 | { 141 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 142 | }, 143 | { 144 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 145 | }, 146 | { 147 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 148 | }, 149 | { 150 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 151 | }, 152 | ] 153 | 154 | LANGUAGE_CODE = 'en-us' 155 | 156 | TIME_ZONE = 'UTC' 157 | 158 | USE_I18N = True 159 | 160 | USE_L10N = True 161 | 162 | USE_TZ = True 163 | 164 | # App-Specific Configs 165 | LOGIN_REDIRECT_URL = '/' 166 | LOGOUT_REDIRECT_URL = '/' 167 | LOGIN_URL = '/accounts/login' 168 | 169 | GITHUB_REPO = config('GITHUB_REPO', '') 170 | GITHUB_JWT = config('GITHUB_JWT', 'jwt') 171 | 172 | SLACK_TOKEN = config('SLACK_TOKEN', 'default-token') 173 | SLACK_BOT_TOKEN = config('SLACK_BOT_TOKEN', SLACK_TOKEN) 174 | BOT_NAME = config('BOT_NAME', 'test2-bot') 175 | 176 | RECAPTCHA_PUBLIC_KEY = config('RECAPTCHA_PUBLIC_KEY', 'MyRecaptchaKey123') 177 | RECAPTCHA_PRIVATE_KEY = config('RECAPTCHA_PRIVATE_KEY', 'MyRecaptchaPrivateKey456') 178 | 179 | if DEBUG: 180 | INTERNAL_IPS = ['127.0.0.1'] 181 | -------------------------------------------------------------------------------- /lib/ecs-deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Setup default values for variables 4 | VERSION="3.4.0" 5 | CLUSTER=false 6 | SERVICE=false 7 | TASK_DEFINITION=false 8 | MAX_DEFINITIONS=0 9 | AWS_ASSUME_ROLE=false 10 | IMAGE=false 11 | MIN=false 12 | MAX=false 13 | TIMEOUT=90 14 | VERBOSE=false 15 | TAGVAR=false 16 | TAGONLY="" 17 | ENABLE_ROLLBACK=false 18 | USE_MOST_RECENT_TASK_DEFINITION=false 19 | AWS_CLI=$(which aws) 20 | AWS_ECS="$AWS_CLI --output json ecs" 21 | FORCE_NEW_DEPLOYMENT=false 22 | SKIP_DEPLOYMENTS_CHECK=false 23 | RUN_TASK=false 24 | 25 | function usage() { 26 | cat < /dev/null 2>&1 || { 102 | echo "Some of the required software is not installed:" 103 | echo " please install $1" >&2; 104 | exit 4; 105 | } 106 | } 107 | 108 | function assumeRole() { 109 | 110 | temp_role=$(aws sts assume-role \ 111 | --role-arn "${AWS_ASSUME_ROLE}" \ 112 | --role-session-name "$(date +"%s")") 113 | 114 | export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq .Credentials.AccessKeyId | xargs) 115 | export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq .Credentials.SecretAccessKey | xargs) 116 | export AWS_SESSION_TOKEN=$(echo $temp_role | jq .Credentials.SessionToken | xargs) 117 | } 118 | 119 | 120 | function assumeRoleClean() { 121 | unset AWS_ACCESS_KEY_ID 122 | unset AWS_SECRET_ACCESS_KEY 123 | unset AWS_SESSION_TOKEN 124 | } 125 | 126 | 127 | # Check that all required variables/combinations are set 128 | function assertRequiredArgumentsSet() { 129 | 130 | # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION and AWS_PROFILE can be set as environment variables 131 | if [ -z ${AWS_ACCESS_KEY_ID+x} ]; then unset AWS_ACCESS_KEY_ID; fi 132 | if [ -z ${AWS_SECRET_ACCESS_KEY+x} ]; then unset AWS_SECRET_ACCESS_KEY; fi 133 | if [ -z ${AWS_DEFAULT_REGION+x} ]; 134 | then unset AWS_DEFAULT_REGION 135 | else 136 | AWS_ECS="$AWS_ECS --region $AWS_DEFAULT_REGION" 137 | fi 138 | if [ -z ${AWS_PROFILE+x} ]; 139 | then unset AWS_PROFILE 140 | else 141 | AWS_ECS="$AWS_ECS --profile $AWS_PROFILE" 142 | fi 143 | 144 | if [ $SERVICE == false ] && [ $TASK_DEFINITION == false ]; then 145 | echo "One of SERVICE or TASK DEFINITION is required. You can pass the value using -n / --service-name for a service, or -d / --task-definition for a task" 146 | exit 5 147 | fi 148 | if [ $SERVICE != false ] && [ $TASK_DEFINITION != false ]; then 149 | echo "Only one of SERVICE or TASK DEFINITION may be specified, but you supplied both" 150 | exit 6 151 | fi 152 | if [ $SERVICE != false ] && [ $CLUSTER == false ]; then 153 | echo "CLUSTER is required. You can pass the value using -c or --cluster" 154 | exit 7 155 | fi 156 | if [ $IMAGE == false ] && [ $FORCE_NEW_DEPLOYMENT == false ]; then 157 | echo "IMAGE is required. You can pass the value using -i or --image" 158 | exit 8 159 | fi 160 | if ! [[ $MAX_DEFINITIONS =~ ^-?[0-9]+$ ]]; then 161 | echo "MAX_DEFINITIONS must be numeric, or not defined." 162 | exit 9 163 | fi 164 | 165 | } 166 | 167 | function parseImageName() { 168 | 169 | # Define regex for image name 170 | # This regex will create groups for: 171 | # - domain 172 | # - port 173 | # - repo 174 | # - image 175 | # - tag 176 | # If a group is missing it will be an empty string 177 | if [[ "x$TAGONLY" == "x" ]]; then 178 | imageRegex="^([a-zA-Z0-9\.\-]+):?([0-9]+)?/([a-zA-Z0-9\._\-]+)(/[\/a-zA-Z0-9\._\-]+)?:?([a-zA-Z0-9\._\-]+)?$" 179 | else 180 | imageRegex="^:?([a-zA-Z0-9\._-]+)?$" 181 | fi 182 | 183 | if [[ $IMAGE =~ $imageRegex ]]; then 184 | # Define variables from matching groups 185 | if [[ "x$TAGONLY" == "x" ]]; then 186 | domain=${BASH_REMATCH[1]} 187 | port=${BASH_REMATCH[2]} 188 | repo=${BASH_REMATCH[3]} 189 | img=${BASH_REMATCH[4]/#\//} 190 | tag=${BASH_REMATCH[5]} 191 | 192 | # Validate what we received to make sure we have the pieces needed 193 | if [[ "x$domain" == "x" ]]; then 194 | echo "Image name does not contain a domain or repo as expected. See usage for supported formats." 195 | exit 10; 196 | fi 197 | if [[ "x$repo" == "x" ]]; then 198 | echo "Image name is missing the actual image name. See usage for supported formats." 199 | exit 11; 200 | fi 201 | 202 | # When a match for image is not found, the image name was picked up by the repo group, so reset variables 203 | if [[ "x$img" == "x" ]]; then 204 | img=$repo 205 | repo="" 206 | fi 207 | else 208 | tag=${BASH_REMATCH[1]} 209 | fi 210 | else 211 | # check if using root level repo with format like mariadb or mariadb:latest 212 | rootRepoRegex="^([a-zA-Z0-9\-]+):?([a-zA-Z0-9\.\-]+)?$" 213 | if [[ $IMAGE =~ $rootRepoRegex ]]; then 214 | img=${BASH_REMATCH[1]} 215 | if [[ "x$img" == "x" ]]; then 216 | echo "Invalid image name. See usage for supported formats." 217 | exit 12 218 | fi 219 | tag=${BASH_REMATCH[2]} 220 | else 221 | echo "Unable to parse image name: $IMAGE, check the format and try again" 222 | exit 13 223 | fi 224 | fi 225 | 226 | # If tag is missing make sure we can get it from env var, or use latest as default 227 | if [[ "x$tag" == "x" ]]; then 228 | if [[ $TAGVAR == false ]]; then 229 | tag="latest" 230 | else 231 | tag=${!TAGVAR} 232 | if [[ "x$tag" == "x" ]]; then 233 | tag="latest" 234 | fi 235 | fi 236 | fi 237 | 238 | # Reassemble image name 239 | if [[ "x$TAGONLY" == "x" ]]; then 240 | 241 | if [[ ! -z ${domain+undefined-guard} ]]; then 242 | useImage="$domain" 243 | fi 244 | if [[ ! -z ${port} ]]; then 245 | useImage="$useImage:$port" 246 | fi 247 | if [[ ! -z ${repo+undefined-guard} ]]; then 248 | if [[ ! "x$repo" == "x" ]]; then 249 | useImage="$useImage/$repo" 250 | fi 251 | fi 252 | if [[ ! -z ${img+undefined-guard} ]]; then 253 | if [[ "x$useImage" == "x" ]]; then 254 | useImage="$img" 255 | else 256 | useImage="$useImage/$img" 257 | fi 258 | fi 259 | imageWithoutTag="$useImage" 260 | if [[ ! -z ${tag+undefined-guard} ]]; then 261 | useImage="$useImage:$tag" 262 | fi 263 | 264 | else 265 | useImage="$TAGONLY" 266 | fi 267 | 268 | # If in test mode output $useImage 269 | if [ "$BASH_SOURCE" != "$0" ]; then 270 | echo $useImage 271 | fi 272 | } 273 | 274 | function getCurrentTaskDefinition() { 275 | if [ $SERVICE != false ]; then 276 | # Get current task definition arn from service 277 | TASK_DEFINITION_ARN=`$AWS_ECS describe-services --services $SERVICE --cluster $CLUSTER | jq -r .services[0].taskDefinition` 278 | TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN` 279 | 280 | # For rollbacks 281 | LAST_USED_TASK_DEFINITION_ARN=$TASK_DEFINITION_ARN 282 | 283 | if [ $USE_MOST_RECENT_TASK_DEFINITION != false ]; then 284 | # Use the most recently created TD of the family; rather than the most recently used. 285 | TASK_DEFINITION_FAMILY=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN | jq -r .taskDefinition.family` 286 | TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_FAMILY` 287 | TASK_DEFINITION_ARN=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_FAMILY | jq -r .taskDefinition.taskDefinitionArn` 288 | fi 289 | elif [ $TASK_DEFINITION != false ]; then 290 | # Get current task definition arn from family[:revision] (or arn) 291 | TASK_DEFINITION_ARN=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION | jq -r .taskDefinition.taskDefinitionArn` 292 | fi 293 | TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN` 294 | } 295 | 296 | function createNewTaskDefJson() { 297 | # Get a JSON representation of the current task definition 298 | # + Update definition to use new image name 299 | # + Filter the def 300 | if [[ "x$TAGONLY" == "x" ]]; then 301 | DEF=$( echo "$TASK_DEFINITION" \ 302 | | sed -e "s|\"image\": *\"${imageWithoutTag}:.*\"|\"image\": \"${useImage}\"|g" \ 303 | | sed -e "s|\"image\": *\"${imageWithoutTag}\"|\"image\": \"${useImage}\"|g" \ 304 | | jq '.taskDefinition' ) 305 | else 306 | DEF=$( echo "$TASK_DEFINITION" \ 307 | | sed -e "s|\(\"image\": *\".*:\)\(.*\)\"|\1${useImage}\"|g" \ 308 | | jq '.taskDefinition' ) 309 | fi 310 | 311 | # Default JQ filter for new task definition 312 | NEW_DEF_JQ_FILTER="family: .family, volumes: .volumes, containerDefinitions: .containerDefinitions, placementConstraints: .placementConstraints" 313 | 314 | # Some options in task definition should only be included in new definition if present in 315 | # current definition. If found in current definition, append to JQ filter. 316 | CONDITIONAL_OPTIONS=(networkMode taskRoleArn placementConstraints) 317 | for i in "${CONDITIONAL_OPTIONS[@]}"; do 318 | re=".*${i}.*" 319 | if [[ "$DEF" =~ $re ]]; then 320 | NEW_DEF_JQ_FILTER="${NEW_DEF_JQ_FILTER}, ${i}: .${i}" 321 | fi 322 | done 323 | 324 | # Updated jq filters for AWS Fargate 325 | REQUIRES_COMPATIBILITIES=$(echo "${DEF}" | jq -r '. | select(.requiresCompatibilities != null) | .requiresCompatibilities[]') 326 | if [[ "${REQUIRES_COMPATIBILITIES}" == 'FARGATE' ]]; then 327 | FARGATE_JQ_FILTER='executionRoleArn: .executionRoleArn, requiresCompatibilities: .requiresCompatibilities, cpu: .cpu, memory: .memory' 328 | NEW_DEF_JQ_FILTER="${NEW_DEF_JQ_FILTER}, ${FARGATE_JQ_FILTER}" 329 | fi 330 | 331 | # Build new DEF with jq filter 332 | NEW_DEF=$(echo "$DEF" | jq "{${NEW_DEF_JQ_FILTER}}") 333 | 334 | # If in test mode output $NEW_DEF 335 | if [ "$BASH_SOURCE" != "$0" ]; then 336 | echo "$NEW_DEF" 337 | fi 338 | } 339 | 340 | function registerNewTaskDefinition() { 341 | # Register the new task definition, and store its ARN 342 | NEW_TASKDEF=`$AWS_ECS register-task-definition --cli-input-json "$NEW_DEF" | jq -r .taskDefinition.taskDefinitionArn` 343 | } 344 | 345 | function rollback() { 346 | echo "Rolling back to ${LAST_USED_TASK_DEFINITION_ARN}" 347 | $AWS_ECS update-service --cluster $CLUSTER --service $SERVICE --task-definition $LAST_USED_TASK_DEFINITION_ARN > /dev/null 348 | } 349 | 350 | function updateServiceForceNewDeployment() { 351 | echo 'Force a new deployment of the service' 352 | $AWS_ECS update-service --cluster $CLUSTER --service $SERVICE --force-new-deployment > /dev/null 353 | } 354 | 355 | function updateService() { 356 | UPDATE_SERVICE_SUCCESS="false" 357 | DEPLOYMENT_CONFIG="" 358 | if [ $MAX != false ]; then 359 | DEPLOYMENT_CONFIG=",maximumPercent=$MAX" 360 | fi 361 | if [ $MIN != false ]; then 362 | DEPLOYMENT_CONFIG="$DEPLOYMENT_CONFIG,minimumHealthyPercent=$MIN" 363 | fi 364 | if [ ! -z "$DEPLOYMENT_CONFIG" ]; then 365 | DEPLOYMENT_CONFIG="--deployment-configuration ${DEPLOYMENT_CONFIG:1}" 366 | fi 367 | 368 | DESIRED_COUNT="" 369 | if [ ! -z ${DESIRED+undefined-guard} ]; then 370 | DESIRED_COUNT="--desired-count $DESIRED" 371 | fi 372 | 373 | # Update the service 374 | UPDATE=`$AWS_ECS update-service --cluster $CLUSTER --service $SERVICE $DESIRED_COUNT --task-definition $NEW_TASKDEF $DEPLOYMENT_CONFIG` 375 | 376 | # Only excepts RUNNING state from services whose desired-count > 0 377 | SERVICE_DESIREDCOUNT=`$AWS_ECS describe-services --cluster $CLUSTER --service $SERVICE | jq '.services[]|.desiredCount'` 378 | if [ $SERVICE_DESIREDCOUNT -gt 0 ]; then 379 | # See if the service is able to come up again 380 | every=10 381 | i=0 382 | while [ $i -lt $TIMEOUT ] 383 | do 384 | # Scan the list of running tasks for that service, and see if one of them is the 385 | # new version of the task definition 386 | 387 | RUNNING_TASKS=$($AWS_ECS list-tasks --cluster "$CLUSTER" --service-name "$SERVICE" --desired-status RUNNING \ 388 | | jq -r '.taskArns[]') 389 | 390 | if [[ ! -z $RUNNING_TASKS ]] ; then 391 | RUNNING=$($AWS_ECS describe-tasks --cluster "$CLUSTER" --tasks $RUNNING_TASKS \ 392 | | jq ".tasks[]| if .taskDefinitionArn == \"$NEW_TASKDEF\" then . else empty end|.lastStatus" \ 393 | | grep -e "RUNNING") || : 394 | 395 | if [ "$RUNNING" ]; then 396 | echo "Service updated successfully, new task definition running."; 397 | 398 | if [[ $MAX_DEFINITIONS -gt 0 ]]; then 399 | FAMILY_PREFIX=${TASK_DEFINITION_ARN##*:task-definition/} 400 | FAMILY_PREFIX=${FAMILY_PREFIX%*:[0-9]*} 401 | TASK_REVISIONS=`$AWS_ECS list-task-definitions --family-prefix $FAMILY_PREFIX --status ACTIVE --sort ASC` 402 | NUM_ACTIVE_REVISIONS=$(echo "$TASK_REVISIONS" | jq ".taskDefinitionArns|length") 403 | if [[ $NUM_ACTIVE_REVISIONS -gt $MAX_DEFINITIONS ]]; then 404 | LAST_OUTDATED_INDEX=$(($NUM_ACTIVE_REVISIONS - $MAX_DEFINITIONS - 1)) 405 | for i in $(seq 0 $LAST_OUTDATED_INDEX); do 406 | OUTDATED_REVISION_ARN=$(echo "$TASK_REVISIONS" | jq -r ".taskDefinitionArns[$i]") 407 | 408 | echo "Deregistering outdated task revision: $OUTDATED_REVISION_ARN" 409 | 410 | $AWS_ECS deregister-task-definition --task-definition "$OUTDATED_REVISION_ARN" > /dev/null 411 | done 412 | fi 413 | 414 | fi 415 | UPDATE_SERVICE_SUCCESS="true" 416 | break 417 | fi 418 | fi 419 | 420 | sleep $every 421 | i=$(( $i + $every )) 422 | done 423 | 424 | if [[ "${UPDATE_SERVICE_SUCCESS}" != "true" ]]; then 425 | # Timeout 426 | echo "ERROR: New task definition not running within $TIMEOUT seconds" 427 | if [[ "${ENABLE_ROLLBACK}" != "false" ]]; then 428 | rollback 429 | fi 430 | exit 1 431 | fi 432 | else 433 | echo "Skipping check for running task definition, as desired-count <= 0" 434 | fi 435 | } 436 | 437 | function waitForGreenDeployment { 438 | DEPLOYMENT_SUCCESS="false" 439 | every=2 440 | i=0 441 | echo "Waiting for service deployment to complete..." 442 | while [ $i -lt $TIMEOUT ] 443 | do 444 | NUM_DEPLOYMENTS=$($AWS_ECS describe-services --services $SERVICE --cluster $CLUSTER | jq "[.services[].deployments[]] | length") 445 | 446 | # Wait to see if more than 1 deployment stays running 447 | # If the wait time has passed, we need to roll back 448 | if [ $NUM_DEPLOYMENTS -eq 1 ]; then 449 | echo "Service deployment successful." 450 | DEPLOYMENT_SUCCESS="true" 451 | # Exit the loop. 452 | i=$TIMEOUT 453 | else 454 | sleep $every 455 | i=$(( $i + $every )) 456 | fi 457 | done 458 | 459 | if [[ "${DEPLOYMENT_SUCCESS}" != "true" ]]; then 460 | if [[ "${ENABLE_ROLLBACK}" != "false" ]]; then 461 | rollback 462 | fi 463 | exit 1 464 | fi 465 | } 466 | 467 | function runTask { 468 | echo "Run task: $NEW_TASKDEF"; 469 | $AWS_ECS run-task --cluster $CLUSTER --task-definition $NEW_TASKDEF > /dev/null 470 | } 471 | 472 | ###################################################### 473 | # When not being tested, run application as expected # 474 | ###################################################### 475 | if [ "$BASH_SOURCE" == "$0" ]; then 476 | set -o errexit 477 | set -o pipefail 478 | set -u 479 | set -e 480 | # If no args are provided, display usage information 481 | if [ $# == 0 ]; then usage; fi 482 | 483 | # Check for AWS, AWS Command Line Interface 484 | require aws 485 | # Check for jq, Command-line JSON processor 486 | require jq 487 | 488 | # Loop through arguments, two at a time for key and value 489 | while [[ $# -gt 0 ]] 490 | do 491 | key="$1" 492 | 493 | case $key in 494 | -k|--aws-access-key) 495 | AWS_ACCESS_KEY_ID="$2" 496 | shift # past argument 497 | ;; 498 | -s|--aws-secret-key) 499 | AWS_SECRET_ACCESS_KEY="$2" 500 | shift # past argument 501 | ;; 502 | -r|--region) 503 | AWS_DEFAULT_REGION="$2" 504 | shift # past argument 505 | ;; 506 | -p|--profile) 507 | AWS_PROFILE="$2" 508 | shift # past argument 509 | ;; 510 | --aws-instance-profile) 511 | echo "--aws-instance-profile is not yet in use" 512 | AWS_IAM_ROLE=true 513 | ;; 514 | -a|--aws-assume-role) 515 | AWS_ASSUME_ROLE="$2" 516 | shift 517 | ;; 518 | -c|--cluster) 519 | CLUSTER="$2" 520 | shift # past argument 521 | ;; 522 | -n|--service-name) 523 | SERVICE="$2" 524 | shift # past argument 525 | ;; 526 | -d|--task-definition) 527 | TASK_DEFINITION="$2" 528 | shift 529 | ;; 530 | -i|--image) 531 | IMAGE="$2" 532 | shift 533 | ;; 534 | -t|--timeout) 535 | TIMEOUT="$2" 536 | shift 537 | ;; 538 | -m|--min) 539 | MIN="$2" 540 | shift 541 | ;; 542 | -M|--max) 543 | MAX="$2" 544 | shift 545 | ;; 546 | -D|--desired-count) 547 | DESIRED="$2" 548 | shift 549 | ;; 550 | -e|--tag-env-var) 551 | TAGVAR="$2" 552 | shift 553 | ;; 554 | -to|--tag-only) 555 | TAGONLY="$2" 556 | shift 557 | ;; 558 | --max-definitions) 559 | MAX_DEFINITIONS="$2" 560 | shift 561 | ;; 562 | --enable-rollback) 563 | ENABLE_ROLLBACK=true 564 | ;; 565 | --use-latest-task-def) 566 | USE_MOST_RECENT_TASK_DEFINITION=true 567 | ;; 568 | --force-new-deployment) 569 | FORCE_NEW_DEPLOYMENT=true 570 | ;; 571 | --skip-deployments-check) 572 | SKIP_DEPLOYMENTS_CHECK=true 573 | ;; 574 | --run-task) 575 | RUN_TASK=true 576 | ;; 577 | -v|--verbose) 578 | VERBOSE=true 579 | ;; 580 | --version) 581 | echo ${VERSION} 582 | exit 0 583 | ;; 584 | *) 585 | usage 586 | exit 2 587 | ;; 588 | esac 589 | shift # past argument or value 590 | done 591 | 592 | if [ $VERBOSE == true ]; then 593 | set -x 594 | fi 595 | 596 | # Check that required arguments are provided 597 | assertRequiredArgumentsSet 598 | 599 | if [[ "$AWS_ASSUME_ROLE" != false ]]; then 600 | assumeRole 601 | fi 602 | 603 | # Not required creation of new a task definition 604 | if [ $FORCE_NEW_DEPLOYMENT == true ]; then 605 | updateServiceForceNewDeployment 606 | waitForGreenDeployment 607 | exit 0 608 | fi 609 | 610 | # Determine image name 611 | parseImageName 612 | echo "Using image name: $useImage" 613 | 614 | # Get current task definition 615 | getCurrentTaskDefinition 616 | echo "Current task definition: $TASK_DEFINITION_ARN"; 617 | 618 | # create new task definition json 619 | createNewTaskDefJson 620 | 621 | # register new task definition 622 | registerNewTaskDefinition 623 | echo "New task definition: $NEW_TASKDEF"; 624 | 625 | # update service if needed 626 | if [ $SERVICE == false ]; then 627 | if [ $RUN_TASK == true ]; then 628 | runTask 629 | fi 630 | echo "Task definition updated successfully" 631 | else 632 | updateService 633 | 634 | if [[ $SKIP_DEPLOYMENTS_CHECK != true ]]; then 635 | waitForGreenDeployment 636 | fi 637 | fi 638 | 639 | if [[ "$AWS_ASSUME_ROLE" != false ]]; then 640 | assumeRoleClean 641 | fi 642 | 643 | exit 0 644 | 645 | fi 646 | ############################# 647 | # End application run logic # 648 | ############################# 649 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "342ae8acf93f8b8675b4ef7bae89e526656dd9480ab2475a401565f6f65f22ae" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "boto3": { 20 | "hashes": [ 21 | "sha256:a85661cb70d10be34e57c56d3958485888893e2987f083c49a48a169042e9c56", 22 | "sha256:d71b276f5c5d48f429292d076099a6c4fbcc9015a158ac86cc5d36b16d5f0043" 23 | ], 24 | "index": "pypi", 25 | "version": "==1.9.35" 26 | }, 27 | "botocore": { 28 | "hashes": [ 29 | "sha256:1a1d528ed30afc9d6364855e6f701862d6cc8ba5231b422c8c83ff8ab4e8a832", 30 | "sha256:a5c93a7182b7f0c447014c15b1df6ac840677e9258f8505ecedff9b66a539a51" 31 | ], 32 | "version": "==1.12.35" 33 | }, 34 | "certifi": { 35 | "hashes": [ 36 | "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", 37 | "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" 38 | ], 39 | "version": "==2018.10.15" 40 | }, 41 | "cffi": { 42 | "hashes": [ 43 | "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", 44 | "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", 45 | "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", 46 | "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", 47 | "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", 48 | "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", 49 | "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", 50 | "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", 51 | "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", 52 | "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", 53 | "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", 54 | "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", 55 | "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", 56 | "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", 57 | "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", 58 | "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", 59 | "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", 60 | "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", 61 | "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", 62 | "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", 63 | "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", 64 | "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", 65 | "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", 66 | "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", 67 | "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", 68 | "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", 69 | "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", 70 | "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", 71 | "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", 72 | "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", 73 | "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", 74 | "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" 75 | ], 76 | "markers": "sys_platform == 'win32' and platform_python_implementation == 'CPython'", 77 | "version": "==1.11.5" 78 | }, 79 | "chardet": { 80 | "hashes": [ 81 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 82 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 83 | ], 84 | "version": "==3.0.4" 85 | }, 86 | "django": { 87 | "hashes": [ 88 | "sha256:1ffab268ada3d5684c05ba7ce776eaeedef360712358d6a6b340ae9f16486916", 89 | "sha256:dd46d87af4c1bf54f4c926c3cfa41dc2b5c15782f15e4329752ce65f5dad1c37" 90 | ], 91 | "index": "pypi", 92 | "version": "==2.1.3" 93 | }, 94 | "django-recaptcha2": { 95 | "hashes": [ 96 | "sha256:9153531b2257d6e779d6bf1ab9d1709e2be3db61818cf57ef8b5b72290949a73", 97 | "sha256:ec80175bb854c58616604de83a9f762698ce4b70d526e2e828e975bda890c089" 98 | ], 99 | "index": "pypi", 100 | "version": "==1.3.1" 101 | }, 102 | "django-storages": { 103 | "hashes": [ 104 | "sha256:8e35d2c7baeda5dc6f0b4f9a0fc142d25f9a1bf72b8cebfcbc5db4863abc552d", 105 | "sha256:b1a63cd5ea286ee5a9fb45de6c3c5c0ae132d58308d06f1ce9865cfcd5e470a7" 106 | ], 107 | "index": "pypi", 108 | "version": "==1.7.1" 109 | }, 110 | "django-widget-tweaks": { 111 | "hashes": [ 112 | "sha256:a69cba6c8a6b98f0cf6eef0535f8212d635e19044ee4533d4d78df700c2e233f", 113 | "sha256:bc645ef88307bc4ac269ee8ee9e572be814cd4a125c2bb6edb59ffcdc194982d" 114 | ], 115 | "index": "pypi", 116 | "version": "==1.4.3" 117 | }, 118 | "djangorestframework": { 119 | "hashes": [ 120 | "sha256:607865b0bb1598b153793892101d881466bd5a991de12bd6229abb18b1c86136", 121 | "sha256:63f76cbe1e7d12b94c357d7e54401103b2e52aef0f7c1650d6c820ad708776e5" 122 | ], 123 | "index": "pypi", 124 | "version": "==3.9.0" 125 | }, 126 | "docutils": { 127 | "hashes": [ 128 | "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", 129 | "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", 130 | "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" 131 | ], 132 | "version": "==0.14" 133 | }, 134 | "gevent": { 135 | "hashes": [ 136 | "sha256:1f277c5cf060b30313c5f9b91588f4c645e11839e14a63c83fcf6f24b1bc9b95", 137 | "sha256:298a04a334fb5e3dcd6f89d063866a09155da56041bc94756da59db412cb45b1", 138 | "sha256:30e9b2878d5b57c68a40b3a08d496bcdaefc79893948989bb9b9fab087b3f3c0", 139 | "sha256:33533bc5c6522883e4437e901059fe5afa3ea74287eeea27a130494ff130e731", 140 | "sha256:3f06f4802824c577272960df003a304ce95b3e82eea01dad2637cc8609c80e2c", 141 | "sha256:419fd562e4b94b91b58cccb3bd3f17e1a11f6162fca6c591a7822bc8a68f023d", 142 | "sha256:4ea938f44b882e02cca9583069d38eb5f257cc15a03e918980c307e7739b1038", 143 | "sha256:51143a479965e3e634252a0f4a1ea07e5307cf8dc773ef6bf9dfe6741785fb4c", 144 | "sha256:5bf9bd1dd4951552d9207af3168f420575e3049016b9c10fe0c96760ce3555b7", 145 | "sha256:6004512833707a1877cc1a5aea90fd182f569e089bc9ab22a81d480dad018f1b", 146 | "sha256:640b3b52121ab519e0980cb38b572df0d2bc76941103a697e828c13d76ac8836", 147 | "sha256:6951655cc18b8371d823e81c700883debb0f633aae76f425dfeb240f76b95a67", 148 | "sha256:71eeb8d9146e8131b65c3364bb760b097c21b7b9fdbec91bf120685a510f997a", 149 | "sha256:7c899e5a6f94d6310352716740f05e41eb8c52d995f27fc01e90380913aa8f22", 150 | "sha256:8465f84ba31aaf52a080837e9c5ddd592ab0a21dfda3212239ce1e1796f4d503", 151 | "sha256:99de2e38dde8669dd30a8a1261bdb39caee6bd00a5f928d01dfdb85ab0502562", 152 | "sha256:9fa4284b44bc42bef6e437488d000ae37499ccee0d239013465638504c4565b7", 153 | "sha256:a1beea0443d3404c03e069d4c4d9fc13d8ec001771c77f9a23f01911a41f0e49", 154 | "sha256:a66a26b78d90d7c4e9bf9efb2b2bd0af49234604ac52eaca03ea79ac411e3f6d", 155 | "sha256:a94e197bd9667834f7bb6bd8dff1736fab68619d0f8cd78a9c1cebe3c4944677", 156 | "sha256:ac0331d3a3289a3d16627742be9c8969f293740a31efdedcd9087dadd6b2da57", 157 | "sha256:d26b57c50bf52fb38dadf3df5bbecd2236f49d7ac98f3cf32d6b8a2d25afc27f", 158 | "sha256:fd23b27387d76410eb6a01ea13efc7d8b4b95974ba212c336e8b1d6ab45a9578" 159 | ], 160 | "version": "==1.3.7" 161 | }, 162 | "greenlet": { 163 | "hashes": [ 164 | "sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0", 165 | "sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28", 166 | "sha256:23d12eacffa9d0f290c0fe0c4e81ba6d5f3a5b7ac3c30a5eaf0126bf4deda5c8", 167 | "sha256:37c9ba82bd82eb6a23c2e5acc03055c0e45697253b2393c9a50cef76a3985304", 168 | "sha256:51503524dd6f152ab4ad1fbd168fc6c30b5795e8c70be4410a64940b3abb55c0", 169 | "sha256:8041e2de00e745c0e05a502d6e6db310db7faa7c979b3a5877123548a4c0b214", 170 | "sha256:81fcd96a275209ef117e9ec91f75c731fa18dcfd9ffaa1c0adbdaa3616a86043", 171 | "sha256:853da4f9563d982e4121fed8c92eea1a4594a2299037b3034c3c898cb8e933d6", 172 | "sha256:8b4572c334593d449113f9dc8d19b93b7b271bdbe90ba7509eb178923327b625", 173 | "sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc", 174 | "sha256:9854f612e1b59ec66804931df5add3b2d5ef0067748ea29dc60f0efdcda9a638", 175 | "sha256:99a26afdb82ea83a265137a398f570402aa1f2b5dfb4ac3300c026931817b163", 176 | "sha256:a19bf883b3384957e4a4a13e6bd1ae3d85ae87f4beb5957e35b0be287f12f4e4", 177 | "sha256:a9f145660588187ff835c55a7d2ddf6abfc570c2651c276d3d4be8a2766db490", 178 | "sha256:ac57fcdcfb0b73bb3203b58a14501abb7e5ff9ea5e2edfa06bb03035f0cff248", 179 | "sha256:bcb530089ff24f6458a81ac3fa699e8c00194208a724b644ecc68422e1111939", 180 | "sha256:beeabe25c3b704f7d56b573f7d2ff88fc99f0138e43480cecdfcaa3b87fe4f87", 181 | "sha256:d634a7ea1fc3380ff96f9e44d8d22f38418c1c381d5fac680b272d7d90883720", 182 | "sha256:d97b0661e1aead761f0ded3b769044bb00ed5d33e1ec865e891a8b128bf7c656" 183 | ], 184 | "markers": "platform_python_implementation == 'CPython'", 185 | "version": "==0.4.15" 186 | }, 187 | "gunicorn": { 188 | "hashes": [ 189 | "sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471", 190 | "sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3" 191 | ], 192 | "index": "pypi", 193 | "version": "==19.9.0" 194 | }, 195 | "idna": { 196 | "hashes": [ 197 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 198 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 199 | ], 200 | "version": "==2.7" 201 | }, 202 | "jinja2": { 203 | "hashes": [ 204 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 205 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 206 | ], 207 | "index": "pypi", 208 | "version": "==2.10" 209 | }, 210 | "jmespath": { 211 | "hashes": [ 212 | "sha256:6a81d4c9aa62caf061cb517b4d9ad1dd300374cd4706997aff9cd6aedd61fc64", 213 | "sha256:f11b4461f425740a1d908e9a3f7365c3d2e569f6ca68a2ff8bc5bcd9676edd63" 214 | ], 215 | "version": "==0.9.3" 216 | }, 217 | "markupsafe": { 218 | "hashes": [ 219 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 220 | ], 221 | "version": "==1.0" 222 | }, 223 | "pillow": { 224 | "hashes": [ 225 | "sha256:00203f406818c3f45d47bb8fe7e67d3feddb8dcbbd45a289a1de7dd789226360", 226 | "sha256:0616f800f348664e694dddb0b0c88d26761dd5e9f34e1ed7b7a7d2da14b40cb7", 227 | "sha256:1f7908aab90c92ad85af9d2fec5fc79456a89b3adcc26314d2cde0e238bd789e", 228 | "sha256:2ea3517cd5779843de8a759c2349a3cd8d3893e03ab47053b66d5ec6f8bc4f93", 229 | "sha256:48a9f0538c91fc136b3a576bee0e7cd174773dc9920b310c21dcb5519722e82c", 230 | "sha256:5280ebc42641a1283b7b1f2c20e5b936692198b9dd9995527c18b794850be1a8", 231 | "sha256:5e34e4b5764af65551647f5cc67cf5198c1d05621781d5173b342e5e55bf023b", 232 | "sha256:63b120421ab85cad909792583f83b6ca3584610c2fe70751e23f606a3c2e87f0", 233 | "sha256:696b5e0109fe368d0057f484e2e91717b49a03f1e310f857f133a4acec9f91dd", 234 | "sha256:870ed021a42b1b02b5fe4a739ea735f671a84128c0a666c705db2cb9abd528eb", 235 | "sha256:916da1c19e4012d06a372127d7140dae894806fad67ef44330e5600d77833581", 236 | "sha256:9303a289fa0811e1c6abd9ddebfc770556d7c3311cb2b32eff72164ddc49bc64", 237 | "sha256:9577888ecc0ad7d06c3746afaba339c94d62b59da16f7a5d1cff9e491f23dace", 238 | "sha256:987e1c94a33c93d9b209315bfda9faa54b8edfce6438a1e93ae866ba20de5956", 239 | "sha256:99a3bbdbb844f4fb5d6dd59fac836a40749781c1fa63c563bc216c27aef63f60", 240 | "sha256:99db8dc3097ceafbcff9cb2bff384b974795edeb11d167d391a02c7bfeeb6e16", 241 | "sha256:a5a96cf49eb580756a44ecf12949e52f211e20bffbf5a95760ac14b1e499cd37", 242 | "sha256:aa6ca3eb56704cdc0d876fc6047ffd5ee960caad52452fbee0f99908a141a0ae", 243 | "sha256:aade5e66795c94e4a2b2624affeea8979648d1b0ae3fcee17e74e2c647fc4a8a", 244 | "sha256:b78905860336c1d292409e3df6ad39cc1f1c7f0964e66844bbc2ebfca434d073", 245 | "sha256:b92f521cdc4e4a3041cc343625b699f20b0b5f976793fb45681aac1efda565f8", 246 | "sha256:bfde84bbd6ae5f782206d454b67b7ee8f7f818c29b99fd02bf022fd33bab14cb", 247 | "sha256:c2b62d3df80e694c0e4a0ed47754c9480521e25642251b3ab1dff050a4e60409", 248 | "sha256:c5e2be6c263b64f6f7656e23e18a4a9980cffc671442795682e8c4e4f815dd9f", 249 | "sha256:c99aa3c63104e0818ec566f8ff3942fb7c7a8f35f9912cb63fd8e12318b214b2", 250 | "sha256:dae06620d3978da346375ebf88b9e2dd7d151335ba668c995aea9ed07af7add4", 251 | "sha256:db5499d0710823fa4fb88206050d46544e8f0e0136a9a5f5570b026584c8fd74", 252 | "sha256:f36baafd82119c4a114b9518202f2a983819101dcc14b26e43fc12cbefdce00e", 253 | "sha256:f52b79c8796d81391ab295b04e520bda6feed54d54931708872e8f9ae9db0ea1", 254 | "sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888" 255 | ], 256 | "index": "pypi", 257 | "version": "==5.3.0" 258 | }, 259 | "psycopg2": { 260 | "hashes": [ 261 | "sha256:0b9e48a1c1505699a64ac58815ca99104aacace8321e455072cee4f7fe7b2698", 262 | "sha256:0f4c784e1b5a320efb434c66a50b8dd7e30a7dc047e8f45c0a8d2694bfe72781", 263 | "sha256:0fdbaa32c9eb09ef09d425dc154628fca6fa69d2f7c1a33f889abb7e0efb3909", 264 | "sha256:11fbf688d5c953c0a5ba625cc42dea9aeb2321942c7c5ed9341a68f865dc8cb1", 265 | "sha256:19eaac4eb25ab078bd0f28304a0cb08702d120caadfe76bb1e6846ed1f68635e", 266 | "sha256:3232ec1a3bf4dba97fbf9b03ce12e4b6c1d01ea3c85773903a67ced725728232", 267 | "sha256:36f8f9c216fcca048006f6dd60e4d3e6f406afde26cfb99e063f137070139eaf", 268 | "sha256:59c1a0e4f9abe970062ed35d0720935197800a7ef7a62b3a9e3a70588d9ca40b", 269 | "sha256:6506c5ff88750948c28d41852c09c5d2a49f51f28c6d90cbf1b6808e18c64e88", 270 | "sha256:6bc3e68ee16f571681b8c0b6d5c0a77bef3c589012352b3f0cf5520e674e9d01", 271 | "sha256:6dbbd7aabbc861eec6b910522534894d9dbb507d5819bc982032c3ea2e974f51", 272 | "sha256:6e737915de826650d1a5f7ff4ac6cf888a26f021a647390ca7bafdba0e85462b", 273 | "sha256:6ed9b2cfe85abc720e8943c1808eeffd41daa73e18b7c1e1a228b0b91f768ccc", 274 | "sha256:711ec617ba453fdfc66616db2520db3a6d9a891e3bf62ef9aba4c95bb4e61230", 275 | "sha256:844dacdf7530c5c612718cf12bc001f59b2d9329d35b495f1ff25045161aa6af", 276 | "sha256:86b52e146da13c896e50c5a3341a9448151f1092b1a4153e425d1e8b62fec508", 277 | "sha256:985c06c2a0f227131733ae58d6a541a5bc8b665e7305494782bebdb74202b793", 278 | "sha256:a86dfe45f4f9c55b1a2312ff20a59b30da8d39c0e8821d00018372a2a177098f", 279 | "sha256:aa3cd07f7f7e3183b63d48300666f920828a9dbd7d7ec53d450df2c4953687a9", 280 | "sha256:b1964ed645ef8317806d615d9ff006c0dadc09dfc54b99ae67f9ba7a1ec9d5d2", 281 | "sha256:b2abbff9e4141484bb89b96eb8eae186d77bc6d5ffbec6b01783ee5c3c467351", 282 | "sha256:cc33c3a90492e21713260095f02b12bee02b8d1f2c03a221d763ce04fa90e2e9", 283 | "sha256:d7de3bf0986d777807611c36e809b77a13bf1888f5c8db0ebf24b47a52d10726", 284 | "sha256:db5e3c52576cc5b93a959a03ccc3b02cb8f0af1fbbdc80645f7a215f0b864f3a", 285 | "sha256:e168aa795ffbb11379c942cf95bf813c7db9aa55538eb61de8c6815e092416f5", 286 | "sha256:e9ca911f8e2d3117e5241d5fa9aaa991cb22fb0792627eeada47425d706b5ec8", 287 | "sha256:eccf962d41ca46e6326b97c8fe0a6687b58dfc1a5f6540ed071ff1474cea749e", 288 | "sha256:efa19deae6b9e504a74347fe5e25c2cb9343766c489c2ae921b05f37338b18d1", 289 | "sha256:f4b0460a21f784abe17b496f66e74157a6c36116fa86da8bf6aa028b9e8ad5fe", 290 | "sha256:f93d508ca64d924d478fb11e272e09524698f0c581d9032e68958cfbdd41faef" 291 | ], 292 | "index": "pypi", 293 | "version": "==2.7.5" 294 | }, 295 | "pycparser": { 296 | "hashes": [ 297 | "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" 298 | ], 299 | "version": "==2.19" 300 | }, 301 | "python-dateutil": { 302 | "hashes": [ 303 | "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", 304 | "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" 305 | ], 306 | "markers": "python_version >= '2.7'", 307 | "version": "==2.7.5" 308 | }, 309 | "python-decouple": { 310 | "hashes": [ 311 | "sha256:1317df14b43efee4337a4aa02914bf004f010cd56d6c4bd894e6474ec8c4fe2d" 312 | ], 313 | "index": "pypi", 314 | "version": "==3.1" 315 | }, 316 | "python-dotenv": { 317 | "hashes": [ 318 | "sha256:122290a38ece9fe4f162dc7c95cae3357b983505830a154d3c98ef7f6c6cea77", 319 | "sha256:4a205787bc829233de2a823aa328e44fd9996fedb954989a21f1fc67c13d7a77" 320 | ], 321 | "index": "pypi", 322 | "version": "==0.9.1" 323 | }, 324 | "pytz": { 325 | "hashes": [ 326 | "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", 327 | "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" 328 | ], 329 | "version": "==2018.7" 330 | }, 331 | "pyyaml": { 332 | "hashes": [ 333 | "sha256:254bf6fda2b7c651837acb2c718e213df29d531eebf00edb54743d10bcb694eb", 334 | "sha256:3108529b78577327d15eec243f0ff348a0640b0c3478d67ad7f5648f93bac3e2", 335 | "sha256:3c17fb92c8ba2f525e4b5f7941d850e7a48c3a59b32d331e2502a3cdc6648e76", 336 | "sha256:8d6d96001aa7f0a6a4a95e8143225b5d06e41b1131044913fecb8f85a125714b", 337 | "sha256:c8a88edd93ee29ede719080b2be6cb2333dfee1dccba213b422a9c8e97f2967b" 338 | ], 339 | "index": "pypi", 340 | "version": "==4.2b4" 341 | }, 342 | "requests": { 343 | "hashes": [ 344 | "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", 345 | "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" 346 | ], 347 | "index": "pypi", 348 | "version": "==2.20.0" 349 | }, 350 | "s3transfer": { 351 | "hashes": [ 352 | "sha256:90dc18e028989c609146e241ea153250be451e05ecc0c2832565231dacdf59c1", 353 | "sha256:c7a9ec356982d5e9ab2d4b46391a7d6a950e2b04c472419f5fdec70cc0ada72f" 354 | ], 355 | "version": "==0.1.13" 356 | }, 357 | "six": { 358 | "hashes": [ 359 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 360 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 361 | ], 362 | "version": "==1.11.0" 363 | }, 364 | "slack-sansio": { 365 | "extras": [ 366 | "requests" 367 | ], 368 | "hashes": [ 369 | "sha256:4803aa9472d2a3353df93012ee9e78a06c2561353ba1f694ab6c41db58f23d1c", 370 | "sha256:cda6f5cb4ca27d5ab1bc327c3ee879458a200ecbeb2e837d49be7ab8d0b53f9b" 371 | ], 372 | "index": "pypi", 373 | "version": "==0.6.1" 374 | }, 375 | "urllib3": { 376 | "hashes": [ 377 | "sha256:41c3db2fc01e5b907288010dec72f9d0a74e37d6994e6eb56849f59fea2265ae", 378 | "sha256:8819bba37a02d143296a4d032373c4dd4aca11f6d4c9973335ca75f9c8475f59" 379 | ], 380 | "markers": "python_version >= '3.4'", 381 | "version": "==1.24" 382 | }, 383 | "websocket": { 384 | "hashes": [ 385 | "sha256:42b506fae914ac5ed654e23ba9742e6a342b1a1c3eb92632b6166c65256469a4" 386 | ], 387 | "index": "pypi", 388 | "version": "==0.2.1" 389 | }, 390 | "websocket-client": { 391 | "hashes": [ 392 | "sha256:8c8bf2d4f800c3ed952df206b18c28f7070d9e3dcbd6ca6291127574f57ee786", 393 | "sha256:e51562c91ddb8148e791f0155fdb01325d99bb52c4cdbb291aee7a3563fd0849" 394 | ], 395 | "markers": "extra == 'requests'", 396 | "version": "==0.54.0" 397 | } 398 | }, 399 | "develop": { 400 | "atomicwrites": { 401 | "hashes": [ 402 | "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", 403 | "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" 404 | ], 405 | "version": "==1.2.1" 406 | }, 407 | "attrs": { 408 | "hashes": [ 409 | "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", 410 | "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" 411 | ], 412 | "version": "==18.2.0" 413 | }, 414 | "colorama": { 415 | "hashes": [ 416 | "sha256:a3d89af5db9e9806a779a50296b5fdb466e281147c2c235e8225ecc6dbf7bbf3", 417 | "sha256:c9b54bebe91a6a803e0772c8561d53f2926bfeb17cd141fbabcb08424086595c" 418 | ], 419 | "markers": "sys_platform == 'win32'", 420 | "version": "==0.4.0" 421 | }, 422 | "django": { 423 | "hashes": [ 424 | "sha256:1ffab268ada3d5684c05ba7ce776eaeedef360712358d6a6b340ae9f16486916", 425 | "sha256:dd46d87af4c1bf54f4c926c3cfa41dc2b5c15782f15e4329752ce65f5dad1c37" 426 | ], 427 | "index": "pypi", 428 | "version": "==2.1.3" 429 | }, 430 | "django-debug-toolbar": { 431 | "hashes": [ 432 | "sha256:08e0e43f6c1fd9820af4cbdcd54b5fb80bf83a2e08b2cc952547a671174999b8", 433 | "sha256:1dcae28d430522debafde2602b3450eb784410b78e16c29a00448032df2a4c90" 434 | ], 435 | "index": "pypi", 436 | "version": "==1.10.1" 437 | }, 438 | "more-itertools": { 439 | "hashes": [ 440 | "sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092", 441 | "sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e", 442 | "sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d" 443 | ], 444 | "version": "==4.3.0" 445 | }, 446 | "pluggy": { 447 | "hashes": [ 448 | "sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095", 449 | "sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f" 450 | ], 451 | "version": "==0.8.0" 452 | }, 453 | "py": { 454 | "hashes": [ 455 | "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", 456 | "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" 457 | ], 458 | "version": "==1.7.0" 459 | }, 460 | "pytest": { 461 | "hashes": [ 462 | "sha256:a9e5e8d7ab9d5b0747f37740276eb362e6a76275d76cebbb52c6049d93b475db", 463 | "sha256:bf47e8ed20d03764f963f0070ff1c8fda6e2671fc5dd562a4d3b7148ad60f5ca" 464 | ], 465 | "index": "pypi", 466 | "version": "==3.9.3" 467 | }, 468 | "pytest-django": { 469 | "hashes": [ 470 | "sha256:49e9ffc856bc6a1bec1c26c5c7b7213dff7cc8bc6b64d624c4d143d04aff0bcf", 471 | "sha256:b379282feaf89069cb790775ab6bbbd2bd2038a68c7ef9b84a41898e0b551081" 472 | ], 473 | "index": "pypi", 474 | "version": "==3.4.3" 475 | }, 476 | "pytz": { 477 | "hashes": [ 478 | "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", 479 | "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" 480 | ], 481 | "version": "==2018.7" 482 | }, 483 | "six": { 484 | "hashes": [ 485 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 486 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 487 | ], 488 | "version": "==1.11.0" 489 | }, 490 | "sqlparse": { 491 | "hashes": [ 492 | "sha256:ce028444cfab83be538752a2ffdb56bc417b7784ff35bb9a3062413717807dec", 493 | "sha256:d9cf190f51cbb26da0412247dfe4fb5f4098edb73db84e02f9fc21fdca31fed4" 494 | ], 495 | "version": "==0.2.4" 496 | } 497 | } 498 | } 499 | --------------------------------------------------------------------------------