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
![]() |