Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI configuration and additions #380

Merged
merged 17 commits into from
Aug 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
1.36
1.37

This file should not be modified. It is used by 4CAT to determine whether it
needs to run migration scripts to e.g. update the database structure to a more
Expand Down
52 changes: 49 additions & 3 deletions common/config_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
import pickle
import json

from pathlib import Path
from common.lib.database import Database
Expand Down Expand Up @@ -340,19 +340,26 @@ class ConfigWrapper:
Wrapper for the config manager

Allows setting a default set of tags or user, so that all subsequent calls
to `get()` are done for those tags or that user.
to `get()` are done for those tags or that user. Can also adjust tags based
on the HTTP request, if used in a Flask context.
"""
def __init__(self, config, user=None, tags=None):
def __init__(self, config, user=None, tags=None, request=None):
"""
Initialise config wrapper

:param ConfigManager config: Initialised config manager
:param user: User to get settings for
:param tags: Tags to get settings for
:param request: Request to get headers from. This can be used to set
a particular tag based on the HTTP headers of the request, e.g. to
serve 4CAT with a different configuration based on the proxy server
used.
"""
self.config = config
self.user = user
self.tags = tags
self.request = request


def set(self, *args, **kwargs):
"""
Expand Down Expand Up @@ -382,6 +389,8 @@ def get_all(self, *args, **kwargs):
if "tags" not in kwargs and self.tags:
kwargs["tags"] = self.tags

kwargs["tags"] = self.request_override(kwargs["tags"])

return self.config.get_all(*args, **kwargs)

def get(self, *args, **kwargs):
Expand All @@ -398,8 +407,45 @@ def get(self, *args, **kwargs):
if "tags" not in kwargs:
kwargs["tags"] = self.tags

kwargs["tags"] = self.request_override(kwargs["tags"])

return self.config.get(*args, **kwargs)

def request_override(self, tags):
"""
Force tag via HTTP request headers

To facilitate loading different configurations based on the HTTP
request, the request object can be passed to the ConfigWrapper and
if a certain request header is set, the value of that header will be
added to the list of tags to consider when retrieving settings.

See the flask.proxy_secret config setting; this is used to prevent
users from changing configuration by forging the header.

:param list|str tags: List of tags to extend based on request
:return list: Amended list of tags
"""
if type(tags) is str:
tags = [tags]

if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \
self.config.get("flask.proxy_secret") and \
self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret"):
# need to ensure not just anyone can add this header to their
# request!
# to this end, the second header must be set to the secret value;
# if it is not set, assume the headers are not being configured by
# the proxy server
if not tags:
tags = []

# can never set admin tag via headers (should always be user-based)
forbidden_overrides = ("admin",)
tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides]

return tags

def __getattr__(self, item):
"""
Generic wrapper
Expand Down
54 changes: 53 additions & 1 deletion common/lib/config_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,14 @@
"tooltip": "User tag priority order. It is recommended to manipulate this with the 'User tags' panel instead of directly.",
"global": True
},
"flask.proxy_secret": {
"type": UserInput.OPTION_TEXT,
"default": "",
"help": "Proxy secret",
"tooltip": "Secret value to authenticate proxy headers. If the value of the X-4CAT-Config-Via-Proxy header "
"matches this value, the X-4CAT-Config-Tag header can be used to enable a given configuration tag. "
"Leave empty to disable this functionality."
},
# YouTube variables to use for processors
"api.youtube.name": {
"type": UserInput.OPTION_TEXT,
Expand Down Expand Up @@ -408,6 +416,49 @@
"remote": "Remote",
},
"global": True
},
# UI settings
# this configures what the site looks like
"ui.homepage": {
"type": UserInput.OPTION_CHOICE,
"options": {
"about": "'About' page",
"create-dataset": "'Create dataset' page",
"datasets": "Dataset overview"
},
"help": "4CAT home page",
"default": "about"
},
"ui.inline_preview": {
"type": UserInput.OPTION_TOGGLE,
"help": "Show inline preview",
"default": False,
"tooltip": "Show main dataset preview directly on dataset pages, instead of behind a 'preview' button"
},
"ui.show_datasource": {
"type": UserInput.OPTION_TOGGLE,
"help": "Show data source",
"default": True,
"tooltip": "Show data source for each dataset. Can be useful to disable if only one data source is enabled."
},
"ui.nav_pages": {
"type": UserInput.OPTION_MULTI_SELECT,
"help": "Pages in navigation",
"options": {
"faq": "FAQ",
"data-policy": "Data Policy",
"citing": "How to cite",
"about": "About",
},
"default": ["faq", "about"],
"tooltip": "These pages will be included in the navigation bar at the top of the interface."
},
"ui.prefer_mapped_preview": {
"type": UserInput.OPTION_TOGGLE,
"help": "Prefer mapped preview",
"default": True,
"tooltip": "If a dataset is a JSON file but it can be mapped to a CSV file, show the CSV in the preview instead"
"of the underlying JSON."
}
}

Expand All @@ -424,5 +475,6 @@
"logging": "Logging",
"path": "File paths",
"privileges": "User privileges",
"dmi-service-manager": "DMI Service Manager"
"dmi-service-manager": "DMI Service Manager",
"ui": "User interface"
}
22 changes: 20 additions & 2 deletions common/lib/user_input.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from dateutil.parser import parse as parse_datetime
from common.lib.exceptions import QueryParametersException
from werkzeug.datastructures import ImmutableMultiDict
import json

import re
Expand Down Expand Up @@ -54,6 +55,18 @@ def parse_all(options, input, silently_correct=True):
from common.lib.helpers import convert_to_int
parsed_input = {}

if type(input) is not dict and type(input) is not ImmutableMultiDict:
raise TypeError("input must be a dictionary or ImmutableMultiDict")

if type(input) is ImmutableMultiDict:
# we are not using to_dict, because that messes up multi-selects
input = {key: input.getlist(key) for key in input}
for key, value in input.items():
if type(value) is list and len(value) == 1:
input[key] = value[0]

print(input)

# all parameters are submitted as option-[parameter ID], this is an
# artifact of how the web interface works and we can simply remove the
# prefix
Expand Down Expand Up @@ -187,8 +200,13 @@ def parse_value(settings, choice, silently_correct=True):
if not choice:
return settings.get("default", [])

chosen = choice.split(",")
return [item for item in chosen if item in settings.get("options", [])]
if type(choice) is str:
# should be a list if the form control was actually a multiselect
# but we have some client side UI helpers that may produce a string
# instead
choice = choice.split(",")

return [item for item in choice if item in settings.get("options", [])]

elif input_type == UserInput.OPTION_CHOICE:
# select box
Expand Down
Loading