Pamela Fox commited on
Commit
b4cc641
·
unverified ·
2 Parent(s): e6f8796 97e9491

Merge pull request #18 from pamelafox/enhance

Browse files
.devcontainer/devcontainer.json CHANGED
@@ -30,15 +30,19 @@
30
  ],
31
  "python.defaultInterpreterPath": "/usr/local/bin/python",
32
  "python.linting.enabled": true,
33
- "python.testing.unittestEnabled": false,
34
  "python.testing.pytestEnabled": true,
 
35
  "[python]": {
36
  "editor.formatOnSave": true,
37
  "editor.codeActionsOnSave": {
38
  "source.fixAll": true
39
  }
40
  },
41
- "python.formatting.provider": "black"
 
 
 
 
42
  },
43
 
44
  // Add the IDs of extensions you want installed when the container is created.
 
30
  ],
31
  "python.defaultInterpreterPath": "/usr/local/bin/python",
32
  "python.linting.enabled": true,
 
33
  "python.testing.pytestEnabled": true,
34
+ "python.testing.unittestEnabled": false,
35
  "[python]": {
36
  "editor.formatOnSave": true,
37
  "editor.codeActionsOnSave": {
38
  "source.fixAll": true
39
  }
40
  },
41
+ "python.formatting.provider": "black",
42
+ "files.exclude": {
43
+ "**/*.coverage": true,
44
+ ".ruff_cache": true
45
+ }
46
  },
47
 
48
  // Add the IDs of extensions you want installed when the container is created.
.devcontainer/docker-compose.yml CHANGED
@@ -7,7 +7,7 @@ services:
7
  dockerfile: .devcontainer/Dockerfile
8
  args:
9
  # [Choice] Python version: 3, 3.8, 3.7, 3.6
10
- IMAGE: python:3.10
11
  # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
12
  USER_UID: 1000
13
  USER_GID: 1000
 
7
  dockerfile: .devcontainer/Dockerfile
8
  args:
9
  # [Choice] Python version: 3, 3.8, 3.7, 3.6
10
+ IMAGE: python:3.11
11
  # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
12
  USER_UID: 1000
13
  USER_GID: 1000
.github/workflows/check.yaml CHANGED
@@ -40,5 +40,9 @@ jobs:
40
  SECRET_KEY: django-insecure-key-${{ github.run_id }}-${{ github.run_attempt }}
41
  run: |
42
  python src/manage.py collectstatic
43
- coverage run --source='.' src/manage.py test quizzes
44
- coverage report
 
 
 
 
 
40
  SECRET_KEY: django-insecure-key-${{ github.run_id }}-${{ github.run_attempt }}
41
  run: |
42
  python src/manage.py collectstatic
43
+ python3 -m pytest | tee pytest-coverage.txt
44
+ - name: Pytest coverage comment
45
+ uses: MishaKav/pytest-coverage-comment@main
46
+ with:
47
+ pytest-coverage-path: ./pytest-coverage.txt
48
+ junitxml-path: ./pytest.xml
.gitignore CHANGED
@@ -4,3 +4,5 @@ staticfiles/
4
  .coverage
5
  .env
6
  .azure
 
 
 
4
  .coverage
5
  .env
6
  .azure
7
+ pytest.xml
8
+ pytest-coverage.txt
.vscode/settings.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "python.testing.unittestArgs": [
3
+ "-v",
4
+ "-s",
5
+ "./src",
6
+ "-p",
7
+ "test*.py"
8
+ ],
9
+ "python.testing.pytestEnabled": false,
10
+ "python.testing.unittestEnabled": true
11
+ }
.vscode/tasks.json CHANGED
@@ -37,7 +37,7 @@
37
  "panel": "dedicated"
38
  }
39
  }, {
40
- "label": "Run migrations",
41
  "type": "shell",
42
  "command": "./src/manage.py migrate",
43
  "problemMatcher": [],
@@ -54,5 +54,14 @@
54
  "reveal": "always",
55
  "panel": "dedicated"
56
  }
 
 
 
 
 
 
 
 
 
57
  }]
58
  }
 
37
  "panel": "dedicated"
38
  }
39
  }, {
40
+ "label": "Apply migrations",
41
  "type": "shell",
42
  "command": "./src/manage.py migrate",
43
  "problemMatcher": [],
 
54
  "reveal": "always",
55
  "panel": "dedicated"
56
  }
57
+ }, {
58
+ "label": "Flush database",
59
+ "type": "shell",
60
+ "command": "./src/manage.py flush --noinput",
61
+ "problemMatcher": [],
62
+ "presentation": {
63
+ "reveal": "always",
64
+ "panel": "dedicated"
65
+ }
66
  }]
67
  }
infra/main.bicep CHANGED
@@ -66,17 +66,19 @@ module web 'core/host/appservice.bicep' = {
66
  tags: union(tags, { 'azd-service-name': 'web' })
67
  appServicePlanId: appServicePlan.outputs.id
68
  runtimeName: 'python'
69
- runtimeVersion: '3.10'
70
  scmDoBuildDuringDeployment: true
71
  ftpsState: 'Disabled'
72
  managedIdentity: true
73
  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'
74
  appSettings: {
75
  ADMIN_URL: 'admin${uniqueString(appServicePlan.outputs.id)}'
76
- DBHOST: postgresServerName
 
77
  DBNAME: postgresDatabaseName
78
  DBUSER: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminUser)'
79
  DBPASS: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminPassword)'
 
80
  SECRET_KEY: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=djangoSecretKey)'
81
  }
82
  }
 
66
  tags: union(tags, { 'azd-service-name': 'web' })
67
  appServicePlanId: appServicePlan.outputs.id
68
  runtimeName: 'python'
69
+ runtimeVersion: '3.11'
70
  scmDoBuildDuringDeployment: true
71
  ftpsState: 'Disabled'
72
  managedIdentity: true
73
  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'
74
  appSettings: {
75
  ADMIN_URL: 'admin${uniqueString(appServicePlan.outputs.id)}'
76
+ DBENGINE: 'django.db.backends.postgresql'
77
+ DBHOST: '${postgresServerName}.postgres.database.azure.com'
78
  DBNAME: postgresDatabaseName
79
  DBUSER: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminUser)'
80
  DBPASS: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminPassword)'
81
+ DBSSL: 'require'
82
  SECRET_KEY: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=djangoSecretKey)'
83
  }
84
  }
pyproject.toml CHANGED
@@ -12,3 +12,9 @@ exclude = '''
12
  | migrations
13
  )/
14
  '''
 
 
 
 
 
 
 
12
  | migrations
13
  )/
14
  '''
15
+
16
+ [tool.pytest.ini_options]
17
+ addopts = "-ra --cov=src --cov-report=term-missing:skip-covered --junitxml=pytest.xml"
18
+ pythonpath = ["src"]
19
+ python_files = ["tests.py"]
20
+ DJANGO_SETTINGS_MODULE = "quizsite.settings"
requirements-dev.txt CHANGED
@@ -3,3 +3,5 @@ black
3
  pre-commit
4
  ruff
5
  coverage
 
 
 
3
  pre-commit
4
  ruff
5
  coverage
6
+ pytest-django
7
+ pytest-cov
src/manage.py CHANGED
@@ -3,21 +3,11 @@
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 = "quizsite.production" if "WEBSITE_HOSTNAME" in os.environ else "quizsite.settings"
20
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
21
 
22
  try:
23
  from django.core.management import execute_from_command_line
 
3
  import os
4
  import sys
5
 
 
 
6
 
7
  def main():
8
  """Run administrative tasks."""
 
 
 
 
 
 
9
 
10
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "quizsite.settings")
 
 
11
 
12
  try:
13
  from django.core.management import execute_from_command_line
src/quizsite/production.py DELETED
@@ -1,25 +0,0 @@
1
- from .settings import * # noqa
2
- import os
3
-
4
- # Configure the domain name using the environment variable
5
- # that Azure automatically creates for us.
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"]
13
-
14
- # Configure Postgres database; the full username for PostgreSQL flexible server is
15
- # username (not @sever-name).
16
- DATABASES = {
17
- "default": {
18
- "ENGINE": "django.db.backends.postgresql",
19
- "NAME": os.environ["DBNAME"],
20
- "HOST": hostname + ".postgres.database.azure.com",
21
- "USER": os.environ["DBUSER"],
22
- "PASSWORD": os.environ["DBPASS"],
23
- "OPTIONS": {"sslmode": "require"},
24
- }
25
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/quizsite/settings.py CHANGED
@@ -13,25 +13,47 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
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.1/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
- 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')}",
34
- ]
 
 
 
 
 
 
 
 
 
 
35
 
36
  # Application definition
37
 
@@ -83,14 +105,16 @@ WSGI_APPLICATION = "quizsite.wsgi.application"
83
 
84
  DATABASES = {
85
  "default": {
86
- "ENGINE": "django.db.backends.postgresql_psycopg2",
87
- "NAME": os.environ["DBNAME"],
88
- "HOST": os.environ["DBHOST"],
89
- "USER": os.environ["DBUSER"],
90
- "PASSWORD": os.environ["DBPASS"],
 
91
  }
92
  }
93
 
 
94
  # Password validation
95
  # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
96
 
@@ -128,7 +152,11 @@ USE_TZ = True
128
  STATIC_URL = "static/"
129
 
130
  # https://whitenoise.evans.io/en/stable/django.html
131
- STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
 
 
 
 
132
  STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
133
 
134
  # Default primary key field type
 
13
  import os
14
  from pathlib import Path
15
 
16
+ import environ
17
+
18
+ env = environ.Env(
19
+ # set casting, default value
20
+ DEBUG=(bool, False),
21
+ DBENGINE=(str, "django.db.backends.postgresql_psycopg2"),
22
+ DBSSL=(str, "disable"),
23
+ ADMIN_URL=(str, "admin/"),
24
+ )
25
+
26
  # Build paths inside the project like this: BASE_DIR / 'subdir'.
27
  BASE_DIR = Path(__file__).resolve().parent.parent
28
 
29
+ # Take environment variables from .env file
30
+ environ.Env.read_env(os.path.join(BASE_DIR.parent, ".env"))
31
 
32
  # Quick-start development settings - unsuitable for production
33
  # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
34
 
35
  # SECURITY WARNING: keep the secret key used in production secret!
36
+ SECRET_KEY = env("SECRET_KEY")
37
 
38
  # SECURITY WARNING: don't run with debug turned on in production!
39
+ DEBUG = env("DEBUG")
40
+
41
+ # Configure the domain name using the environment variable
42
+ # that Azure automatically creates for us.
43
+ if env.get_value("WEBSITE_HOSTNAME", default=None):
44
+ ALLOWED_HOSTS = [os.environ["WEBSITE_HOSTNAME"]]
45
+ CSRF_TRUSTED_ORIGINS = ["https://" + os.environ["WEBSITE_HOSTNAME"]]
46
+ else:
47
+ ALLOWED_HOSTS = []
48
+ CSRF_TRUSTED_ORIGINS = [
49
+ "http://localhost:8000",
50
+ ]
51
+ if env.get_value("CODESPACE_NAME", default=None):
52
+ CSRF_TRUSTED_ORIGINS.append(
53
+ f"https://{env('CODESPACE_NAME')}-8000.{env('GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN')}"
54
+ )
55
+
56
+ ADMIN_URL = env("ADMIN_URL")
57
 
58
  # Application definition
59
 
 
105
 
106
  DATABASES = {
107
  "default": {
108
+ "ENGINE": env("DBENGINE"),
109
+ "NAME": env("DBNAME"),
110
+ "HOST": env("DBHOST"),
111
+ "USER": env("DBUSER"),
112
+ "PASSWORD": env("DBPASS"),
113
+ "OPTIONS": {"sslmode": env("DBSSL")},
114
  }
115
  }
116
 
117
+
118
  # Password validation
119
  # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
120
 
 
152
  STATIC_URL = "static/"
153
 
154
  # https://whitenoise.evans.io/en/stable/django.html
155
+ STORAGES = {
156
+ "staticfiles": {
157
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
158
+ },
159
+ }
160
  STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
161
 
162
  # Default primary key field type
src/quizsite/wsgi.py CHANGED
@@ -11,7 +11,6 @@ import os
11
 
12
  from django.core.wsgi import get_wsgi_application
13
 
14
- settings_module = "quizsite.production" if "WEBSITE_HOSTNAME" in os.environ else "quizsite.settings"
15
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
16
 
17
  application = get_wsgi_application()
 
11
 
12
  from django.core.wsgi import get_wsgi_application
13
 
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "quizsite.settings")
 
15
 
16
  application = get_wsgi_application()
src/quizzes/models.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  from django.contrib.postgres import fields
2
  from django.db import models
3
 
@@ -16,6 +18,9 @@ class Question(models.Model):
16
  def __str__(self):
17
  return self.prompt
18
 
 
 
 
19
 
20
  class Answer(models.Model):
21
  question = models.OneToOneField(Question, on_delete=models.CASCADE)
@@ -24,14 +29,17 @@ class Answer(models.Model):
24
  class Meta:
25
  abstract = True
26
 
 
 
 
 
 
 
27
 
28
  class FreeTextAnswer(Answer):
29
  case_sensitive = models.BooleanField(default=False)
30
 
31
- def __str__(self):
32
- return self.correct_answer
33
-
34
- def is_correct(self, user_answer):
35
  if not self.case_sensitive:
36
  return user_answer.lower() == self.correct_answer.lower()
37
  return user_answer == self.correct_answer
@@ -40,8 +48,5 @@ class FreeTextAnswer(Answer):
40
  class MultipleChoiceAnswer(Answer):
41
  choices = fields.ArrayField(models.CharField(max_length=200, blank=True))
42
 
43
- def __str__(self):
44
  return f"{self.correct_answer} from {self.choices}"
45
-
46
- def is_correct(self, user_answer):
47
- return user_answer == self.correct_answer
 
1
+ import typing
2
+
3
  from django.contrib.postgres import fields
4
  from django.db import models
5
 
 
18
  def __str__(self):
19
  return self.prompt
20
 
21
+ def get_answer(self) -> typing.Union["Answer", None]:
22
+ return getattr(self, "multiplechoiceanswer", None) or getattr(self, "freetextanswer", None)
23
+
24
 
25
  class Answer(models.Model):
26
  question = models.OneToOneField(Question, on_delete=models.CASCADE)
 
29
  class Meta:
30
  abstract = True
31
 
32
+ def __str__(self) -> str:
33
+ return self.correct_answer
34
+
35
+ def is_correct(self, user_answer) -> bool:
36
+ return user_answer == self.correct_answer
37
+
38
 
39
  class FreeTextAnswer(Answer):
40
  case_sensitive = models.BooleanField(default=False)
41
 
42
+ def is_correct(self, user_answer) -> bool:
 
 
 
43
  if not self.case_sensitive:
44
  return user_answer.lower() == self.correct_answer.lower()
45
  return user_answer == self.correct_answer
 
48
  class MultipleChoiceAnswer(Answer):
49
  choices = fields.ArrayField(models.CharField(max_length=200, blank=True))
50
 
51
+ def __str__(self) -> str:
52
  return f"{self.correct_answer} from {self.choices}"
 
 
 
src/quizzes/templates/quizzes/partial.html CHANGED
@@ -1,5 +1,13 @@
 
 
 
 
 
 
1
  {% if is_correct %}
2
  ✅ You got it!
3
  {% else %}
4
  ❌ Sorry! The correct answer is {{ correct_answer }}
5
  {% endif %}
 
 
 
1
+ {% if error %}
2
+ <div class="alert alert-danger" role="alert">
3
+ {{ error }}
4
+ </div>
5
+ {% else %}
6
+
7
  {% if is_correct %}
8
  ✅ You got it!
9
  {% else %}
10
  ❌ Sorry! The correct answer is {{ correct_answer }}
11
  {% endif %}
12
+
13
+ {% endif %}
src/quizzes/tests.py CHANGED
@@ -53,12 +53,12 @@ class IndexViewTests(TestCase):
53
  response = self.client.get(reverse("quizzes:index"))
54
  self.assertEqual(response.status_code, 200)
55
  self.assertContains(response, "No quizzes are available.")
56
- self.assertQuerysetEqual(response.context["quiz_list"], [])
57
 
58
  def test_one_quiz(self):
59
  quiz, _, _ = create_quiz()
60
  response = self.client.get(reverse("quizzes:index"))
61
- self.assertQuerysetEqual(
62
  response.context["quiz_list"],
63
  [quiz],
64
  )
 
53
  response = self.client.get(reverse("quizzes:index"))
54
  self.assertEqual(response.status_code, 200)
55
  self.assertContains(response, "No quizzes are available.")
56
+ self.assertQuerySetEqual(response.context["quiz_list"], [])
57
 
58
  def test_one_quiz(self):
59
  quiz, _, _ = create_quiz()
60
  response = self.client.get(reverse("quizzes:index"))
61
+ self.assertQuerySetEqual(
62
  response.context["quiz_list"],
63
  [quiz],
64
  )
src/quizzes/views.py CHANGED
@@ -36,7 +36,9 @@ def display_question(request, quiz_id, question_id):
36
 
37
  def grade_question(request, question_id):
38
  question = get_object_or_404(Question, pk=question_id)
39
- answer = getattr(question, "multiplechoiceanswer", None) or getattr(question, "freetextanswer")
 
 
40
  is_correct = answer.is_correct(request.POST.get("answer"))
41
  return render(
42
  request,
 
36
 
37
  def grade_question(request, question_id):
38
  question = get_object_or_404(Question, pk=question_id)
39
+ answer = question.get_answer()
40
+ if answer is None:
41
+ return render(request, "quizzes/partial.html", {"error": "Question must have an answer"}, status=422)
42
  is_correct = answer.is_correct(request.POST.get("answer"))
43
  return render(
44
  request,
src/requirements.txt CHANGED
@@ -2,3 +2,4 @@ Django==4.2
2
  psycopg2==2.9.6
3
  python-dotenv==1.0.0
4
  whitenoise[brotli]==6.4.0
 
 
2
  psycopg2==2.9.6
3
  python-dotenv==1.0.0
4
  whitenoise[brotli]==6.4.0
5
+ django-environ==0.10.0