├── .devcontainer ├── Dockerfile ├── README.md ├── devcontainer.json └── docker-compose.yml ├── .env ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yaml └── workflows │ ├── bicep-audit.yml │ └── python-test.yaml ├── .gitignore ├── .vscode ├── README.md └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── azure.yaml ├── azureproject ├── __init__.py ├── asgi.py ├── production.py ├── settings.py ├── urls.py └── wsgi.py ├── infra ├── README.md ├── appinsights.bicep ├── main.bicep ├── main.parameters.json └── resources.bicep ├── manage.py ├── requirements.txt ├── restaurant_review ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_review_rating.py │ └── __init__.py ├── models.py ├── templates │ ├── 500.html │ └── restaurant_review │ │ ├── base.html │ │ ├── create_restaurant.html │ │ ├── details.html │ │ ├── index.html │ │ └── star_rating.html ├── templatetags │ ├── __init__.py │ └── restaurant_extras.py ├── tests.py ├── urls.py └── views.py ├── screenshot_website.png ├── startup.sh └── static ├── bootstrap ├── css │ ├── bootstrap-grid.css │ ├── bootstrap-grid.css.map │ ├── bootstrap-grid.min.css │ ├── bootstrap-grid.min.css.map │ ├── bootstrap-grid.rtl.css │ ├── bootstrap-grid.rtl.css.map │ ├── bootstrap-grid.rtl.min.css │ ├── bootstrap-grid.rtl.min.css.map │ ├── bootstrap-reboot.css │ ├── bootstrap-reboot.css.map │ ├── bootstrap-reboot.min.css │ ├── bootstrap-reboot.min.css.map │ ├── bootstrap-reboot.rtl.css │ ├── bootstrap-reboot.rtl.css.map │ ├── bootstrap-reboot.rtl.min.css │ ├── bootstrap-reboot.rtl.min.css.map │ ├── bootstrap-utilities.css │ ├── bootstrap-utilities.css.map │ ├── bootstrap-utilities.min.css │ ├── bootstrap-utilities.min.css.map │ ├── bootstrap-utilities.rtl.css │ ├── bootstrap-utilities.rtl.css.map │ ├── bootstrap-utilities.rtl.min.css │ ├── bootstrap-utilities.rtl.min.css.map │ ├── bootstrap.css │ ├── bootstrap.css.map │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── bootstrap.rtl.css │ ├── bootstrap.rtl.css.map │ ├── bootstrap.rtl.min.css │ └── bootstrap.rtl.min.css.map └── js │ ├── bootstrap.bundle.js │ ├── bootstrap.bundle.js.map │ ├── bootstrap.bundle.min.js │ ├── bootstrap.bundle.min.js.map │ ├── bootstrap.esm.js │ ├── bootstrap.esm.js.map │ ├── bootstrap.esm.min.js │ ├── bootstrap.esm.min.js.map │ ├── bootstrap.js │ ├── bootstrap.js.map │ ├── bootstrap.min.js │ └── bootstrap.min.js.map ├── favicon.ico ├── fontawesome ├── LICENSE.txt ├── attribution.js ├── css │ ├── all.css │ └── all.min.css └── webfonts │ ├── fa-brands-400.eot │ ├── fa-brands-400.svg │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.eot │ ├── fa-regular-400.svg │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.eot │ ├── fa-solid-900.svg │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff │ └── fa-solid-900.woff2 └── images └── azure-icon.svg /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/python:3.12 2 | 3 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 4 | && apt-get -y install --no-install-recommends postgresql-client \ 5 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 6 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | # .devcontainer directory 2 | 3 | This `.devcontainer` directory contains the configuration for a [dev container](https://docs.github.com/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers) and isn't used by the sample application. 4 | 5 | The dev container configuration lets you open therepository in a [GitHub codespace](https://docs.github.com/codespaces/overview) or a dev container in Visual Studio Code. For your convenience, the dev container is configured with the following: 6 | 7 | - Python 8 | - Running `pip install -r requirements.txt` from the project at container start. 9 | - PostgreSQL 10 | - Redis 11 | - [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview) (so you can run `azd` commands directly). 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "python-app-service-postgresql-redis-infra", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 6 | "features": { 7 | "ghcr.io/azure/azure-dev/azd:latest": {} 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | // Add the IDs of extensions you want installed when the container is created. 12 | "extensions": [ 13 | "ms-azuretools.azure-dev", 14 | "ms-python.python", 15 | "ms-python.vscode-pylance", 16 | "mtxr.sqltools", 17 | "mtxr.sqltools-driver-pg", 18 | "GitHub.copilot" 19 | ], 20 | "settings": { 21 | "sqltools.connections": [ 22 | { 23 | "name": "Container database", 24 | "driver": "PostgreSQL", 25 | "previewLimit": 50, 26 | "server": "localhost", 27 | "port": 5432, 28 | "database": "app", 29 | "username": "app_user", 30 | "password": "app_password" 31 | } 32 | ], 33 | "python.languageServer": "Pylance", 34 | "python.linting.enabled": true, 35 | "python.linting.mypyEnabled": true, 36 | "python.testing.pytestEnabled": true, 37 | "python.formatting.provider": "black", 38 | "python.formatting.blackArgs": [ 39 | "--line-length=80" 40 | ], 41 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 42 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 43 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 44 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 45 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 46 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 47 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 48 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 49 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", 50 | "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest" 51 | } 52 | } 53 | }, 54 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 55 | // "forwardPorts": [], 56 | // Use 'postCreateCommand' to run commands after the container is created. 57 | "postCreateCommand": "pip install -r requirements.txt", 58 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 59 | "remoteUser": "vscode" 60 | } 61 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | 9 | volumes: 10 | - ..:/workspace:cached 11 | 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: sleep infinity 14 | 15 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 16 | network_mode: service:db 17 | 18 | db: 19 | image: postgres:latest 20 | restart: unless-stopped 21 | volumes: 22 | - postgres-data:/var/lib/postgresql/data 23 | environment: 24 | POSTGRES_DB: app 25 | POSTGRES_USER: app_user 26 | POSTGRES_PASSWORD: app_password 27 | 28 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 29 | # (Adding the "ports" property to this file will not forward from a Codespace.) 30 | 31 | redis: 32 | image: redis 33 | restart: unless-stopped 34 | 35 | volumes: 36 | postgres-data: 37 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DBNAME=app 2 | DBHOST=localhost 3 | DBUSER=app_user 4 | DBPASS=app_password 5 | CACHELOCATION=redis://redis:6379/0 6 | SECRET_KEY=secret_key 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "pip" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/bicep-audit.yml: -------------------------------------------------------------------------------- 1 | name: Validate AZD template 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "infra/**" 8 | pull_request: 9 | branches: 10 | - main 11 | paths: 12 | - "infra/**" 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | security-events: write 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Run Microsoft Security DevOps Analysis 25 | uses: microsoft/security-devops-action@preview 26 | id: msdo 27 | continue-on-error: true 28 | with: 29 | tools: templateanalyzer 30 | 31 | - name: Upload alerts to Security tab 32 | uses: github/codeql-action/upload-sarif@v3 33 | if: github.repository_owner == 'Azure-Samples' 34 | with: 35 | sarif_file: ${{ steps.msdo.outputs.sarifFile }} 36 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yaml: -------------------------------------------------------------------------------- 1 | name: Python check 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test_package: 11 | name: Test ${{ matrix.os }} Python ${{ matrix.python_version }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: ["ubuntu-24.04"] 17 | python_version: ["3.10", "3.11"] 18 | services: 19 | postgres: 20 | image: postgres:17 21 | env: 22 | POSTGRES_PASSWORD: postgres 23 | ports: 24 | - 5432:5432 25 | # needed because the postgres container does not provide a healthcheck 26 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Setup python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python_version }} 33 | architecture: x64 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -r requirements.txt 38 | pip install flake8 39 | - name: Look for major issues with flake8 40 | run: | 41 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 42 | - name: Make sure Python code is all compilable 43 | run: | 44 | python -m compileall azureproject -f 45 | python -m compileall restaurant_review -f 46 | - name: Run Django server 47 | run: | 48 | python manage.py migrate 49 | python manage.py test restaurant_review && 50 | python manage.py runserver & 51 | env: 52 | DBNAME: postgres 53 | DBHOST: localhost 54 | DBUSER: postgres 55 | DBPASS: postgres 56 | SECRET_KEY: django-insecure-key-${{ github.run_id }}-${{ github.run_attempt }} 57 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .azure -------------------------------------------------------------------------------- /.vscode/README.md: -------------------------------------------------------------------------------- 1 | # .vscode directory 2 | 3 | This `.vscode` directory contains configuration that lets you launch and debug in Visual Studio Code and isn't used by the sample application. 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Server", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/manage.py", 9 | "args": [ 10 | "runserver", 11 | ], 12 | "cwd": "${workspaceFolder}", 13 | "django": true 14 | }, 15 | { 16 | "name": "Migrate", 17 | "type": "python", 18 | "request": "launch", 19 | "program": "${workspaceFolder}/manage.py", 20 | "args": [ 21 | "migrate" 22 | ], 23 | "django": true 24 | }, 25 | { 26 | "name": "Super", 27 | "type": "python", 28 | "request": "launch", 29 | "program": "${workspaceFolder}/manage.py", 30 | "args": [ 31 | "createsuperuser" 32 | ], 33 | "django": true 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 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 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - python 6 | - bicep 7 | - html 8 | products: 9 | - azure 10 | - azure-app-service 11 | - azure-database-postgresql 12 | - azure-virtual-network 13 | urlFragment: msdocs-django-postgresql-sample-app 14 | name: Deploy a Python (Django) web app with PostgreSQL in Azure 15 | description: This is a Python web app using the Django framework and the Azure Database for PostgreSQL relational database service. 16 | --- 17 | 18 | 19 | # Deploy a Python (Django) web app with PostgreSQL in Azure 20 | 21 | This is a Python web app using the Django framework and the Azure Database for PostgreSQL relational database service. The Django app is hosted in a fully managed Azure App Service. This app is designed to be be run locally and then deployed to Azure. You can either deploy this project by following the tutorial [*Deploy a Python (Django or Flask) web app with PostgreSQL in Azure*](https://docs.microsoft.com/azure/app-service/tutorial-python-postgresql-app) or by using the [Azure Developer CLI (azd)](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview) according to the instructions below. 22 | 23 | Additionally, the sample application demonstrates Azure Redis Cache access by caching the restaurant details page for 60 seconds. You can add the Azure Redis Cache integration in the [secure-by-default web app + database creation wizard](https://portal.azure.com/?feature.customportal=false#create/Microsoft.AppServiceWebAppDatabaseV3), and it's also included in the [AZD template](https://github.com/Azure-Samples/python-app-service-postgresql-infra). 24 | 25 | ## Requirements 26 | 27 | The [requirements.txt](./requirements.txt) has the following packages, all used by a typical data-driven Django application: 28 | 29 | | Package | Description | 30 | | ------- | ----------- | 31 | | [Django](https://pypi.org/project/Django/) | Web application framework. | 32 | | [pyscopg2-binary](https://pypi.org/project/psycopg-binary/) | PostgreSQL database adapter for Python. | 33 | | [python-dotenv](https://pypi.org/project/python-dotenv/) | Read key-value pairs from .env file and set them as environment variables. In this sample app, those variables describe how to connect to the database locally.

This package is used in the [manage.py](./manage.py) file to load environment variables. | 34 | | [whitenoise](https://pypi.org/project/whitenoise/) | Static file serving for WSGI applications, used in the deployed app.

This package is used in the [azureproject/production.py](./azureproject/production.py) file, which configures production settings. | 35 | | [django-redis](https://pypi.org/project/django-redis/) | Redis cache backend for Django. | 36 | 37 | ## Run the sample 38 | 39 | This project has a [dev container configuration](.devcontainer/), which makes it easier to develop apps locally, deploy them to Azure, and monitor them. The easiest way to run this sample application is inside a GitHub codespace. Follow these steps: 40 | 41 | 1. Fork this repository to your account. For instructions, see [Fork a repo](https://docs.github.com/get-started/quickstart/fork-a-repo). 42 | 43 | 1. From the repository root of your fork, select **Code** > **Codespaces** > **+**. 44 | 45 | 1. In the codespace terminal, run the following commands: 46 | 47 | ```shell 48 | # Run database migrations 49 | python3 manage.py migrate 50 | # Start the development server 51 | python3 manage.py runserver 52 | ``` 53 | 54 | 1. When you see the message `Your application running on port 8000 is available.`, click **Open in Browser**. 55 | 56 | ### Quick deploy 57 | 58 | This project is designed to work well with the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview), which makes it easier to develop apps locally, deploy them to Azure, and monitor them. 59 | 60 | 🎥 Watch a deployment of the code in this [screencast](https://www.youtube.com/watch?v=JDlZ4TgPKYc). 61 | > Learn more about developing and deploying Django apps to Azure from Microsoft's comprehensive beginner series: 62 | > [Django for Beginners](https://www.youtube.com/playlist?list=PLlrxD0HtieHjHCQ0JB_RrhbQp_9ccJztr). 63 | 64 | Steps for deployment: 65 | 66 | 1. Sign up for a [free Azure account](https://azure.microsoft.com/free/) 67 | 2. Install the [Azure Dev CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd). (If you opened this repository in a Dev Container, it's already installed for you.) 68 | 3. Initialize a new `azd` environment: 69 | 70 | ```shell 71 | azd init 72 | ``` 73 | 74 | It will prompt you to provide a name (like "django-app"), which will later be used in the name of the deployed resources. 75 | 76 | 4. Provision and deploy all the resources: 77 | 78 | ```shell 79 | azd up 80 | ``` 81 | 82 | It will prompt you to login, pick a subscription, and provide a location (like "eastus"). Then it will provision the resources in your account and deploy the latest code. If you get an error with deployment, changing the location (like to "centralus") can help, as there may be availability constraints for some of the resources. 83 | 84 | 5. When `azd` has finished deploying, you'll see an endpoint URI in the command output. Visit that URI, and you should see the front page of the restaurant review app! 🎉 If you see an error, open the Azure Portal from the URL in the command output, navigate to the App Service, select Logstream, and check the logs for any errors. 85 | 86 | ![Screenshot of Django restaurants website](screenshot_website.png) 87 | 88 | 6. If you'd like to access `/admin`, you'll need a Django superuser. Navigate to the Azure Portal for the App Service, select SSH, and run this command: 89 | 90 | ```shell 91 | python3 manage.py createsuperuser 92 | ``` 93 | 94 | 7. When you've made any changes to the app code, you can just run: 95 | 96 | ```shell 97 | azd deploy 98 | ``` 99 | 100 | ## Getting help 101 | 102 | If you're working with this project and running into issues, please post in [Issues](/issues). 103 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | # azure.yaml is an azd configuration file and isn't used by the sample application. 3 | 4 | name: python-app-service-postgresql-redis-infra 5 | metadata: 6 | template: python-app-service-postgresql-redis-infra@0.0.1-beta 7 | services: 8 | web: 9 | project: . 10 | language: py 11 | host: appservice 12 | hooks: 13 | postprovision: 14 | posix: 15 | shell: sh 16 | run: printf '\nApp Service app has the following connection settings:\n' && printf "$CONNECTION_SETTINGS" | jq -r '.[]' | sed 's/\(.*\)/\t- \1/' && printf "\nSee the settings in the portal:\033[1;36m $WEB_APP_CONFIG\n" 17 | interactive: true 18 | continueOnError: true 19 | windows: 20 | shell: pwsh 21 | run: Write-Host "`n`nApp Service app has the following connection settings:`n" $CONNECTION_SETTINGS | ConvertFrom-Json | ForEach-Object { Write-Host "\t- $_" } 22 | interactive: true 23 | continueOnError: true 24 | postdeploy: 25 | posix: 26 | shell: sh 27 | run: printf "Open SSH session to App Service container at:\033[1;36m $WEB_APP_SSH\033[0m\nStream App Service logs at:\033[1;36m $WEB_APP_LOG_STREAM\n" 28 | interactive: true 29 | continueOnError: true 30 | windows: 31 | shell: pwsh 32 | run: Write-Host "`n`nOpen SSH session to App Service container at:`n" $WEB_APP_SSH; Write-Host "Stream App Service logs at:`n" $WEB_APP_LOG_STREAM 33 | interactive: true 34 | continueOnError: true -------------------------------------------------------------------------------- /azureproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/msdocs-django-postgresql-sample-app/75e65b9b94896fe6dae94d0c707b731800d17929/azureproject/__init__.py -------------------------------------------------------------------------------- /azureproject/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for azureproject project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'azureproject.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /azureproject/production.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .settings import * # noqa 4 | from .settings import BASE_DIR 5 | 6 | # Configure the domain name using the environment variable 7 | # that Azure automatically creates for us. 8 | ALLOWED_HOSTS = [os.environ['WEBSITE_HOSTNAME']] if 'WEBSITE_HOSTNAME' in os.environ else [] 9 | CSRF_TRUSTED_ORIGINS = ['https://' + os.environ['WEBSITE_HOSTNAME']] if 'WEBSITE_HOSTNAME' in os.environ else [] 10 | DEBUG = False 11 | 12 | # WhiteNoise configuration 13 | MIDDLEWARE = [ 14 | 'django.middleware.security.SecurityMiddleware', 15 | # Add whitenoise middleware after the security middleware 16 | 'whitenoise.middleware.WhiteNoiseMiddleware', 17 | 'django.contrib.sessions.middleware.SessionMiddleware', 18 | 'django.middleware.common.CommonMiddleware', 19 | 'django.middleware.csrf.CsrfViewMiddleware', 20 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 21 | 'django.contrib.messages.middleware.MessageMiddleware', 22 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 23 | ] 24 | 25 | SESSION_ENGINE = "django.contrib.sessions.backends.cache" 26 | STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 27 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 28 | 29 | DATABASES = { 30 | 'default': { 31 | 'ENGINE': 'django.db.backends.postgresql', 32 | 'NAME': os.environ['AZURE_POSTGRESQL_NAME'], 33 | 'HOST': os.environ['AZURE_POSTGRESQL_HOST'], 34 | 'USER': os.environ['AZURE_POSTGRESQL_USER'], 35 | 'PASSWORD': os.environ['AZURE_POSTGRESQL_PASSWORD'], 36 | } 37 | } 38 | 39 | CACHES = { 40 | "default": { 41 | "BACKEND": "django_redis.cache.RedisCache", 42 | "LOCATION": os.environ['AZURE_REDIS_CONNECTIONSTRING'], 43 | "OPTIONS": { 44 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 45 | "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", 46 | }, 47 | } 48 | } -------------------------------------------------------------------------------- /azureproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for azureproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = os.getenv('SECRET_KEY') 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | if 'CODESPACE_NAME' in os.environ: 32 | CSRF_TRUSTED_ORIGINS = [f'https://{os.getenv("CODESPACE_NAME")}-8000.{os.getenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN")}'] 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | 'restaurant_review.apps.RestaurantReviewConfig', 38 | 'django.contrib.admin', 39 | 'django.contrib.auth', 40 | 'django.contrib.contenttypes', 41 | 'django.contrib.sessions', 42 | 'django.contrib.messages', 43 | 'django.contrib.staticfiles', 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | SESSION_ENGINE = "django.contrib.sessions.backends.cache" 57 | ROOT_URLCONF = 'azureproject.urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': [], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = 'azureproject.wsgi.application' 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 80 | 81 | # To use sqllite as the database engine, 82 | # uncomment the following block and comment out the Postgres section below 83 | 84 | # DATABASES = { 85 | # 'default': { 86 | # 'ENGINE': 'django.db.backends.sqlite3', 87 | # 'NAME': BASE_DIR / 'db.sqlite3', 88 | # } 89 | # } 90 | 91 | 92 | # Configure Postgres database for local development 93 | # Set these environment variables in the .env file for this project. 94 | DATABASES = { 95 | 'default': { 96 | 'ENGINE': 'django.db.backends.postgresql', 97 | 'NAME': os.environ.get('DBNAME'), 98 | 'HOST': os.environ.get('DBHOST'), 99 | 'USER': os.environ.get('DBUSER'), 100 | 'PASSWORD': os.environ.get('DBPASS'), 101 | } 102 | } 103 | 104 | 105 | # Password validation 106 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 107 | 108 | AUTH_PASSWORD_VALIDATORS = [ 109 | { 110 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 111 | }, 112 | { 113 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 114 | }, 115 | { 116 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 117 | }, 118 | { 119 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 120 | }, 121 | ] 122 | 123 | CACHES = { 124 | "default": { 125 | "BACKEND": "django_redis.cache.RedisCache", 126 | "LOCATION": os.environ.get('CACHELOCATION'), 127 | "OPTIONS": { 128 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 129 | }, 130 | } 131 | } 132 | 133 | # Internationalization 134 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 135 | 136 | LANGUAGE_CODE = 'en-us' 137 | 138 | TIME_ZONE = 'UTC' 139 | 140 | USE_I18N = True 141 | 142 | USE_TZ = True 143 | 144 | 145 | # Static files (CSS, JavaScript, Images) 146 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 147 | 148 | STATICFILES_DIRS = (str(BASE_DIR.joinpath('static')),) 149 | STATIC_URL = 'static/' 150 | 151 | # Default primary key field type 152 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 153 | 154 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 155 | -------------------------------------------------------------------------------- /azureproject/urls.py: -------------------------------------------------------------------------------- 1 | """azureproject URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | 19 | urlpatterns = [ 20 | path('', include('restaurant_review.urls')), 21 | path('admin/', admin.site.urls), 22 | ] 23 | -------------------------------------------------------------------------------- /azureproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for azureproject 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/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | # Check for the WEBSITE_HOSTNAME environment variable to see if we are running in Azure Ap Service 15 | # If so, then load the settings from production.py 16 | settings_module = 'azureproject.production' if 'WEBSITE_HOSTNAME' in os.environ else 'azureproject.settings' 17 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module) 18 | 19 | application = get_wsgi_application() 20 | -------------------------------------------------------------------------------- /infra/README.md: -------------------------------------------------------------------------------- 1 | # infra directory 2 | 3 | This `infra` directory contains azd files used for `azd provision` and isn't used by the sample application. 4 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name which is used to generate a short unique hash for each resource') 6 | param name string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | param location string 11 | 12 | @secure() 13 | @description('PostGreSQL Server administrator password') 14 | param databasePassword string 15 | 16 | @secure() 17 | @description('Django SECRET_KEY for securing signed data') 18 | param secretKey string 19 | 20 | param principalId string = '' 21 | 22 | var resourceToken = toLower(uniqueString(subscription().id, name, location)) 23 | var tags = { 'azd-env-name': name } 24 | 25 | resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { 26 | name: '${name}-rg' 27 | location: location 28 | tags: tags 29 | } 30 | 31 | module resources 'resources.bicep' = { 32 | name: 'resources' 33 | scope: resourceGroup 34 | params: { 35 | name: name 36 | location: location 37 | resourceToken: resourceToken 38 | tags: tags 39 | databasePassword: databasePassword 40 | principalId: principalId 41 | secretKey: secretKey 42 | } 43 | } 44 | 45 | output AZURE_LOCATION string = location 46 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = resources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING 47 | output WEB_URI string = resources.outputs.WEB_URI 48 | output CONNECTION_SETTINGS array = resources.outputs.CONNECTION_SETTINGS 49 | output WEB_APP_LOG_STREAM string = resources.outputs.WEB_APP_LOG_STREAM 50 | output WEB_APP_SSH string = resources.outputs.WEB_APP_SSH 51 | output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG 52 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "name": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "databasePassword": { 12 | "value": "$(secretOrRandomPassword)" 13 | }, 14 | "principalId": { 15 | "value": "${AZURE_PRINCIPAL_ID}" 16 | }, 17 | "secretKey": { 18 | "value": "$(secretOrRandomPassword)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /infra/resources.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string 3 | param resourceToken string 4 | param principalId string 5 | param tags object 6 | @secure() 7 | param databasePassword string 8 | @secure() 9 | param secretKey string 10 | var appName = '${name}-${resourceToken}' 11 | 12 | var pgServerName = '${appName}-server' 13 | 14 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { 15 | name: '${appName}-vnet' 16 | location: location 17 | tags: tags 18 | properties: { 19 | addressSpace: { 20 | addressPrefixes: [ 21 | '10.0.0.0/16' 22 | ] 23 | } 24 | subnets: [ 25 | { 26 | name: 'database-subnet' 27 | properties: { 28 | addressPrefix: '10.0.0.0/24' 29 | delegations: [ 30 | { 31 | name: '${appName}-subnet-delegation' 32 | properties: { 33 | serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' 34 | } 35 | } 36 | ] 37 | privateEndpointNetworkPolicies: 'Enabled' 38 | privateLinkServiceNetworkPolicies: 'Enabled' 39 | } 40 | } 41 | { 42 | name: 'webapp-subnet' 43 | properties: { 44 | addressPrefix: '10.0.1.0/24' 45 | delegations: [ 46 | { 47 | name: 'dlg-appServices' 48 | properties: { 49 | serviceName: 'Microsoft.Web/serverFarms' 50 | } 51 | } 52 | ] 53 | } 54 | } 55 | { 56 | name: 'cache-subnet' 57 | properties:{ 58 | addressPrefix: '10.0.2.0/24' 59 | privateEndpointNetworkPolicies: 'Disabled' 60 | } 61 | } 62 | { 63 | name: 'vault-subnet' 64 | properties: { 65 | addressPrefix: '10.0.3.0/24' 66 | privateEndpointNetworkPolicies: 'Disabled' 67 | } 68 | } 69 | ] 70 | } 71 | resource subnetForDb 'subnets' existing = { 72 | name: 'database-subnet' 73 | } 74 | resource subnetForVault 'subnets' existing = { 75 | name: 'vault-subnet' 76 | } 77 | resource subnetForApp 'subnets' existing = { 78 | name: 'webapp-subnet' 79 | } 80 | resource subnetForCache 'subnets' existing = { 81 | name: 'cache-subnet' 82 | } 83 | } 84 | 85 | // Resources needed to secure Key Vault behind a private endpoint 86 | resource privateDnsZoneKeyVault 'Microsoft.Network/privateDnsZones@2020-06-01' = { 87 | name: 'privatelink.vaultcore.azure.net' 88 | location: 'global' 89 | resource vnetLink 'virtualNetworkLinks@2020-06-01' = { 90 | location: 'global' 91 | name: '${appName}-vaultlink' 92 | properties: { 93 | virtualNetwork: { 94 | id: virtualNetwork.id 95 | } 96 | registrationEnabled: false 97 | } 98 | } 99 | } 100 | resource vaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = { 101 | name: '${appName}-vault-privateEndpoint' 102 | location: location 103 | properties: { 104 | subnet: { 105 | id: virtualNetwork::subnetForVault.id 106 | } 107 | privateLinkServiceConnections: [ 108 | { 109 | name: '${appName}-vault-privateEndpoint' 110 | properties: { 111 | privateLinkServiceId: keyVault.id 112 | groupIds: ['vault'] 113 | } 114 | } 115 | ] 116 | } 117 | resource privateDnsZoneGroup 'privateDnsZoneGroups@2024-01-01' = { 118 | name: 'default' 119 | properties: { 120 | privateDnsZoneConfigs: [ 121 | { 122 | name: 'vault-config' 123 | properties: { 124 | privateDnsZoneId: privateDnsZoneKeyVault.id 125 | } 126 | } 127 | ] 128 | } 129 | } 130 | } 131 | 132 | resource privateDnsZoneDB 'Microsoft.Network/privateDnsZones@2024-06-01' = { 133 | name: '${pgServerName}.private.postgres.database.azure.com' 134 | location: 'global' 135 | tags: tags 136 | dependsOn: [ 137 | virtualNetwork 138 | ] 139 | resource privateDnsZoneLinkDB 'virtualNetworkLinks@2024-06-01' = { 140 | name: '${appName}-dblink' 141 | location: 'global' 142 | properties: { 143 | virtualNetwork: { 144 | id: virtualNetwork.id 145 | } 146 | registrationEnabled: false 147 | } 148 | } 149 | } 150 | 151 | // Resources needed to secure Redis Cache behind a private endpoint 152 | resource cachePrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-03-01' = { 153 | name: '${appName}-cache-privateEndpoint' 154 | location: location 155 | properties: { 156 | subnet: { 157 | id: virtualNetwork::subnetForCache.id 158 | } 159 | privateLinkServiceConnections: [ 160 | { 161 | name: '${appName}-cache-privateEndpoint' 162 | properties: { 163 | privateLinkServiceId: redisCache.id 164 | groupIds: ['redisCache'] 165 | } 166 | } 167 | ] 168 | } 169 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 170 | name: 'default' 171 | properties: { 172 | privateDnsZoneConfigs: [ 173 | { 174 | name: 'cache-config' 175 | properties: { 176 | privateDnsZoneId: privateDnsZoneCache.id 177 | } 178 | } 179 | ] 180 | } 181 | } 182 | } 183 | resource privateDnsZoneCache 'Microsoft.Network/privateDnsZones@2024-06-01' = { 184 | name: 'privatelink.redis.cache.windows.net' 185 | location: 'global' 186 | dependsOn: [ 187 | virtualNetwork 188 | ] 189 | resource privateDnsZoneLinkCache 'virtualNetworkLinks@2020-06-01' = { 190 | name: '${appName}-cachelink' 191 | location: 'global' 192 | properties: { 193 | virtualNetwork: { 194 | id: virtualNetwork.id 195 | } 196 | registrationEnabled: false 197 | } 198 | } 199 | } 200 | 201 | // The Key Vault is used to manage SQL database and redis secrets. 202 | // Current user has the admin permissions to configure key vault secrets, but by default doesn't have the permissions to read them. 203 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { 204 | name: '${take(replace(appName, '-', ''), 17)}-vault' 205 | location: location 206 | properties: { 207 | enableRbacAuthorization: true 208 | tenantId: subscription().tenantId 209 | sku: { family: 'A', name: 'standard' } 210 | // Only allow requests from the private endpoint in the VNET. 211 | publicNetworkAccess: 'Disabled' // To see the secret in the portal, change to 'Enabled' 212 | networkAcls: { 213 | defaultAction: 'Deny' // To see the secret in the portal, change to 'Allow' 214 | bypass: 'None' 215 | } 216 | } 217 | } 218 | 219 | // Grant the current user with key vault secret user role permissions over the key vault. This lets you inspect the secrets, such as in the portal 220 | // If you remove this section, you can't read the key vault secrets, but the app still has access with its managed identity. 221 | resource keyVaultSecretUserRoleRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { 222 | scope: subscription() 223 | name: '4633458b-17de-408a-b874-0445c86b69e6' // The built-in Key Vault Secret User role 224 | } 225 | resource keyVaultSecretUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-08-01-preview' = { 226 | scope: keyVault 227 | name: guid(resourceGroup().id, principalId, keyVaultSecretUserRoleRoleDefinition.id) 228 | properties: { 229 | roleDefinitionId: keyVaultSecretUserRoleRoleDefinition.id 230 | principalId: principalId 231 | principalType: 'User' 232 | } 233 | } 234 | 235 | resource dbserver 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-preview' = { 236 | location: location 237 | tags: tags 238 | name: pgServerName 239 | sku: { 240 | name: 'Standard_B1ms' 241 | tier: 'Burstable' 242 | } 243 | properties: { 244 | version: '12' 245 | administratorLogin: 'postgresadmin' 246 | administratorLoginPassword: databasePassword 247 | storage: { 248 | storageSizeGB: 128 249 | } 250 | backup: { 251 | backupRetentionDays: 7 252 | geoRedundantBackup: 'Disabled' 253 | } 254 | network: { 255 | delegatedSubnetResourceId: virtualNetwork::subnetForDb.id 256 | privateDnsZoneArmResourceId: privateDnsZoneDB.id 257 | } 258 | highAvailability: { 259 | mode: 'Disabled' 260 | } 261 | maintenanceWindow: { 262 | customWindow: 'Disabled' 263 | dayOfWeek: 0 264 | startHour: 0 265 | startMinute: 0 266 | } 267 | } 268 | 269 | resource db 'databases@2024-08-01' = { 270 | name: '${appName}-database' 271 | } 272 | dependsOn: [ 273 | privateDnsZoneDB::privateDnsZoneLinkDB 274 | ] 275 | } 276 | 277 | // The Redis cache is configured to the minimum pricing tier 278 | resource redisCache 'Microsoft.Cache/redis@2024-11-01' = { 279 | name: '${appName}-cache' 280 | location: location 281 | properties: { 282 | sku: { 283 | name: 'Basic' 284 | family: 'C' 285 | capacity: 0 286 | } 287 | redisConfiguration: {} 288 | enableNonSslPort: false 289 | redisVersion: '6' 290 | publicNetworkAccess: 'Disabled' 291 | } 292 | } 293 | 294 | // The App Service plan is configured to the B1 pricing tier 295 | resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { 296 | name: '${appName}-plan' 297 | location: location 298 | kind: 'linux' 299 | properties: { 300 | reserved: true 301 | } 302 | sku: { 303 | name: 'B1' 304 | } 305 | } 306 | 307 | resource web 'Microsoft.Web/sites@2024-04-01' = { 308 | name: appName 309 | location: location 310 | tags: union(tags, { 'azd-service-name': 'web' }) // Needed by AZD 311 | properties: { 312 | siteConfig: { 313 | linuxFxVersion: 'PYTHON|3.12' // Set to Python 3.12 314 | ftpsState: 'Disabled' 315 | appCommandLine: 'startup.sh' 316 | minTlsVersion: '1.2' 317 | } 318 | serverFarmId: appServicePlan.id 319 | httpsOnly: true 320 | } 321 | identity: { 322 | type: 'SystemAssigned' 323 | } 324 | 325 | // For app setting configuration see the appsettings resource 326 | 327 | // Disable basic authentication for FTP and SCM 328 | resource ftp 'basicPublishingCredentialsPolicies@2023-12-01' = { 329 | name: 'ftp' 330 | properties: { 331 | allow: false 332 | } 333 | } 334 | resource scm 'basicPublishingCredentialsPolicies@2023-12-01' = { 335 | name: 'scm' 336 | properties: { 337 | allow: false 338 | } 339 | } 340 | 341 | // Enable App Service native logs 342 | resource logs 'config' = { 343 | name: 'logs' 344 | properties: { 345 | applicationLogs: { 346 | fileSystem: { 347 | level: 'Verbose' 348 | } 349 | } 350 | detailedErrorMessages: { 351 | enabled: true 352 | } 353 | failedRequestsTracing: { 354 | enabled: true 355 | } 356 | httpLogs: { 357 | fileSystem: { 358 | enabled: true 359 | retentionInDays: 1 360 | retentionInMb: 35 361 | } 362 | } 363 | } 364 | } 365 | 366 | // Enable VNET integration 367 | resource webappVnetConfig 'networkConfig' = { 368 | name: 'virtualNetwork' 369 | properties: { 370 | subnetResourceId: virtualNetwork::subnetForApp.id 371 | } 372 | } 373 | 374 | dependsOn: [ virtualNetwork ] 375 | } 376 | 377 | // Service Connector from the app to the key vault, which generates the connection settings for the App Service app 378 | // The application code doesn't make any direct connections to the key vault, but the setup expedites the managed identity access 379 | // so that the cache connector can be configured with key vault references. 380 | resource vaultConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { 381 | scope: web 382 | name: 'vaultConnector' 383 | properties: { 384 | clientType: 'python' 385 | targetService: { 386 | type: 'AzureResource' 387 | id: keyVault.id 388 | } 389 | authInfo: { 390 | authType: 'systemAssignedIdentity' // Use a system-assigned managed identity. No password is used. 391 | } 392 | vNetSolution: { 393 | type: 'privateLink' 394 | } 395 | } 396 | dependsOn: [ 397 | vaultPrivateEndpoint 398 | ] 399 | } 400 | 401 | // Connector to the PostgreSQL database, which generates the connection string for the App Service app 402 | resource dbConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { 403 | scope: web 404 | name: 'defaultConnector' 405 | properties: { 406 | targetService: { 407 | type: 'AzureResource' 408 | id: dbserver::db.id 409 | } 410 | authInfo: { 411 | authType: 'secret' 412 | name: 'postgresadmin' 413 | secretInfo: { 414 | secretType: 'rawValue' 415 | value: databasePassword 416 | } 417 | } 418 | secretStore: { 419 | keyVaultId: keyVault.id // Configure secrets as key vault references. No secret is exposed in App Service. 420 | } 421 | clientType: 'django' 422 | } 423 | } 424 | 425 | // Service Connector from the app to the cache, which generates an app setting for the App Service app 426 | resource cacheConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { 427 | scope: web 428 | name: 'RedisConnector' 429 | properties: { 430 | clientType: 'python' 431 | targetService: { 432 | type: 'AzureResource' 433 | id: resourceId('Microsoft.Cache/Redis/Databases', redisCache.name, '0') 434 | } 435 | authInfo: { 436 | authType: 'accessKey' 437 | } 438 | secretStore: { 439 | keyVaultId: keyVault.id // Configure secrets as key vault references. No secret is exposed in App Service. 440 | } 441 | vNetSolution: { 442 | type: 'privateLink' 443 | 444 | } 445 | } 446 | dependsOn: [ 447 | cachePrivateEndpoint 448 | ] 449 | } 450 | 451 | resource webdiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 452 | name: 'AllLogs' 453 | scope: web 454 | properties: { 455 | workspaceId: logAnalyticsWorkspace.id 456 | logs: [ 457 | { 458 | category: 'AppServiceHTTPLogs' 459 | enabled: true 460 | } 461 | { 462 | category: 'AppServiceConsoleLogs' 463 | enabled: true 464 | } 465 | { 466 | category: 'AppServiceAppLogs' 467 | enabled: true 468 | } 469 | { 470 | category: 'AppServiceAuditLogs' 471 | enabled: true 472 | } 473 | { 474 | category: 'AppServiceIPSecAuditLogs' 475 | enabled: true 476 | } 477 | { 478 | category: 'AppServicePlatformLogs' 479 | enabled: true 480 | } 481 | ] 482 | metrics: [ 483 | { 484 | category: 'AllMetrics' 485 | enabled: true 486 | } 487 | ] 488 | } 489 | } 490 | 491 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { 492 | name: '${appName}-workspace' 493 | location: location 494 | tags: tags 495 | properties: any({ 496 | retentionInDays: 30 497 | features: { 498 | searchVersion: 1 499 | } 500 | sku: { 501 | name: 'PerGB2018' 502 | } 503 | }) 504 | } 505 | 506 | module applicationInsightsResources 'appinsights.bicep' = { 507 | name: 'applicationinsights-resources' 508 | params: { 509 | prefix: appName 510 | location: location 511 | tags: tags 512 | workspaceId: logAnalyticsWorkspace.id 513 | } 514 | } 515 | 516 | func checkAndFormatSecrets(config object) string => config.configType == 'KeyVaultSecret' ? '@Microsoft.KeyVault(SecretUri=${config.value})' : config.value 517 | 518 | // Add the app settings, by merging them with the ones created by the service connectors 519 | var aggregatedAppSettings = union( 520 | reduce(vaultConnector.listConfigurations().configurations, {}, (cur, next) => union(cur, { '${next.name}': checkAndFormatSecrets(next) })), 521 | reduce(dbConnector.listConfigurations().configurations, {}, (cur, next) => union(cur, { '${next.name}': checkAndFormatSecrets(next) })), 522 | reduce(cacheConnector.listConfigurations().configurations, {}, (cur, next) => union(cur, { '${next.name}': checkAndFormatSecrets(next) })), 523 | { 524 | SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' 525 | FLASK_DEBUG: 'False' 526 | SECRET_KEY: secretKey 527 | // Add other app settings here, for example: 528 | // 'FOO': 'BAR' 529 | } 530 | ) 531 | resource appsettings 'Microsoft.Web/sites/config@2024-04-01' = { 532 | name: 'appsettings' 533 | parent: web 534 | properties: aggregatedAppSettings 535 | } 536 | // Why is this needed? 537 | // The service connectors automatically add necessary respective app settings to the App Service app. However, if you configure a separate 538 | // set of app settings in a config/appsettings resource, expecting a cummulative effect, the app settings actually overwrite the ones 539 | // created by the service connectors, and the service connectors don't recreate the app settings after the first run. This configuration 540 | // is a workaround to ensure that the app settings are aggregated correctly and consistent across multiple deployments. 541 | 542 | output WEB_URI string = 'https://${web.properties.defaultHostName}' 543 | output CONNECTION_SETTINGS array = map(concat(dbConnector.listConfigurations().configurations, cacheConnector.listConfigurations().configurations, vaultConnector.listConfigurations().configurations), config => config.name) 544 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsightsResources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING 545 | output WEB_APP_LOG_STREAM string = format('https://portal.azure.com/#@/resource{0}/logStream', web.id) 546 | output WEB_APP_SSH string = format('https://{0}.scm.azurewebsites.net/webssh/host', web.name) 547 | output WEB_APP_CONFIG string = format('https://portal.azure.com/#@/resource{0}/environmentVariablesAppSettings', web.id) 548 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | from dotenv import load_dotenv 7 | 8 | 9 | def main(): 10 | """Run administrative tasks.""" 11 | # If WEBSITE_HOSTNAME is defined as an environment variable, then we're running on Azure App Service 12 | 13 | # Only for Local Development - Load environment variables from the .env file 14 | if 'WEBSITE_HOSTNAME' not in os.environ: 15 | print("Loading environment variables for .env file") 16 | load_dotenv('./.env') 17 | 18 | # When running on Azure App Service you should use the production settings. 19 | settings_module = "azureproject.production" if 'WEBSITE_HOSTNAME' in os.environ else 'azureproject.settings' 20 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module) 21 | 22 | try: 23 | from django.core.management import execute_from_command_line 24 | except ImportError as exc: 25 | raise ImportError( 26 | "Couldn't import Django. Are you sure it's installed and " 27 | "available on your PYTHONPATH environment variable? Did you " 28 | "forget to activate a virtual environment?" 29 | ) from exc 30 | execute_from_command_line(sys.argv) 31 | 32 | 33 | if __name__ == '__main__': 34 | main() 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.1.6 2 | psycopg2-binary==2.9.10 3 | python-dotenv==1.0.1 4 | whitenoise==6.9.0 5 | django-redis==5.4.0 6 | -------------------------------------------------------------------------------- /restaurant_review/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/msdocs-django-postgresql-sample-app/75e65b9b94896fe6dae94d0c707b731800d17929/restaurant_review/__init__.py -------------------------------------------------------------------------------- /restaurant_review/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Restaurant, Review 4 | 5 | # Register your models here. 6 | 7 | admin.site.register(Restaurant) 8 | admin.site.register(Review) 9 | -------------------------------------------------------------------------------- /restaurant_review/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RestaurantReviewConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'restaurant_review' 7 | -------------------------------------------------------------------------------- /restaurant_review/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.2 on 2022-02-08 04:34 2 | 3 | import django.core.validators 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 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Restaurant', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=50)), 21 | ('street_address', models.CharField(max_length=50)), 22 | ('description', models.CharField(max_length=250)), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='Review', 27 | fields=[ 28 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('user_name', models.CharField(max_length=20)), 30 | ('rating', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), 31 | ('review_text', models.CharField(max_length=500)), 32 | ('review_date', models.DateTimeField(verbose_name='review date')), 33 | ('restaurant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='restaurant_review.restaurant')), 34 | ], 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /restaurant_review/migrations/0002_alter_review_rating.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-11-04 21:25 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("restaurant_review", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="review", 16 | name="rating", 17 | field=models.IntegerField( 18 | validators=[ 19 | django.core.validators.MinValueValidator(1), 20 | django.core.validators.MaxValueValidator(5), 21 | ] 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /restaurant_review/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/msdocs-django-postgresql-sample-app/75e65b9b94896fe6dae94d0c707b731800d17929/restaurant_review/migrations/__init__.py -------------------------------------------------------------------------------- /restaurant_review/models.py: -------------------------------------------------------------------------------- 1 | from django.core.validators import MaxValueValidator, MinValueValidator 2 | from django.db import models 3 | 4 | # Create your models here. 5 | 6 | class Restaurant(models.Model): 7 | name = models.CharField(max_length=50) 8 | street_address = models.CharField(max_length=50) 9 | description = models.CharField(max_length=250) 10 | 11 | def __str__(self): 12 | return self.name 13 | 14 | 15 | class Review(models.Model): 16 | restaurant = models.ForeignKey(Restaurant, on_delete=models.CASCADE) 17 | user_name = models.CharField(max_length=20) 18 | rating = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)]) 19 | review_text = models.CharField(max_length=500) 20 | review_date = models.DateTimeField('review date') 21 | 22 | def __str__(self): 23 | return f"{self.restaurant.name} ({self.review_date:%x})" -------------------------------------------------------------------------------- /restaurant_review/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello Azure - Python Django and PostgreSQL Tutorial 4 | 5 | 6 | 7 |
8 |

There was a problem (500 error) with the sample app.

9 |
Try the following troubleshooting tips if running locally: 10 | 19 |
20 |
Try the following troubleshooting tips if running in Azure App Service: 21 | 31 |
32 |
33 | 34 | -------------------------------------------------------------------------------- /restaurant_review/templates/restaurant_review/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | {% block head %} 6 | Django web app with PostgreSQL in Azure - {% block title %}{% endblock %} 7 | 8 | 9 | 10 | {% endblock %} 11 | 12 | 13 | 37 | 38 |
39 | {% block content %}{% endblock %} 40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /restaurant_review/templates/restaurant_review/create_restaurant.html: -------------------------------------------------------------------------------- 1 | {% extends "restaurant_review/base.html" %} 2 | {% block title %}Restaurant Create{% endblock %} 3 | {% block head %} 4 | {{ block.super }} 5 | 11 | {% endblock %} 12 | {% block content %} 13 |

Add New Restaurant

14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 | 29 | 30 |
31 | {% endblock %} 32 | 33 | -------------------------------------------------------------------------------- /restaurant_review/templates/restaurant_review/details.html: -------------------------------------------------------------------------------- 1 | {% extends "restaurant_review/base.html" %} 2 | {% load restaurant_extras %} 3 | {% block title %}Restaurant Details{% endblock %} 4 | {% block head %} 5 | {{ block.super }} 6 | 12 | {% endblock %} 13 | {% block content %} 14 |

{{ restaurant.name }}

15 | 16 |
17 |
Street address:
18 |
{{ restaurant.street_address }}
19 |
20 |
21 |
Description:
22 |
{{ restaurant.description }}
23 |
24 |
25 |
Rating:
26 |
27 |
28 | 29 |

Reviews

30 | 31 |

32 | 35 | 36 |

37 | 38 | 39 | {% if restaurant.review_set %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {% for review in restaurant.review_set.all %} 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% endfor %} 58 | 59 |
DateUserRatingReview
{{ review.review_date }}{{ review.user_name }}{{ review.rating }}{{ review.review_text }}
60 | {% else %} 61 |

No reviews of this restaurant yet.

62 | {% endif %} 63 | 64 | 65 | 117 | {% endblock %} 118 | 119 | -------------------------------------------------------------------------------- /restaurant_review/templates/restaurant_review/index.html: -------------------------------------------------------------------------------- 1 | {% extends "restaurant_review/base.html" %} 2 | {% load restaurant_extras %} 3 | {% block title %}Restaurant List{% endblock %} 4 | {% block head %} 5 | {{ block.super }} 6 | 43 | {% endblock %} 44 | {% block content %} 45 | {% if LastViewedRestaurant %} 46 |

Last viewed restaurant (saved in cache): {{ LastViewedRestaurant }}

47 | {% endif %} 48 |

Restaurants

49 | 50 | {% if restaurants %} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {% for restaurant in restaurants %} 61 | 62 | 63 | 64 | 65 | 66 | {% endfor %} 67 | 68 |
NameRatingDetails
{{ restaurant.name }}{% star_rating restaurant.avg_rating restaurant.review_count %} Details
69 | {% else %} 70 |

No restaurants exist. Select Add new restaurant to add one.

71 | {% endif %} 72 | 73 |
74 | Add new restaurant 75 |
76 | {% endblock %} -------------------------------------------------------------------------------- /restaurant_review/templates/restaurant_review/star_rating.html: -------------------------------------------------------------------------------- 1 | {% if review_count %} 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | {{ avg_rating|floatformat:1 }} ({{review_count}} {% if review_count == 1 %} review{% else %} reviews{% endif %}) 21 | 22 |
23 | {% else %} 24 | No ratings yet 25 | {% endif %} 26 | -------------------------------------------------------------------------------- /restaurant_review/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/msdocs-django-postgresql-sample-app/75e65b9b94896fe6dae94d0c707b731800d17929/restaurant_review/templatetags/__init__.py -------------------------------------------------------------------------------- /restaurant_review/templatetags/restaurant_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.inclusion_tag('restaurant_review/star_rating.html') 7 | def star_rating(avg_rating, review_count): 8 | stars_percent = round((avg_rating / 5.0) * 100) if review_count > 0 else 0 9 | return {'avg_rating': avg_rating, 'review_count': review_count, 'stars_percent': stars_percent} 10 | -------------------------------------------------------------------------------- /restaurant_review/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | from .models import Restaurant 7 | 8 | 9 | def create_restaurant(): 10 | return Restaurant.objects.create( 11 | name="Test Restaurant", 12 | street_address="123 Test Street", 13 | description="Test Description", 14 | ) 15 | 16 | 17 | # Initial tests created with GitHub Copilot 18 | class RestaurantRoutesTestCase(TestCase): 19 | def test_restaurant_review_page_loads(self): 20 | restaurant = create_restaurant() 21 | response = self.client.get(reverse("index")) 22 | self.assertEqual(response.status_code, 200) 23 | self.assertContains(response, restaurant.name) 24 | 25 | def test_restaurant_details_page_loads(self): 26 | restaurant = create_restaurant() 27 | response = self.client.get(reverse("details", args=(restaurant.id,))) 28 | self.assertEqual(response.status_code, 200) 29 | self.assertContains(response, restaurant.name) 30 | 31 | def test_create_restaurant_page_loads(self): 32 | response = self.client.get(reverse("create_restaurant")) 33 | self.assertEqual(response.status_code, 200) 34 | self.assertContains(response, "Add New Restaurant") 35 | 36 | def test_add_restaurant(self): 37 | response = self.client.post( 38 | reverse("add_restaurant"), 39 | { 40 | "restaurant_name": "Test Restaurant", 41 | "street_address": "123 Test Street", 42 | "description": "Test Description", 43 | }, 44 | ) 45 | restaurant = Restaurant.objects.get(name="Test Restaurant") 46 | self.assertEqual(restaurant.name, "Test Restaurant") 47 | self.assertEqual(restaurant.street_address, "123 Test Street") 48 | self.assertEqual(restaurant.description, "Test Description") 49 | self.assertRedirects(response, reverse("details", args=(restaurant.id,))) 50 | 51 | def test_add_review(self): 52 | restaurant = create_restaurant() 53 | response = self.client.post( 54 | reverse("add_review", args=(restaurant.id,)), 55 | {"user_name": "Test User", "rating": 5, "review_text": "Test Review"}, 56 | ) 57 | self.assertEqual(restaurant.review_set.count(), 1) 58 | review = restaurant.review_set.first() 59 | self.assertEqual(review.user_name, "Test User") 60 | self.assertEqual(review.rating, 5) 61 | self.assertEqual(review.review_text, "Test Review") 62 | self.assertRedirects(response, reverse("details", args=(restaurant.id,))) 63 | 64 | 65 | class RestaurantModels(TestCase): 66 | def test_create_restaurant(self): 67 | restaurant = create_restaurant() 68 | self.assertEqual(restaurant.name, "Test Restaurant") 69 | self.assertEqual(restaurant.street_address, "123 Test Street") 70 | self.assertEqual(restaurant.description, "Test Description") 71 | self.assertEqual(str(restaurant), "Test Restaurant") 72 | 73 | def test_create_review(self): 74 | restaurant = create_restaurant() 75 | review = restaurant.review_set.create( 76 | user_name="Test User", 77 | rating=5, 78 | review_text="Test Review", 79 | review_date=datetime.datetime(2001, 1, 1), 80 | ) 81 | self.assertEqual(review.user_name, "Test User") 82 | self.assertEqual(review.rating, 5) 83 | self.assertEqual(review.review_text, "Test Review") 84 | self.assertEqual(str(review), "Test Restaurant (01/01/01)") 85 | -------------------------------------------------------------------------------- /restaurant_review/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('', views.index, name='index'), 7 | path('/', views.details, name='details'), 8 | path('create', views.create_restaurant, name='create_restaurant'), 9 | path('add', views.add_restaurant, name='add_restaurant'), 10 | path('review/', views.add_review, name='add_review'), 11 | ] 12 | -------------------------------------------------------------------------------- /restaurant_review/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Avg, Count 2 | from django.http import HttpResponseRedirect 3 | from django.shortcuts import get_object_or_404, render 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.views.decorators.cache import cache_page 8 | 9 | from restaurant_review.models import Restaurant, Review 10 | 11 | # Create your views here. 12 | 13 | def index(request): 14 | print('Request for index page received') 15 | restaurants = Restaurant.objects.annotate(avg_rating=Avg('review__rating')).annotate(review_count=Count('review')) 16 | lastViewedRestaurant = request.session.get("lastViewedRestaurant", False) 17 | return render(request, 'restaurant_review/index.html', {'LastViewedRestaurant': lastViewedRestaurant, 'restaurants': restaurants}) 18 | 19 | @cache_page(60) 20 | def details(request, id): 21 | print('Request for restaurant details page received') 22 | restaurant = get_object_or_404(Restaurant, pk=id) 23 | request.session["lastViewedRestaurant"] = restaurant.name 24 | return render(request, 'restaurant_review/details.html', {'restaurant': restaurant}) 25 | 26 | 27 | def create_restaurant(request): 28 | print('Request for add restaurant page received') 29 | return render(request, 'restaurant_review/create_restaurant.html') 30 | 31 | 32 | @csrf_exempt 33 | def add_restaurant(request): 34 | try: 35 | name = request.POST['restaurant_name'] 36 | street_address = request.POST['street_address'] 37 | description = request.POST['description'] 38 | except (KeyError): 39 | # Redisplay the form 40 | return render(request, 'restaurant_review/add_restaurant.html', { 41 | 'error_message': "You must include a restaurant name, address, and description", 42 | }) 43 | else: 44 | restaurant = Restaurant() 45 | restaurant.name = name 46 | restaurant.street_address = street_address 47 | restaurant.description = description 48 | Restaurant.save(restaurant) 49 | 50 | return HttpResponseRedirect(reverse('details', args=(restaurant.id,))) 51 | 52 | 53 | @csrf_exempt 54 | def add_review(request, id): 55 | restaurant = get_object_or_404(Restaurant, pk=id) 56 | try: 57 | user_name = request.POST['user_name'] 58 | rating = request.POST['rating'] 59 | review_text = request.POST['review_text'] 60 | except (KeyError): 61 | # Redisplay the form. 62 | return render(request, 'restaurant_review/add_review.html', { 63 | 'error_message': "Error adding review", 64 | }) 65 | else: 66 | review = Review() 67 | review.restaurant = restaurant 68 | review.review_date = timezone.now() 69 | review.user_name = user_name 70 | review.rating = rating 71 | review.review_text = review_text 72 | Review.save(review) 73 | 74 | return HttpResponseRedirect(reverse('details', args=(id,))) 75 | -------------------------------------------------------------------------------- /screenshot_website.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/msdocs-django-postgresql-sample-app/75e65b9b94896fe6dae94d0c707b731800d17929/screenshot_website.png -------------------------------------------------------------------------------- /startup.sh: -------------------------------------------------------------------------------- 1 | # startup.sh is used by infra/resources.bicep to automate database migrations and isn't used by the sample application 2 | python manage.py migrate 3 | gunicorn --workers 2 --threads 4 --timeout 60 --access-logfile \ 4 | '-' --error-logfile '-' --bind=0.0.0.0:8000 \ 5 | --chdir=/home/site/wwwroot azureproject.wsgi -------------------------------------------------------------------------------- /static/bootstrap/css/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.3 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | :root { 9 | --bs-blue: #0d6efd; 10 | --bs-indigo: #6610f2; 11 | --bs-purple: #6f42c1; 12 | --bs-pink: #d63384; 13 | --bs-red: #dc3545; 14 | --bs-orange: #fd7e14; 15 | --bs-yellow: #ffc107; 16 | --bs-green: #198754; 17 | --bs-teal: #20c997; 18 | --bs-cyan: #0dcaf0; 19 | --bs-white: #fff; 20 | --bs-gray: #6c757d; 21 | --bs-gray-dark: #343a40; 22 | --bs-gray-100: #f8f9fa; 23 | --bs-gray-200: #e9ecef; 24 | --bs-gray-300: #dee2e6; 25 | --bs-gray-400: #ced4da; 26 | --bs-gray-500: #adb5bd; 27 | --bs-gray-600: #6c757d; 28 | --bs-gray-700: #495057; 29 | --bs-gray-800: #343a40; 30 | --bs-gray-900: #212529; 31 | --bs-primary: #0d6efd; 32 | --bs-secondary: #6c757d; 33 | --bs-success: #198754; 34 | --bs-info: #0dcaf0; 35 | --bs-warning: #ffc107; 36 | --bs-danger: #dc3545; 37 | --bs-light: #f8f9fa; 38 | --bs-dark: #212529; 39 | --bs-primary-rgb: 13, 110, 253; 40 | --bs-secondary-rgb: 108, 117, 125; 41 | --bs-success-rgb: 25, 135, 84; 42 | --bs-info-rgb: 13, 202, 240; 43 | --bs-warning-rgb: 255, 193, 7; 44 | --bs-danger-rgb: 220, 53, 69; 45 | --bs-light-rgb: 248, 249, 250; 46 | --bs-dark-rgb: 33, 37, 41; 47 | --bs-white-rgb: 255, 255, 255; 48 | --bs-black-rgb: 0, 0, 0; 49 | --bs-body-color-rgb: 33, 37, 41; 50 | --bs-body-bg-rgb: 255, 255, 255; 51 | --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 52 | --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 53 | --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); 54 | --bs-body-font-family: var(--bs-font-sans-serif); 55 | --bs-body-font-size: 1rem; 56 | --bs-body-font-weight: 400; 57 | --bs-body-line-height: 1.5; 58 | --bs-body-color: #212529; 59 | --bs-body-bg: #fff; 60 | } 61 | 62 | *, 63 | *::before, 64 | *::after { 65 | box-sizing: border-box; 66 | } 67 | 68 | @media (prefers-reduced-motion: no-preference) { 69 | :root { 70 | scroll-behavior: smooth; 71 | } 72 | } 73 | 74 | body { 75 | margin: 0; 76 | font-family: var(--bs-body-font-family); 77 | font-size: var(--bs-body-font-size); 78 | font-weight: var(--bs-body-font-weight); 79 | line-height: var(--bs-body-line-height); 80 | color: var(--bs-body-color); 81 | text-align: var(--bs-body-text-align); 82 | background-color: var(--bs-body-bg); 83 | -webkit-text-size-adjust: 100%; 84 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 85 | } 86 | 87 | hr { 88 | margin: 1rem 0; 89 | color: inherit; 90 | background-color: currentColor; 91 | border: 0; 92 | opacity: 0.25; 93 | } 94 | 95 | hr:not([size]) { 96 | height: 1px; 97 | } 98 | 99 | h6, h5, h4, h3, h2, h1 { 100 | margin-top: 0; 101 | margin-bottom: 0.5rem; 102 | font-weight: 500; 103 | line-height: 1.2; 104 | } 105 | 106 | h1 { 107 | font-size: calc(1.375rem + 1.5vw); 108 | } 109 | @media (min-width: 1200px) { 110 | h1 { 111 | font-size: 2.5rem; 112 | } 113 | } 114 | 115 | h2 { 116 | font-size: calc(1.325rem + 0.9vw); 117 | } 118 | @media (min-width: 1200px) { 119 | h2 { 120 | font-size: 2rem; 121 | } 122 | } 123 | 124 | h3 { 125 | font-size: calc(1.3rem + 0.6vw); 126 | } 127 | @media (min-width: 1200px) { 128 | h3 { 129 | font-size: 1.75rem; 130 | } 131 | } 132 | 133 | h4 { 134 | font-size: calc(1.275rem + 0.3vw); 135 | } 136 | @media (min-width: 1200px) { 137 | h4 { 138 | font-size: 1.5rem; 139 | } 140 | } 141 | 142 | h5 { 143 | font-size: 1.25rem; 144 | } 145 | 146 | h6 { 147 | font-size: 1rem; 148 | } 149 | 150 | p { 151 | margin-top: 0; 152 | margin-bottom: 1rem; 153 | } 154 | 155 | abbr[title], 156 | abbr[data-bs-original-title] { 157 | -webkit-text-decoration: underline dotted; 158 | text-decoration: underline dotted; 159 | cursor: help; 160 | -webkit-text-decoration-skip-ink: none; 161 | text-decoration-skip-ink: none; 162 | } 163 | 164 | address { 165 | margin-bottom: 1rem; 166 | font-style: normal; 167 | line-height: inherit; 168 | } 169 | 170 | ol, 171 | ul { 172 | padding-left: 2rem; 173 | } 174 | 175 | ol, 176 | ul, 177 | dl { 178 | margin-top: 0; 179 | margin-bottom: 1rem; 180 | } 181 | 182 | ol ol, 183 | ul ul, 184 | ol ul, 185 | ul ol { 186 | margin-bottom: 0; 187 | } 188 | 189 | dt { 190 | font-weight: 700; 191 | } 192 | 193 | dd { 194 | margin-bottom: 0.5rem; 195 | margin-left: 0; 196 | } 197 | 198 | blockquote { 199 | margin: 0 0 1rem; 200 | } 201 | 202 | b, 203 | strong { 204 | font-weight: bolder; 205 | } 206 | 207 | small { 208 | font-size: 0.875em; 209 | } 210 | 211 | mark { 212 | padding: 0.2em; 213 | background-color: #fcf8e3; 214 | } 215 | 216 | sub, 217 | sup { 218 | position: relative; 219 | font-size: 0.75em; 220 | line-height: 0; 221 | vertical-align: baseline; 222 | } 223 | 224 | sub { 225 | bottom: -0.25em; 226 | } 227 | 228 | sup { 229 | top: -0.5em; 230 | } 231 | 232 | a { 233 | color: #0d6efd; 234 | text-decoration: underline; 235 | } 236 | a:hover { 237 | color: #0a58ca; 238 | } 239 | 240 | a:not([href]):not([class]), a:not([href]):not([class]):hover { 241 | color: inherit; 242 | text-decoration: none; 243 | } 244 | 245 | pre, 246 | code, 247 | kbd, 248 | samp { 249 | font-family: var(--bs-font-monospace); 250 | font-size: 1em; 251 | direction: ltr /* rtl:ignore */; 252 | unicode-bidi: bidi-override; 253 | } 254 | 255 | pre { 256 | display: block; 257 | margin-top: 0; 258 | margin-bottom: 1rem; 259 | overflow: auto; 260 | font-size: 0.875em; 261 | } 262 | pre code { 263 | font-size: inherit; 264 | color: inherit; 265 | word-break: normal; 266 | } 267 | 268 | code { 269 | font-size: 0.875em; 270 | color: #d63384; 271 | word-wrap: break-word; 272 | } 273 | a > code { 274 | color: inherit; 275 | } 276 | 277 | kbd { 278 | padding: 0.2rem 0.4rem; 279 | font-size: 0.875em; 280 | color: #fff; 281 | background-color: #212529; 282 | border-radius: 0.2rem; 283 | } 284 | kbd kbd { 285 | padding: 0; 286 | font-size: 1em; 287 | font-weight: 700; 288 | } 289 | 290 | figure { 291 | margin: 0 0 1rem; 292 | } 293 | 294 | img, 295 | svg { 296 | vertical-align: middle; 297 | } 298 | 299 | table { 300 | caption-side: bottom; 301 | border-collapse: collapse; 302 | } 303 | 304 | caption { 305 | padding-top: 0.5rem; 306 | padding-bottom: 0.5rem; 307 | color: #6c757d; 308 | text-align: left; 309 | } 310 | 311 | th { 312 | text-align: inherit; 313 | text-align: -webkit-match-parent; 314 | } 315 | 316 | thead, 317 | tbody, 318 | tfoot, 319 | tr, 320 | td, 321 | th { 322 | border-color: inherit; 323 | border-style: solid; 324 | border-width: 0; 325 | } 326 | 327 | label { 328 | display: inline-block; 329 | } 330 | 331 | button { 332 | border-radius: 0; 333 | } 334 | 335 | button:focus:not(:focus-visible) { 336 | outline: 0; 337 | } 338 | 339 | input, 340 | button, 341 | select, 342 | optgroup, 343 | textarea { 344 | margin: 0; 345 | font-family: inherit; 346 | font-size: inherit; 347 | line-height: inherit; 348 | } 349 | 350 | button, 351 | select { 352 | text-transform: none; 353 | } 354 | 355 | [role=button] { 356 | cursor: pointer; 357 | } 358 | 359 | select { 360 | word-wrap: normal; 361 | } 362 | select:disabled { 363 | opacity: 1; 364 | } 365 | 366 | [list]::-webkit-calendar-picker-indicator { 367 | display: none; 368 | } 369 | 370 | button, 371 | [type=button], 372 | [type=reset], 373 | [type=submit] { 374 | -webkit-appearance: button; 375 | } 376 | button:not(:disabled), 377 | [type=button]:not(:disabled), 378 | [type=reset]:not(:disabled), 379 | [type=submit]:not(:disabled) { 380 | cursor: pointer; 381 | } 382 | 383 | ::-moz-focus-inner { 384 | padding: 0; 385 | border-style: none; 386 | } 387 | 388 | textarea { 389 | resize: vertical; 390 | } 391 | 392 | fieldset { 393 | min-width: 0; 394 | padding: 0; 395 | margin: 0; 396 | border: 0; 397 | } 398 | 399 | legend { 400 | float: left; 401 | width: 100%; 402 | padding: 0; 403 | margin-bottom: 0.5rem; 404 | font-size: calc(1.275rem + 0.3vw); 405 | line-height: inherit; 406 | } 407 | @media (min-width: 1200px) { 408 | legend { 409 | font-size: 1.5rem; 410 | } 411 | } 412 | legend + * { 413 | clear: left; 414 | } 415 | 416 | ::-webkit-datetime-edit-fields-wrapper, 417 | ::-webkit-datetime-edit-text, 418 | ::-webkit-datetime-edit-minute, 419 | ::-webkit-datetime-edit-hour-field, 420 | ::-webkit-datetime-edit-day-field, 421 | ::-webkit-datetime-edit-month-field, 422 | ::-webkit-datetime-edit-year-field { 423 | padding: 0; 424 | } 425 | 426 | ::-webkit-inner-spin-button { 427 | height: auto; 428 | } 429 | 430 | [type=search] { 431 | outline-offset: -2px; 432 | -webkit-appearance: textfield; 433 | } 434 | 435 | /* rtl:raw: 436 | [type="tel"], 437 | [type="url"], 438 | [type="email"], 439 | [type="number"] { 440 | direction: ltr; 441 | } 442 | */ 443 | ::-webkit-search-decoration { 444 | -webkit-appearance: none; 445 | } 446 | 447 | ::-webkit-color-swatch-wrapper { 448 | padding: 0; 449 | } 450 | 451 | ::-webkit-file-upload-button { 452 | font: inherit; 453 | } 454 | 455 | ::file-selector-button { 456 | font: inherit; 457 | } 458 | 459 | ::-webkit-file-upload-button { 460 | font: inherit; 461 | -webkit-appearance: button; 462 | } 463 | 464 | output { 465 | display: inline-block; 466 | } 467 | 468 | iframe { 469 | border: 0; 470 | } 471 | 472 | summary { 473 | display: list-item; 474 | cursor: pointer; 475 | } 476 | 477 | progress { 478 | vertical-align: baseline; 479 | } 480 | 481 | [hidden] { 482 | display: none !important; 483 | } 484 | 485 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /static/bootstrap/css/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.3 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /static/bootstrap/css/bootstrap-reboot.min.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../scss/bootstrap-reboot.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap-reboot.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss"],"names":[],"mappings":"AAAA;;;;;;ACAA,MAQI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EACA,oBAAA,EAAA,CAAA,EAAA,CAAA,GACA,iBAAA,GAAA,CAAA,GAAA,CAAA,IAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAQA,sBAAA,0BACA,oBAAA,KACA,sBAAA,IACA,sBAAA,IACA,gBAAA,QAIA,aAAA,KCnCF,ECgDA,QADA,SD5CE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BEmPI,UAAA,yBFjPJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YAUF,GACE,OAAA,KAAA,EACA,MAAA,QACA,iBAAA,aACA,OAAA,EACA,QAAA,IAGF,eACE,OAAA,IAUF,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IAIF,GEwMQ,UAAA,uBAlKJ,0BFtCJ,GE+MQ,UAAA,QF1MR,GEmMQ,UAAA,sBAlKJ,0BFjCJ,GE0MQ,UAAA,MFrMR,GE8LQ,UAAA,oBAlKJ,0BF5BJ,GEqMQ,UAAA,SFhMR,GEyLQ,UAAA,sBAlKJ,0BFvBJ,GEgMQ,UAAA,QF3LR,GEgLM,UAAA,QF3KN,GE2KM,UAAA,KFhKN,EACE,WAAA,EACA,cAAA,KCoBF,6BDTA,YAEE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GCKA,GDHE,aAAA,KCSF,GDNA,GCKA,GDFE,WAAA,EACA,cAAA,KAGF,MCMA,MACA,MAFA,MDDE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,ECLA,ODOE,YAAA,OAQF,ME4EM,UAAA,OFrEN,KACE,QAAA,KACA,iBAAA,QASF,ICnBA,IDqBE,SAAA,SEwDI,UAAA,MFtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,QACA,gBAAA,UAEA,QACE,MAAA,QAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KCvBJ,KACA,ID6BA,IC5BA,KDgCE,YAAA,yBEcI,UAAA,IFZJ,UAAA,IACA,aAAA,cAOF,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KEAI,UAAA,OFKJ,SELI,UAAA,QFOF,MAAA,QACA,WAAA,OAIJ,KEZM,UAAA,OFcJ,MAAA,QACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,MAAA,MExBI,UAAA,OF0BJ,MAAA,KACA,iBAAA,QG7SE,cAAA,MHgTF,QACE,QAAA,EE/BE,UAAA,IFiCF,YAAA,IASJ,OACE,OAAA,EAAA,EAAA,KAMF,IChDA,IDkDE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,QACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBCvDF,MAGA,GAFA,MAGA,GDsDA,MCxDA,GD8DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,ECrEF,OD0EA,MCxEA,SADA,OAEA,SD4EE,OAAA,EACA,YAAA,QE9HI,UAAA,QFgIJ,YAAA,QAIF,OC3EA,OD6EE,eAAA,KAKF,cACE,OAAA,QAGF,OAGE,UAAA,OAGA,gBACE,QAAA,EAOJ,0CACE,QAAA,KCjFF,cACA,aACA,cDuFA,OAIE,mBAAA,OCvFF,6BACA,4BACA,6BDwFI,sBACE,OAAA,QAON,mBACE,QAAA,EACA,aAAA,KAKF,SACE,OAAA,SAUF,SACE,UAAA,EACA,QAAA,EACA,OAAA,EACA,OAAA,EAQF,OACE,MAAA,KACA,MAAA,KACA,QAAA,EACA,cAAA,MEnNM,UAAA,sBFsNN,YAAA,QExXE,0BFiXJ,OExMQ,UAAA,QFiNN,SACE,MAAA,KC/FJ,kCDsGA,uCCvGA,mCADA,+BAGA,oCAJA,6BAKA,mCD2GE,QAAA,EAGF,4BACE,OAAA,KASF,cACE,eAAA,KACA,mBAAA,UAmBF,4BACE,mBAAA,KAKF,+BACE,QAAA,EAMF,6BACE,KAAA,QADF,uBACE,KAAA,QAMF,6BACE,KAAA,QACA,mBAAA,OAKF,OACE,QAAA,aAKF,OACE,OAAA,EAOF,QACE,QAAA,UACA,OAAA,QAQF,SACE,eAAA,SAQF,SACE,QAAA","sourcesContent":["/*!\n * Bootstrap Reboot v5.1.3 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)\n */\n\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"root\";\n@import \"reboot\";\n",":root {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$variable-prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n font-size: var(--#{$variable-prefix}root-font-size);\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`