| {{ tm("date") }} | - {% if show_user %}{{ tm("author") }} | {% endif %} -{{ tm("action") }} | - {% if show_model_type %}{{ tm("object_type") }} | {% endif %} -{{ tm("name") }} | -{{ tm("field") }} | -{{ tm("previous_change") }} | -{{ tm("this_change") }} | -|||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ date_display(edit_entry.right_version.timestamp) }} | - {% if show_user %} -- {% if edit_entry.author %} - {{ edit_entry.author.pretty_name }} - {% else %} - {{ tm("n/a") }} - {% endif %} - | - {% endif %} -{{ tm("saved") }} | - {% if show_model_type %}{{ edit_entry.eternal._meta.verbose_name }} | {% endif %} -{{ edit_entry.live_name }} | -{{ tm("no_change_detected_compared_to_previous_version") }} | -|||||||||||||||||||||||||
| {{ date_display(edit_entry.right_version.timestamp) }} | - {% if show_user %} +||||||||||||||||||||||||||||||
| {{ tm("date") }} | + {% if show_user %}{{ tm("author") }} | {% endif %} +{{ tm("action") }} | + {% if show_model_type %}{{ tm("object_type") }} | {% endif %} +{{ tm("name") }} | +{{ tm("field") }} | +{{ tm("previous_change") }} | +{{ tm("this_change") }} | +||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ date_display(edit_entry.right_version.timestamp) }} | + {% if show_user %} ++ {% if edit_entry.author %} + {{ edit_entry.author.pretty_name }} + {% else %} + {{ tm("n/a") }} + {% endif %} + | + {% endif %} +{{ tm("saved") }} | + {% if show_model_type %}{{ edit_entry.eternal._meta.verbose_name }} | {% endif %} +{{ edit_entry.live_name }} | +{{ tm("no_change_detected_compared_to_previous_version") }} | +||||||
| {{ date_display(edit_entry.right_version.timestamp) }} | + {% if show_user %} ++ {% if edit_entry.author %} + {{ edit_entry.author.pretty_name }} + {% else %} + {{ tm("n/a") }} + {% endif %} + | + {% endif %} +{{ diff.action }} | + {% if show_model_type %}{{ edit_entry.eternal._meta.verbose_name }} | {% endif %} +{{ edit_entry.live_name }} | - {% if edit_entry.author %} - {{ edit_entry.author.pretty_name }} - {% else %} - {{ tm("n/a") }} - {% endif %} + {% if diff.field %}{{ diff.field.verbose_name }}{% endif %} | - {% endif %} -{{ diff.action }} | - {% if show_model_type %}{{ edit_entry.eternal._meta.verbose_name }} | {% endif %} -{{ edit_entry.live_name }} | -- {% if diff.field %}{{ diff.field.verbose_name }}{% endif %} - | - {###############autoescape content is escaped ######################} - {% autoescape false %} -{{ diff.get_before_diff() }} | - {% endautoescape %} - {% autoescape false %} -{{ diff.get_after_diff() }} | - {% endautoescape %} - {% block extra_columns %}{% endblock %} -{{ diff.get_before_diff() }} | + {% endautoescape %} + {% autoescape false %} +{{ diff.get_after_diff() }} | + {% endautoescape %} + {% block extra_columns %}{% endblock %} + + {% endfor %} {% endfor %} - {% endfor %} - -
| {{ tm("stratifier_value") }} | -{{ tm("indicator_value") }} | -{{ tm("last_modified") }} | -{{ tm("hso_submission") }} | -{{ tm("program_submission") }} | -
|---|
| - {% if datum.dimension_type.is_literal %} - {{ datum.literal_dimension_val }} - {% else %} - {{ datum.dimension_value.name }} - {% endif %} - | -{{ datum.value }} | -{{ date_display(datum.last_version_date) }} | -{{ submission_status_label(datum.submission_status(submission_type="hso") ) }} | -{{ submission_status_label(datum.submission_status(submission_type="program") ) }} | +{{ tm("stratifier_value") }} | +{{ tm("indicator_value") }} | +{{ tm("last_modified") }} | +{{ tm("hso_submission") }} | +{{ tm("program_submission") }} |
|---|
{{ tm("indicator_data") }} : {{ indicator.name }} ({{ period }})
+{{ tm("indicator_data") }} : {{ indicator.bilingual_name }} ({{ period }})
Cancel + href="{{ url('view_indicator_for_period', args=[indicator.id, period.id]) }}">{{ tm("cancel") }} +{{ tm("quintile_metadata_description") }}
-| {{ tm("quintile") }} | -{{ vb_name('cpho.Indicator', 'g1') }} | -{{ vb_name('cpho.Indicator', 'g2_lower') }} | -{{ vb_name('cpho.Indicator', 'g2_upper') }} | -{{ vb_name('cpho.Indicator', 'g3_lower') }} | -{{ vb_name('cpho.Indicator', 'g3_upper') }} | -{{ vb_name('cpho.Indicator', 'g4_lower') }} | -{{ vb_name('cpho.Indicator', 'g4_upper') }} | -{{ vb_name('cpho.Indicator', 'g5') }} | -
|---|---|---|---|---|---|---|---|---|
| {{ row_name }} | -
- {{ form.g1 }}
- {{ hidden_a11y_label(form.g1, row_name) }}
- |
-
- {{ form.g2_lower }}
- {{ hidden_a11y_label(form.g2_lower, row_name) }}
- |
-
- {{ form.g2_upper }}
- {{ hidden_a11y_label(form.g2_upper, row_name) }}
- |
-
- {{ form.g3_lower }}
- {{ hidden_a11y_label(form.g3_lower, row_name ,) }}
- |
-
- {{ form.g3_upper }}
- {{ hidden_a11y_label(form.g3_upper, row_name) }}
- |
-
- {{ form.g4_lower }}
- {{ hidden_a11y_label(form.g4_lower, row_name) }}
- |
-
- {{ form.g4_upper }}
- {{ hidden_a11y_label(form.g4_upper, row_name) }}
- |
-
- {{ form.g5 }}
- {{ hidden_a11y_label(form.g5, row_name) }}
- |
-
| {{ tm("quintile") }} | +{{ vb_name('cpho.Indicator', 'g1') }} | +{{ vb_name('cpho.Indicator', 'g2_lower') }} | +{{ vb_name('cpho.Indicator', 'g2_upper') }} | +{{ vb_name('cpho.Indicator', 'g3_lower') }} | +{{ vb_name('cpho.Indicator', 'g3_upper') }} | +{{ vb_name('cpho.Indicator', 'g4_lower') }} | +{{ vb_name('cpho.Indicator', 'g4_upper') }} | +{{ vb_name('cpho.Indicator', 'g5') }} | +
|---|---|---|---|---|---|---|---|---|
| {{ row_name }} | +
+ {{ form.g1 }}
+ {{ hidden_a11y_label(form.g1, row_name) }}
+ |
+
+ {{ form.g2_lower }}
+ {{ hidden_a11y_label(form.g2_lower, row_name) }}
+ |
+
+ {{ form.g2_upper }}
+ {{ hidden_a11y_label(form.g2_upper, row_name) }}
+ |
+
+ {{ form.g3_lower }}
+ {{ hidden_a11y_label(form.g3_lower, row_name ,) }}
+ |
+
+ {{ form.g3_upper }}
+ {{ hidden_a11y_label(form.g3_upper, row_name) }}
+ |
+
+ {{ form.g4_lower }}
+ {{ hidden_a11y_label(form.g4_lower, row_name) }}
+ |
+
+ {{ form.g4_upper }}
+ {{ hidden_a11y_label(form.g4_upper, row_name) }}
+ |
+
+ {{ form.g5 }}
+ {{ hidden_a11y_label(form.g5, row_name) }}
+ |
+
| {{ indicator.name }} | +{{ indicator.bilingual_name }} | {{ indicator.get_category_display() }} | {{ indicator.get_topic_display() }} | {{ tm("view_indicator") }} + aria-label="{{ tm("view_indicator") }}, {{ indicator.bilingual_name }}">{{ tm("view_indicator") }} |
| {{ tm("field") }} | -{{ tm("value") }} | -
|---|
| {{ tm("field") }} | +{{ tm("value") }} | +
|---|---|
| {{ tm("relevant_dimensions") }} | -
-
|
-
| {{ tm("relevant_dimensions") }} | +
+
|
+
{{ tm("quintile_metadata_description") }}
-| {{ tm("quintile") }} | -G1 | -G2 {{ tm("lower") }} | -G2 {{ tm("upper") }} | -G3 {{ tm("lower") }} | -G3 {{ tm("upper") }} | -G4 {{ tm("lower") }} | -G4 {{ tm("upper") }} | -G5 | -
|---|---|---|---|---|---|---|---|---|
| {{ tm("quintile_values") }} | -{{ ind.g1 }} | -{{ ind.g2_lower }} | -{{ ind.g2_upper }} | -{{ ind.g3_lower }} | -{{ ind.g3_upper }} | -{{ ind.g4_lower }} | -{{ ind.g4_upper }} | -{{ ind.g5 }} | -
| {{ tm("quintile") }} | +G1 | +G2 {{ tm("lower") }} | +G2 {{ tm("upper") }} | +G3 {{ tm("lower") }} | +G3 {{ tm("upper") }} | +G4 {{ tm("lower") }} | +G4 {{ tm("upper") }} | +G5 | +
|---|---|---|---|---|---|---|---|---|
| {{ tm("quintile_values") }} | +{{ ind.g1 }} | +{{ ind.g2_lower }} | +{{ ind.g2_upper }} | +{{ ind.g3_lower }} | +{{ ind.g3_upper }} | +{{ ind.g4_lower }} | +{{ ind.g4_upper }} | +{{ ind.g5 }} | +
| {{ tm("oecd_country") }} | -{{ tm("value") }} | -{{ tm("unit") }} | -{{ tm("year") }} | -{{ tm("comparison_to_oecd_average") }} | -{{ tm("labels") }} | -{{ tm("methodology_differences") }} | -
|---|
| {{ item.oecd_country }} | -{{ item.value }} | -{{ item.get_unit_display() }} | -{{ item.year }} | -{{ item.get_comparison_to_oecd_avg_display() }} | -{{ item.get_labels_display() }} | -{{ tm(item.get_methodology_differences_display() ) }} | +{{ tm("oecd_country") }} | +{{ tm("value") }} | +{{ tm("unit") }} | +{{ tm("year") }} | +{{ tm("comparison_to_oecd_average") }} | +{{ tm("labels") }} | +{{ tm("methodology_differences") }} |
|---|
| {{ tm("year_or_range") }} | -{{ tm("data_point") }} | -{{ tm("line_of_best_fit_point") }} | -{{ tm("trend_segment") }} | -{{ tm("trend") }} | -{{ tm("data_quality") }} | -{{ tm("unit") }} | -{{ tm("data_lower_ci") }} | -{{ tm("data_upper_ci") }} | -
|---|
| {{ item.year }} | -{{ item.data_point }} | -{{ item.line_of_best_fit_point }} | -{{ item.trend_segment }} | -{{ item.get_trend_display() }} | -{{ item.get_data_quality_display() }} | -{{ item.get_unit_display() }} | -{{ item.data_point_lower_ci }} | -{{ item.data_point_upper_ci }} | +{{ tm("year_or_range") }} | +{{ tm("data_point") }} | +{{ tm("line_of_best_fit_point") }} | +{{ tm("trend_segment") }} | +{{ tm("trend") }} | +{{ tm("data_quality") }} | +{{ tm("unit") }} | +{{ tm("data_lower_ci") }} | +{{ tm("data_upper_ci") }} |
|---|
{{ tm("users") }}
-| {{ tm("user") }} | -{{ tm("last_login") }} | - - - {% for user in indicator_directory.users.all() %} -
|---|---|
| {{ user }} | -- {% if user.last_login %} - {{ date_display(user.last_login) }} - {% else %} - {{ tm("never") }} - {% endif %} - | -
| {{ tm("user") }} | +{{ tm("last_login") }} | + + + {% for user in indicator_directory.users.all() %} +
|---|---|
| {{ user }} | ++ {% if user.last_login %} + {{ date_display(user.last_login) }} + {% else %} + {{ tm("never") }} + {% endif %} + | +
{{ tm("user_management") }}
-| {{ tdt("Username") }} | -{{ tdt("Special roles") }} | -{{ tdt("Last login") }} | -{{ tdt("Modify") }} | -
|---|
| {{ user }} | -- {% if user.is_admin %}{{ tdt("Admin") }}{% endif %} - {% if user.is_hso %}{{ tdt("HSO") }}{% endif %} - | -- {% if user.last_login %}{{ date_display(user.last_login) }}{% endif %} - | -- {{ tdt("Modify") }} - | +{{ tdt("Username") }} | +{{ tdt("Special roles") }} | +{{ tdt("Last login") }} | +{{ tdt("Modify") }} |
|---|
{{ tdt("Indicator directories") }}
+{{ tm("indicator_directories") }}
-
{% for directory in indicator_directories %}
- diff --git a/server/cpho/models/indicators.py b/server/cpho/models/indicators.py index e7a19afc..d55af52a 100644 --- a/server/cpho/models/indicators.py +++ b/server/cpho/models/indicators.py @@ -369,12 +369,14 @@ class Meta: config_name="notes", null=True, blank=True ) + @property + def bilingual_name(self): + if get_lang_code() == "fr" and self.name_fr: + return self.name_fr + "" + return self.name + "" + def __str__(self): - return " ".join( - [ - str(self.name), - ] - ) + return self.bilingual_name def get_relevant_periods(self): # filter Period. to years that have data or have the same type as the indicator's relevant_period_types diff --git a/server/cpho/rules.py b/server/cpho/rules.py index d7e46f3e..a00d5f23 100644 --- a/server/cpho/rules.py +++ b/server/cpho/rules.py @@ -73,7 +73,8 @@ def can_use_indicator_upload(user): @auto_rule def can_manage_users(user): - return is_admin_or_hso(user) + # return is_admin_or_hso(user) + return is_admin(user) @auto_rule diff --git a/server/cpho/scripts/countries.csv b/server/cpho/scripts/countries.csv index 605f9a73..5947fe94 100644 --- a/server/cpho/scripts/countries.csv +++ b/server/cpho/scripts/countries.csv @@ -1,43 +1,43 @@ id,name_en,name_fr -0000,Australia, -0001,Austria, -0002,Belgium, -0003,Canada, -0004,Chile, -0005,Colombia, -0006,Costa Rica, -0007,Czech Republic, -0008,Denmark, -0009,Estonia, -0010,Finland, -0011,France, -0012,Germany, -0013,Greece, -0014,Hungary, -0015,Iceland, -0016,Ireland, -0017,Israel, -0018,Italy, -0019,Japan, -0020,Korea, -0021,Latvia, -0022,Lithuania, -0023,Luxembourg, -0024,Netherlands, -0025,New Zealand, -0026,Norway, -0027,Poland, -0028,Portugal, -0029,Slovak Republic, -0030,Slovenia, -0031,Spain, -0032,Sweden, -0033,Switzerland, -0034,Türkiye, -0035,United Kingdom, -0036,United States, -0038,Mexico, -0040,Brazil, -0041,Russia, -0046,China, -0047,OECD Average, +0000,Australia,Australie +0001,Austria,Autriche +0002,Belgium,Belgique +0003,Canada,Canada +0004,Chile,Chili +0005,Colombia,Colombie +0006,Costa Rica,Costa Rica +0007,Czech Republic,République tchèque +0008,Denmark,Danemark +0009,Estonia,Estonie +0010,Finland,Finlande +0011,France,France +0012,Germany,Allemagne +0013,Greece,Grèce +0014,Hungary,Hongrie +0015,Iceland,Islande +0016,Ireland,Irlande +0017,Israel,Israël +0018,Italy,Italie +0019,Japan,Japon +0020,Korea,Korea +0021,Latvia,Lettonie +0022,Lithuania,Lituanie +0023,Luxembourg,Luxembourg +0024,Netherlands,Pays-Bas +0025,New Zealand,Nouvelle-Zélande +0026,Norway,Norvège +0027,Poland,Pologne +0028,Portugal,Portugal +0029,Slovak Republic,République slovaque +0030,Slovenia,Slovénie +0031,Spain,Espagne +0032,Sweden,Suède +0033,Switzerland, Suisse +0034,Türkiye, Turquie +0035,United Kingdom,Royaume-Uni +0036,United States,États-Unis +0038,Mexico,Mexique +0040,Brazil,Bresil +0041,Russia,Russie +0046,China,Chine +0047,OECD Average,Moyenne OCDE diff --git a/server/cpho/translations.py b/server/cpho/translations.py index 6c8a6d78..2edccaeb 100644 --- a/server/cpho/translations.py +++ b/server/cpho/translations.py @@ -1,4 +1,29 @@ translation_entries = { + "skip_to_main": { + "en": "Skip to main content", + "fr": "Passer au contenu principal", + }, + "required_explainer": { + "en": "Required fields are indicated with a red asterisk", + "fr": "Les champs obligatoires sont indiqués par une étoile rouge", + }, + "toggle_navigation": { + "en": "Toggle navigation", + "fr": "Basculer la navigation", + }, + "close": { + "en": "Close", + "fr": "Fermer", + }, + "breadcrumb_trail": {"en": "Breadcrumb trail", "fr": "Fil d'Ariane"}, + "please_select_an_option_below": { + "en": "Please select an option below", + "fr": "Veuillez sélectionner une option ci-dessous", + }, + "symbol_of_the_government_of_canada": { + "en": "Symbol of the Government of Canada", + "fr": "Symbole du gouvernement du Canada", + }, "site_title": { "en": "CPHO Phase 2", "fr": "DSPC Phase 2", @@ -40,6 +65,14 @@ "translated_using": "Edit", "fr_auto": "Modifier", }, + "create_new_user": { + "en": "Create new user", + "fr": "Créer un nouvel utilisateur", + }, + "indicator_directories": { + "en": "Indicator directories", + "fr": "Répertoires d'indicateurs", + }, "delete": { "en": "Delete", "fr": None, @@ -1471,6 +1504,14 @@ "en": "Hospital Setting Metadata", "fr": "Métadonnées de cadre hospitalier", }, + "export_benchmarking": { + "en": "Export Benchmarking", + "fr": "Exporter l'analyse comparative", + }, + "editor_for": { + "en": "Editor for", + "fr": "Éditeur pour", + }, "filter": { "en": "Filter", "fr": "Trier", diff --git a/server/cpho/util.py b/server/cpho/util.py index 2c949b4d..d7a096d1 100644 --- a/server/cpho/util.py +++ b/server/cpho/util.py @@ -6,6 +6,7 @@ from django.utils.translation import get_language import pytz +from data_fetcher import cache_within_request from cpho.constants import ADMIN_GROUP_NAME, HSO_GROUP_NAME from cpho.text import tdt, tm @@ -37,12 +38,12 @@ def __get__(self, cls, owner): class GroupFetcher: @classproperty - @lru_cache + @cache_within_request def admin_group(cls): return Group.objects.get_or_create(name=ADMIN_GROUP_NAME)[0] @classproperty - @lru_cache + @cache_within_request def hso_group(cls): return Group.objects.get_or_create(name=HSO_GROUP_NAME)[0] diff --git a/server/cpho/views/indicator_data.py b/server/cpho/views/indicator_data.py index ee895d1a..c990dc63 100644 --- a/server/cpho/views/indicator_data.py +++ b/server/cpho/views/indicator_data.py @@ -14,6 +14,8 @@ from phac_aspc.rules import test_rule +from server.form_util import DescribedByErrorMixin, StandardFormMixin + from cpho.constants import SUBMISSION_STATUSES from cpho.models import ( DimensionType, @@ -51,7 +53,11 @@ def get_form_kwargs(self, index): } -class IndicatorDatumForm(ModelForm): +class IndicatorDatumForm( + ModelForm, + StandardFormMixin, + DescribedByErrorMixin, +): class Meta: model = IndicatorDatum fields = [ @@ -428,8 +434,6 @@ def post(self, *args, **kwargs): ) else: # get will just render the forms and their errors - print(self.predefined_values_formset.errors) - print(self.age_group_formset.errors) messages.error(self.request, tm("error_saving_form")) return self.get(*args, **kwargs) diff --git a/server/cpho/views/indicator_metadata.py b/server/cpho/views/indicator_metadata.py index 4c2dc7cb..bb5410d1 100644 --- a/server/cpho/views/indicator_metadata.py +++ b/server/cpho/views/indicator_metadata.py @@ -17,6 +17,8 @@ from phac_aspc.rules import test_rule +from server.form_util import DescribedByErrorMixin, StandardFormMixin + from cpho.constants import SUBMISSION_STATUSES from cpho.models import ( Benchmarking, @@ -39,7 +41,12 @@ ) -class BenchmarkingForm(RequiredIfNotDeletedMixin, ModelForm): +class BenchmarkingForm( + RequiredIfNotDeletedMixin, + ModelForm, + DescribedByErrorMixin, + StandardFormMixin, +): class Meta: model = Benchmarking fields = [ @@ -298,7 +305,12 @@ def get_context_data(self, **kwargs): return context -class TrendAnalysisForm(RequiredIfNotDeletedMixin, ModelForm): +class TrendAnalysisForm( + RequiredIfNotDeletedMixin, + ModelForm, + StandardFormMixin, + DescribedByErrorMixin, +): class Meta: model = TrendAnalysis fields = [ diff --git a/server/cpho/views/indicators.py b/server/cpho/views/indicators.py index d94ca596..2290cc14 100644 --- a/server/cpho/views/indicators.py +++ b/server/cpho/views/indicators.py @@ -43,6 +43,18 @@ def label_from_instance(self, obj): return obj.name_en +class CustomCkEditorWidget(CKEditorWidget): + def __init__(self, *args, label=None, **kwargs): + super().__init__(*args, **kwargs) + + if not label: + raise Exception("Ckeditor requires an explicit label") + + title = tm("editor_for") + " : " + label + + self.config["title"] = title + + class IndicatorForm(ModelForm): class Meta: model = Indicator @@ -98,8 +110,10 @@ def ckEditorField(required=False, french=False, label=None): return forms.CharField( required=required, - widget=CKEditorWidget( - config_name="notes", attrs={"class": class_string} + widget=CustomCkEditorWidget( + config_name="notes", + label=label, + attrs={"class": class_string}, ), label=label if label else None, ) diff --git a/server/pytest.ini b/server/pytest.ini index 3daaa9de..2ba2e11d 100644 --- a/server/pytest.ini +++ b/server/pytest.ini @@ -1,4 +1,6 @@ [pytest] DJANGO_SETTINGS_MODULE= server.settings -addopts = -p no:warnings -v -s +addopts = -p no:warnings -v -s -m "not selenium" asyncio_mode = auto +markers = + selenium: end-to-end browser tests diff --git a/server/requirements.txt b/server/requirements.txt index 048e101b..0311310b 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -24,7 +24,7 @@ pytz==2022.2.1 requests==2.32.0 whitenoise==6.4.0 rules==3.3 -django-ckeditor==6.7.1 +django-ckeditor==6.7.3 PyYAML==6.0.1 # necessary to apply fixtures in prod diff --git a/server/requirements_selenium.txt b/server/requirements_selenium.txt new file mode 100644 index 00000000..81a73ea0 --- /dev/null +++ b/server/requirements_selenium.txt @@ -0,0 +1 @@ +selenium==4.39.0 diff --git a/server/server/form_util.py b/server/server/form_util.py index 04e39581..3ad86065 100644 --- a/server/server/form_util.py +++ b/server/server/form_util.py @@ -102,6 +102,33 @@ def translate_choices(choices): choices[i] = (value, _("no")) +class DescribedByErrorMixin(forms.Form): + """ + adds aria-describedby attributes for error messages + assumes error messages follow the field naming convention + """ + + def error_id_for_field(self, bound_field): + id_for_label = bound_field.id_for_label + error_id = f"{id_for_label}_errormessage" + return error_id + + def full_clean(self): + r = super().full_clean() + for bound_field in self.visible_fields(): + if self.errors.get(bound_field.name): + error_id = self.error_id_for_field(bound_field) + widget = bound_field.field.widget + current_aria_describedby = widget.attrs.get( + "aria-describedby", "" + ) + widget.attrs["aria-describedby"] = ( + current_aria_describedby + " " + error_id + ) + + return r + + class StandardFormMixin( FormControlMixin, NumberInputMixin, diff --git a/server/static/cpho.css b/server/static/cpho.css index 15bd7464..6eede980 100644 --- a/server/static/cpho.css +++ b/server/static/cpho.css @@ -160,10 +160,22 @@ span.new-ind-tag { width: 5% } -/* Accessibility */ -.btn:focus { - color: white; - background-color: #0535d2; + + +.form-control, +.form-select, +.phac_aspc_form_autocomplete.phac_aspc_form_autocomplete { + border-color: #8594A3; +} + + +.btn:focus, +.form-select:focus, +.form-control:focus, +.phac_aspc_form_autocomplete_focus_ring.active, +a:focus, +input[type="checkbox"]:focus, +input[type="radio"]:focus { outline-offset: 0.125rem; border-color: #0535d2; box-shadow: 0 0 0 0.125rem white; diff --git a/server/static/js/manage_benchmarking_scripts.js b/server/static/js/manage_benchmarking_scripts.js new file mode 100644 index 00000000..57717c44 --- /dev/null +++ b/server/static/js/manage_benchmarking_scripts.js @@ -0,0 +1,63 @@ +let addButton = document.querySelector("#add-form"); +if (addButton) { + addButton.addEventListener("click", addForm); +} + +function addForm(e) { + let forms = document.querySelectorAll( + ".benchmarking-form-list > .benchmarking-form" + ); + let newFormIndex = forms.length; + let numForms = forms.length + 1; + let formTemplateNode = document.querySelector( + ".benchmarking-empty-form-container" + ); + let container = document.querySelector(".benchmarking-form-list"); + let totalFormsInput = document.querySelector("#id_benchmarking-TOTAL_FORMS"); //management form inputs + + e.preventDefault(); + let formRegex = RegExp(`benchmarking-__prefix__-`, "g"); + console.log(formRegex); + let newFormHtml = formTemplateNode.innerHTML.replace( + formRegex, + `benchmarking-${newFormIndex}-` + ); + + let newForm = document.createElement("tr"); + newForm.classList.add("benchmarking-form"); + newForm.innerHTML = newFormHtml; + container.appendChild(newForm); + totalFormsInput.setAttribute("value", `${numForms}`); + + // focus on first input of newly added form + newForm.querySelector("select,input").focus(); + updateInvisibleRowLabels(); +} + + +let rowWord = "Entry"; +if (window.location.href.includes("fr-ca")) { + rowWord = "Entrée"; +} +function updateInvisibleRowLabels() { + document.querySelectorAll(".benchmarking-form").forEach(function (row) { + const countrySelector = row.querySelector( + 'select[name$="-oecd_country"]' + ) + const countryText = countrySelector.options[countrySelector.selectedIndex].text; + const rowLabelSpan = row.querySelector('span[id$="-row-label"] .country-name'); + if (countryText.replaceAll("-", "")) { + rowLabelSpan.textContent = countryText; + } else { + rowIndex = Array.from(row.parentNode.children).indexOf(row) + 1; + rowLabelSpan.textContent = `${rowWord} ${rowIndex}`; + } + }); +} + + +updateInvisibleRowLabels(); + +document.addEventListener("change", function (e) { + updateInvisibleRowLabels(); +}); diff --git a/server/static/js/manage_trend_analysis_script.js b/server/static/js/manage_trend_analysis_script.js new file mode 100644 index 00000000..3c3a55a1 --- /dev/null +++ b/server/static/js/manage_trend_analysis_script.js @@ -0,0 +1,63 @@ +let addButton = document.querySelector("#add-form"); +addButton.addEventListener("click", addForm); + +function addForm(e) { + let forms = document.querySelectorAll( + ".trend-analysis-form-list > .trend-analysis-form" + ); + let newFormIndex = forms.length; + let numForms = forms.length + 1; + let formTemplateNode = document.querySelector( + ".trend-analysis-empty-form-container" + ); + let container = document.querySelector(".trend-analysis-form-list"); + let totalFormsInput = document.querySelector( + "#id_trend_analysis-TOTAL_FORMS" + ); //management form inputs + + e.preventDefault(); + let formRegex = RegExp(`trend_analysis-__prefix__-`, "g"); + console.log(formRegex); + let newFormHtml = formTemplateNode.innerHTML.replace( + formRegex, + `trend_analysis-${newFormIndex}-` + ); + + let newForm = document.createElement("tr"); + newForm.classList.add("trend-analysis-form"); + newForm.innerHTML = newFormHtml; + container.appendChild(newForm); + totalFormsInput.setAttribute("value", `${numForms}`); + + // a11y: focus on first input of newly added form + newForm.querySelector("select,input").focus(); + updateInvisibleRowLabels(); +} + + +let rowWord = "Entry"; +if (window.location.href.includes("fr-ca")) { + rowWord = "Entrée"; +} +function updateInvisibleRowLabels() { + document.querySelectorAll(".trend-analysis-form").forEach(function (row) { + const yearInput = row.querySelector( + 'input[name$="-year"]' + ) + const yearText = yearInput.value; + const rowLabelSpan = row.querySelector('span[id$="-row-label"] .year-key'); + if (yearText.trim()) { + rowLabelSpan.textContent = yearText; + } else { + rowIndex = Array.from(row.parentNode.children).indexOf(row) + 1; + rowLabelSpan.textContent = `${rowWord} ${rowIndex}`; + } + }); +} + + +updateInvisibleRowLabels(); + +document.addEventListener("change", function (e) { + updateInvisibleRowLabels(); +}); diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 8ac11005..e44f0bac 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import Group from django.core.management import call_command from django.db import transaction from django.test.client import Client @@ -6,9 +7,9 @@ from jinja2 import Template as Jinja2Template from phac_aspc.django.settings.utils import configure_settings_for_tests +from cpho import constants from cpho.management.commands.seed_countries import seed_countries from cpho.models import User -from cpho.util import GroupFetcher # Modify django settings to skip axes authentication backend configure_settings_for_tests() @@ -24,26 +25,40 @@ def enable_db_access_for_all_tests(db): @pytest.fixture(scope="session") -def globally_scoped_fixture_helper(django_db_setup, django_db_blocker): - with django_db_blocker.unblock(): - # Wrap in try + atomic block to do non crashing rollback - # This means we don't have to re-create a test DB each time - try: - with transaction.atomic(): - yield - raise Exception - except Exception: - pass +def globally_scoped_fixture_helper( + django_db_setup, django_db_blocker, request +): + + # If ANY collected test is marked selenium, don't do the global atomic trick. + if ( + request.config.getoption("-m", default="") + and "selenium" in request.config.getoption("-m") + and "not selenium" not in request.config.getoption("-m") + ): + print("Activating selenium transaction mode") + with django_db_blocker.unblock(): + yield + return + + else: + with django_db_blocker.unblock(): + # Wrap in try + atomic block to do non crashing rollback + # This means we don't have to re-create a test DB each time + try: + with transaction.atomic(): + yield + raise Exception + except Exception: + pass @pytest.fixture(scope="session", autouse=True) -def seed_core_data(globally_scoped_fixture_helper): +def seed_core_data_fixture(globally_scoped_fixture_helper): + print("Seeding core data...") call_command("loaddata", "cpho/fixtures/periods.yaml") call_command("loaddata", "cpho/fixtures/dimension_lookups.yaml") seed_countries(mode="reset") - # ensure groups created (caching groups break accross tests if not pre-created) - GroupFetcher.hso_group - GroupFetcher.admin_group + Group.objects.get_or_create(name=constants.ADMIN_GROUP_NAME) @pytest.fixture @@ -64,7 +79,8 @@ def hso_user(): username="hso", password="hso", ) - hso.groups.add(GroupFetcher.hso_group) + group, _ = Group.objects.get_or_create(name=constants.HSO_GROUP_NAME) + hso.groups.add(group) return hso diff --git a/server/tests/pytest_test_runner.py b/server/tests/pytest_test_runner.py index f5ba686f..2cd0ad04 100644 --- a/server/tests/pytest_test_runner.py +++ b/server/tests/pytest_test_runner.py @@ -12,18 +12,26 @@ def add_arguments(cls, parser): help="remaps to -k test-selection argument in pytest", ) + parser.add_argument( + "--selenium", + action="store_true", + help="run selenium tests", + ) + def __init__( self, verbosity=1, failfast=False, keepdb=True, select=None, + selenium=False, **kwargs, ): self.verbosity = verbosity self.failfast = failfast self.keepdb = keepdb self.select = select + self.selenium = selenium def run_tests(self, test_labels): """Run pytest and return the exitcode. @@ -45,6 +53,8 @@ def run_tests(self, test_labels): argv.append("--exitfirst") if self.keepdb: argv.append("--reuse-db") + if self.selenium: + argv.append("-m selenium") argv.extend(test_labels) return pytest.main(argv) diff --git a/server/tests/selenium/__init__.py b/server/tests/selenium/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/tests/selenium/conftest.py b/server/tests/selenium/conftest.py new file mode 100644 index 00000000..0508b494 --- /dev/null +++ b/server/tests/selenium/conftest.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.contrib.auth import ( + BACKEND_SESSION_KEY, + HASH_SESSION_KEY, + SESSION_KEY, +) +from django.contrib.sessions.backends.db import SessionStore + +import pytest + + +# override the global autouse fixture just for selenium tests in this package +# see tests/conftest.py's enable_db_access_for_all_tests +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(transactional_db): + pass + + +@pytest.fixture(scope="session") +def driver(): + from selenium import webdriver + + options = webdriver.ChromeOptions() + options.add_argument("--headless=new") + d = webdriver.Chrome(options=options) + yield d + d.quit() + + +@pytest.fixture +def force_login(driver, live_server): + """ + Returns a function: force_login(user) that logs the browser in by setting session cookie. + """ + + def _force_login(user): + # 1) create a session in Django + session = SessionStore() + session[SESSION_KEY] = str(user.pk) + session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0] + session[HASH_SESSION_KEY] = user.get_session_auth_hash() + session.save() + + # 2) visit domain once so Selenium is allowed to set cookies + driver.get(live_server.url + "/404-does-not-exist") + + # 3) set the session cookie in the browser + driver.add_cookie( + { + "name": settings.SESSION_COOKIE_NAME, + "value": session.session_key, + "path": "/", + } + ) + + return _force_login diff --git a/server/tests/selenium/selenium_util.py b/server/tests/selenium/selenium_util.py new file mode 100644 index 00000000..992ccf89 --- /dev/null +++ b/server/tests/selenium/selenium_util.py @@ -0,0 +1,17 @@ +def get_element(driver, selector): + from selenium.webdriver.common.by import By + + return driver.find_element(By.CSS_SELECTOR, selector) + + +def get_elements(driver, selector): + from selenium.webdriver.common.by import By + + return driver.find_elements(By.CSS_SELECTOR, selector) + + +def click_button(driver, selector): + # just using element.click() can fail if the element is not in view + element = get_element(driver, selector) + driver.execute_script("arguments[0].scrollIntoView();", element) + driver.execute_script("arguments[0].click();", element) diff --git a/server/tests/selenium/test_dynamic_formsets.py b/server/tests/selenium/test_dynamic_formsets.py new file mode 100644 index 00000000..89e3b9ab --- /dev/null +++ b/server/tests/selenium/test_dynamic_formsets.py @@ -0,0 +1,104 @@ +# this is a bunch of meta tests + +from django.urls import reverse + +import pytest + +from cpho.model_factories import IndicatorFactory +from cpho.models import DimensionType, Indicator, Period + +from .selenium_util import click_button, get_element, get_elements + +# tag the whole module +pytestmark = [ + pytest.mark.selenium, + pytest.mark.django_db(transaction=True), +] + + +def test_dynamic_agegroup_formset(live_server, driver, hso_user, force_login): + + period = Period.objects.first() + ind = IndicatorFactory() + age_group_dim = DimensionType.objects.get(code="age") + + record0_25 = ind.data.create( + period=period, + dimension_type=age_group_dim, + literal_dimension_val="0-25", + value=5, + ) + + url = reverse( + "manage_indicator_data", args=[ind.id, period.id, age_group_dim.pk] + ) + + force_login(hso_user) + + driver.get(live_server.url + url) + + assert "CPHO Phase 2" in driver.title + + original_form_count = len( + get_elements(driver, "form table tr.age-group-form") + ) + assert original_form_count == 2 # one existing + one empty form + + click_button(driver, "#add-form") + + new_form_count = len(get_elements(driver, "form table tr.age-group-form")) + assert new_form_count == original_form_count + 1 + + +def test_dynamic_trend_formset(live_server, driver, hso_user, force_login): + + period = Period.objects.first() + ind = IndicatorFactory() + + url = reverse("manage_trend_analysis_data", args=[ind.id]) + + force_login(hso_user) + + driver.get(live_server.url + url) + + assert "CPHO Phase 2" in driver.title + + original_form_count = len( + get_elements(driver, "form table tr.trend-analysis-form") + ) + assert original_form_count == 1 # just an empty form + + click_button(driver, "#add-form") + + new_form_count = len( + get_elements(driver, "form table tr.trend-analysis-form") + ) + assert new_form_count == original_form_count + 1 + + +def test_dynamic_benchmarking_formset( + live_server, driver, hso_user, force_login +): + + period = Period.objects.first() + ind = IndicatorFactory() + + url = reverse("manage_benchmarking_data", args=[ind.id]) + + force_login(hso_user) + + driver.get(live_server.url + url) + + assert "CPHO Phase 2" in driver.title + + original_form_count = len( + get_elements(driver, "form table tr.benchmarking-form") + ) + assert original_form_count == 1 # just an empty form + + click_button(driver, "#add-form") + + new_form_count = len( + get_elements(driver, "form table tr.benchmarking-form") + ) + assert new_form_count == original_form_count + 1 diff --git a/server/tests/selenium/test_indicator_form_interaction.py b/server/tests/selenium/test_indicator_form_interaction.py new file mode 100644 index 00000000..44f23cf1 --- /dev/null +++ b/server/tests/selenium/test_indicator_form_interaction.py @@ -0,0 +1,49 @@ +# this is a bunch of meta tests + +from django.urls import reverse + +import pytest + +from cpho.model_factories import IndicatorFactory +from cpho.models import DimensionType, Indicator, Period + +from .selenium_util import click_button, get_element, get_elements + +# tag the whole module +pytestmark = [ + pytest.mark.selenium, + pytest.mark.django_db(transaction=True), +] + + +def test_indicator_form_interaction( + live_server, driver, hso_user, force_login +): + ind = IndicatorFactory() + url = reverse("edit_indicator", args=[ind.id]) + + force_login(hso_user) + + driver.get(live_server.url + url) + + assert "CPHO Phase 2" in driver.title + + english_name_input = get_element(driver, "#id_name") + french_name_input = get_element(driver, "#id_name_fr") + fr_visibility_toggle = get_element(driver, "#show_french") + # check toggle is not checked + assert not fr_visibility_toggle.get_property("checked") + + # check enlish visible + assert english_name_input.is_displayed() + + # check french hidden + assert not french_name_input.is_displayed() + + click_button(driver, "#show_french") + # check toggle is checked + assert fr_visibility_toggle.get_property("checked") + # check french visible + assert french_name_input.is_displayed() + # check english still visible + assert english_name_input.is_displayed() diff --git a/server/tests/selenium/test_selenium_configuration.py b/server/tests/selenium/test_selenium_configuration.py new file mode 100644 index 00000000..08f78871 --- /dev/null +++ b/server/tests/selenium/test_selenium_configuration.py @@ -0,0 +1,34 @@ +# this is a bunch of meta tests + +from django.urls import reverse + +import pytest + +from cpho.models import User + +# tag the whole module +pytestmark = [ + pytest.mark.selenium, + pytest.mark.django_db(transaction=True), +] + + +# first, check that tests are working with independent DBs +def test_selenium_isolated_db_one(live_server, driver): + u = User.objects.create(username="testuser") + + +def test_selenium_isolated_db_two(live_server, driver): + # without isolation, this would fail uniqueness constraint + u = User.objects.create(username="testuser") + + +def test_login_works(live_server, driver, hso_user, force_login): + force_login(hso_user) + + driver.get(live_server.url + reverse("list_indicators")) + # result = driver.get(live_server.url + reverse("list_users")) + # div = driver.find_element(By.TAG_NAME, "div") + + assert "CPHO Phase 2" in driver.title + assert "login" not in driver.title.lower()