├── apps ├── dashboard │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── admin.py │ ├── tests.py │ ├── apps.py │ ├── context_processors.py │ ├── urls.py │ ├── views.py │ └── templates │ │ ├── fax-detail.html │ │ ├── home.html │ │ ├── dashboard-base.html │ │ └── new-fax.html ├── authentication │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── middleware.py │ ├── apps.py │ ├── templates │ │ ├── auth-base.html │ │ ├── auth-error.html │ │ └── login.html │ ├── auth0backend.py │ ├── managers.py │ ├── urls.py │ ├── pipeline.py │ ├── admin.py │ ├── mixins.py │ ├── authentication.py │ ├── models.py │ ├── views.py │ └── forms.py ├── core │ ├── migrations │ │ └── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ └── react_tracker.py │ ├── __init__.py │ ├── exceptions.py │ ├── admin.py │ ├── views.py │ ├── templates │ │ ├── styles.html │ │ └── scripts.html │ ├── __pycache__ │ │ ├── apps.cpython-37.pyc │ │ ├── mixins.cpython-37.pyc │ │ ├── __init__.cpython-37.pyc │ │ ├── exceptions.cpython-37.pyc │ │ ├── formatters.cpython-37.pyc │ │ ├── pagination.cpython-37.pyc │ │ ├── validators.cpython-37.pyc │ │ └── tests.cpython-37-PYTEST.pyc │ ├── validators.py │ ├── apps.py │ ├── pagination.py │ ├── utils.py │ ├── view_mixins.py │ ├── formatters.py │ ├── serializers.py │ ├── tests.py │ └── mixins.py └── fax │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── configure.py │ ├── migrations │ ├── __init__.py │ ├── 0003_auto_20190513_2238.py │ ├── 0005_auto_20190513_2321.py │ ├── 0006_auto_20190514_0150.py │ ├── 0007_auto_20190514_0154.py │ ├── 0004_auto_20190513_2316.py │ ├── 0002_auto_20190513_2237.py │ └── 0001_initial.py │ ├── __init__.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ ├── tasks.py │ ├── urls.py │ ├── forms.py │ ├── views.py │ └── models.py ├── static └── REAMDE.md ├── Procfile ├── gunicorn.conf ├── accurate_replica ├── __init__.py ├── wsgi.py ├── celery.py ├── urls.py └── settings.py ├── Procfile.dev ├── README.md ├── send_fax.py ├── manage.py ├── Pipfile ├── config ├── nginx.conf ├── nginx.conf.erb └── mime.types ├── lazy_clients.py ├── Makefile ├── .gitignore └── Pipfile.lock /apps/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/fax/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/fax/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/dashboard/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/authentication/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/fax/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/fax/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "fax.apps.FaxConfig" 2 | -------------------------------------------------------------------------------- /static/REAMDE.md: -------------------------------------------------------------------------------- 1 | # I exist only so this directory gets uploaded 2 | -------------------------------------------------------------------------------- /apps/core/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "core.apps.CoreConfig" 2 | -------------------------------------------------------------------------------- /apps/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidNumberException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /apps/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /apps/core/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /apps/fax/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /apps/dashboard/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /apps/dashboard/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /apps/authentication/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /apps/core/templates/styles.html: -------------------------------------------------------------------------------- 1 | {% for style in styles %} 2 | 3 | {% endfor %} 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn accurate_replica.wsgi:application 2 | worker: celery worker --beat --app accurate_replica --loglevel info 3 | -------------------------------------------------------------------------------- /apps/core/templates/scripts.html: -------------------------------------------------------------------------------- 1 | {% for script in scripts %} 2 | 3 | {% endfor %} 4 | -------------------------------------------------------------------------------- /apps/dashboard/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DashboardConfig(AppConfig): 5 | name = 'dashboard' 6 | -------------------------------------------------------------------------------- /gunicorn.conf: -------------------------------------------------------------------------------- 1 | def when_ready(server): 2 | open('/tmp/app-initialized', 'w').close() 3 | 4 | bind = 'unix:///tmp/nginx.socket' 5 | -------------------------------------------------------------------------------- /apps/core/__pycache__/apps.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/apps.cpython-37.pyc -------------------------------------------------------------------------------- /apps/core/__pycache__/mixins.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/mixins.cpython-37.pyc -------------------------------------------------------------------------------- /apps/core/__pycache__/__init__.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/__init__.cpython-37.pyc -------------------------------------------------------------------------------- /apps/core/__pycache__/exceptions.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/exceptions.cpython-37.pyc -------------------------------------------------------------------------------- /apps/core/__pycache__/formatters.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/formatters.cpython-37.pyc -------------------------------------------------------------------------------- /apps/core/__pycache__/pagination.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/pagination.cpython-37.pyc -------------------------------------------------------------------------------- /apps/core/__pycache__/validators.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/validators.cpython-37.pyc -------------------------------------------------------------------------------- /accurate_replica/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .celery import app as celery_app # noqa 4 | 5 | __all__ = ['celery_app'] 6 | -------------------------------------------------------------------------------- /apps/core/__pycache__/tests.cpython-37-PYTEST.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/accurate_replica/master/apps/core/__pycache__/tests.cpython-37-PYTEST.pyc -------------------------------------------------------------------------------- /apps/core/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | uuid4hex = re.compile('[0-9a-f]{32}\Z', re.I) 5 | 6 | def is_uuid4(string): 7 | return uuid4hex.match(string) 8 | -------------------------------------------------------------------------------- /apps/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class CoreConfig(AppConfig): 6 | name = "core" 7 | verbose_name = _("Core") 8 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: gunicorn accurate_replica.wsgi:application --log-level warn --access-logfile - --error-logfile - --log-file - --reload # -c gunicorn.conf 2 | worker: celery worker --beat --app accurate_replica --loglevel info 3 | -------------------------------------------------------------------------------- /apps/dashboard/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from core.formatters import pretty_print_phone_number 3 | 4 | 5 | def fax_number(request): 6 | return {'TWILIO_NUMBER': pretty_print_phone_number(settings.TWILIO_NUMBER)} 7 | -------------------------------------------------------------------------------- /apps/core/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import LimitOffsetPagination 2 | 3 | 4 | class DefaultLimitOffsetPagination(LimitOffsetPagination): 5 | limit_query_param = "limit" 6 | offset_query_param = "offset" 7 | default_limit = 100 8 | max_limit = 2500 9 | -------------------------------------------------------------------------------- /apps/fax/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from fax.models import Fax 4 | 5 | 6 | class FaxModelAdmin(admin.ModelAdmin): 7 | list_display = ('sid', 'direction', '_to', '_from', 'created_on', 'created_by') 8 | ordering = ['-created_on'] 9 | 10 | admin.site.register(Fax, FaxModelAdmin) 11 | -------------------------------------------------------------------------------- /apps/fax/apps.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from django.apps import AppConfig 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class FaxConfig(AppConfig): 8 | name = "fax" 9 | 10 | def ready(self): 11 | from fax import tasks 12 | logger.warning(f'Fax Module Ready: loaded tasks - {id(tasks)}') 13 | -------------------------------------------------------------------------------- /apps/fax/tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from celery import shared_task 4 | 5 | from fax.models import Fax 6 | 7 | 8 | @shared_task 9 | def _receive_fax(uuid): 10 | fax = Fax.objects.get(uuid=uuid) 11 | fax.receive_fax() 12 | fax.send_notification_email() 13 | 14 | 15 | @shared_task 16 | def _send_fax(uuid): 17 | fax = Fax.objects.get(uuid=uuid) 18 | fax.send_fax() 19 | -------------------------------------------------------------------------------- /apps/dashboard/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.conf.urls import url 3 | 4 | from dashboard.views import Home, FaxDetail, NewFax 5 | 6 | 7 | app_name = "dashboard" 8 | 9 | 10 | urlpatterns = [ 11 | url("fax/new", NewFax.as_view(), name="new-fax"), 12 | path("fax/", FaxDetail.as_view(), name="fax-detail"), 13 | path("", Home.as_view(), name="home"), 14 | ] 15 | -------------------------------------------------------------------------------- /apps/fax/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from fax.views import FaxStatusCallback, FaxSentView, FaxReceivedView 4 | 5 | 6 | app_name = "fax" 7 | 8 | 9 | urlpatterns = [ 10 | path("status//", FaxStatusCallback.as_view(), name="status-callback"), 11 | path("sent", FaxSentView.as_view(), name="sent"), 12 | path("received", FaxReceivedView.as_view(), name="received"), 13 | ] 14 | -------------------------------------------------------------------------------- /apps/authentication/middleware.py: -------------------------------------------------------------------------------- 1 | from social_django.middleware import SocialAuthExceptionMiddleware 2 | 3 | class CustomSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): 4 | def get_message(self, request, exception): 5 | default_msg = super(CustomSocialAuthExceptionMiddleware).get_message(request, exception) 6 | # in case of display default message 7 | return "Custom messages text write here." 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Accurate Replica 2 | 3 | Twilio + Django Fascimile Service 4 | 5 | ## Archived 6 | 7 | [Twilio Programmable Fax will be reaching end of life.](https://www.twilio.com/changelog/programmable-fax-end-life-one-year-notice) 8 | 9 | ### TODO 10 | 11 | - [ ] Add alerts for incoming fax 12 | - [ ] Deploy to Heroku Button 13 | - [ ] Finish README and docs 14 | - [ ] Figure out staticfiles (local nginx-buildpack workflow) 15 | -------------------------------------------------------------------------------- /accurate_replica/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for accurate_replica project. 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.2/howto/deployment/wsgi/ 8 | """ 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "accurate_replica.settings") 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /apps/fax/migrations/0003_auto_20190513_2238.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-13 22:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('fax', '0002_auto_20190513_2237'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='fax', 15 | name='sid', 16 | field=models.CharField(blank=True, max_length=34, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /apps/fax/migrations/0005_auto_20190513_2321.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-13 23:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('fax', '0004_auto_20190513_2316'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='fax', 15 | name='direction', 16 | field=models.CharField(default='outbound', max_length=16), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /apps/authentication/apps.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.apps import AppConfig 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class AuthenticationConfig(AppConfig): 11 | name = "authentication" 12 | verbose_name = _("Authentication") 13 | 14 | def ready(self): 15 | from authentication import signals 16 | from authentication import tasks 17 | 18 | logger.warning( 19 | f"Authentication Ready: loaded signals and tasks - {id(signals)}:{id(tasks)}" 20 | ) 21 | -------------------------------------------------------------------------------- /apps/fax/migrations/0006_auto_20190514_0150.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-14 01:50 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('fax', '0005_auto_20190513_2321'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='fax', 16 | name='twilio_metadata', 17 | field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /apps/core/utils.py: -------------------------------------------------------------------------------- 1 | from accompaniment.models import Responder 2 | from operators.models import Operator 3 | from attorneys.models import Attorney 4 | 5 | 6 | MODEL_MAPPING = {"responder": Responder, "operator": Operator, "attorney": Attorney} 7 | 8 | 9 | def get_phone_number(entity: str) -> str: 10 | """ 11 | takes entity string and returns entitie's phone number 12 | 13 | format: responder:9a89d16f-9617-44dc-945a-22b4ed6b4055 14 | """ 15 | type_, uuid = entity.split(":") 16 | model = MODEL_MAPPING.get(type_) 17 | if not model: 18 | return 19 | return model.user.phone_number 20 | -------------------------------------------------------------------------------- /accurate_replica/celery.py: -------------------------------------------------------------------------------- 1 | # http://docs.celeryproject.org/en/latest/django/first-steps-with-django.html 2 | from __future__ import absolute_import, unicode_literals 3 | import os 4 | 5 | from celery import Celery 6 | 7 | 8 | # set the default Django settings module for the 'celery' program. 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "accurate_replica.settings") 10 | 11 | app = Celery("accurate_replica") 12 | 13 | 14 | # Using a string here means the worker will not have to 15 | # pickle the object when using Windows. 16 | app.config_from_object("django.conf:settings", namespace="CELERY") 17 | app.autodiscover_tasks() 18 | -------------------------------------------------------------------------------- /send_fax.py: -------------------------------------------------------------------------------- 1 | import os 2 | from twilio.rest import Client 3 | 4 | 5 | account_sid = os.environ.get("TWILIO_ACCOUNT_SID") 6 | auth_token = os.environ.get("TWILIO_AUTH_TOKEN") 7 | 8 | client = Client(account_sid, auth_token) 9 | 10 | from_number = "+18728147688" 11 | to_number = "+13182599773" 12 | 13 | media_url = ( 14 | # "https://s3.amazonaws.com/accurate-replica-001/Jackson+Parish+Clearance+request.pdf" 15 | "https://s3.amazonaws.com/accurate-replica-001/7b200b52-7318-46a0-814a-d6ec606bd7c5.pdf" 16 | ) 17 | 18 | fax = client.fax.faxes.create(from_=from_number, to=to_number, media_url=media_url) 19 | 20 | print(fax.sid) 21 | -------------------------------------------------------------------------------- /apps/authentication/templates/auth-base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{title|default:'Authentication'}} | Accurate Replica 4 | 5 | 6 | {% block headscripts %}{% endblock headscripts %} 7 | 8 | 9 | {% block content %}{% endblock content %} 10 | {% block end_scripts %}{% endblock end_scripts %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/fax/migrations/0007_auto_20190514_0154.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-14 01:54 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 | dependencies = [ 11 | ('fax', '0006_auto_20190514_0150'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='fax', 17 | name='created_by', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /apps/fax/migrations/0004_auto_20190513_2316.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-13 23:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('fax', '0003_auto_20190513_2238'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='fax', 15 | name='error_message', 16 | field=models.CharField(blank=True, max_length=64, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='fax', 20 | name='fax_status', 21 | field=models.CharField(default='queued', max_length=16), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "accurate_replica.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [packages] 7 | auth0-python = "*" 8 | boto3 = "*" 9 | celery = "*" 10 | django = "==3.0.1" 11 | django-environ = "*" 12 | django-s3-storage = "*" 13 | django-sendgrid-v5 = "*" 14 | gunicorn = "*" 15 | psycopg2-binary = "*" 16 | raven = "*" 17 | redis = "*" 18 | requests = "*" 19 | sendgrid = "*" 20 | social-auth-app-django = "*" 21 | twilio = "*" 22 | ujson = "*" 23 | urllib3 = ">=1.24.2" 24 | 25 | [dev-packages] 26 | bandit = "*" 27 | coverage = "*" 28 | honcho = "*" 29 | ipython = "*" 30 | pytest = "*" 31 | pytest-cov = "*" 32 | pytest-django = "*" 33 | django-extensions = "*" 34 | "flake8" = "*" 35 | graphviz = "*" 36 | pydotplus = "*" 37 | 38 | [requires] 39 | python_version = "3.7" 40 | -------------------------------------------------------------------------------- /apps/core/view_mixins.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | 3 | 4 | class GroupRequiredMixin(object): 5 | """ 6 | blatantly stolen from this gist: https://gist.github.com/ceolson01/206139a093b3617155a6 7 | 8 | group_required - list of strings, required param 9 | """ 10 | 11 | group_required = None 12 | 13 | def dispatch(self, request, *args, **kwargs): 14 | if not request.user.is_authenticated: 15 | raise PermissionDenied 16 | else: 17 | user_groups = [group for group in request.user.groups.values_list('name', flat=True)] 18 | if len(set(user_groups).intersection(self.group_required)) <= 0: 19 | raise PermissionDenied 20 | return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs) 21 | -------------------------------------------------------------------------------- /apps/fax/migrations/0002_auto_20190513_2237.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-13 22:37 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('fax', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='fax', 16 | name='_from', 17 | field=models.CharField(default='+18728147688', max_length=16), 18 | ), 19 | migrations.AlterField( 20 | model_name='fax', 21 | name='sid', 22 | field=models.CharField(blank=True, max_length=32, null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='fax', 26 | name='twilio_metadata', 27 | field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, null=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /apps/fax/management/commands/configure.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from lazy_clients import LazyLoadedTwilioClient 4 | 5 | from django.conf import settings 6 | from django.urls import reverse 7 | 8 | 9 | class Command(BaseCommand): 10 | help = 'Configure Twilio Number Endpoints' 11 | 12 | def handle(self, *args, **options): 13 | self.stdout.write(str(dir(self.style))) 14 | voice_url = f'{settings.URL}{reverse("fax:sent")}' 15 | self.stdout.write(self.style.WARNING(f'Configuring Phone Number: {settings.TWILIO_NUMBER_SID}')) 16 | self.stdout.write(self.style.WARNING(f' voice url: {voice_url}')) 17 | 18 | client = LazyLoadedTwilioClient().get_client() 19 | number = client.incoming_phone_numbers.get(settings.TWILIO_NUMBER_SID) 20 | number.update(voice_url=voice_url) 21 | 22 | self.stdout.write(self.style.SUCCESS('Phone Number Successfully Configured!')) 23 | -------------------------------------------------------------------------------- /apps/authentication/auth0backend.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from social_core.backends.oauth import BaseOAuth2 3 | 4 | 5 | class Auth0(BaseOAuth2): 6 | """Auth0 OAuth authentication backend""" 7 | 8 | name = "auth0" 9 | SCOPE_SEPARATOR = " " 10 | ACCESS_TOKEN_METHOD = "POST" 11 | EXTRA_DATA = [("picture", "picture")] 12 | 13 | def authorization_url(self): 14 | """Return the authorization endpoint.""" 15 | return "https://" + self.setting("DOMAIN") + "/authorize" 16 | 17 | def access_token_url(self): 18 | """Return the token endpoint.""" 19 | return "https://" + self.setting("DOMAIN") + "/oauth/token" 20 | 21 | def get_user_id(self, details, response): 22 | """Return current user id.""" 23 | return details["user_id"] 24 | 25 | def get_user_details(self, response): 26 | url = "https://" + self.setting("DOMAIN") + "/userinfo" 27 | headers = {"authorization": "Bearer " + response["access_token"]} 28 | resp = requests.get(url, headers=headers) 29 | userinfo = resp.json() 30 | 31 | return { 32 | "username": userinfo["nickname"], 33 | "first_name": userinfo["name"], 34 | "picture": userinfo["picture"], 35 | "user_id": userinfo["sub"], 36 | } 37 | -------------------------------------------------------------------------------- /apps/core/formatters.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | # TODO: we need a prettifier that can take non-US numbers 5 | def pretty_print_phone_number(number: str) -> str: 6 | """E.164 number to prettified +1 (xxx) xxx xxxx""" 7 | country_code, area_code, three, four = ( 8 | number[0:2], 9 | number[2:5], 10 | number[5:8], 11 | number[8:], 12 | ) 13 | return f"{country_code} ({area_code}) {three} {four}" 14 | 15 | 16 | class InvalidUSPhoneNumberException(Exception): 17 | pass 18 | 19 | 20 | def e164_format_phone_number(value: str) -> str: 21 | """ 22 | any number to E.164 23 | """ 24 | value = value.strip() 25 | 26 | # get only the digits out 27 | numbers = "".join([c for c in value if c.isdigit()]) 28 | 29 | if value[:2] == "+1" and len(numbers) != 11: 30 | raise InvalidUSPhoneNumberException 31 | 32 | if len(numbers) == 11: 33 | if numbers[0] == "1": 34 | return f"+{numbers}" 35 | raise InvalidUSPhoneNumberException 36 | 37 | elif len(numbers) == 10: 38 | return f"+1{numbers}" 39 | 40 | else: 41 | raise InvalidUSPhoneNumberException 42 | 43 | 44 | def to_snake_case(string): 45 | return "_".join([s.strip() for s in string.strip().lower().split(" ")]) 46 | 47 | 48 | first_cap_re = re.compile("(.)([A-Z][a-z]+)") 49 | all_cap_re = re.compile("([a-z0-9])([A-Z])") 50 | 51 | 52 | def camel_to_snake(name): 53 | s1 = first_cap_re.sub(r"\1_\2", name) 54 | return all_cap_re.sub(r"\1_\2", s1).lower() 55 | -------------------------------------------------------------------------------- /apps/fax/forms.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django import forms 5 | 6 | from core.formatters import e164_format_phone_number 7 | from fax.models import Fax 8 | from fax.tasks import _send_fax 9 | 10 | 11 | class OutboundFaxForm(forms.Form): 12 | to = forms.CharField(max_length=17, label="to") 13 | content = forms.FileField() 14 | 15 | def __init__(self, *args, **kwargs): 16 | self.created_by = kwargs.pop('created_by', None) 17 | super(OutboundFaxForm, self).__init__(*args, **kwargs) 18 | 19 | def clean_to(self): 20 | to = self.cleaned_data.get('to', '').strip() 21 | if not to: 22 | raise forms.ValidationError('Please provide a valid fax number.') 23 | cleaned = e164_format_phone_number(to) 24 | return cleaned 25 | 26 | def clean_content(self): 27 | content = self.cleaned_data['content'] 28 | return content 29 | 30 | def clean(self): 31 | super().clean() 32 | logging.warning(self.cleaned_data) 33 | 34 | to = self.cleaned_data['to'] 35 | if to == settings.TWILIO_NUMBER: 36 | raise forms.ValidationError('Sending fax to self is disallowed.') 37 | return self.cleaned_data 38 | 39 | def save(self): 40 | kwargs = self.cleaned_data 41 | kwargs['_to'] = kwargs.pop('to') # N.B. update to match the model field 42 | fax = Fax.objects.create(created_by=self.created_by, **kwargs) 43 | _send_fax.delay(fax.uuid) 44 | return fax 45 | -------------------------------------------------------------------------------- /apps/authentication/managers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth.base_user import BaseUserManager 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class UserManager(BaseUserManager): 10 | use_in_migrations = True 11 | 12 | def _create_user(self, email, password, **extra_fields): 13 | """ 14 | Creates and saves a User with the given email and password. 15 | """ 16 | if not email: 17 | raise ValueError("The given email must be set") 18 | email = self.normalize_email(email) 19 | user = self.model(email=email, **extra_fields) 20 | user.set_password(password) 21 | user.save(using=self._db) 22 | return user 23 | 24 | def create_user(self, email, password=None, **extra_fields): 25 | logger.warning(extra_fields) 26 | logger.warning(email) 27 | extra_fields.setdefault("is_superuser", False) 28 | extra_fields.setdefault("is_staff", False) 29 | return self._create_user(email, password, **extra_fields) 30 | 31 | def create_superuser(self, email, password, **extra_fields): 32 | extra_fields.setdefault("is_superuser", True) 33 | extra_fields.setdefault("is_staff", True) 34 | 35 | if extra_fields.get("is_superuser") is not True: 36 | raise ValueError("Superuser must have is_superuser=True.") 37 | if extra_fields.get("is_staff") is not True: 38 | raise ValueError("Superuser must have is_staff=True.") 39 | 40 | return self._create_user(email, password, **extra_fields) 41 | -------------------------------------------------------------------------------- /apps/core/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | from rest_framework import serializers 4 | 5 | 6 | class UserModelSerializer(serializers.ModelSerializer): 7 | full_name = serializers.SerializerMethodField() 8 | token = serializers.SerializerMethodField() 9 | organizations = serializers.SerializerMethodField() 10 | twilio_worker_sid = serializers.SerializerMethodField() 11 | groups = serializers.SerializerMethodField() 12 | 13 | class Meta: 14 | model = get_user_model() 15 | fields = ( 16 | "uuid", 17 | "email", 18 | "full_name", 19 | "groups", 20 | "organizations", 21 | "phone_number", 22 | "token", 23 | "twilio_worker_sid", 24 | "image_url", 25 | "is_superuser", 26 | "is_staff", 27 | ) 28 | read_only_fields = fields 29 | 30 | def get_full_name(self, user): 31 | return user.get_full_name() 32 | 33 | def get_token(self, user): 34 | return user.auth_token.key 35 | 36 | def get_organizations(self, user): 37 | return [org.short_name for org in user.organizations.all()] 38 | 39 | def get_twilio_worker_sid(self, user): 40 | if hasattr(user, "agent") and user.agent: 41 | return user.agent.twilio_worker_sid 42 | 43 | def get_groups(self, user): 44 | groups = [g.name for g in user.groups.all()] 45 | if user.is_superuser: 46 | groups += ['supervisor', 'wfo.full_access'] 47 | return groups 48 | -------------------------------------------------------------------------------- /apps/dashboard/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth.mixins import LoginRequiredMixin 4 | from django.http import HttpResponse 5 | from django.shortcuts import redirect, render 6 | from django.views import View 7 | 8 | from fax.models import Fax 9 | from fax.forms import OutboundFaxForm 10 | 11 | 12 | class DashboardHomeRedirectView(View): 13 | """redirect to dashboard home""" 14 | 15 | def get(self, request, *args, **kwargs): 16 | if request.user.is_authenticated: 17 | return redirect("dashboard:home") 18 | return redirect("authentication:login") 19 | 20 | 21 | class Home(LoginRequiredMixin, View): 22 | def get(self, request): 23 | faxes = Fax.objects.all().order_by('-created_on') 24 | return render(request, "home.html", context={'faxes': faxes}) 25 | 26 | 27 | class FaxDetail(LoginRequiredMixin, View): 28 | def get(self, request, uuid): 29 | fax = Fax.objects.get(uuid=uuid) 30 | return render(request, "fax-detail.html", context={'fax': fax}) 31 | 32 | 33 | class NewFax(LoginRequiredMixin, View): 34 | def get(self, request): 35 | form = OutboundFaxForm() 36 | return render(request, "new-fax.html", context={'form': form}) 37 | 38 | def post(self, request): 39 | form = OutboundFaxForm(request.POST, request.FILES, created_by=request.user) 40 | if form.is_valid(): 41 | fax = form.save() 42 | return redirect('dashboard:fax-detail', uuid=str(fax.uuid)) 43 | else: 44 | return render(request, 'new-fax.html', context={'form': form}) 45 | -------------------------------------------------------------------------------- /apps/fax/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-13 20:43 2 | 3 | from django.conf import settings 4 | import django.contrib.postgres.fields.jsonb 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Fax', 21 | fields=[ 22 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 23 | ('created_on', models.DateTimeField(auto_now_add=True)), 24 | ('updated_on', models.DateTimeField(auto_now=True)), 25 | ('direction', models.CharField(max_length=16)), 26 | ('_from', models.CharField(max_length=16)), 27 | ('sid', models.CharField(blank=True, max_length=16, null=True)), 28 | ('status', models.CharField(default='queued', max_length=16)), 29 | ('_to', models.CharField(max_length=16)), 30 | ('twilio_metadata', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), 31 | ('content', models.FileField(upload_to='fax-media/')), 32 | ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), 33 | ], 34 | options={ 35 | 'abstract': False, 36 | }, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /apps/core/templatetags/react_tracker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import os 4 | 5 | from django import template 6 | from django.conf import settings 7 | 8 | import ujson as json 9 | 10 | register = template.Library() 11 | 12 | 13 | @register.inclusion_tag("scripts.html", takes_context=True) 14 | def react_js(context): 15 | path = os.path.join(settings.BASE_DIR, "frontend", "webpack-stats.json") 16 | with open(path, "r") as of: 17 | contents = json.loads(of.read()) 18 | 19 | status = contents["status"] 20 | if status == "compiling": 21 | return [] 22 | 23 | chunks = contents["chunks"] 24 | js_files = [] 25 | for name, chunk in chunks.items(): 26 | for _chunk in chunk: 27 | name = _chunk["name"] 28 | if name.split(".")[-1] in ["js", "map"]: 29 | logging.debug(_chunk) 30 | js_files.append(_chunk["name"]) 31 | return {"scripts": js_files, "publicPath": contents["publicPath"]} 32 | 33 | 34 | @register.inclusion_tag("styles.html", takes_context=True) 35 | def react_css(context): 36 | path = os.path.join(settings.BASE_DIR, "frontend", "webpack-stats.json") 37 | with open(path, "r") as of: 38 | contents = json.loads(of.read()) 39 | 40 | status = contents["status"] 41 | if status == "compiling": 42 | return [] 43 | 44 | chunks = contents["chunks"] 45 | css_files = [] 46 | for name, chunk in chunks.items(): 47 | for _chunk in chunk: 48 | if name.split(".")[-1] in ["css"]: 49 | logging.debug(_chunk) 50 | css_files.append(_chunk["name"]) 51 | 52 | return {"styles": css_files, "publicPath": contents["publicPath"]} 53 | -------------------------------------------------------------------------------- /apps/core/tests.py: -------------------------------------------------------------------------------- 1 | from core.formatters import e164_format_phone_number, InvalidUSPhoneNumberException 2 | from core.formatters import pretty_print_phone_number 3 | 4 | 5 | def test_e164_formatter__should_pass(): 6 | numbers = [ 7 | "+1 321 555 0123", 8 | "+1 (321) 555-0123", 9 | "+1 (321) 555 0123", 10 | "(321) 555 0123", 11 | "321.555.0123", 12 | "3215550123", 13 | "+1 321 55 5 0 123", 14 | "1 321 55 5 0 123", 15 | ] 16 | 17 | for number in numbers: 18 | formatted = e164_format_phone_number(number) 19 | 20 | assert len(formatted) == 12 # test for correct length 21 | assert formatted[:2] == "+1" # test for correct prefix 22 | 23 | 24 | def test_e164_formatter__should_not_pass(): 25 | numbers = ["+1321555012", "+132155501234", "321555012", "32155501234"] 26 | 27 | for number in numbers: 28 | try: 29 | formatted = e164_format_phone_number(number) 30 | assert ( 31 | False 32 | ) # this case is only reached if the formatter does not raise an exception 33 | except InvalidUSPhoneNumberException as e: 34 | assert True 35 | 36 | 37 | def test_pretty_print_phone_number(): 38 | numbers = [ 39 | "+1 321 555 0123", 40 | "+1 (321) 555-0123", 41 | "+1 (321) 555 0123", 42 | "(321) 555 0123", 43 | "321.555.0123", 44 | "3215550123", 45 | "+1 321 55 5 0 123", 46 | "1 321 55 5 0 123", 47 | ] 48 | 49 | for number in numbers: 50 | assert ( 51 | pretty_print_phone_number(e164_format_phone_number(number)) 52 | == "+1 (321) 555 0123" 53 | ) 54 | -------------------------------------------------------------------------------- /config/nginx.conf: -------------------------------------------------------------------------------- 1 | daemon off; 2 | #Heroku dynos have at least 4 cores. 3 | worker_processes 4; 4 | 5 | events { 6 | use epoll; 7 | accept_mutex on; 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | gzip on; 13 | gzip_comp_level 2; 14 | gzip_min_length 512; 15 | 16 | server_tokens off; 17 | 18 | log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id'; 19 | access_log logs/nginx/access.log l2met; 20 | error_log logs/nginx/error.log; 21 | 22 | include mime.types; 23 | default_type application/octet-stream; 24 | sendfile on; 25 | 26 | #Must read the body in 5 seconds. 27 | client_body_timeout 5; 28 | 29 | upstream app_server { 30 | server unix:/tmp/nginx.socket fail_timeout=0; 31 | } 32 | 33 | server { 34 | listen 9901; 35 | server_name _; 36 | keepalive_timeout 5; 37 | 38 | # proxy requests for static files 39 | location /static { 40 | alias /Users/olmeca/code/accurate_replica/staticfiles; # requires django to run collecstatic 41 | } 42 | 43 | # proxy requests for the favicon 44 | location /favicon.ico { 45 | alias /Users/olmeca/code/accurate_replica/staticfiles/favicon.ico; 46 | } 47 | 48 | # reject requests for PHP files: for the bots and script-kiddies 49 | location ~ \.php$ { 50 | return 404; 51 | } 52 | 53 | # all other requests are forwarded to our application 54 | location / { 55 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 56 | proxy_set_header Host $http_host; 57 | proxy_redirect off; 58 | proxy_pass http://app_server; 59 | } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /apps/dashboard/templates/fax-detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard-base.html' %} 2 | 3 | {% block content %} 4 |
5 | {% if fax.direction == 'inbound' %} 6 |

Fax from {{fax.from_number}}

7 | {% else %} 8 |

Fax to {{fax.to_number}}

9 | {% endif %} 10 | 11 |
12 |

13 | Status: {{fax.status}} 14 |

15 | 16 | {% if fax.error_message %} 17 |

18 | Error Message: 19 | 20 | ⚠️ 21 | 22 | {{fax.error_message}} 23 |

24 | {% endif %} 25 | 26 | {% if fax.direction == 'outbound' %} 27 |

28 | Created By: {{fax.created_by.email}} 29 |

30 | {% endif %} 31 | 32 |

33 | Created On: {{fax.created_on}} 34 |

35 | 36 | 37 | {% if fax.content_url %} 38 |
39 |
40 |

41 | 42 | 🗂 43 | 44 | Contents 45 |

46 | direct link to file 47 | 48 | 🔗 49 | 50 | 51 |
52 |
53 | 54 | View File 55 | 56 |