├── .babelrc ├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml ├── requirements.txt.temp └── settings.vscode.json ├── .dockerignore ├── .env-sample ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── azure-ci-pipeline.yml ├── azure-deploy-pipeline.yml ├── docker-compose.yml ├── manage.py ├── package-lock.json ├── package.json ├── pytest.ini ├── requirements.pipelines.txt ├── requirements.txt ├── scripts ├── create_db.py ├── post_build.sh └── psql.py ├── tweeter ├── .gitignore ├── __init__.py ├── admin.py ├── apps.py ├── fixtures │ └── initial_data.json ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── permissions.py ├── serializers.py ├── src │ ├── components │ │ ├── App.css │ │ ├── App.js │ │ ├── Auth.js │ │ ├── DataProvider.js │ │ ├── Tweets.css │ │ └── Tweets.js │ └── index.js ├── templates │ ├── registration │ │ └── login.html │ ├── signup.html │ └── tweeter │ │ └── index.html ├── tests.py └── views.py ├── tweeter3 ├── __init__.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── devcontainer.py │ ├── development.py │ └── production.py ├── urls.py └── wsgi.py ├── uwsgi.ini └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "presets": [ 4 | "@babel/preset-env", "@babel/preset-react", 5 | ], 6 | // "presets": [ 7 | // [ 8 | // "latest", { 9 | // "es2015": { 10 | // "modules": false 11 | // } 12 | // } 13 | // ] 14 | // ], 15 | "plugins": [ 16 | "transform-class-properties", "@babel/transform-runtime" 17 | ] 18 | } -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See LICENSE in the project root for license information. 4 | #----------------------------------------------------------------------------------------- 5 | 6 | FROM python:3 7 | 8 | # Copy default endpoint specific user settings overrides into container to specify Python path 9 | COPY .devcontainer/settings.vscode.json /root/.vscode-remote/data/Machine/settings.json 10 | 11 | ENV PYTHONUNBUFFERED 1 12 | 13 | RUN mkdir /workspace 14 | WORKDIR /workspace 15 | 16 | ENV SHELL /bin/bash 17 | 18 | # Install node for building the front end 19 | RUN apt-get update 20 | RUN apt-get -y install curl gnupg 21 | RUN curl -sL https://deb.nodesource.com/setup_11.x | bash - 22 | RUN apt-get -y install nodejs 23 | 24 | # Install git, process tools 25 | RUN apt-get update && apt-get -y install git procps 26 | 27 | # Install Python dependencies from requirements.txt if it exists 28 | COPY .devcontainer/requirements.txt.temp requirements.txt* /workspace/ 29 | RUN if [ -f "requirements.txt" ]; then pip install -r requirements.txt && rm requirements.txt; fi 30 | 31 | # Clean up 32 | RUN apt-get autoremove -y \ 33 | && apt-get clean -y \ 34 | && rm -rf /var/lib/apt/lists/* 35 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3 & PostgreSQL", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | "extensions": [ 7 | "ms-python.python", 8 | "VisualStudioExptTeam.vscodeintellicode" 9 | ] 10 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | ports: 9 | - 8000:8000 10 | volumes: 11 | - ~/.gitconfig:/root/.gitconfig 12 | - ..:/workspace 13 | command: sleep infinity 14 | environment: 15 | DJANGO_SETTINGS_MODULE: tweeter3.settings.devcontainer 16 | 17 | db: 18 | image: postgres 19 | restart: always 20 | ports: 21 | - 5432:5432 22 | environment: 23 | POSTGRES_PASSWORD: LocalPassword -------------------------------------------------------------------------------- /.devcontainer/requirements.txt.temp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/python-sample-tweeterapp/b61b19dd30d5320253aa1fa28f7a2fb515c1124e/.devcontainer/requirements.txt.temp -------------------------------------------------------------------------------- /.devcontainer/settings.vscode.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/usr/local/bin/python", 3 | "_workbench.uiExtensions" : [ "peterjausovec.vscode-docker" ], 4 | "python.jediEnabled": false 5 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile* 4 | docker-compose* 5 | .dockerignore 6 | .git 7 | .gitignore 8 | .env 9 | */bin 10 | */obj 11 | README.md 12 | LICENSE 13 | .vscode 14 | env 15 | static/frontend 16 | **/__pycache__ 17 | **/*.pyc -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | # Sample Production Environment Variable File 2 | # These variables should be set in App Service App Settings 3 | # See: https://docs.microsoft.com/en-us/azure/app-service/web-sites-configure#app-settings 4 | 5 | # Set the Production Settings Module 6 | DJANGO_SETTINGS_MODULE=tweeter3.settings.production 7 | 8 | # Configure the SECRET_KEY for Django See: https://docs.djangoproject.com/en/2.1/ref/settings/#secret-key 9 | SECRET_KEY=my-secret-key 10 | 11 | # For deployment purposes, configure the Azure App Service Hostname 12 | AZURE_HOSTNAME=myhost 13 | 14 | # App Service Build Settings (not used in Docker scenario) 15 | POST_BUILD_SCRIPT_PATH=scripts/pre_deploy.sh 16 | # App Service Port setting 17 | WEBSITES_PORT=8000 18 | 19 | # Configure the PostgreSQL Database with App Specific Settings 20 | DB_USER=db_user 21 | DB_PASSWORD=db_password 22 | DB_NAME=db_name 23 | DB_HOST=db_host 24 | 25 | # Required for error emails 26 | SEND_ADMIN_EMAILS=true 27 | EMAIL_HOST_USER=your-email-account 28 | EMAIL_HOST_PASSWORD=your-email-password 29 | ADMIN_EMAIL_TO=email-target-account -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | #lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | staticfiles/ 60 | .env* 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | node_modules 115 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Django", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/manage.py", 12 | "args": [ 13 | "runserver", 14 | "--noreload", 15 | "--nothreading" 16 | ], 17 | "django": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tweeter" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.nosetestsEnabled": false, 7 | "python.testing.pytestEnabled": true 8 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build node front-end in a node container 2 | FROM node 3 | 4 | WORKDIR /nodebuild 5 | ADD . /nodebuild 6 | RUN npm install 7 | RUN npm run build 8 | 9 | # Use a python uwsgi nginx image for the runtime image (no node runtime here) 10 | FROM tiangolo/uwsgi-nginx 11 | 12 | ENV LISTEN_PORT=8000 13 | EXPOSE 8000 14 | 15 | # Indicate where uwsgi.ini lives 16 | ENV UWSGI_INI uwsgi.ini 17 | 18 | # Tell nginx where static files live 19 | ENV STATIC_URL /app/tweeter3/staticfiles 20 | 21 | WORKDIR /app 22 | COPY --from=0 /nodebuild /app 23 | 24 | # Install pip requirements and collect django static files 25 | RUN python3 -m pip install -r requirements.txt 26 | RUN python3 manage.py collectstatic --noinput 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Getting Started with Dev Containers 3 | 4 | 1. Get set up with Visual Studio Code insiders, Docker and remote extensions [instructions here](https://vscode-docs-remote.azurewebsites.net/docs/remote/remote-overview#_getting-started) 5 | 6 | 1. If on windows, set git to use LF line endings: 7 | ``` 8 | git config --global core.autocrlf false 9 | ``` 10 | 1. Clone repo 11 | ``` 12 | git clone https://github.com/Microsoft/python-sample-tweeterapp 13 | ``` 14 | 1. From Visual Studio Code Insiders, run the ```Remote-Containers: Open Folder in Container...``` and select the ```python-sample-tweeterapp``` folder 15 | 16 | After the dev container has installed and files appear in the explorer, you are good to go! 17 | 18 | ## Run some code! 19 | 20 | 21 | Build the node front end by opening a new terminal with ```Ctrl-Shift-` ```, and running: 22 | 23 | ``` 24 | npm install 25 | npm run dev 26 | ``` 27 | 28 | 29 | Start the Django server by opening a new terminal (```Ctrl-Shift-` ```), and running: 30 | 31 | ``` 32 | python manage.py migrate 33 | python manage.py loaddata initial_data 34 | python manage.py runserver 35 | ``` 36 | 37 | ## Presentations 38 | 39 | * Build 2019: Building Python Web Applications with Visual Studio Code, Docker, and Azure 40 | * [[slides](https://1drv.ms/p/s!Ak36tGOBftKVv0ga1kcCTiPXhuff)] [[Sentiment API Code](https://github.com/qubitron/qsentiment)] 41 | 42 | # Contributing 43 | 44 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 45 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 46 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 47 | 48 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 49 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 50 | provided by the bot. You will only need to do this once across all repos using our CLA. 51 | 52 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 53 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 54 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 55 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /azure-ci-pipeline.yml: -------------------------------------------------------------------------------- 1 | # Run Python pytests 2 | # https://docs.microsoft.com/azure/devops/pipelines/languages/python 3 | trigger: 4 | - master 5 | 6 | pool: 7 | vmImage: 'Ubuntu 16.04' 8 | strategy: 9 | matrix: 10 | Python37: 11 | PYTHON_VERSION: '3.7' 12 | Python36: 13 | PYTHON_VERSION: '3.6' 14 | maxParallel: 3 15 | 16 | steps: 17 | - task: UsePythonVersion@0 18 | inputs: 19 | versionSpec: '$(PYTHON_VERSION)' 20 | architecture: 'x64' 21 | - script: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.pipelines.txt 24 | pip install pytest pytest-azurepipelines 25 | displayName: 'Install dependencies' 26 | - script: | 27 | python -m pytest . 28 | env: 29 | DJANGO_SETTINGS_MODULE: tweeter3.settings.development 30 | displayName: 'pytest' -------------------------------------------------------------------------------- /azure-deploy-pipeline.yml: -------------------------------------------------------------------------------- 1 | # Docker image 2 | # Build a Docker image to deploy, run, or push to a container registry. 3 | # Add steps that use Docker Compose, tag images, push to a registry, run an image, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/docker 5 | 6 | pool: 7 | vmImage: 'Ubuntu-16.04' 8 | 9 | variables: 10 | imageName: 'pythondemos.azurecr.io/tweeter-app:latest' 11 | containerName: 'tweeterapp' 12 | steps: 13 | - script: docker login pythondemos.azurecr.io -u pythondemos -p $DOCKER_LOGIN_PASSWORD 14 | env: 15 | DOCKER_LOGIN_PASSWORD: $(ACR_KEY) 16 | displayName: 'docker login' 17 | - script: docker build -f Dockerfile -t $(imageName) . 18 | displayName: 'docker build' 19 | - script: docker run --name $(containerName) --detach -e DJANGO_SETTINGS_MODULE=tweeter3.settings.production -e DB_USER=$DB_USER -e DB_PASSWORD=$DB_PASSWORD -e DB_NAME=$DB_NAME -e DB_HOST=$DB_HOST $(imageName) 20 | env: 21 | DB_USER: $(DB_USER) 22 | DB_PASSWORD: $(DB_PASSWORD) 23 | DB_NAME: $(DB_NAME) 24 | DB_HOST: $(DB_HOST) 25 | displayName: 'start container' 26 | - script: docker exec $(containerName) python3 manage.py migrate 27 | displayName: run django migrations 28 | - script: docker push $(imageName) 29 | displayName: 'docker push' 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | tweeter3: 5 | image: pythondemos.azurecr.io/tweeter-app 6 | container_name: tweeterapp 7 | build: . 8 | env_file: .env 9 | ports: 10 | - 8000:8000 11 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import dotenv 5 | 6 | if __name__ == '__main__': 7 | # Use .env file if it is found 8 | env_path = os.getcwd() + '/.env' 9 | if os.path.exists(env_path): 10 | dotenv.read_dotenv(env_path) 11 | 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tweeter3.settings.development') 13 | if (os.environ.get('DJANGO_SETTINGS_MODULE') == 'tweeter3.settings.devcontainer'): 14 | from django.core.management.commands.runserver import Command as runserver 15 | runserver.default_addr = "0.0.0.0" 16 | 17 | try: 18 | from django.core.management import execute_from_command_line 19 | except ImportError as exc: 20 | raise ImportError( 21 | "Couldn't import Django. Are you sure it's installed and " 22 | "available on your PYTHONPATH environment variable? Did you " 23 | "forget to activate a virtual environment?" 24 | ) from exc 25 | execute_from_command_line(sys.argv) 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tweeterapp", 3 | "version": "1.0.0", 4 | "description": "tweeter3 =======", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack-dev-server --hot --inline", 9 | "dev": "webpack --watch --mode development ./tweeter/src/index.js --output ./tweeter/static/frontend/main.js", 10 | "build": "webpack --mode production ./tweeter/src/index.js --output ./tweeter/static/frontend/main.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/qubitron/tweeterapp.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/qubitron/tweeterapp/issues" 21 | }, 22 | "homepage": "https://github.com/qubitron/tweeterapp#readme", 23 | "devDependencies": { 24 | "@babel/core": "^7.4.3", 25 | "@babel/plugin-transform-runtime": "^7.4.4", 26 | "@babel/preset-env": "^7.4.3", 27 | "@babel/preset-react": "^7.0.0", 28 | "antd": "^3.16.5", 29 | "autoprefixer": "^9.5.1", 30 | "babel-loader": "^8.0.5", 31 | "babel-plugin-transform-class-properties": "^6.24.1", 32 | "babel-preset-latest": "^6.24.1", 33 | "css-loader": "^2.1.1", 34 | "file-loader": "^3.0.1", 35 | "postcss-flexbugs-fixes": "^4.1.0", 36 | "postcss-loader": "^3.0.0", 37 | "postcss-normalize": "^7.0.1", 38 | "prop-types": "^15.7.2", 39 | "react": "^16.8.6", 40 | "react-dom": "^16.8.6", 41 | "react-router-dom": "^5.0.0", 42 | "weak-key": "^1.0.1", 43 | "webpack": "^4.30.0", 44 | "webpack-cli": "^3.3.12", 45 | "webpack-dev-server": "^3.11.0" 46 | }, 47 | "dependencies": { 48 | "@babel/runtime": "^7.4.4", 49 | "babel-preset-es2015": "^6.24.1", 50 | "js-cookie": "^2.2.0", 51 | "react-hot-loader": "^4.8.4", 52 | "style-loader": "^0.23.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | DJANGO_SETTINGS_MODULE = tweeter3.settings 4 | 5 | python_files = tests.py test_*.py *_tests.py 6 | 7 | addopts = -p no:warnings -------------------------------------------------------------------------------- /requirements.pipelines.txt: -------------------------------------------------------------------------------- 1 | astroid==2.0.4 2 | Django>=2.1.6 3 | python-dotenv 4 | djangorestframework==3.9.1 5 | isort==4.3.4 6 | lazy-object-proxy==1.3.1 7 | mccabe==0.6.1 8 | psycopg2-binary==2.7.5 9 | # Use the following in docker to work around: https://github.com/unbit/uwsgi/issues/1569 10 | #psycopg2==2.7.4 --no-binary psycopg2 11 | pycodestyle==2.4.0 12 | pytz==2018.5 13 | six==1.11.0 14 | whitenoise==4.1 15 | wrapt==1.10.11 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astroid==2.0.4 2 | Django>=2.2 3 | python-dotenv 4 | djangorestframework==3.11.0 5 | isort==4.3.4 6 | lazy-object-proxy==1.3.1 7 | mccabe==0.6.1 8 | psycopg2==2.8.5 --no-binary psycopg2 9 | pycodestyle==2.4.0 10 | pytz==2018.5 11 | six==1.11.0 12 | whitenoise==5.1.0 13 | wrapt==1.10.11 14 | -------------------------------------------------------------------------------- /scripts/create_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import urllib.request 4 | import dotenv 5 | 6 | # Load dotenv file 7 | dotenv.load_dotenv() 8 | 9 | REQUIRED_ENV_VARS = ( 10 | 'DB_USER', 11 | 'DB_PASSWORD', 12 | 'DB_NAME', 13 | 'DB_HOST' 14 | ) 15 | 16 | AZ_GROUP=os.getenv('AZ_GROUP', 'appsvc_rg_linux_centralus') 17 | AZ_LOCATION=os.getenv('AZ_LOCATION', 'Central US') 18 | create_group_command = [ 19 | "az", "group", "create", 20 | "--name", AZ_GROUP, 21 | "--location", AZ_LOCATION 22 | ] 23 | print("Creating resource group if needed...") 24 | subprocess.call(create_group_command, shell=True) 25 | 26 | missing = [] 27 | for v in REQUIRED_ENV_VARS: 28 | if v not in os.environ: 29 | missing.append(v) 30 | if missing: 31 | print("Required Environment Variables Unset:") 32 | print("\t" + "\n\t".join(missing)) 33 | print("Exiting.") 34 | exit() 35 | 36 | print("This script will take about 3 minutes to run.") 37 | 38 | # Ref: https://docs.microsoft.com/en-gb/cli/azure/postgres/server?view=azure-cli-latest#az-postgres-server-create 39 | # SKUs: https://docs.microsoft.com/en-us/azure/postgresql/concepts-pricing-tiers 40 | # {pricing tier}_{compute generation}_{vCores} 41 | create_server_command = [ 42 | 'az', 'postgres', 'server', 'create', 43 | '--resource-group', AZ_GROUP, 44 | '--location', AZ_LOCATION, 45 | '--name', os.getenv('DB_HOST'), 46 | '--admin-user', os.getenv('DB_USER'), 47 | '--admin-password', os.getenv('DB_PASSWORD'), 48 | '--sku-name', 'B_Gen5_1', 49 | ] 50 | 51 | print("Creating PostgreSQL server...") 52 | subprocess.check_call(create_server_command, shell=True) 53 | 54 | # Set up firewall. 55 | # Ref: https://docs.microsoft.com/en-gb/cli/azure/postgres/server/firewall-rule?view=azure-cli-latest#az-postgres-server-firewall-rule-create 56 | azure_firewall_command = [ 57 | 'az', 'postgres', 'server', 'firewall-rule', 'create', 58 | '--resource-group', AZ_GROUP, 59 | '--server-name', os.getenv('DB_HOST'), 60 | '--start-ip-address', '0.0.0.0', 61 | '--end-ip-address', '0.0.0.0', 62 | '--name', 'AllowAllAzureIPs', 63 | ] 64 | 65 | with urllib.request.urlopen('http://ip.42.pl/raw') as f: 66 | my_ip = f.read().decode("utf-8") 67 | 68 | local_ip_firewall_command = [ 69 | 'az', 'postgres', 'server', 'firewall-rule', 'create', 70 | '--resource-group', AZ_GROUP, 71 | '--server-name', os.getenv('DB_HOST'), 72 | '--start-ip-address', my_ip, 73 | '--end-ip-address', my_ip, 74 | '--name', 'AllowMyIP', 75 | ] 76 | 77 | print("Allowing access from Azure...") 78 | subprocess.check_call(azure_firewall_command, shell=True) 79 | print("Allowing access from local IP...") 80 | subprocess.check_call(local_ip_firewall_command, shell=True) 81 | 82 | create_db_command = [ 83 | 'az', 'postgres', 'db', 'create', 84 | '--resource-group', AZ_GROUP, 85 | '--server-name', os.getenv('DB_HOST'), 86 | '--name', os.getenv('DB_NAME'), 87 | ] 88 | 89 | print("Creating App DB...") 90 | subprocess.check_call(create_db_command, shell=True) 91 | 92 | connect_details_command = [ 93 | 'az', 'postgres', 'server', 'show', 94 | '--resource-group', AZ_GROUP, 95 | '--name', os.getenv('DB_HOST'), 96 | ] 97 | print("Getting access details...") 98 | subprocess.check_call(connect_details_command, shell=True) 99 | -------------------------------------------------------------------------------- /scripts/post_build.sh: -------------------------------------------------------------------------------- 1 | python manage.py migrate 2 | -------------------------------------------------------------------------------- /scripts/psql.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import urllib.request 4 | import dotenv 5 | 6 | dotenv.read_dotenv(os.getcwd() + "/.env") 7 | 8 | REQUIRED_ENV_VARS = ( 9 | 'DB_USER', 10 | 'DB_PASSWORD', 11 | 'DB_NAME', 12 | 'DB_HOST' 13 | ) 14 | 15 | missing = [] 16 | for v in REQUIRED_ENV_VARS: 17 | if v not in os.environ: 18 | missing.append(v) 19 | if missing: 20 | print("Required Environment Variables Unset:") 21 | print("\t" + "\n\t".join(missing)) 22 | print("Exiting.") 23 | exit() 24 | 25 | os.environ.setdefault('PGPASSWORD', os.environ.get('DB_PASSWORD')) 26 | 27 | psql_command = [ 28 | "psql", 29 | "-v", f"db_password=\"'{os.environ.get('DB_PASSWORD')}", 30 | "-h", f"{os.environ.get('DB_HOST')}.postgres.database.azure.com", 31 | "-U", f"{os.environ.get('DB_USER')}@{os.environ.get('DB_HOST')}", 32 | "postgres" 33 | ] 34 | subprocess.check_call(psql_command, shell=True) 35 | -------------------------------------------------------------------------------- /tweeter/.gitignore: -------------------------------------------------------------------------------- 1 | static/frontend -------------------------------------------------------------------------------- /tweeter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/python-sample-tweeterapp/b61b19dd30d5320253aa1fa28f7a2fb515c1124e/tweeter/__init__.py -------------------------------------------------------------------------------- /tweeter/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /tweeter/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TweeterConfig(AppConfig): 5 | name = 'tweeter' 6 | -------------------------------------------------------------------------------- /tweeter/fixtures/initial_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "pk": 1, 5 | "model": "auth.user", 6 | "fields": { 7 | "username": "nina", 8 | "first_name": "", 9 | "last_name": "", 10 | "is_active": true, 11 | "is_superuser": true, 12 | "is_staff": true, 13 | "last_login": "2014-08-31T00:20:41.771Z", 14 | "groups": [], 15 | "user_permissions": [], 16 | "password": "pbkdf2_sha256$12000$2LSBfxYO9fJJ$UT/BLyRLwBQIOUtOfA2aKkGw+xe44ZNYD2TWXqXoT3E=", 17 | "email": "", 18 | "date_joined": "2014-08-30T18:10:53.539Z" 19 | } 20 | }, 21 | { 22 | "pk": 2, 23 | "model": "auth.user", 24 | "fields": { 25 | "username": "admin", 26 | "first_name": "Admin", 27 | "last_name": "", 28 | "is_active": true, 29 | "is_superuser": true, 30 | "is_staff": true, 31 | "last_login": "2014-08-31T00:22:38Z", 32 | "groups": [], 33 | "user_permissions": [], 34 | "password": "pbkdf2_sha256$12000$6JY3bvlplRf0$Rm6rcK9M3LNuTw1uZ3B/Je7rq420UCaf2iwmY8pIv2U=", 35 | "email": "admin@admin.com", 36 | "date_joined": "2014-08-31T00:22:38Z" 37 | } 38 | }, 39 | { 40 | "pk": 3, 41 | "model": "auth.user", 42 | "fields": { 43 | "username": "bob", 44 | "first_name": "Bob", 45 | "last_name": "Bobman", 46 | "is_active": true, 47 | "is_superuser": false, 48 | "is_staff": false, 49 | "last_login": "2014-08-31T00:23:06Z", 50 | "groups": [], 51 | "user_permissions": [], 52 | "password": "pbkdf2_sha256$12000$ehuPT7BoKVpF$VZTxDeaHtLG7jU4wQ1erlFciwMk7l8aCp9MIjSVa/NU=", 53 | "email": "bob@bob.com", 54 | "date_joined": "2014-08-31T00:23:06Z" 55 | } 56 | }, 57 | { 58 | "pk": 4, 59 | "model": "auth.user", 60 | "fields": { 61 | "username": "amy", 62 | "first_name": "Amy", 63 | "last_name": "Smith", 64 | "is_active": true, 65 | "is_superuser": false, 66 | "is_staff": false, 67 | "last_login": "2014-08-31T00:23:24Z", 68 | "groups": [], 69 | "user_permissions": [], 70 | "password": "pbkdf2_sha256$12000$Tv4vWeWICkfS$qcMP+xddceQZMVjHbdOhOsV6LKOQntKOkiaqdEzS2p8=", 71 | "email": "amy@smith.com", 72 | "date_joined": "2014-08-31T00:23:24Z" 73 | } 74 | }, 75 | { 76 | "pk": 1, 77 | "model": "tweeter.tweet", 78 | "fields": { 79 | "text": "I'm an Admin! ", 80 | "user": 2, 81 | "timestamp": "2014-08-30T18:51:04Z" 82 | } 83 | }, 84 | { 85 | "pk": 2, 86 | "model": "tweeter.tweet", 87 | "fields": { 88 | "text": "Bob is the coolest name!!", 89 | "user": 3, 90 | "timestamp": "2014-08-29T18:51:19Z" 91 | } 92 | }, 93 | { 94 | "pk": 3, 95 | "model": "tweeter.tweet", 96 | "fields": { 97 | "text": "I <3 Tweeter", 98 | "user": 4, 99 | "timestamp": "2014-08-30T15:52:09Z" 100 | } 101 | }, 102 | { 103 | "pk": 1, 104 | "model": "admin.logentry", 105 | "fields": { 106 | "action_flag": 1, 107 | "action_time": "2014-08-31T00:22:38.417Z", 108 | "object_repr": "Admin", 109 | "object_id": "2", 110 | "change_message": "", 111 | "user": 1, 112 | "content_type": 4 113 | } 114 | }, 115 | { 116 | "pk": 2, 117 | "model": "admin.logentry", 118 | "fields": { 119 | "action_flag": 2, 120 | "action_time": "2014-08-31T00:22:57.048Z", 121 | "object_repr": "Admin", 122 | "object_id": "2", 123 | "change_message": "Changed first_name, email, is_staff and is_superuser.", 124 | "user": 1, 125 | "content_type": 4 126 | } 127 | }, 128 | { 129 | "pk": 3, 130 | "model": "admin.logentry", 131 | "fields": { 132 | "action_flag": 1, 133 | "action_time": "2014-08-31T00:23:06.858Z", 134 | "object_repr": "Bob", 135 | "object_id": "3", 136 | "change_message": "", 137 | "user": 1, 138 | "content_type": 4 139 | } 140 | }, 141 | { 142 | "pk": 4, 143 | "model": "admin.logentry", 144 | "fields": { 145 | "action_flag": 2, 146 | "action_time": "2014-08-31T00:23:17.098Z", 147 | "object_repr": "Bob", 148 | "object_id": "3", 149 | "change_message": "Changed first_name, last_name and email.", 150 | "user": 1, 151 | "content_type": 4 152 | } 153 | }, 154 | { 155 | "pk": 5, 156 | "model": "admin.logentry", 157 | "fields": { 158 | "action_flag": 1, 159 | "action_time": "2014-08-31T00:23:24.481Z", 160 | "object_repr": "Amy", 161 | "object_id": "4", 162 | "change_message": "", 163 | "user": 1, 164 | "content_type": 4 165 | } 166 | }, 167 | { 168 | "pk": 6, 169 | "model": "admin.logentry", 170 | "fields": { 171 | "action_flag": 2, 172 | "action_time": "2014-08-31T00:23:35.860Z", 173 | "object_repr": "Amy", 174 | "object_id": "4", 175 | "change_message": "Changed first_name, last_name and email.", 176 | "user": 1, 177 | "content_type": 4 178 | } 179 | }, 180 | { 181 | "pk": 7, 182 | "model": "admin.logentry", 183 | "fields": { 184 | "action_flag": 1, 185 | "action_time": "2014-08-31T00:51:05.451Z", 186 | "object_repr": "I'm an Admin! ", 187 | "object_id": "1", 188 | "change_message": "", 189 | "user": 1, 190 | "content_type": 7 191 | } 192 | }, 193 | { 194 | "pk": 8, 195 | "model": "admin.logentry", 196 | "fields": { 197 | "action_flag": 1, 198 | "action_time": "2014-08-31T00:51:26.097Z", 199 | "object_repr": "Bob is the coolest name EVAR", 200 | "object_id": "2", 201 | "change_message": "", 202 | "user": 1, 203 | "content_type": 7 204 | } 205 | }, 206 | { 207 | "pk": 9, 208 | "model": "admin.logentry", 209 | "fields": { 210 | "action_flag": 1, 211 | "action_time": "2014-08-31T00:52:12.916Z", 212 | "object_repr": "I <3 Tweeter", 213 | "object_id": "3", 214 | "change_message": "", 215 | "user": 1, 216 | "content_type": 7 217 | } 218 | }, 219 | { 220 | "pk": 10, 221 | "model": "admin.logentry", 222 | "fields": { 223 | "action_flag": 2, 224 | "action_time": "2014-08-31T01:06:18.846Z", 225 | "object_repr": "admin", 226 | "object_id": "2", 227 | "change_message": "Changed username.", 228 | "user": 1, 229 | "content_type": 4 230 | } 231 | }, 232 | { 233 | "pk": 11, 234 | "model": "admin.logentry", 235 | "fields": { 236 | "action_flag": 2, 237 | "action_time": "2014-08-31T01:06:26.076Z", 238 | "object_repr": "bob", 239 | "object_id": "3", 240 | "change_message": "Changed username.", 241 | "user": 1, 242 | "content_type": 4 243 | } 244 | }, 245 | { 246 | "pk": 12, 247 | "model": "admin.logentry", 248 | "fields": { 249 | "action_flag": 2, 250 | "action_time": "2014-08-31T01:06:34.096Z", 251 | "object_repr": "amy", 252 | "object_id": "4", 253 | "change_message": "Changed username.", 254 | "user": 1, 255 | "content_type": 4 256 | } 257 | } 258 | ] -------------------------------------------------------------------------------- /tweeter/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-10 06:35 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='Tweet', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('text', models.CharField(max_length=140)), 22 | ('timestamp', models.DateTimeField(auto_now_add=True)), 23 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | options={ 26 | 'ordering': ['-timestamp'], 27 | }, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /tweeter/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/python-sample-tweeterapp/b61b19dd30d5320253aa1fa28f7a2fb515c1124e/tweeter/migrations/__init__.py -------------------------------------------------------------------------------- /tweeter/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | 4 | 5 | class Tweet(models.Model): 6 | user = models.ForeignKey(User, on_delete=models.CASCADE) 7 | text = models.CharField(max_length=140) 8 | timestamp = models.DateTimeField(auto_now_add=True) 9 | 10 | class Meta: 11 | ordering = ['-timestamp'] 12 | -------------------------------------------------------------------------------- /tweeter/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsSelfOrAdmin(permissions.BasePermission): 5 | """ 6 | Object-level permission to only only the current User, 7 | or an admin User, to view and edit a User. 8 | """ 9 | 10 | def has_object_permission(self, request, view, obj): 11 | # A User can edit and view their own data 12 | is_self = obj == request.user 13 | is_admin = request.user.is_superuser 14 | return is_self or is_admin 15 | 16 | 17 | class IsAuthorOrReadOnly(permissions.BasePermission): 18 | """ 19 | Permission that allows only the author to edit 20 | tweets attributed to them 21 | """ 22 | def has_object_permission(self, request, view, obj): 23 | if request.method in permissions.SAFE_METHODS: 24 | # Allow read only permissions to any user 25 | # to view the tweet 26 | return True 27 | else: 28 | # Check that the request user owns the object 29 | # being edited 30 | return obj.user == request.user 31 | -------------------------------------------------------------------------------- /tweeter/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import serializers 3 | 4 | from tweeter.models import Tweet 5 | 6 | 7 | class TweetSerializer(serializers.ModelSerializer): 8 | user = serializers.SlugRelatedField( 9 | many=False, 10 | read_only=True, 11 | slug_field='username' 12 | ) 13 | 14 | class Meta: 15 | model = Tweet 16 | fields = ('id', 'user', 'text', 'timestamp') 17 | 18 | def validate_text(self, value): 19 | if len(value) < 5: 20 | raise serializers.ValidationError( 21 | 'Text is too short.' 22 | ) 23 | if len(value) > 140: 24 | raise serializers.ValidationError( 25 | 'Text is too long.' 26 | ) 27 | return value 28 | 29 | 30 | class UserSerializer(serializers.ModelSerializer): 31 | tweets = TweetSerializer(many=True, source="tweet_set") 32 | 33 | class Meta: 34 | model = User 35 | fields = ('id','username', 'first_name', 'last_name', 'last_login', 'tweets') 36 | -------------------------------------------------------------------------------- /tweeter/src/components/App.css: -------------------------------------------------------------------------------- 1 | @import-normalize; 2 | 3 | .main-body { 4 | display: flex; 5 | flex-direction: column; 6 | background-color: #11171d; 7 | min-height: 80rem; 8 | } 9 | 10 | body { 11 | display: flex; 12 | font-family: 'Helvetica'; 13 | flex-direction: column; 14 | margin: 0; 15 | padding: 0; 16 | color:rgb(47, 47, 47); 17 | font-size: 14px; 18 | background-color: #ffffff; 19 | } 20 | 21 | #inner-body { 22 | margin: 16px 23 | } 24 | 25 | #menubar { 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center 29 | } 30 | 31 | .header { 32 | background-color: #3498db; 33 | padding: 1em 0; 34 | color: white; 35 | text-align: center; 36 | margin-top: 0; 37 | font-family: "Lato"; 38 | font-size: 1.6em; 39 | } 40 | 41 | .icon-bar { 42 | color: blue; 43 | margin: 8; 44 | display: flex; 45 | width: 80px; 46 | justify-content: space-between 47 | } 48 | 49 | .icon-bar .fa { 50 | color: rgb(0, 120, 212) 51 | } 52 | 53 | .icon-bar .fa:hover { 54 | color: rgb(36, 58, 94) 55 | } -------------------------------------------------------------------------------- /tweeter/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from "react-dom" 3 | import { BrowserRouter as Router, Route, Link } from "react-router-dom" 4 | 5 | import DataProvider from "./DataProvider" 6 | import Tweets from "./Tweets" 7 | import "./App.css" 8 | import {Login, Signup} from './Auth' 9 | 10 | class App extends Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | addTweetText: '', 15 | tweets : [] 16 | } 17 | } 18 | 19 | render() { 20 | return ( 21 | 22 |

Tweeter!

23 |
24 | 44 | 45 | { 48 | return ( 49 |
50 | 51 |
52 | ) 53 | }} 54 | />} /> 55 | 56 | 57 | 58 |
59 | 60 |
61 | ) 62 | } 63 | } 64 | 65 | ReactDOM.render(, document.getElementById("app")); 66 | 67 | -------------------------------------------------------------------------------- /tweeter/src/components/Auth.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | import Cookies from 'js-cookie' 4 | 5 | export const Login = () => (
6 |

Login

7 |
8 | 9 |

10 |

11 | 12 |
13 |
) 14 | 15 | export const Signup = () => (
16 |

Sign Up

17 |
18 | 19 |

20 | 21 | Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. 22 |

23 |

24 | 25 |

    26 |
  • Your password can't be too similar to your other personal information.
  • 27 |
  • Your password must contain at least 8 characters.
  • Your password can't be a commonly used password.
  • 28 |
  • Your password can't be entirely numeric.
  • 29 |
30 |

31 |

32 | 33 | 34 | Enter the same password as before, for verification. 35 |

36 | 37 |
38 |
) 39 | -------------------------------------------------------------------------------- /tweeter/src/components/DataProvider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | 5 | class DataProvider extends Component { 6 | static propTypes = { 7 | endpoint: PropTypes.string.isRequired, 8 | render: PropTypes.func.isRequired, 9 | tweets: PropTypes.object 10 | }; 11 | 12 | state = { 13 | data: [], 14 | loaded: false, 15 | placeholder: "Loading..." 16 | }; 17 | 18 | componentWillReceiveProps(nextProps){ 19 | if(nextProps.tweets!==this.props.tweets){ 20 | 21 | const last = nextProps.tweets[nextProps.tweets.length - 1]; 22 | console.log("OK THE TWEET TO SEND DJANGO IS ", last); 23 | 24 | } 25 | } 26 | 27 | componentDidMount() { 28 | fetch(this.props.endpoint) 29 | .then(response => { 30 | if (response.status !== 200) { 31 | return this.setState({ placeholder: "Something went wrong" }); 32 | } 33 | return response.json(); 34 | }) 35 | .then(data => this.setState({ data: data, loaded: true })); 36 | } 37 | render() { 38 | const { data, loaded, placeholder } = this.state; 39 | return loaded ? this.props.render(data) :

{placeholder}

; 40 | } 41 | } 42 | export default DataProvider; -------------------------------------------------------------------------------- /tweeter/src/components/Tweets.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | .tweettext-container { 6 | display: flex; 7 | align-items: center 8 | } 9 | .tweettext-container button { 10 | height: 32px; 11 | } 12 | .tweettext-container input { 13 | width: 300px; 14 | } 15 | .tweet { 16 | display: flex; 17 | margin: 16px; 18 | align-items: center 19 | } 20 | 21 | .username { 22 | font-size: 1.2em 23 | } 24 | 25 | .bubble { 26 | width: 100%; 27 | background-color: #3d474b25; 28 | padding-left: 20px; 29 | position: relative; 30 | border-radius: 8px; 31 | text-align: left; 32 | display: inline-block; 33 | } 34 | .bubble:hover > .over-bubble { 35 | opacity: 1; 36 | } 37 | 38 | .bubble-container { 39 | width: 75%; 40 | display: block; 41 | position: relative; 42 | padding-left: 20px; 43 | display: inline-block; 44 | } 45 | 46 | .arrow { 47 | content: ''; 48 | display: block; 49 | position: absolute; 50 | left: 6px; 51 | bottom: 25%; 52 | height: 0; 53 | width: 0; 54 | border-top: 20px solid transparent; 55 | border-bottom: 20px solid transparent; 56 | border-right: 20px solid #ecf0f1; 57 | } 58 | 59 | .avatar { 60 | width: 64px; 61 | height: 64px; 62 | position: relative; 63 | overflow: hidden; 64 | } 65 | .avatar .fa:hover { 66 | color: rgb(115, 115, 115) 67 | } 68 | 69 | 70 | .icon-twitter { 71 | font-size: 1.5em; 72 | } 73 | 74 | .new { 75 | position: absolute; 76 | right: 5%; 77 | } 78 | 79 | .over-bubble { 80 | line-height: 1.4em; 81 | background-color: rgba(236, 240, 241, 0.8); 82 | position: relative; 83 | border-radius: 8px; 84 | text-align: center; 85 | display: inline-block; 86 | position: absolute !important; 87 | height: 100%; 88 | width: 100%; 89 | opacity: 0; 90 | top: 0; 91 | left: 0; 92 | z-index: 999; 93 | transition-property: all; 94 | transition-duration: 0.3s; 95 | transition-timing-function: ease-in; 96 | font-size: 2.8em; 97 | text-shadow: white 1px 1px 0; 98 | } 99 | 100 | .action-buttons { 101 | display: flex; 102 | height: 100%; 103 | width: 100%; 104 | justify-content: flex-end; 105 | align-items: flex-end 106 | } 107 | 108 | .action-buttons .fa:hover { 109 | color: rgb(0, 120, 212) 110 | } -------------------------------------------------------------------------------- /tweeter/src/components/Tweets.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from "prop-types"; 3 | import "./Tweets.css" 4 | import Cookies from 'js-cookie' 5 | 6 | class Sentiment extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | sentiment: null 11 | } 12 | } 13 | 14 | componentDidMount() { 15 | this.getSentiment() 16 | } 17 | 18 | async getSentiment() { 19 | const response = await fetch('http://msbuildsentiment.azurewebsites.net/' + this.props.message); 20 | const text = await response.text() 21 | this.setState({sentiment: text}) 22 | } 23 | 24 | render() { 25 | if (this.state.sentiment == null) { 26 | return
27 | } 28 | return (
Sentiment: {this.state.sentiment}
) 29 | } 30 | } 31 | 32 | class Tweets extends Component { 33 | constructor(props) { 34 | super(props); 35 | this.state = { 36 | addTweetText: '', 37 | tweets: props.tweets 38 | } 39 | } 40 | 41 | // Posts tweet text to /api/tweets and adds the response to the list of tweets 42 | async addTweet() { 43 | const response = await fetch("api/tweets/", { 44 | method: "POST", 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'X-CSRFToken': Cookies.get('csrftoken') 48 | }, 49 | body: JSON.stringify({ 50 | text: this.state.addTweetText 51 | }) 52 | }) 53 | const data = await response.json(); 54 | const newTweets = Object.assign([], this.state.tweets); 55 | newTweets.unshift(data) 56 | console.log(newTweets) 57 | this.setState({tweets: newTweets, addTweetText: ''}) 58 | } 59 | 60 | render() { 61 | return ( 62 |
63 |
64 | this.setState({addTweetText: event.target.value})} 66 | value={this.state.addTweetText} 67 | onKeyUp={event => { 68 | if (event.key == "Enter") { 69 | this.addTweet() 70 | } 71 | }} 72 | placeholder="Say something . . ." 73 | type="text" className="tweet"/> 74 | 75 |
76 | 77 |
78 | {this.state.tweets.length == 0 &&

Nothing to show

} 79 | {this.state.tweets.map(tweet => ( 80 |
81 |
82 | 83 |
84 |
85 |
86 |

@{tweet.user}

87 |

{tweet.text}

88 | 89 |
90 |
91 | 92 | 93 | 94 |
95 |
96 |
97 |
98 |
99 |
100 | ))} 101 |
102 |
) 103 | } 104 | } 105 | 106 | Tweets.propTypes = { 107 | tweets: PropTypes.array.isRequired 108 | }; 109 | 110 | export default Tweets; -------------------------------------------------------------------------------- /tweeter/src/index.js: -------------------------------------------------------------------------------- 1 | import App from "./components/App"; 2 | 3 | 4 | -------------------------------------------------------------------------------- /tweeter/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% block title %}Login{% endblock %} 2 | 3 | {% block content %} 4 |

Login

5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /tweeter/templates/signup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block title %}Sign Up{% endblock %} 4 | 5 | {% block content %} 6 |

Sign up

7 |
8 | {% csrf_token %} 9 | {{ form.as_p }} 10 | 11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /tweeter/templates/tweeter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Tweeter 9 | 10 | 11 |
12 | 13 | 14 | {% load static %} 15 | 16 | -------------------------------------------------------------------------------- /tweeter/tests.py: -------------------------------------------------------------------------------- 1 | import django 2 | django.setup() 3 | 4 | import unittest 5 | import pytest 6 | from .serializers import TweetSerializer 7 | from tweeter.models import Tweet 8 | from django.contrib.auth.models import User 9 | from datetime import datetime 10 | from rest_framework import serializers 11 | 12 | # Create your tests here. 13 | class TestTweets(unittest.TestCase): 14 | 15 | def test_serializer_validation(self): 16 | ts = TweetSerializer() 17 | self.assertRaises(serializers.ValidationError,ts.validate_text,"hi!") 18 | self.assertRaises(serializers.ValidationError,ts.validate_text," ") 19 | self.assertRaises(serializers.ValidationError,ts.validate_text," " * 71) 20 | self.assertEqual(ts.validate_text(" " * 70)," "* 70) 21 | 22 | def test_tweet_creation(self): 23 | time = datetime.now() 24 | tweet = Tweet(text = "Hi! I'm Bob :)", user=User(username='bob'), timestamp = time) 25 | self.assertEqual(tweet.text, "Hi! I'm Bob :)") 26 | self.assertEqual(tweet.user.username, 'bob') 27 | self.assertEqual(tweet.timestamp, time) 28 | 29 | time = datetime(2010,10,10,10,10) 30 | tweet = Tweet(text = " ", user=User(username='amy'), timestamp = time) 31 | self.assertEqual(tweet.text, " ") 32 | self.assertEqual(tweet.user.username, 'amy') 33 | self.assertEqual(tweet.timestamp, time) 34 | 35 | def test_tweet_creation_notunicode(self): 36 | time = datetime(year=2010,month=10,day=10,hour=10,minute=10,second=10) 37 | tweet = Tweet(text = "Hi� Ý'm ßoß ձ", user=User(username='ßoß'), timestamp = time) 38 | self.assertEqual(tweet.text, "Hi� Ý'm ßoß ձ") 39 | self.assertEqual(tweet.user.username, 'ßoß') 40 | self.assertEqual(tweet.timestamp, time) -------------------------------------------------------------------------------- /tweeter/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate, login 2 | from django.contrib.auth.models import User 3 | 4 | from django.contrib.auth.forms import UserCreationForm 5 | from django.shortcuts import render 6 | from django.urls import reverse_lazy 7 | from django.views import generic 8 | from django.http import HttpResponseRedirect 9 | from django.views.decorators.csrf import ensure_csrf_cookie 10 | 11 | from rest_framework import viewsets 12 | from rest_framework.response import Response 13 | from rest_framework.decorators import api_view 14 | from rest_framework.permissions import IsAuthenticatedOrReadOnly 15 | 16 | from tweeter.models import Tweet, User 17 | from tweeter.permissions import IsAuthorOrReadOnly, IsSelfOrAdmin 18 | from tweeter.serializers import TweetSerializer, UserSerializer 19 | 20 | @ensure_csrf_cookie 21 | def index(request): 22 | return render(request, 'tweeter/index.html') 23 | 24 | @api_view(['GET']) 25 | def current_user(request): 26 | serializer = UserSerializer(request.user) 27 | return Response(serializer.data) 28 | 29 | @api_view(['POST']) 30 | def create_user(request): 31 | data = request.data 32 | user = User.objects.create_user(data['username'], password=data['password1']) 33 | return HttpResponseRedirect('/login') 34 | 35 | class SignUp(generic.CreateView): 36 | form_class = UserCreationForm 37 | success_url = reverse_lazy('login') 38 | template_name = 'signup.html' 39 | 40 | class UserViewSet(viewsets.ModelViewSet): 41 | queryset = User.objects.all() 42 | serializer_class = UserSerializer 43 | permission_classes = [IsSelfOrAdmin] 44 | 45 | class TweetViewSet(viewsets.ModelViewSet): 46 | queryset = Tweet.objects.all() 47 | serializer_class = TweetSerializer 48 | permission_classes = [IsAuthorOrReadOnly] 49 | 50 | def perform_create(self, serializer): 51 | user = self.request.user 52 | if user.is_anonymous: 53 | user = User.objects.filter(first_name="Bob").first() 54 | serializer.save(user=user) 55 | -------------------------------------------------------------------------------- /tweeter3/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/python-sample-tweeterapp/b61b19dd30d5320253aa1fa28f7a2fb515c1124e/tweeter3/__init__.py -------------------------------------------------------------------------------- /tweeter3/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/python-sample-tweeterapp/b61b19dd30d5320253aa1fa28f7a2fb515c1124e/tweeter3/settings/__init__.py -------------------------------------------------------------------------------- /tweeter3/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tweeter3 project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.2. 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 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Application definition 19 | 20 | INSTALLED_APPS = [ 21 | 'django.contrib.admin', 22 | 'django.contrib.auth', 23 | 'django.contrib.contenttypes', 24 | 'django.contrib.sessions', 25 | 'django.contrib.messages', 26 | 'django.contrib.staticfiles', 27 | 'tweeter', 28 | 'rest_framework', 29 | ] 30 | 31 | MIDDLEWARE = [ 32 | 'django.middleware.security.SecurityMiddleware', 33 | # Disable whitenoise for demo 34 | 'whitenoise.middleware.WhiteNoiseMiddleware', 35 | 'django.contrib.sessions.middleware.SessionMiddleware', 36 | 'django.middleware.common.CommonMiddleware', 37 | 'django.middleware.csrf.CsrfViewMiddleware', 38 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 39 | 'django.contrib.messages.middleware.MessageMiddleware', 40 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 41 | ] 42 | 43 | ROOT_URLCONF = 'tweeter3.urls' 44 | 45 | TEMPLATES = [ 46 | { 47 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 48 | 'DIRS': [], 49 | 'APP_DIRS': True, 50 | 'OPTIONS': { 51 | 'context_processors': [ 52 | 'django.template.context_processors.debug', 53 | 'django.template.context_processors.request', 54 | 'django.contrib.auth.context_processors.auth', 55 | 'django.contrib.messages.context_processors.messages', 56 | ], 57 | }, 58 | }, 59 | ] 60 | 61 | WSGI_APPLICATION = 'tweeter3.wsgi.application' 62 | 63 | 64 | # Password validation 65 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 66 | 67 | AUTH_PASSWORD_VALIDATORS = [ 68 | { 69 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 70 | }, 71 | { 72 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 73 | }, 74 | { 75 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 76 | }, 77 | { 78 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 79 | }, 80 | ] 81 | 82 | 83 | # Internationalization 84 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 85 | 86 | LANGUAGE_CODE = 'en-us' 87 | 88 | TIME_ZONE = 'UTC' 89 | 90 | USE_I18N = True 91 | 92 | USE_L10N = True 93 | 94 | USE_TZ = True 95 | 96 | 97 | # Static files (CSS, JavaScript, Images) 98 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 99 | 100 | STATIC_URL = '/static/' 101 | 102 | # Disable whitenoise for demo 103 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 104 | STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage' 105 | 106 | LOGIN_REDIRECT_URL = '/' 107 | LOGOUT_REDIRECT_URL = '/' -------------------------------------------------------------------------------- /tweeter3/settings/devcontainer.py: -------------------------------------------------------------------------------- 1 | from tweeter3.settings.base import * 2 | 3 | # Quick-start development settings - unsuitable for production 4 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 5 | 6 | # SECURITY WARNING: keep the secret key used in production secret! 7 | SECRET_KEY = ')njwhhlatzp1=%s0q33w*4g4f5pyjhil&gzqgh$c_ira5(2nye' 8 | 9 | # SECURITY WARNING: don't run with debug turned on in production! 10 | DEBUG = True 11 | 12 | ALLOWED_HOSTS = [] 13 | 14 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 15 | 16 | DB_USER = "postgres" 17 | DB_NAME = "postgres" 18 | DB_HOST = "db" 19 | DB_PASSWORD = "LocalPassword" 20 | 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django.db.backends.postgresql', 24 | 'NAME': DB_NAME, 25 | 'USER': f'{DB_USER}', 26 | 'PASSWORD': DB_PASSWORD, 27 | 'HOST': f'{DB_HOST}', 28 | 'PORT': '', 29 | } 30 | } 31 | 32 | # Disable whitenoise for demo 33 | # INSTALLED_APPS = ['whitenoise.runserver_nostatic'] + INSTALLED_APPS -------------------------------------------------------------------------------- /tweeter3/settings/development.py: -------------------------------------------------------------------------------- 1 | from tweeter3.settings.base import * 2 | 3 | # Quick-start development settings - unsuitable for production 4 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 5 | 6 | # SECURITY WARNING: keep the secret key used in production secret! 7 | SECRET_KEY = ')njwhhlatzp1=%s0q33w*4g4f5pyjhil&gzqgh$c_ira5(2nye' 8 | 9 | # SECURITY WARNING: don't run with debug turned on in production! 10 | DEBUG = True 11 | 12 | ALLOWED_HOSTS = [] 13 | 14 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 15 | 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 20 | } 21 | } 22 | 23 | # Disable whitenoise for demo 24 | # INSTALLED_APPS = ['whitenoise.runserver_nostatic'] + INSTALLED_APPS -------------------------------------------------------------------------------- /tweeter3/settings/production.py: -------------------------------------------------------------------------------- 1 | from tweeter3.settings.base import * 2 | 3 | import os 4 | 5 | SECRET_KEY = os.environ.get('SECRET_KEY', 'my-secret-key') 6 | 7 | DEBUG = False 8 | 9 | AZURE_APPSERVICE_HOSTNAME = os.environ.get('AZURE_APPSERVICE_HOSTNAME', '') 10 | ALLOWED_HOSTS = ['*'] 11 | #ALLOWED_HOSTS = ["127.0.0.1", "localhost", f"{AZURE_APPSERVICE_HOSTNAME}.azurewebsites.net"] 12 | 13 | # Database 14 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 15 | 16 | DB_USER = os.environ['DB_USER'] 17 | DB_NAME = os.environ['DB_NAME'] 18 | DB_HOST = os.environ['DB_HOST'] 19 | DB_PASSWORD = os.environ['DB_PASSWORD'] 20 | 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django.db.backends.postgresql', 24 | 'NAME': DB_NAME, 25 | 'USER': f'{DB_USER}@{DB_HOST}', 26 | 'PASSWORD': DB_PASSWORD, 27 | 'HOST': f'{DB_HOST}.postgres.database.azure.com', 28 | 'PORT': '', 29 | } 30 | } 31 | 32 | # Need to explicitly enable logging for production configurations 33 | LOGGING = { 34 | 'version': 1, 35 | 'disable_existing_loggers': False, 36 | 'handlers': { 37 | 'console': { 38 | 'class': 'logging.StreamHandler', 39 | }, 40 | }, 41 | 'loggers': { 42 | 'django': { 43 | 'handlers': ['console'], 44 | 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), 45 | }, 46 | }, 47 | } 48 | 49 | if os.environ.get('SEND_ADMIN_EMAILS'): 50 | # Optional Email Settings 51 | EMAIL_HOST = os.environ.get('EMAIL_HOST') 52 | EMAIL_PORT = os.environ.get('EMAIL_PORT') 53 | EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') 54 | EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') 55 | EMAIL_USE_TLS = True 56 | DEFAULT_FROM_EMAIL = EMAIL_HOST_USER 57 | EMAIL_FROM = EMAIL_HOST_USER 58 | EMAIL_SUBJECT_PREFIX = '[Tweeter] ' 59 | EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 60 | 61 | # ADMINS 62 | ADMINS = [('Website Admin', os.environ.get('EMAIL_HOST_USER'))] -------------------------------------------------------------------------------- /tweeter3/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | tweeter3 URL Configuration 3 | """ 4 | from django.conf.urls import include, url 5 | from django.contrib import admin 6 | from django.urls import path, include 7 | from rest_framework import routers 8 | 9 | from tweeter import views 10 | 11 | router = routers.DefaultRouter() 12 | router.register(r'users', views.UserViewSet) 13 | router.register(r'tweets', views.TweetViewSet) 14 | 15 | urlpatterns = [ 16 | path('admin/', admin.site.urls), 17 | path('accounts/current/', views.current_user, name='current'), 18 | path('accounts/signup/', views.create_user, name='signup'), 19 | path('accounts/',include('django.contrib.auth.urls')), 20 | url(r'^api/', include(router.urls)), 21 | url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), 22 | url(r'^.*/$', views.index, name='index'), 23 | url(r'^$', views.index, name='index'), 24 | #configure a new url mapping for frontend. 25 | # path('', include('frontend.urls')) 26 | ] 27 | -------------------------------------------------------------------------------- /tweeter3/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for tweeter3 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.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', 'tweeter3.settings.development') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | chdir = . 3 | module = tweeter3.wsgi:application 4 | uid = 1000 5 | master = true 6 | threads = 2 7 | processes = 4 -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const autoprefixer = require('autoprefixer'); 3 | 4 | module.exports = { 5 | entry: { 6 | javascript: './frontend/static/frontend/main.js', 7 | html: './frontend/templates/frontend/react_index.html', 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.js$/, 13 | exclude: /node_modules/, 14 | loaders: ["react-hot-loader/webpack", "babel-loader"], 15 | }, 16 | { 17 | test: /\.css$/, 18 | use: [ 19 | require.resolve('style-loader'), 20 | { 21 | loader: require.resolve('css-loader'), 22 | options: { 23 | importLoaders: 1, 24 | }, 25 | }, 26 | { 27 | loader: require.resolve('postcss-loader'), 28 | options: { 29 | ident: 'postcss', // https://webpack.js.org/guides/migrating/#complex-options 30 | plugins: () => [ 31 | require('postcss-flexbugs-fixes'), 32 | autoprefixer({ 33 | browsers: [ 34 | '>1%', 35 | 'last 4 versions', 36 | 'Firefox ESR', 37 | 'not ie < 9', // React doesn't support IE8 anyway 38 | ], 39 | flexbox: 'no-2009', 40 | }), 41 | ], 42 | }, 43 | }, 44 | ], 45 | }, 46 | { 47 | test: /\.html$/, 48 | loader: "file-loader?name=[name].[ext]", 49 | }, 50 | ] 51 | }, 52 | }; 53 | 54 | 55 | 56 | --------------------------------------------------------------------------------