Merge pull request #9 from pamelafox/secret-keyvault
Browse files- .gitattributes +3 -0
- .pre-commit-config.yaml +3 -5
- README.md +45 -16
- infra/core/database/postgresql/flexibleserver.bicep +28 -15
- infra/core/host/appservice.bicep +3 -13
- infra/main.bicep +71 -26
- infra/main.parameters.json +8 -2
- pyproject.toml +6 -6
- quizsite/production.py +2 -0
- quizsite/settings.py +3 -2
- quizsite/urls.py +3 -2
- quizzes/admin.py +2 -1
- quizzes/migrations/0001_initial.py +2 -5
- quizzes/migrations/0002_remove_question_answer_status_and_more.py +3 -8
- quizzes/models.py +1 -1
- quizzes/tests.py +1 -1
- quizzes/urls.py +2 -2
- quizzes/views.py +2 -2
- readme_diagram.png +0 -0
- readme_screenshot.png +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* text=auto eol=lf
|
| 2 |
+
*.{cmd,[cC][mM][dD]} text eol=crlf
|
| 3 |
+
*.{bat,[bB][aA][tT]} text eol=crlf
|
.pre-commit-config.yaml
CHANGED
|
@@ -1,17 +1,15 @@
|
|
| 1 |
repos:
|
| 2 |
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 3 |
-
rev:
|
| 4 |
hooks:
|
| 5 |
- id: check-yaml
|
| 6 |
- id: end-of-file-fixer
|
| 7 |
- id: trailing-whitespace
|
| 8 |
- repo: https://github.com/psf/black
|
| 9 |
-
rev:
|
| 10 |
hooks:
|
| 11 |
- id: black
|
| 12 |
-
args: ['--config=./pyproject.toml']
|
| 13 |
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
| 14 |
-
rev: v0.0.
|
| 15 |
hooks:
|
| 16 |
- id: ruff
|
| 17 |
-
exclude: '.venv/'
|
|
|
|
| 1 |
repos:
|
| 2 |
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 3 |
+
rev: v4.4.0
|
| 4 |
hooks:
|
| 5 |
- id: check-yaml
|
| 6 |
- id: end-of-file-fixer
|
| 7 |
- id: trailing-whitespace
|
| 8 |
- repo: https://github.com/psf/black
|
| 9 |
+
rev: 23.1.0
|
| 10 |
hooks:
|
| 11 |
- id: black
|
|
|
|
| 12 |
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
| 13 |
+
rev: v0.0.258
|
| 14 |
hooks:
|
| 15 |
- id: ruff
|
|
|
README.md
CHANGED
|
@@ -2,26 +2,42 @@
|
|
| 2 |
|
| 3 |
# Quizzes app
|
| 4 |
|
| 5 |
-
An example Django app that serves quizzes and lets people know how they scored.
|
| 6 |
-
Quizzes and their questions are stored in a PostGreSQL database.
|
| 7 |
-
There is no user authentication or per-user data stored.
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
-
then it's best to first [create a Python virtual environment](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) and activate that.
|
| 15 |
|
| 16 |
-
1.
|
|
|
|
|
|
|
| 17 |
|
| 18 |
```shell
|
| 19 |
python3 -m pip install -r requirements-dev.txt
|
| 20 |
```
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
|
| 26 |
```shell
|
| 27 |
python -c 'import secrets; print(secrets.token_hex())'
|
|
@@ -39,11 +55,11 @@ then it's best to first [create a Python virtual environment](https://docs.pytho
|
|
| 39 |
python3 manage.py runserver
|
| 40 |
```
|
| 41 |
|
| 42 |
-
5. Navigate to
|
| 43 |
|
| 44 |
### Admin
|
| 45 |
|
| 46 |
-
This app comes with the built-in Django admin.
|
| 47 |
|
| 48 |
1. Create a superuser:
|
| 49 |
|
|
@@ -86,7 +102,7 @@ azd up
|
|
| 86 |
python manage.py createsuperuser
|
| 87 |
```
|
| 88 |
|
| 89 |
-
|
| 90 |
|
| 91 |
This project includes a Github workflow for deploying the resources to Azure
|
| 92 |
on every push to main. That workflow requires several Azure-related authentication secrets
|
|
@@ -96,21 +112,34 @@ to be stored as Github action secrets. To set that up, run:
|
|
| 96 |
azd pipeline config
|
| 97 |
```
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
### Costs
|
| 100 |
|
| 101 |
Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage.
|
| 102 |
|
| 103 |
-
You can try the Azure pricing calculator for the resources:
|
| 104 |
|
| 105 |
- Azure App Service: Basic Tier with 1 CPU core, 1.75GB RAM. Pricing is hourly. [Pricing](https://azure.microsoft.com/pricing/details/app-service/linux/)
|
| 106 |
- PostgreSQL Flexible Server: Burstable Tier with 1 CPU core, 32GB storage. Pricing is hourly. [Pricing](https://azure.microsoft.com/pricing/details/postgresql/flexible-server/)
|
| 107 |
-
-
|
| 108 |
-
- Private DNS Zone: Pricing based on number of zones per region per month. [Pricing](https://azure.microsoft.com/en-in/pricing/details/dns/)
|
| 109 |
- Log analytics: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/)
|
| 110 |
|
| 111 |
⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use,
|
| 112 |
either by deleting the resource group in the Portal or running `azd down`.
|
| 113 |
|
|
|
|
| 114 |
## Getting help
|
| 115 |
|
| 116 |
If you're working with this project and running into issues, please post in **Discussions**.
|
|
|
|
| 2 |
|
| 3 |
# Quizzes app
|
| 4 |
|
| 5 |
+
An example Django app that serves quizzes and lets people know how they scored. Quizzes and their questions are stored in a PostgreSQL database. There is no user authentication or per-user data stored.
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+

|
| 8 |
+
|
| 9 |
+
The project is designed for deployment on Azure App Service with a PostgreSQL flexible server. See deployment instructions below.
|
| 10 |
+
|
| 11 |
+

|
| 12 |
+
|
| 13 |
+
The code is tested with `django.test`, linted with [ruff](https://github.com/charliermarsh/ruff), and formatted with [black](https://black.readthedocs.io/en/stable/). Code quality issues are all checked with both [pre-commit](https://pre-commit.com/) and Github actions.
|
| 14 |
+
|
| 15 |
+
## Opening the project
|
| 16 |
|
| 17 |
+
This project has [Dev Container support](https://code.visualstudio.com/docs/devcontainers/containers), so it will be be setup automatically if you open it in Github Codespaces or in local VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
|
| 18 |
|
| 19 |
+
If you're not using one of those options for opening the project, then you'll need to:
|
|
|
|
| 20 |
|
| 21 |
+
1. Create a [Python virtual environment](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) and activate it.
|
| 22 |
+
|
| 23 |
+
2. Install the requirements:
|
| 24 |
|
| 25 |
```shell
|
| 26 |
python3 -m pip install -r requirements-dev.txt
|
| 27 |
```
|
| 28 |
|
| 29 |
+
3. Install the pre-commit hooks:
|
| 30 |
+
|
| 31 |
+
```shell
|
| 32 |
+
pre-commit install
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
## Local development
|
| 36 |
+
|
| 37 |
|
| 38 |
+
1. Create an `.env` file using `.env.sample` as a guide. Set the value of `DBNAME` to the name of an existing database in your local PostgreSQL instance. Set the values of `DBHOST`, `DBUSER`, and `DBPASS` as appropriate for your local PostgreSQL instance. If you're in the devcontainer, copy the values exactly from `.env.sample`.
|
| 39 |
+
|
| 40 |
+
2. Fill in a secret value for `SECRET_KEY`. You can use this command to generate an appropriate value.
|
| 41 |
|
| 42 |
```shell
|
| 43 |
python -c 'import secrets; print(secrets.token_hex())'
|
|
|
|
| 55 |
python3 manage.py runserver
|
| 56 |
```
|
| 57 |
|
| 58 |
+
5. Navigate to the displayed URL to verify the website is working.
|
| 59 |
|
| 60 |
### Admin
|
| 61 |
|
| 62 |
+
This app comes with the built-in Django admin interface.
|
| 63 |
|
| 64 |
1. Create a superuser:
|
| 65 |
|
|
|
|
| 102 |
python manage.py createsuperuser
|
| 103 |
```
|
| 104 |
|
| 105 |
+
### CI/CD pipeline
|
| 106 |
|
| 107 |
This project includes a Github workflow for deploying the resources to Azure
|
| 108 |
on every push to main. That workflow requires several Azure-related authentication secrets
|
|
|
|
| 112 |
azd pipeline config
|
| 113 |
```
|
| 114 |
|
| 115 |
+
## Security
|
| 116 |
+
|
| 117 |
+
It is important to secure the databases in web applications to prevent unwanted data access.
|
| 118 |
+
This infrastructure uses the following mechanisms to secure the PostgreSQL database:
|
| 119 |
+
|
| 120 |
+
* Azure Firewall: The database is accessible only from other Azure IPs, not from public IPs. (Note that includes other customers using Azure).
|
| 121 |
+
* Admin Username: Unique string generated based on subscription ID and stored in Key Vault.
|
| 122 |
+
* Admin Password: Randomly generated and stored in Key Vault.
|
| 123 |
+
* PostgreSQL Version: Latest available on Azure, version 14, which includes security improvements.
|
| 124 |
+
|
| 125 |
+
⚠️ For even more security, consider using an Azure Virtual Network to connect the Web App to the Database.
|
| 126 |
+
See [the Django-on-Azure project](https://github.com/tonybaloney/django-on-azure) for example infrastructure files.
|
| 127 |
+
|
| 128 |
### Costs
|
| 129 |
|
| 130 |
Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage.
|
| 131 |
|
| 132 |
+
You can try the [Azure pricing calculator](https://azure.com/e/560b5f259111424daa7eb23c6848d164) for the resources:
|
| 133 |
|
| 134 |
- Azure App Service: Basic Tier with 1 CPU core, 1.75GB RAM. Pricing is hourly. [Pricing](https://azure.microsoft.com/pricing/details/app-service/linux/)
|
| 135 |
- PostgreSQL Flexible Server: Burstable Tier with 1 CPU core, 32GB storage. Pricing is hourly. [Pricing](https://azure.microsoft.com/pricing/details/postgresql/flexible-server/)
|
| 136 |
+
- Key Vault: Standard tier. Costs are per transaction, a few transactions are used on each deploy. [Pricing](https://azure.microsoft.com/pricing/details/key-vault/)
|
|
|
|
| 137 |
- Log analytics: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/)
|
| 138 |
|
| 139 |
⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use,
|
| 140 |
either by deleting the resource group in the Portal or running `azd down`.
|
| 141 |
|
| 142 |
+
|
| 143 |
## Getting help
|
| 144 |
|
| 145 |
If you're working with this project and running into issues, please post in **Discussions**.
|
infra/core/database/postgresql/flexibleserver.bicep
CHANGED
|
@@ -4,20 +4,19 @@ param tags object = {}
|
|
| 4 |
|
| 5 |
param sku object
|
| 6 |
param storage object
|
| 7 |
-
param delegatedSubnetResourceId string = ''
|
| 8 |
-
param privateDnsZoneArmResourceId string = ''
|
| 9 |
-
param privateDnsZoneLink object = {}
|
| 10 |
-
|
| 11 |
-
param databaseName string
|
| 12 |
param administratorLogin string
|
| 13 |
@secure()
|
| 14 |
param administratorLoginPassword string
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
// PostgreSQL version
|
| 17 |
-
@allowed(['11', '12', '13', '14', '15'])
|
| 18 |
param version string
|
| 19 |
|
| 20 |
-
|
|
|
|
| 21 |
location: location
|
| 22 |
tags: tags
|
| 23 |
name: name
|
|
@@ -27,25 +26,39 @@ resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-pr
|
|
| 27 |
administratorLogin: administratorLogin
|
| 28 |
administratorLoginPassword: administratorLoginPassword
|
| 29 |
storage: storage
|
| 30 |
-
network: union(
|
| 31 |
-
!empty(delegatedSubnetResourceId) ? { delegatedSubnetResourceId: delegatedSubnetResourceId } : {},
|
| 32 |
-
!empty(privateDnsZoneArmResourceId) ? {privateDnsZoneArmResourceId: privateDnsZoneArmResourceId } : {})
|
| 33 |
highAvailability: {
|
| 34 |
mode: 'Disabled'
|
| 35 |
}
|
| 36 |
}
|
| 37 |
|
| 38 |
-
resource database 'databases' = {
|
| 39 |
-
name:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
resource
|
| 43 |
-
name: '
|
| 44 |
properties: {
|
| 45 |
startIpAddress: '0.0.0.0'
|
| 46 |
endIpAddress: '0.0.0.0'
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
param sku object
|
| 6 |
param storage object
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
param administratorLogin string
|
| 8 |
@secure()
|
| 9 |
param administratorLoginPassword string
|
| 10 |
+
param databaseNames array = []
|
| 11 |
+
param allowAzureIPsFirewall bool = false
|
| 12 |
+
param allowAllIPsFirewall bool = false
|
| 13 |
+
param allowedSingleIPs array = []
|
| 14 |
|
| 15 |
// PostgreSQL version
|
|
|
|
| 16 |
param version string
|
| 17 |
|
| 18 |
+
// Latest official version 2022-12-01 does not have Bicep types available
|
| 19 |
+
resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = {
|
| 20 |
location: location
|
| 21 |
tags: tags
|
| 22 |
name: name
|
|
|
|
| 26 |
administratorLogin: administratorLogin
|
| 27 |
administratorLoginPassword: administratorLoginPassword
|
| 28 |
storage: storage
|
|
|
|
|
|
|
|
|
|
| 29 |
highAvailability: {
|
| 30 |
mode: 'Disabled'
|
| 31 |
}
|
| 32 |
}
|
| 33 |
|
| 34 |
+
resource database 'databases' = [for name in databaseNames: {
|
| 35 |
+
name: name
|
| 36 |
+
}]
|
| 37 |
+
|
| 38 |
+
resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) {
|
| 39 |
+
name: 'allow-all-IPs'
|
| 40 |
+
properties: {
|
| 41 |
+
startIpAddress: '0.0.0.0'
|
| 42 |
+
endIpAddress: '255.255.255.255'
|
| 43 |
+
}
|
| 44 |
}
|
| 45 |
|
| 46 |
+
resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) {
|
| 47 |
+
name: 'allow-all-azure-internal-IPs'
|
| 48 |
properties: {
|
| 49 |
startIpAddress: '0.0.0.0'
|
| 50 |
endIpAddress: '0.0.0.0'
|
| 51 |
}
|
| 52 |
}
|
| 53 |
|
| 54 |
+
resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: {
|
| 55 |
+
name: 'allow-single-${replace(ip, '.', '')}'
|
| 56 |
+
properties: {
|
| 57 |
+
startIpAddress: ip
|
| 58 |
+
endIpAddress: ip
|
| 59 |
+
}
|
| 60 |
+
}]
|
| 61 |
+
|
| 62 |
}
|
| 63 |
+
|
| 64 |
+
output POSTGRES_DOMAIN_NAME string = postgresServer.properties.fullyQualifiedDomainName
|
infra/core/host/appservice.bicep
CHANGED
|
@@ -33,10 +33,7 @@ param numberOfWorkers int = -1
|
|
| 33 |
param scmDoBuildDuringDeployment bool = false
|
| 34 |
param use32BitWorkerProcess bool = false
|
| 35 |
param ftpsState string = 'FtpsOnly'
|
| 36 |
-
|
| 37 |
-
// Microsoft.Web/sites/networkConfig
|
| 38 |
-
param subnetResourceId string = ''
|
| 39 |
-
param virtualNetwork object
|
| 40 |
|
| 41 |
resource appService 'Microsoft.Web/sites@2022-03-01' = {
|
| 42 |
name: name
|
|
@@ -49,11 +46,13 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = {
|
|
| 49 |
linuxFxVersion: linuxFxVersion
|
| 50 |
alwaysOn: alwaysOn
|
| 51 |
ftpsState: ftpsState
|
|
|
|
| 52 |
appCommandLine: appCommandLine
|
| 53 |
numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null
|
| 54 |
minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null
|
| 55 |
use32BitWorkerProcess: use32BitWorkerProcess
|
| 56 |
functionAppScaleLimit: functionAppScaleLimit != -1 ? functionAppScaleLimit : null
|
|
|
|
| 57 |
cors: {
|
| 58 |
allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins)
|
| 59 |
}
|
|
@@ -87,15 +86,6 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = {
|
|
| 87 |
configAppSettings
|
| 88 |
]
|
| 89 |
}
|
| 90 |
-
|
| 91 |
-
resource webappVnetConfig 'networkConfig' = if (!(empty(virtualNetwork))) {
|
| 92 |
-
name: 'virtualNetwork'
|
| 93 |
-
properties: {
|
| 94 |
-
subnetResourceId: subnetResourceId
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
dependsOn: empty(virtualNetwork) ? [] : [virtualNetwork]
|
| 99 |
}
|
| 100 |
|
| 101 |
resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) {
|
|
|
|
| 33 |
param scmDoBuildDuringDeployment bool = false
|
| 34 |
param use32BitWorkerProcess bool = false
|
| 35 |
param ftpsState string = 'FtpsOnly'
|
| 36 |
+
param healthCheckPath string = ''
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
resource appService 'Microsoft.Web/sites@2022-03-01' = {
|
| 39 |
name: name
|
|
|
|
| 46 |
linuxFxVersion: linuxFxVersion
|
| 47 |
alwaysOn: alwaysOn
|
| 48 |
ftpsState: ftpsState
|
| 49 |
+
minTlsVersion: '1.2'
|
| 50 |
appCommandLine: appCommandLine
|
| 51 |
numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null
|
| 52 |
minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null
|
| 53 |
use32BitWorkerProcess: use32BitWorkerProcess
|
| 54 |
functionAppScaleLimit: functionAppScaleLimit != -1 ? functionAppScaleLimit : null
|
| 55 |
+
healthCheckPath: healthCheckPath
|
| 56 |
cors: {
|
| 57 |
allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins)
|
| 58 |
}
|
|
|
|
| 86 |
configAppSettings
|
| 87 |
]
|
| 88 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) {
|
infra/main.bicep
CHANGED
|
@@ -9,9 +9,20 @@ param name string
|
|
| 9 |
@description('Primary location for all resources')
|
| 10 |
param location string
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
@secure()
|
| 13 |
@description('PostGreSQL Server administrator password')
|
| 14 |
-
param
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
var resourceToken = toLower(uniqueString(subscription().id, name, location))
|
| 17 |
var tags = { 'azd-env-name': name }
|
|
@@ -26,19 +37,7 @@ var prefix = '${name}-${resourceToken}'
|
|
| 26 |
|
| 27 |
var postgresServerName = '${prefix}-postgresql'
|
| 28 |
|
| 29 |
-
|
| 30 |
-
name: 'virtualnetwork'
|
| 31 |
-
scope: resourceGroup
|
| 32 |
-
params: {
|
| 33 |
-
name: '${prefix}-vnet'
|
| 34 |
-
location: location
|
| 35 |
-
tags: tags
|
| 36 |
-
postgresServerName: postgresServerName
|
| 37 |
-
}
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
var databaseName = 'django'
|
| 41 |
-
var databaseUser = 'django'
|
| 42 |
|
| 43 |
module postgresServer 'core/database/postgresql/flexibleserver.bicep' = {
|
| 44 |
name: 'postgresql'
|
|
@@ -54,13 +53,11 @@ module postgresServer 'core/database/postgresql/flexibleserver.bicep' = {
|
|
| 54 |
storage: {
|
| 55 |
storageSizeGB: 32
|
| 56 |
}
|
| 57 |
-
version: '
|
| 58 |
-
administratorLogin:
|
| 59 |
-
administratorLoginPassword:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
privateDnsZoneArmResourceId: virtualNetwork.outputs.privateDnsZoneId
|
| 63 |
-
privateDnsZoneLink: virtualNetwork.outputs.privateDnsZoneLink
|
| 64 |
}
|
| 65 |
}
|
| 66 |
|
|
@@ -78,13 +75,13 @@ module web 'core/host/appservice.bicep' = {
|
|
| 78 |
ftpsState: 'Disabled'
|
| 79 |
managedIdentity: true
|
| 80 |
appCommandLine: 'python manage.py migrate && gunicorn --workers 2 --threads 4 --timeout 60 --access-logfile \'-\' --error-logfile \'-\' --bind=0.0.0.0:8000 --chdir=/home/site/wwwroot quizsite.wsgi'
|
| 81 |
-
virtualNetwork: virtualNetwork
|
| 82 |
-
subnetResourceId: virtualNetwork.outputs.webSubnetId
|
| 83 |
appSettings: {
|
|
|
|
| 84 |
DBHOST: postgresServerName
|
| 85 |
-
DBNAME:
|
| 86 |
-
DBUSER:
|
| 87 |
-
DBPASS:
|
|
|
|
| 88 |
}
|
| 89 |
}
|
| 90 |
}
|
|
@@ -104,6 +101,54 @@ module appServicePlan 'core/host/appserviceplan.bicep' = {
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = {
|
| 108 |
name: 'loganalytics'
|
| 109 |
scope: resourceGroup
|
|
|
|
| 9 |
@description('Primary location for all resources')
|
| 10 |
param location string
|
| 11 |
|
| 12 |
+
@secure()
|
| 13 |
+
@description('PostGreSQL Server administrator username')
|
| 14 |
+
param postgresAdminUser string = 'admin${uniqueString(subscription().subscriptionId)}'
|
| 15 |
+
|
| 16 |
@secure()
|
| 17 |
@description('PostGreSQL Server administrator password')
|
| 18 |
+
param postgresAdminPassword string
|
| 19 |
+
|
| 20 |
+
@description('Id of the user or app to assign application roles')
|
| 21 |
+
param principalId string = ''
|
| 22 |
+
|
| 23 |
+
@secure()
|
| 24 |
+
@description('Django SECRET_KEY for cryptographic signing')
|
| 25 |
+
param djangoSecretKey string
|
| 26 |
|
| 27 |
var resourceToken = toLower(uniqueString(subscription().id, name, location))
|
| 28 |
var tags = { 'azd-env-name': name }
|
|
|
|
| 37 |
|
| 38 |
var postgresServerName = '${prefix}-postgresql'
|
| 39 |
|
| 40 |
+
var postgresDatabaseName = 'django'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
module postgresServer 'core/database/postgresql/flexibleserver.bicep' = {
|
| 43 |
name: 'postgresql'
|
|
|
|
| 53 |
storage: {
|
| 54 |
storageSizeGB: 32
|
| 55 |
}
|
| 56 |
+
version: '14'
|
| 57 |
+
administratorLogin: postgresAdminUser
|
| 58 |
+
administratorLoginPassword: postgresAdminPassword
|
| 59 |
+
databaseNames: [postgresDatabaseName]
|
| 60 |
+
allowAzureIPsFirewall: true
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
}
|
| 63 |
|
|
|
|
| 75 |
ftpsState: 'Disabled'
|
| 76 |
managedIdentity: true
|
| 77 |
appCommandLine: 'python manage.py migrate && gunicorn --workers 2 --threads 4 --timeout 60 --access-logfile \'-\' --error-logfile \'-\' --bind=0.0.0.0:8000 --chdir=/home/site/wwwroot quizsite.wsgi'
|
|
|
|
|
|
|
| 78 |
appSettings: {
|
| 79 |
+
ADMIN_URL: 'admin${uniqueString(appServicePlan.outputs.id)}'
|
| 80 |
DBHOST: postgresServerName
|
| 81 |
+
DBNAME: postgresDatabaseName
|
| 82 |
+
DBUSER: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminUser)'
|
| 83 |
+
DBPASS: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminPassword)'
|
| 84 |
+
SECRET_KEY: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=djangoSecretKey)'
|
| 85 |
}
|
| 86 |
}
|
| 87 |
}
|
|
|
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
| 104 |
+
module webKeyVaultAccess 'core/security/keyvault-access.bicep' = {
|
| 105 |
+
name: 'web-keyvault-access'
|
| 106 |
+
scope: resourceGroup
|
| 107 |
+
params: {
|
| 108 |
+
keyVaultName: keyVault.outputs.name
|
| 109 |
+
principalId: web.outputs.identityPrincipalId
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Store secrets in a keyvault
|
| 114 |
+
module keyVault './core/security/keyvault.bicep' = {
|
| 115 |
+
name: 'keyvault'
|
| 116 |
+
scope: resourceGroup
|
| 117 |
+
params: {
|
| 118 |
+
name: '${take(replace(prefix, '-', ''), 17)}-vault'
|
| 119 |
+
location: location
|
| 120 |
+
tags: tags
|
| 121 |
+
principalId: principalId
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
var secrets = [
|
| 126 |
+
{
|
| 127 |
+
name: 'djangoSecretKey'
|
| 128 |
+
value: djangoSecretKey
|
| 129 |
+
}
|
| 130 |
+
{
|
| 131 |
+
name: 'postgresAdminUser'
|
| 132 |
+
value: postgresAdminUser
|
| 133 |
+
}
|
| 134 |
+
{
|
| 135 |
+
name: 'postgresAdminPassword'
|
| 136 |
+
value: postgresAdminPassword
|
| 137 |
+
}
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
@batchSize(1)
|
| 141 |
+
module keyVaultSecrets './core/security/keyvault-secret.bicep' = [for secret in secrets: {
|
| 142 |
+
name: 'keyvault-secret-${secret.name}'
|
| 143 |
+
scope: resourceGroup
|
| 144 |
+
params: {
|
| 145 |
+
keyVaultName: keyVault.outputs.name
|
| 146 |
+
name: secret.name
|
| 147 |
+
secretValue: secret.value
|
| 148 |
+
}
|
| 149 |
+
}]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = {
|
| 153 |
name: 'loganalytics'
|
| 154 |
scope: resourceGroup
|
infra/main.parameters.json
CHANGED
|
@@ -8,8 +8,14 @@
|
|
| 8 |
"location": {
|
| 9 |
"value": "${AZURE_LOCATION}"
|
| 10 |
},
|
| 11 |
-
"
|
| 12 |
-
"value": "$
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
}
|
| 15 |
}
|
|
|
|
| 8 |
"location": {
|
| 9 |
"value": "${AZURE_LOCATION}"
|
| 10 |
},
|
| 11 |
+
"principalId": {
|
| 12 |
+
"value": "${AZURE_PRINCIPAL_ID}"
|
| 13 |
+
},
|
| 14 |
+
"postgresAdminPassword": {
|
| 15 |
+
"value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} postgresAdminPassword)"
|
| 16 |
+
},
|
| 17 |
+
"djangoSecretKey": {
|
| 18 |
+
"value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} djangoSecretKey)"
|
| 19 |
}
|
| 20 |
}
|
| 21 |
}
|
pyproject.toml
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
[tool.black]
|
|
|
|
| 2 |
line-length = 120
|
| 3 |
-
target-version = ['py39']
|
| 4 |
exclude = '''
|
| 5 |
/(
|
| 6 |
| \.venv
|
| 7 |
| migrations
|
| 8 |
)/
|
| 9 |
-
|
| 10 |
'''
|
| 11 |
-
|
| 12 |
-
[tool.ruff]
|
| 13 |
-
line-length = 120
|
| 14 |
-
ignore = ['D203']
|
|
|
|
| 1 |
+
[tool.ruff]
|
| 2 |
+
select = ["E", "F", "I", "UP"]
|
| 3 |
+
target-version = "py310"
|
| 4 |
+
line-length = 120
|
| 5 |
+
|
| 6 |
[tool.black]
|
| 7 |
+
target-version = ['py310']
|
| 8 |
line-length = 120
|
|
|
|
| 9 |
exclude = '''
|
| 10 |
/(
|
| 11 |
| \.venv
|
| 12 |
| migrations
|
| 13 |
)/
|
|
|
|
| 14 |
'''
|
|
|
|
|
|
|
|
|
|
|
|
quizsite/production.py
CHANGED
|
@@ -6,6 +6,7 @@ import os
|
|
| 6 |
ALLOWED_HOSTS = [os.environ["WEBSITE_HOSTNAME"]] if "WEBSITE_HOSTNAME" in os.environ else []
|
| 7 |
CSRF_TRUSTED_ORIGINS = ["https://" + os.environ["WEBSITE_HOSTNAME"]] if "WEBSITE_HOSTNAME" in os.environ else []
|
| 8 |
DEBUG = False
|
|
|
|
| 9 |
|
| 10 |
# DBHOST is only the server name, not the full URL
|
| 11 |
hostname = os.environ["DBHOST"]
|
|
@@ -19,5 +20,6 @@ DATABASES = {
|
|
| 19 |
"HOST": hostname + ".postgres.database.azure.com",
|
| 20 |
"USER": os.environ["DBUSER"],
|
| 21 |
"PASSWORD": os.environ["DBPASS"],
|
|
|
|
| 22 |
}
|
| 23 |
}
|
|
|
|
| 6 |
ALLOWED_HOSTS = [os.environ["WEBSITE_HOSTNAME"]] if "WEBSITE_HOSTNAME" in os.environ else []
|
| 7 |
CSRF_TRUSTED_ORIGINS = ["https://" + os.environ["WEBSITE_HOSTNAME"]] if "WEBSITE_HOSTNAME" in os.environ else []
|
| 8 |
DEBUG = False
|
| 9 |
+
ADMIN_URL = os.environ["ADMIN_URL"]
|
| 10 |
|
| 11 |
# DBHOST is only the server name, not the full URL
|
| 12 |
hostname = os.environ["DBHOST"]
|
|
|
|
| 20 |
"HOST": hostname + ".postgres.database.azure.com",
|
| 21 |
"USER": os.environ["DBUSER"],
|
| 22 |
"PASSWORD": os.environ["DBPASS"],
|
| 23 |
+
"OPTIONS": {"sslmode": "require"},
|
| 24 |
}
|
| 25 |
}
|
quizsite/settings.py
CHANGED
|
@@ -10,9 +10,8 @@ For the full list of settings and their values, see
|
|
| 10 |
https://docs.djangoproject.com/en/4.1/ref/settings/
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
from pathlib import Path
|
| 14 |
import os
|
| 15 |
-
|
| 16 |
|
| 17 |
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
| 18 |
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
@@ -27,6 +26,8 @@ SECRET_KEY = os.getenv("SECRET_KEY")
|
|
| 27 |
# SECURITY WARNING: don't run with debug turned on in production!
|
| 28 |
DEBUG = True
|
| 29 |
|
|
|
|
|
|
|
| 30 |
CSRF_TRUSTED_ORIGINS = [
|
| 31 |
"http://localhost:8000",
|
| 32 |
f"https://{os.getenv('CODESPACE_NAME')}-8000.{os.getenv('GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN')}",
|
|
|
|
| 10 |
https://docs.djangoproject.com/en/4.1/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
|
|
|
|
| 26 |
# SECURITY WARNING: don't run with debug turned on in production!
|
| 27 |
DEBUG = True
|
| 28 |
|
| 29 |
+
ADMIN_URL = "admin/"
|
| 30 |
+
|
| 31 |
CSRF_TRUSTED_ORIGINS = [
|
| 32 |
"http://localhost:8000",
|
| 33 |
f"https://{os.getenv('CODESPACE_NAME')}-8000.{os.getenv('GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN')}",
|
quizsite/urls.py
CHANGED
|
@@ -13,10 +13,11 @@ 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("
|
| 21 |
-
path(
|
| 22 |
]
|
|
|
|
| 13 |
1. Import the include() function: from django.urls import include, path
|
| 14 |
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
| 15 |
"""
|
| 16 |
+
from django.conf import settings
|
| 17 |
from django.contrib import admin
|
| 18 |
from django.urls import include, path
|
| 19 |
|
| 20 |
urlpatterns = [
|
| 21 |
+
path("", include("quizzes.urls")),
|
| 22 |
+
path(settings.ADMIN_URL, admin.site.urls),
|
| 23 |
]
|
quizzes/admin.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from django.contrib import admin
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
admin.site.register(Quiz)
|
| 5 |
|
|
|
|
| 1 |
from django.contrib import admin
|
| 2 |
+
|
| 3 |
+
from .models import FreeTextAnswer, MultipleChoiceAnswer, Question, Quiz
|
| 4 |
|
| 5 |
admin.site.register(Quiz)
|
| 6 |
|
quizzes/migrations/0001_initial.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
# Generated by Django 4.1.1 on 2022-09-14 18:17
|
| 2 |
|
| 3 |
import django.contrib.postgres.fields
|
| 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 = []
|
|
@@ -46,9 +45,7 @@ class Migration(migrations.Migration):
|
|
| 46 |
),
|
| 47 |
(
|
| 48 |
"quiz",
|
| 49 |
-
models.ForeignKey(
|
| 50 |
-
on_delete=django.db.models.deletion.CASCADE, to="quizzes.quiz"
|
| 51 |
-
),
|
| 52 |
),
|
| 53 |
],
|
| 54 |
),
|
|
|
|
| 1 |
# Generated by Django 4.1.1 on 2022-09-14 18:17
|
| 2 |
|
| 3 |
import django.contrib.postgres.fields
|
|
|
|
| 4 |
import django.db.models.deletion
|
| 5 |
+
from django.db import migrations, models
|
| 6 |
|
| 7 |
|
| 8 |
class Migration(migrations.Migration):
|
|
|
|
| 9 |
initial = True
|
| 10 |
|
| 11 |
dependencies = []
|
|
|
|
| 45 |
),
|
| 46 |
(
|
| 47 |
"quiz",
|
| 48 |
+
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="quizzes.quiz"),
|
|
|
|
|
|
|
| 49 |
),
|
| 50 |
],
|
| 51 |
),
|
quizzes/migrations/0002_remove_question_answer_status_and_more.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
# Generated by Django 4.1.1 on 2022-09-15 00:57
|
| 2 |
|
| 3 |
-
from django.db import migrations, models
|
| 4 |
import django.db.models.deletion
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
class Migration(migrations.Migration):
|
| 8 |
-
|
| 9 |
dependencies = [
|
| 10 |
("quizzes", "0001_initial"),
|
| 11 |
]
|
|
@@ -18,15 +17,11 @@ class Migration(migrations.Migration):
|
|
| 18 |
migrations.AlterField(
|
| 19 |
model_name="freetextanswer",
|
| 20 |
name="question",
|
| 21 |
-
field=models.OneToOneField(
|
| 22 |
-
on_delete=django.db.models.deletion.CASCADE, to="quizzes.question"
|
| 23 |
-
),
|
| 24 |
),
|
| 25 |
migrations.AlterField(
|
| 26 |
model_name="multiplechoiceanswer",
|
| 27 |
name="question",
|
| 28 |
-
field=models.OneToOneField(
|
| 29 |
-
on_delete=django.db.models.deletion.CASCADE, to="quizzes.question"
|
| 30 |
-
),
|
| 31 |
),
|
| 32 |
]
|
|
|
|
| 1 |
# Generated by Django 4.1.1 on 2022-09-15 00:57
|
| 2 |
|
|
|
|
| 3 |
import django.db.models.deletion
|
| 4 |
+
from django.db import migrations, models
|
| 5 |
|
| 6 |
|
| 7 |
class Migration(migrations.Migration):
|
|
|
|
| 8 |
dependencies = [
|
| 9 |
("quizzes", "0001_initial"),
|
| 10 |
]
|
|
|
|
| 17 |
migrations.AlterField(
|
| 18 |
model_name="freetextanswer",
|
| 19 |
name="question",
|
| 20 |
+
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="quizzes.question"),
|
|
|
|
|
|
|
| 21 |
),
|
| 22 |
migrations.AlterField(
|
| 23 |
model_name="multiplechoiceanswer",
|
| 24 |
name="question",
|
| 25 |
+
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="quizzes.question"),
|
|
|
|
|
|
|
| 26 |
),
|
| 27 |
]
|
quizzes/models.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
from django.db import models
|
| 2 |
from django.contrib.postgres import fields
|
|
|
|
| 3 |
|
| 4 |
|
| 5 |
class Quiz(models.Model):
|
|
|
|
|
|
|
| 1 |
from django.contrib.postgres import fields
|
| 2 |
+
from django.db import models
|
| 3 |
|
| 4 |
|
| 5 |
class Quiz(models.Model):
|
quizzes/tests.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from django.test import TestCase
|
| 2 |
from django.urls import reverse
|
| 3 |
|
| 4 |
-
from .models import
|
| 5 |
|
| 6 |
|
| 7 |
def create_quiz():
|
|
|
|
| 1 |
from django.test import TestCase
|
| 2 |
from django.urls import reverse
|
| 3 |
|
| 4 |
+
from .models import FreeTextAnswer, MultipleChoiceAnswer, Question, Quiz
|
| 5 |
|
| 6 |
|
| 7 |
def create_quiz():
|
quizzes/urls.py
CHANGED
|
@@ -6,7 +6,7 @@ app_name = "quizzes"
|
|
| 6 |
|
| 7 |
urlpatterns = [
|
| 8 |
path("", views.IndexView.as_view(), name="index"),
|
| 9 |
-
path("
|
| 10 |
-
path("
|
| 11 |
path("questions/<int:question_id>/grade/", views.grade_question, name="grade_question"),
|
| 12 |
]
|
|
|
|
| 6 |
|
| 7 |
urlpatterns = [
|
| 8 |
path("", views.IndexView.as_view(), name="index"),
|
| 9 |
+
path("quizzes/<int:quiz_id>/", views.display_quiz, name="display_quiz"),
|
| 10 |
+
path("quizzes/<int:quiz_id>/questions/<int:question_id>", views.display_question, name="display_question"),
|
| 11 |
path("questions/<int:question_id>/grade/", views.grade_question, name="grade_question"),
|
| 12 |
]
|
quizzes/views.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
from django.shortcuts import get_object_or_404,
|
| 2 |
from django.urls import reverse
|
| 3 |
from django.views import generic
|
| 4 |
|
| 5 |
-
from .models import
|
| 6 |
|
| 7 |
|
| 8 |
class IndexView(generic.ListView):
|
|
|
|
| 1 |
+
from django.shortcuts import get_object_or_404, redirect, render
|
| 2 |
from django.urls import reverse
|
| 3 |
from django.views import generic
|
| 4 |
|
| 5 |
+
from .models import Question, Quiz
|
| 6 |
|
| 7 |
|
| 8 |
class IndexView(generic.ListView):
|
readme_diagram.png
ADDED
|
readme_screenshot.png
ADDED
|