Skip to content

Commit 468594a

Browse files
feat: build job application form from harvest v3 questions
1 parent 0b385ee commit 468594a

5 files changed

Lines changed: 264 additions & 48 deletions

File tree

templates/careers/job-detail.html

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
"name": "Canonical",
2323
"value": {{ job.id | string | tojson }}
2424
},
25+
{% if job.first_published_at %}
2526
"datePosted" : {{ job.first_published_at | tojson }},
27+
{% endif %}
2628
"employmentType" : "FULL_TIME",
2729
{% if job.is_remote %}
2830
"jobLocationType": "TELECOMMUTE",
@@ -79,38 +81,39 @@
7981
<div class="col-6">
8082
<hr class="p-rule"/>
8183
<h3 class="p-heading--5">Apply for this role</h3>
84+
{% macro question_help(question, allow_links=False) %}
85+
{%- if question.description %}
86+
<p class="p-form-help-text" id="{{ question.submission_name }}_help">{% if allow_links and 'a href' in question.description %}{{ question.description | safe }}{% else %}{{ question.description | striptags }}{% endif %}</p>
87+
{%- endif %}
88+
{% endmacro %}
8289
<form id="job-apply-form" action="/careers/{{ job.id }}" method="POST" enctype="multipart/form-data" class="js-roles-list--form" data-ga-submit-category="Form" data-ga-submit-action="job application: {{ job.id }}" data-ga-submit-label="Submit Application" >
8390
<fieldset class="u-border-none u-margin-bottom-0 u-padding-0">
8491
<p class="required-legend u-align-text--right ">Required</p>
8592
<input type="hidden" name="id" value="{{ job.id }}" />
86-
{% if job.questions %}
87-
{% for question in job.questions %}
88-
<label for="{{ question.name }}" class="{% if question.required %}is-required{% endif %}">{{ question.label }}</label>
89-
{% if question.answer_type == "short_text" %}
90-
<input id="{{ question.name }}" name="{{ question.name }}" {% if question.description %}aria-describedby="{{ question.name }}_help"{% endif %} type="text" {% if question.required %}required{% endif %} maxlength="255"/>
91-
{% if question.description %}<p class="p-form-help-text" id="{{ question.name }}_help">{{ question.description | striptags }}</p>{% endif %}
92-
{% elif question.answer_type == "attachment" %}
93-
<input id="{{ question.name }}" name="{{ question.name }}" {% if question.description %}aria-describedby="{{ question.name }}_help"{% endif %} type="file" {% if question.required %}required{% endif %} accept=".pdf, .doc, .docx, .txt, .rtf"/>
94-
{% if question.description %}<p class="p-form-help-text" id="{{ question.name }}_help">{{ question.description | striptags }}</p>{% endif %}
95-
{% elif question.answer_type == "long_text" %}
96-
<textarea id="{{ question.name }}" name="{{ question.name }}" {% if question.description %}aria-describedby="{{ question.name }}_help"{% endif %} type="textarea" {% if question.required %}required{% endif %}></textarea>
97-
{% if question.description %}<p class="p-form-help-text" id="{{ question.name }}_help">{{ question.description | striptags }}</p>{% endif %}
98-
{% elif question.answer_type == "single_select" or question.answer_type == "boolean" %}
99-
<select name="{{ question.name }}" id="{{ question.name }}" {% if question.description %}aria-describedby="{{ question.name }}_help"{% endif %} {% if question.required %}required{% endif %}>
100-
<option value="" disabled="disabled" selected="">Select an option</option>
101-
{% for answer_option in question.get("values",[]) %}
102-
<option value="{{ answer_option.value }}">{{ answer_option.label }}</option>
103-
{% endfor %}
104-
</select>
105-
{% if question.description %}<p class="p-form-help-text" id="{{ question.name }}_help">{% if 'a href' in question.description %}{{ question.description | safe }}{% else %}{{ question.description | striptags }}{% endif %}</p>{% endif %}
106-
{% elif question.answer_type == "multi_value_multi_select" or question.answer_type == "multi_select" %}
107-
<select name="{{ question.name }}" id="{{ question.name }}" {% if question.description %}aria-describedby="{{ question.name }}_help"{% endif %} multiple="" {% if question.required %}required{% endif %}>
93+
{% if job.application_questions %}
94+
{% for question in job.application_questions %}
95+
<label for="{{ question.submission_name }}" class="{% if question.required %}is-required{% endif %}">{{ question.label }}</label>
96+
{% if question.control_type == "text" %}
97+
<input id="{{ question.submission_name }}" name="{{ question.submission_name }}" {% if question.description %}aria-describedby="{{ question.submission_name }}_help"{% endif %} type="text" {% if question.required %}required{% endif %} maxlength="255"/>
98+
{{ question_help(question) }}
99+
{% elif question.control_type == "file" %}
100+
<input id="{{ question.submission_name }}" name="{{ question.submission_name }}" {% if question.description %}aria-describedby="{{ question.submission_name }}_help"{% endif %} type="file" {% if question.required %}required{% endif %} accept=".pdf, .doc, .docx, .txt, .rtf"/>
101+
{{ question_help(question) }}
102+
{% elif question.control_type == "textarea" %}
103+
<textarea id="{{ question.submission_name }}" name="{{ question.submission_name }}" {% if question.description %}aria-describedby="{{ question.submission_name }}_help"{% endif %} {% if question.required %}required{% endif %}></textarea>
104+
{{ question_help(question) }}
105+
{% elif question.control_type == "select" %}
106+
<select name="{{ question.submission_name }}" id="{{ question.submission_name }}" {% if question.description %}aria-describedby="{{ question.submission_name }}_help"{% endif %} {% if question.multiple %}multiple=""{% endif %} {% if question.required %}required{% endif %}>
107+
{% if question.multiple %}
108108
<option value="" disabled="disabled">Select...</option>
109-
{% for answer_option in question.get("values",[]) %}
109+
{% else %}
110+
<option value="" disabled="disabled" selected="">Select an option</option>
111+
{% endif %}
112+
{% for answer_option in question.options %}
110113
<option value="{{ answer_option.value }}">{{ answer_option.label }}</option>
111114
{% endfor %}
112115
</select>
113-
{% if question.description %}<p class="p-form-help-text" id="{{ question.name }}_help">{{ question.description | safe }}</p>{% endif %}
116+
{{ question_help(question, allow_links=True) }}
114117
{% endif %}
115118
{% endfor %}
116119
{% endif %}

tests/test_app.py

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,138 @@
11
import unittest
2-
from webapp.app import app, is_remote
2+
from webapp.app import app, build_job_application_questions, is_remote
33
from unittest.mock import MagicMock, patch
44
from webapp.app import job_details
55

66

77
class TestIsRemote(unittest.TestCase):
88
def test_is_remote(self):
99
self.assertTrue(is_remote({"location": None}))
10-
self.assertTrue(is_remote({"location": {"name": None}}))
11-
self.assertTrue(is_remote({"location": {"name": "Home Based - EMEA"}}))
12-
self.assertFalse(is_remote({"location": {"name": "Paris, France"}}))
10+
self.assertTrue(is_remote({"location": "Home Based - EMEA"}))
11+
self.assertFalse(is_remote({"location": "Paris, France"}))
12+
13+
14+
class TestJobApplicationQuestions(unittest.TestCase):
15+
def test_builds_form_model_from_v3_questions(self):
16+
questions = [
17+
{
18+
"answer_type": "short_text",
19+
"description": None,
20+
"label": "First name",
21+
"name": "first_name",
22+
"private": False,
23+
"required": True,
24+
},
25+
{
26+
"answer_type": "short_text",
27+
"description": "Include the country code",
28+
"label": "Phone Number",
29+
"name": "phone_number",
30+
"private": True,
31+
"required": False,
32+
},
33+
{
34+
"answer_type": "boolean",
35+
"description": None,
36+
"label": "Can you travel?",
37+
"name": "question_1",
38+
"options": [],
39+
"private": False,
40+
"required": True,
41+
},
42+
{
43+
"answer_type": "multi_select",
44+
"description": None,
45+
"label": "Regions",
46+
"name": "question_2",
47+
"options": [
48+
{"id": 10, "label": "Americas"},
49+
{"id": 20, "label": "EMEA"},
50+
],
51+
"private": False,
52+
"required": False,
53+
},
54+
{
55+
"answer_type": "hidden",
56+
"label": "Internal value",
57+
"name": "internal_value",
58+
},
59+
]
60+
61+
result = build_job_application_questions(questions)
62+
63+
self.assertEqual(
64+
[question["submission_name"] for question in result],
65+
["first_name", "phone", "question_1", "question_2[]"],
66+
)
67+
self.assertEqual(result[1]["label"], "Phone")
68+
self.assertTrue(result[1]["private"])
69+
self.assertEqual(
70+
result[2]["options"],
71+
[
72+
{"value": 0, "label": "No"},
73+
{"value": 1, "label": "Yes"},
74+
],
75+
)
76+
self.assertTrue(result[3]["multiple"])
77+
self.assertEqual(
78+
result[3]["options"],
79+
[
80+
{"value": 10, "label": "Americas"},
81+
{"value": 20, "label": "EMEA"},
82+
],
83+
)
84+
85+
def test_rejects_unknown_v3_question_type(self):
86+
with self.assertRaisesRegex(ValueError, "unsupported Harvest V3"):
87+
build_job_application_questions(
88+
[
89+
{
90+
"answer_type": "future_type",
91+
"label": "Future question",
92+
"name": "question_3",
93+
}
94+
]
95+
)
96+
97+
def test_job_template_renders_form_model_and_omits_missing_date(self):
98+
job = {
99+
"active": True,
100+
"first_published_at": None,
101+
"id": 1234,
102+
"live": True,
103+
"questions": [
104+
{
105+
"answer_type": "multi_select",
106+
"description": None,
107+
"label": "Regions",
108+
"name": "question_2",
109+
"options": [
110+
{"id": 10, "label": "Americas"},
111+
{"id": 20, "label": "EMEA"},
112+
],
113+
"private": True,
114+
"required": True,
115+
}
116+
],
117+
"skills": [],
118+
"title": "Test role",
119+
}
120+
harvest = MagicMock()
121+
harvest.get_job_post.return_value = job
122+
greenhouse = MagicMock()
123+
greenhouse.get_vacancy.return_value = MagicMock(
124+
content="<p>Job content</p>",
125+
location="Home based - Worldwide",
126+
)
127+
128+
with app.test_request_context("/careers/1234/test-role"):
129+
response = job_details(MagicMock(), greenhouse, harvest, "1234")
130+
rendered = response.get_data(as_text=True)
131+
132+
self.assertIn('name="question_2[]"', rendered)
133+
self.assertIn('<option value="10">Americas</option>', rendered)
134+
self.assertIn('<option value="20">EMEA</option>', rendered)
135+
self.assertNotIn('"datePosted"', rendered)
13136

14137

15138
class TestCacheControlHeaders(unittest.TestCase):
@@ -99,43 +222,37 @@ def test_multi_location_string_overrides_harvest_location(self):
99222
harvest_job = {
100223
"active": True,
101224
"live": True,
102-
"location": {"name": "Home based - EMEA"},
225+
"location": "Home based - EMEA",
103226
}
104227
captured = self._run_job_details(
105228
harvest_job,
106229
"Home Based - Americas; Home based - EMEA",
107230
)
108231

109232
self.assertEqual(
110-
captured["job"]["location"]["name"],
233+
captured["job"]["location"],
111234
"Home Based - Americas; Home based - EMEA",
112235
)
113236

114237
def test_single_location_is_overridden_with_board_value(self):
115238
harvest_job = {
116239
"active": True,
117240
"live": True,
118-
"location": {"name": "Home based - EMEA"},
241+
"location": "Home based - EMEA",
119242
}
120243
captured = self._run_job_details(harvest_job, "Home Based - Americas")
121244

122-
self.assertEqual(
123-
captured["job"]["location"]["name"],
124-
"Home Based - Americas",
125-
)
245+
self.assertEqual(captured["job"]["location"], "Home Based - Americas")
126246

127-
def test_missing_board_location_keeps_harvest_location(self):
247+
def test_missing_board_location_is_preserved_as_unknown(self):
128248
harvest_job = {
129249
"active": True,
130250
"live": True,
131-
"location": {"name": "Home based - EMEA"},
132251
}
133252
captured = self._run_job_details(harvest_job, None)
134253

135-
self.assertEqual(
136-
captured["job"]["location"]["name"],
137-
"Home based - EMEA",
138-
)
254+
self.assertIsNone(captured["job"]["location"])
255+
self.assertTrue(captured["job"]["is_remote"])
139256

140257
def test_missing_harvest_location_does_not_error(self):
141258
harvest_job = {
@@ -148,4 +265,7 @@ def test_missing_harvest_location_does_not_error(self):
148265
"Home Based - Americas; Home based - EMEA",
149266
)
150267

151-
self.assertIsNone(captured["job"]["location"])
268+
self.assertEqual(
269+
captured["job"]["location"],
270+
"Home Based - Americas; Home based - EMEA",
271+
)

tests/test_greenhouse.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import requests
99
import json
10+
from werkzeug.datastructures import MultiDict
1011

1112
import webapp.greenhouse as greenhouse
1213
from webapp.greenhouse import (
@@ -196,6 +197,28 @@ def test_submit_application_builds_payload(self):
196197
self.assertEqual(payload["resume_content_filename"], "resume.pdf")
197198
self.assertEqual(payload["cover_letter_content_filename"], "cover.txt")
198199

200+
def test_submit_application_preserves_multi_select_values(self):
201+
session = MagicMock()
202+
gh = greenhouse.Greenhouse(session=session, api_key="key", debug=False)
203+
form_data = MultiDict(
204+
[
205+
("first_name", "Alice"),
206+
("question_123[]", "10"),
207+
("question_123[]", "20"),
208+
]
209+
)
210+
211+
gh.submit_application(
212+
form_data=form_data,
213+
form_files={},
214+
job_id="999",
215+
)
216+
217+
payload = json.loads(session.post.call_args.kwargs["data"])
218+
self.assertEqual(payload["first_name"], "Alice")
219+
self.assertEqual(payload["question_123"], [10, 20])
220+
self.assertNotIn("question_123[]", payload)
221+
199222
def test_submit_application_debug_short_circuits(self):
200223
"""
201224
Test that application submission short-circuits when debug is enabled

0 commit comments

Comments
 (0)