Skip to content
Snippets Groups Projects
Commit 8a972473 authored by Remco Tukker's avatar Remco Tukker
Browse files

added tooltips and readonly survey variant

parent 0bbcea13
No related branches found
No related tags found
1 merge request!52Feature/admin workflow surveys page
......@@ -18,7 +18,47 @@ routes = Blueprint('survey', __name__)
logger = logging.getLogger(__name__)
# TODO (partial) schemas
LIST_SURVEYS_RESPONSE_SCHEMA = {
'$schema': 'http://json-schema.org/draft-07/schema#',
'definitions': {
'response': {
'type': 'object',
'properties': {
'nren': {'type': 'string'},
'status': {'type': 'string'},
},
'required': ['nren', 'status'],
'additionalProperties': False
},
'survey': {
'type': 'object',
'properties': {
'year': {'type': 'string'},
'status': {'type': 'string'},
'responses': {'type': 'array', 'items': {'$ref': '#/definitions/response'}},
},
'required': ['year', 'status', 'responses'],
'additionalProperties': False
}
},
'type': 'array',
'items': {'$ref': '#/definitions/survey'}
}
SURVEY_RESPONSE_SCHEMA = {
'$schema': 'http://json-schema.org/draft-07/schema#',
'type': 'object',
'properties': {
'model': {'type': 'object'},
'data': {'type': 'object'},
'page': {'type': 'number'},
'verification_status': {'type': 'array', 'items': {'type': 'string'}}
},
'required': ['year', 'status', 'responses'],
'additionalProperties': False
}
class VerificationStatus(str, Enum):
New = "new" # a question that was not answered last year
......@@ -28,10 +68,68 @@ class VerificationStatus(str, Enum):
Edited = "edited" # a question for which last years answer was edited
# TODO admin only
@routes.route('/list', methods=['GET'])
@common.require_accepts_json
def list_surveys() -> Any:
"""
retrieve a list of surveys and responses, including their status
response will be formatted as:
.. asjson::
compendium_v2.routes.survey.LIST_SURVEYS_RESPONSE_SCHEMA
"""
surveys = db.session.scalars(
select(Survey).options(
load_only(Survey.year, Survey.status),
joinedload(Survey.responses).load_only(SurveyResponse.status)
.joinedload(SurveyResponse.nren).load_only(NREN.name)
).order_by(Survey.year.desc())
).unique()
def response_key(response):
return response.status.value + response.nren.name.lower()
class SurveyDict(TypedDict):
year: int
status: str
responses: List[Dict[str, str]]
entries: List[SurveyDict] = [
{
"year": entry.year,
"status": entry.status.value,
"responses": [
{"nren": r.nren.name, "status": r.status.value}
for r in sorted(entry.responses, key=response_key)
]
}
for entry in surveys
]
# add in nrens without a response if the survey is open
nren_names = set([name for name in db.session.scalars(select(NREN.name))])
for entry in entries:
if entry["status"] != SurveyStatus.open.value:
continue
nrens_with_responses = set([r["nren"] for r in entry["responses"]])
for nren_name in sorted(nren_names.difference(nrens_with_responses), key=str.lower):
entry["responses"].append({"nren": nren_name, "status": "not started"})
return jsonify(entries)
# TODO admin only
@routes.route('/new', methods=['POST'])
@common.require_accepts_json
def start_new_survey() -> Any:
"""
endpoint to initiate a new survey
:returns: ``{'success': True}`` or a 400 or 404 status with a descriptive message
"""
all_surveys = db.session.scalars(select(Survey).options(load_only(Survey.status)))
if any([survey.status != SurveyStatus.published for survey in all_surveys]):
return "All earlier surveys should be published before starting a new one", 400
......@@ -56,6 +154,11 @@ def start_new_survey() -> Any:
@routes.route('/open/<int:year>', methods=['POST'])
@common.require_accepts_json
def open_survey(year) -> Any:
"""
endpoint to open a survey to the nrens
:returns: ``{'success': True}`` or a 400 or 404 status with a descriptive message
"""
survey = db.session.scalar(select(Survey).where(Survey.year == year))
if not survey:
return "Survey not found", 404
......@@ -77,6 +180,11 @@ def open_survey(year) -> Any:
@routes.route('/close/<int:year>', methods=['POST'])
@common.require_accepts_json
def close_survey(year) -> Any:
"""
endpoint to close a survey to the nrens
:returns: ``{'success': True}`` or a 400 or 404 status with a descriptive message
"""
survey = db.session.scalar(select(Survey).where(Survey.year == year))
if not survey:
return "Survey not found", 404
......@@ -94,6 +202,11 @@ def close_survey(year) -> Any:
@routes.route('/publish/<int:year>', methods=['POST'])
@common.require_accepts_json
def publish_survey(year) -> Any:
"""
endpoint to publish a survey to the compendium website
:returns: ``{'success': True}`` or a 400 or 404 status with a descriptive message
"""
survey = db.session.scalar(select(Survey).where(Survey.year == year))
if not survey:
return "Survey not found", 404
......@@ -113,54 +226,19 @@ def publish_survey(year) -> Any:
# TODO admin only
@routes.route('/list', methods=['GET'])
@routes.route('/try/<int:year>', methods=['GET'])
@common.require_accepts_json
def list_surveys() -> Any:
surveys = db.session.scalars(
select(Survey).options(
load_only(Survey.year, Survey.status),
joinedload(Survey.responses).load_only(SurveyResponse.status)
.joinedload(SurveyResponse.nren).load_only(NREN.name)
).order_by(Survey.year.desc())
).unique()
def response_key(response):
return response.status.value + response.nren.name.lower()
class SurveyDict(TypedDict):
year: int
status: str
responses: List[Dict[str, str]]
entries: List[SurveyDict] = [
{
"year": entry.year,
"status": entry.status.value,
"responses": [
{"nren": r.nren.name, "status": r.status.value}
for r in sorted(entry.responses, key=response_key)
]
}
for entry in surveys
]
# add in nrens without a response if the survey is open
nren_names = set([name for name in db.session.scalars(select(NREN.name))])
for entry in entries:
if entry["status"] != SurveyStatus.open.value:
continue
nrens_with_responses = set([r["nren"] for r in entry["responses"]])
for nren_name in sorted(nren_names.difference(nrens_with_responses), key=str.lower):
entry["responses"].append({"nren": nren_name, "status": "not started"})
def try_survey(year) -> Any:
"""
Get a survey without any associated nren for trying out the survey.
The survey will behave exactly as when an NREN opens it.
return jsonify(entries)
response will be formatted as:
.. asjson::
compendium_v2.routes.survey.SURVEY_RESPONSE_SCHEMA
# TODO admin only
@routes.route('/try/<int:year>', methods=['GET'])
@common.require_accepts_json
def try_survey(year) -> Any:
"""
return get_survey(year, all_visible=False)
......@@ -168,6 +246,16 @@ def try_survey(year) -> Any:
@routes.route('/inspect/<int:year>', methods=['GET'])
@common.require_accepts_json
def inspect_survey(year) -> Any:
"""
Get a survey without any associated nren for inspecting all questions.
All questions are made visible and any VisibleIf condition added to the title.
response will be formatted as:
.. asjson::
compendium_v2.routes.survey.SURVEY_RESPONSE_SCHEMA
"""
return get_survey(year, all_visible=True)
......@@ -186,47 +274,21 @@ def get_survey(year, all_visible) -> Any:
})
def prepare_survey_model(survey, year, all_visible=False):
if survey is None or survey == {}:
# TODO remove this at some point, its just convenient for now while we are changing the survey model a lot
# or is it really?? we can also keep the surveys on disk, which makes it really easy to diff, etc
p = Path(__file__).with_name('survey_model.json')
with p.open('r') as f:
survey = json.load(f)
replacements = {
"[%websiteurlregex%]":
r"^(https?:\/\/)?([\da-zA-Z\.-]+\.[a-zA-Z\.]{2,6}|[\d\.]+)([\/:?=&#%]{1}[\d_a-zA-Z\.-]+)*[\/\?]?$",
"[%lastyear%]": str(year - 1),
"[%surveyyear%]": str(year)
}
def replacer(string):
for key, replacement in replacements.items():
string = string.replace(key, replacement)
return string
def visitor(object, items):
for key, value in items:
if type(value) == dict:
visitor(value, value.items())
elif type(value) == list:
visitor(value, enumerate(value))
elif all_visible and key == 'visibleIf':
object['title'] = object['title'] + ' (visibleif: [' + value.replace('{', '#').replace('}', '#') + '])'
object[key] = 'true'
elif type(value) == str:
object[key] = replacer(value)
visitor(survey, survey.items())
return survey
@routes.route('/load/<int:year>/<string:nren_name>', methods=['GET'])
@common.require_accepts_json
def load_survey(year, nren_name) -> Any:
"""
Get a survey for an nren.
If the survey was saved before, that data will be loaded.
If the survey was not saved before and the survey was completed last year,
the data from last year will be prefilled.
response will be formatted as:
.. asjson::
compendium_v2.routes.survey.SURVEY_RESPONSE_SCHEMA
"""
nren = db.session.scalar(select(NREN).filter(NREN.name == nren_name))
if not nren:
return "NREN not found", 404
......@@ -267,9 +329,51 @@ def load_survey(year, nren_name) -> Any:
})
def prepare_survey_model(survey, year, all_visible=False):
if survey is None or survey == {}:
# TODO remove this at some point, its just convenient for now while we are changing the survey model a lot
# or is it really?? we can also keep the surveys on disk, which makes it really easy to diff, etc
p = Path(__file__).with_name('survey_model.json')
with p.open('r') as f:
survey = json.load(f)
replacements = {
"[%websiteurlregex%]":
r"^(https?:\/\/)?([\da-zA-Z\.-]+\.[a-zA-Z\.]{2,6}|[\d\.]+)([\/:?=&#%]{1}[\d_a-zA-Z\.-]+)*[\/\?]?$",
"[%lastyear%]": str(year - 1),
"[%surveyyear%]": str(year)
}
def replacer(string):
for key, replacement in replacements.items():
string = string.replace(key, replacement)
return string
def visitor(object, items):
for key, value in items:
if type(value) == dict:
visitor(value, value.items())
elif type(value) == list:
visitor(value, enumerate(value))
elif all_visible and key == 'visibleIf':
object['title'] = object['title'] + ' (visibleif: [' + value.replace('{', '#').replace('}', '#') + '])'
object[key] = 'true'
elif type(value) == str:
object[key] = replacer(value)
visitor(survey, survey.items())
return survey
@routes.route('/save/<int:year>/<string:nren_name>', methods=['POST'])
@common.require_accepts_json
def save_survey(year, nren_name) -> Any:
"""
endpoint to save a survey response
:returns: ``{'success': True}`` or a 400 or 404 status with a descriptive message
"""
nren = db.session.scalar(select(NREN).filter(NREN.name == nren_name))
if nren is None:
......
......@@ -17,6 +17,7 @@ function App(): ReactElement {
<Route path="survey/admin/inspect/:year" element={<SurveyComponent loadFrom={'/api/survey/inspect/'} />}/>
<Route path="survey/admin/try/:year" element={<SurveyComponent loadFrom={'/api/survey/try/'} />}/>
<Route path="survey/respond/:year/:nren" element={<SurveyComponent loadFrom={'/api/survey/load/'} saveTo={'/api/survey/save/'} />}/>
<Route path="survey/show/:year/:nren" element={<SurveyComponent loadFrom={'/api/survey/load/'} readonly />}/>
<Route path="*" element={<ShowUser />} />
</Routes>
</Router>
......
......@@ -24,7 +24,7 @@ enum VerificationStatus {
}
function SurveyComponent({ loadFrom, saveTo = ''}) {
function SurveyComponent({ loadFrom, saveTo = '', readonly = false}) {
const [surveyModel, setSurveyModel] = useState<Model>();
const [progress, setProgress] = useState<Progress[]>([]);
const verificationStatus = useRef<Map<string, VerificationStatus>>(new Map());
......@@ -96,6 +96,10 @@ function SurveyComponent({ loadFrom, saveTo = ''}) {
const survey = new Model(json['model']);
if (readonly) {
survey.mode = 'display';
}
if (json['data'] !== null) {
survey.data = json['data'];
survey.clearIncorrectValues(true); // TODO test if this really removes all old values and such
......
......@@ -59,16 +59,34 @@ function SurveyManagementComponent() {
return (
<div>
<Button onClick={newSurvey} disabled={!newSurveyAllowed}>start new survey</Button>
<Button onClick={newSurvey} disabled={!newSurveyAllowed} style={{pointerEvents: 'auto'}}
title="Create a new survey for the next year. Only possible if all current surveys are published.">
start new survey
</Button>
<Accordion>
{surveys.map(survey => (
<Accordion.Item eventKey={survey.year.toString()} key={survey.year}>
<Accordion.Header>{survey.year} - {survey.status} -
<Button onClick={() => navigate(`/survey/admin/inspect/${survey.year}`)}>inspect</Button>
<Button onClick={() => navigate(`/survey/admin/try/${survey.year}`)}>try</Button>
<Button onClick={() => postSurveyStatus(survey.year, 'open')} disabled={openSurveys || survey.status!='closed'}>open</Button>
<Button onClick={() => postSurveyStatus(survey.year, 'close')} disabled={survey.status!='open'}>close</Button>
<Button onClick={() => postSurveyStatus(survey.year, 'publish')} disabled={(survey.status!='closed' && survey.status!='published') || !survey.responses.every(r => r.status == 'checked')}>publish</Button>
<Button onClick={() => navigate(`/survey/admin/inspect/${survey.year}`)}
title="Open the survey for inspection with all questions visible and any visibleIf logic added to the title.">
inspect
</Button>
<Button onClick={() => navigate(`/survey/admin/try/${survey.year}`)}
title="Open the survey exactly as the nrens will see it, but without any nren data.">
try
</Button>
<Button onClick={() => postSurveyStatus(survey.year, 'open')} disabled={openSurveys || survey.status!='closed'} style={{pointerEvents: 'auto'}}
title="Allow the NRENs to respond to this survey. Only 1 survey may be open at a time, and published surveys cannot be opened anymore.">
open
</Button>
<Button onClick={() => postSurveyStatus(survey.year, 'close')} disabled={survey.status!='open'} style={{pointerEvents: 'auto'}}
title="Do not allow the NRENs to respond to this survey anymore. Only surveys with status open can be closed.">
close
</Button>
<Button onClick={() => postSurveyStatus(survey.year, 'publish')} disabled={(survey.status!='closed' && survey.status!='published') || !survey.responses.every(r => r.status == 'checked')} style={{pointerEvents: 'auto'}}
title="Publish or re-publish all survey responses to the compendium website. This is only possible if the survey is closed or published already, and all repsonses are checked.">
publish
</Button>
{/* maybe something like this: <Button disabled={survey.status!='published'}>Show in compendium</Button>
<Button disabled={survey.status!='published'}>Hide from compendium</Button> */}
</Accordion.Header>
......@@ -81,8 +99,14 @@ function SurveyManagementComponent() {
<td>{response.status}</td>
<td>locked by</td>
<td>
inspect (with the option to mark as checked? or just use the edit button below?)
<Button onClick={() => navigate(`/survey/respond/${survey.year}/${response.nren}`)}>edit</Button>
<Button onClick={() => navigate(`/survey/show/${survey.year}/${response.nren}`)} style={{pointerEvents: 'auto'}}
title="Open the responses of the NREN in readonly mode. Allows marking the responses as checked if the survey is completed.">
show
</Button>
<Button onClick={() => navigate(`/survey/respond/${survey.year}/${response.nren}`)} style={{pointerEvents: 'auto'}}
title="Edit the responses of the NREN.">
edit
</Button>
remove lock (only available when locked)
</td>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment