common.config_manager
1import itertools 2import threading 3import pickle 4import time 5import json 6 7from pymemcache.client.base import Client as MemcacheClient 8from pymemcache.exceptions import MemcacheError 9from pymemcache import serde 10from pathlib import Path 11from common.lib.database import Database 12 13from common.lib.exceptions import ConfigException 14from common.lib.config_definition import config_definition 15 16import configparser 17import os 18 19class CacheMiss: 20 """ 21 Helper class to distinguish memcache misses from true `None` values 22 """ 23 pass 24 25 26class ConfigManager: 27 db = None 28 dbconn = None 29 cache = {} 30 memcache = None 31 32 core_settings = {} 33 config_definition = {} 34 35 def __init__(self, db=None): 36 # ensure core settings (including database config) are loaded 37 self.load_core_settings() 38 self.load_user_settings() 39 self.memcache = self.load_memcache() 40 41 # establish database connection if none available 42 if db: 43 self.with_db(db) 44 45 def with_db(self, db=None): 46 """ 47 Initialise database 48 49 Not done on init, because something may need core settings before the 50 database can be initialised 51 52 :param db: Database object. If None, initialise it using the core config 53 """ 54 if db or not self.db: 55 # Replace w/ db if provided else only initialise if not already 56 self.db = db if db else Database(logger=None, dbname=self.get("DB_NAME"), user=self.get("DB_USER"), 57 password=self.get("DB_PASSWORD"), host=self.get("DB_HOST"), 58 port=self.get("DB_PORT"), appname="config-reader") 59 else: 60 # self.db already initialized and no db provided 61 pass 62 63 def load_user_settings(self): 64 """ 65 Load settings configurable by the user 66 67 Does not load the settings themselves, but rather the definition so 68 values can be validated, etc 69 """ 70 # basic 4CAT settings 71 self.config_definition.update(config_definition) 72 73 # module settings can't be loaded directly because modules need the 74 # config manager to load, so that becomes circular 75 # instead, this is cached on startup and then loaded here 76 module_config_path = self.get("PATH_ROOT").joinpath("config/module_config.bin") 77 if module_config_path.exists(): 78 try: 79 with module_config_path.open("rb") as infile: 80 retries = 0 81 module_config = None 82 # if 4CAT is being run in two different containers 83 # (front-end and back-end) they might both be running this 84 # bit of code at the same time. If the file is half-written 85 # loading it will fail, so allow for a few retries 86 while retries < 3: 87 try: 88 module_config = pickle.load(infile) 89 break 90 except Exception: # this can be a number of exceptions, all with the same recovery path 91 time.sleep(0.1) 92 retries += 1 93 continue 94 95 if module_config is None: 96 # not really a way to gracefully recover from this, but 97 # we can at least describe the error 98 raise RuntimeError("Could not read module_config.bin. The 4CAT developers did a bad job of " 99 "preventing this. Shame on them!") 100 101 self.config_definition.update(module_config) 102 except (ValueError, TypeError): 103 pass 104 105 def load_core_settings(self): 106 """ 107 Load 4CAT core settings 108 109 These are (mostly) stored in config.ini and cannot be changed from the 110 web interface. 111 112 :return: 113 """ 114 config_file = Path(__file__).parent.parent.joinpath("config/config.ini") 115 116 config_reader = configparser.ConfigParser() 117 in_docker = False 118 if config_file.exists(): 119 config_reader.read(config_file) 120 if config_reader["DOCKER"].getboolean("use_docker_config"): 121 # Can use throughtout 4CAT to know if Docker environment 122 in_docker = True 123 else: 124 # config should be created! 125 raise ConfigException("No config/config.ini file exists! Update and rename the config.ini-example file.") 126 127 self.core_settings.update({ 128 "CONFIG_FILE": config_file.resolve(), 129 "USING_DOCKER": in_docker, 130 "DB_HOST": config_reader["DATABASE"].get("db_host"), 131 "DB_PORT": config_reader["DATABASE"].get("db_port"), 132 "DB_USER": config_reader["DATABASE"].get("db_user"), 133 "DB_NAME": config_reader["DATABASE"].get("db_name"), 134 "DB_PASSWORD": config_reader["DATABASE"].get("db_password"), 135 136 "API_HOST": config_reader["API"].get("api_host"), 137 "API_PORT": config_reader["API"].getint("api_port"), 138 139 "MEMCACHE_SERVER": config_reader.get("MEMCACHE", option="memcache_host", fallback={}), 140 141 "PATH_ROOT": Path(os.path.abspath(os.path.dirname(__file__))).joinpath( 142 "..").resolve(), # better don"t change this 143 "PATH_LOGS": Path(config_reader["PATHS"].get("path_logs", "")), 144 "PATH_IMAGES": Path(config_reader["PATHS"].get("path_images", "")), 145 "PATH_DATA": Path(config_reader["PATHS"].get("path_data", "")), 146 "PATH_LOCKFILE": Path(config_reader["PATHS"].get("path_lockfile", "")), 147 "PATH_SESSIONS": Path(config_reader["PATHS"].get("path_sessions", "")), 148 149 "ANONYMISATION_SALT": config_reader["GENERATE"].get("anonymisation_salt"), 150 "SECRET_KEY": config_reader["GENERATE"].get("secret_key") 151 }) 152 153 154 def load_memcache(self): 155 """ 156 Initialise memcache client 157 158 The config reader can optionally use Memcache to keep fetched values in 159 memory. 160 """ 161 if self.get("MEMCACHE_SERVER"): 162 try: 163 # do one test fetch to test if connection is valid 164 memcache = MemcacheClient(self.get("MEMCACHE_SERVER"), serde=serde.pickle_serde, key_prefix=b"4cat-config") 165 memcache.set("4cat-init-dummy", time.time()) 166 memcache.init_thread_id = threading.get_ident() 167 return memcache 168 except (SystemError, ValueError, MemcacheError, ConnectionError, OSError): 169 # we have no access to the logger here so we simply pass 170 # later we can detect elsewhere that a memcache address is 171 # configured but no connection is there - then we can log 172 # config reader still works without memcache 173 pass 174 175 return None 176 177 def ensure_database(self): 178 """ 179 Ensure the database is in sync with the config definition 180 181 Deletes all stored settings not defined in 4CAT, and creates a global 182 setting for all settings not yet in the database. 183 """ 184 self.with_db() 185 186 # create global values for known keys with the default 187 known_settings = self.get_all_setting_names() 188 for setting, parameters in self.config_definition.items(): 189 if setting in known_settings: 190 continue 191 192 self.db.log.debug(f"Creating setting: {setting} with default value {parameters.get('default', '')}") 193 self.set(setting, parameters.get("default", "")) 194 195 # make sure settings and user table are in sync 196 user_tags = list(set(itertools.chain(*[u["tags"] for u in self.db.fetchall("SELECT DISTINCT tags FROM users")]))) 197 known_tags = [t["tag"] for t in self.db.fetchall("SELECT DISTINCT tag FROM settings")] 198 tag_order = self.get("flask.tag_order") 199 200 for tag in known_tags: 201 # add tags used by a setting to tag order 202 if tag and tag not in tag_order: 203 tag_order.append(tag) 204 205 for tag in user_tags: 206 # add tags used by a user to tag order 207 if tag and tag not in tag_order: 208 tag_order.append(tag) 209 210 # admin tag should always be first in order 211 if "admin" in tag_order: 212 tag_order.remove("admin") 213 214 tag_order.insert(0, "admin") 215 216 self.set("flask.tag_order", tag_order) 217 self.db.commit() 218 219 def get_all_setting_names(self, with_core=True): 220 """ 221 Get names of all settings 222 223 For when the value doesn't matter! 224 225 :param bool with_core: Also include core (i.e. config.ini) settings 226 :return list: List of setting names known by the database and core settings 227 """ 228 # attempt to initialise the database connection so we can include 229 # user settings 230 if not self.db: 231 self.with_db() 232 233 settings = list(self.core_settings.keys()) if with_core else [] 234 settings.extend([s["name"] for s in self.db.fetchall("SELECT DISTINCT name FROM settings")]) 235 236 return settings 237 238 def get_all(self, is_json=False, user=None, tags=None, with_core=True, memcache=None): 239 """ 240 Get all known settings 241 242 This is *not optimised* but used rarely enough that that doesn't 243 matter so much. 244 245 :param bool is_json: if True, the value is returned as stored and not 246 interpreted as JSON if it comes from the database 247 :param user: User object or name. Adds a tag `user:[username]` in 248 front of the tag list. 249 :param tags: Tag or tags for the required setting. If a tag is 250 provided, the method checks if a special value for the setting exists 251 with the given tag, and returns that if one exists. First matching tag 252 wins. 253 :param bool with_core: Also include core (i.e. config.ini) settings 254 :param MemcacheClient memcache: Memcache client. If `None` and 255 `self.memcache` exists, use that instead. 256 257 :return dict: Setting value, as a dictionary with setting names as keys 258 and setting values as values. 259 """ 260 for setting in self.get_all_setting_names(with_core=with_core): 261 yield setting, self.get(setting, None, is_json, user, tags, memcache) 262 263 264 def get(self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None): 265 """ 266 Get a setting's value from the database 267 268 If the setting does not exist, the provided fallback value is returned. 269 270 :param str attribute_name: Setting to return. 271 :param default: Value to return if setting does not exist 272 :param bool is_json: if True, the value is returned as stored and not 273 interpreted as JSON if it comes from the database 274 :param user: User object or name. Adds a tag `user:[username]` in 275 front of the tag list. 276 :param tags: Tag or tags for the required setting. If a tag is 277 provided, the method checks if a special value for the setting exists 278 with the given tag, and returns that if one exists. First matching tag 279 wins. 280 :param MemcacheClient memcache: Memcache client. If `None` and 281 `self.memcache` exists, use that instead. 282 283 :return: Setting value, or the provided fallback, or `None`. 284 """ 285 # core settings are not from the database 286 # they are therefore also not memcached - too little gain 287 if type(attribute_name) is not str: 288 raise TypeError(f"attribute_name must be a str, {attribute_name.__class__.__name__} given") 289 290 if attribute_name in self.core_settings: 291 # we never get to the database or memcache part of this method if 292 # this is a core setting we already know 293 return self.core_settings[attribute_name] 294 295 # if trying to access a setting that's not a core setting, attempt to 296 # initialise the database connection 297 if not self.db: 298 self.with_db() 299 300 # get tags to look for 301 # copy() because else we keep adding onto the same list, which 302 # interacts badly with get_all() 303 if tags: 304 tags = tags.copy() 305 tags = self.get_active_tags(user, tags, memcache) 306 307 # now we have all tags - get the config values for each (if available) 308 # and then return the first matching one. Add the 'empty' tag at the 309 # end to fall back to the global value if no specific one exists. 310 tags.append("") 311 312 # short-circuit via memcache if appropriate 313 if not memcache and self.memcache: 314 memcache = self.memcache 315 316 # first check if we have all the values in memcache, in which case we 317 # do not need a database query 318 if memcache: 319 if threading.get_ident() != memcache.init_thread_id: 320 raise RuntimeError("Thread-unsafe use of memcache! Please make sure you are using a configuration " 321 "wrapper to read with a thread-local memcache connection.") 322 323 cached_values = {tag: memcache.get(self._get_memcache_id(attribute_name, tag), default=CacheMiss) for tag in tags} 324 325 else: 326 cached_values = {t: CacheMiss for t in tags} 327 328 # for the tags we could not get from memcache, run a database query 329 # (and save to cache if possible) 330 missing_tags = [t for t in cached_values if cached_values[t] is CacheMiss] 331 if missing_tags: 332 # query database for any values within the required tags 333 query = "SELECT * FROM settings WHERE name = %s AND tag IN %s" 334 replacements = (attribute_name, tuple(missing_tags)) 335 queried_settings = {setting["tag"]: setting["value"] for setting in self.db.fetchall(query, replacements)} 336 337 if memcache: 338 for tag, value in queried_settings.items(): 339 memcache.set(self._get_memcache_id(attribute_name, tag), value) 340 341 cached_values.update(queried_settings) 342 343 # there may be some tags for which we still do not have a value at 344 # this point. these simply do not have a tag-specific value but that in 345 # itself is worth caching, otherwise we're going to query for a 346 # non-existent value each time. 347 # so: cache a magic value for such setting/tag combinations, and 348 # replace the magic value with a CacheMiss in the dict that will be 349 # parsed 350 unconfigured_magic = "__unconfigured__" 351 if memcache: 352 for tag in [t for t in cached_values if cached_values[t] is CacheMiss]: 353 # should this be more magic? 354 memcache.set(self._get_memcache_id(attribute_name, tag), unconfigured_magic) 355 356 for tag in [t for t in cached_values if cached_values[t] == unconfigured_magic]: 357 cached_values[tag] = CacheMiss 358 359 # now we may still have some CacheMisses in the values dict, if there 360 # was no setting in the database with that tag. So, find the first 361 # value that is not a CacheMiss. If nothing matches, try the global tag 362 # and if even that does not match (no setting saved at all) return the 363 # default 364 for tag in tags: 365 if tag in cached_values and cached_values.get(tag) is not CacheMiss: 366 value = cached_values[tag] 367 break 368 else: 369 value = None 370 371 # parse some values... 372 if not is_json and value is not None: 373 value = json.loads(value) 374 # TODO: Which default should have priority? The provided default feels like it should be the highest priority, but I think that is an old implementation and perhaps should be removed. - Dale 375 elif value is None and attribute_name in self.config_definition and "default" in self.config_definition[attribute_name]: 376 value = self.config_definition[attribute_name]["default"] 377 elif value is None and default is not None: 378 value = default 379 380 return value 381 382 def get_active_tags(self, user=None, tags=None, memcache=None): 383 """ 384 Get active tags for given user/tag list 385 386 Used internally to harmonize tag setting for various methods, but can 387 also be called directly to verify tag activation. 388 389 :param user: User object or name. Adds a tag `user:[username]` in 390 front of the tag list. 391 :param tags: Tag or tags for the required setting. If a tag is 392 provided, the method checks if a special value for the setting exists 393 with the given tag, and returns that if one exists. First matching tag 394 wins. 395 :param MemcacheClient memcache: Memcache client. If `None` and 396 `self.memcache` exists, use that instead. 397 :return list: List of tags 398 """ 399 # be flexible about the input types here 400 if tags is None: 401 tags = [] 402 elif type(tags) is str: 403 tags = [tags] 404 405 user = self._normalise_user(user) 406 407 # user-specific settings are just a special type of tag (which takes 408 # precedence), same goes for user groups. so if a user was passed, get 409 # that user's tags (including the 'special' user: tag) and add them 410 # to the list 411 if user: 412 user_tags = CacheMiss 413 414 if not memcache and self.memcache: 415 memcache = self.memcache 416 417 if memcache: 418 memcache_id = f"_usertags-{user}" 419 user_tags = memcache.get(memcache_id, default=CacheMiss) 420 421 if user_tags is CacheMiss: 422 user_tags = self.db.fetchone("SELECT tags FROM users WHERE name = %s", (user,)) 423 if user_tags and memcache: 424 memcache.set(memcache_id, user_tags) 425 426 if user_tags: 427 try: 428 tags.extend(user_tags["tags"]) 429 except (TypeError, ValueError): 430 # should be a JSON list, but isn't 431 pass 432 433 tags.insert(0, f"user:{user}") 434 435 return tags 436 437 def set(self, attribute_name, value, is_json=False, tag="", overwrite_existing=True, memcache=None): 438 """ 439 Insert OR set value for a setting 440 441 If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the 442 setting exists the setting is not updated. 443 444 :param str attribute_name: Attribute to set 445 :param value: Value to set (will be serialised as JSON) 446 :param bool is_json: True for a value that is already a serialised JSON string; False if value is object that needs to 447 be serialised into a JSON string 448 :param bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists 449 :param str tag: Tag to write setting for 450 :param MemcacheClient memcache: Memcache client. If `None` and 451 `self.memcache` exists, use that instead. 452 453 :return int: number of updated rows 454 """ 455 # Check value is valid JSON 456 if is_json: 457 try: 458 json.dumps(json.loads(value)) 459 except json.JSONDecodeError: 460 return None 461 else: 462 try: 463 value = json.dumps(value) 464 except json.JSONDecodeError: 465 return None 466 467 if attribute_name in self.config_definition and self.config_definition.get(attribute_name).get("global"): 468 tag = "" 469 470 if overwrite_existing: 471 query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT (name, tag) DO UPDATE SET value = EXCLUDED.value" 472 else: 473 query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING" 474 475 self.db.execute(query, (attribute_name, value, tag)) 476 updated_rows = self.db.cursor.rowcount 477 self.db.log.debug(f"Updated setting for {attribute_name}: {value} (tag: {tag})") 478 479 if not memcache and self.memcache: 480 memcache = self.memcache 481 482 if memcache: 483 # invalidate any cached value for this setting 484 memcache_id = self._get_memcache_id(attribute_name, tag) 485 memcache.delete(memcache_id) 486 487 return updated_rows 488 489 def delete_for_tag(self, attribute_name, tag): 490 """ 491 Delete config override for a given tag 492 493 :param str attribute_name: 494 :param str tag: 495 :return int: number of deleted rows 496 """ 497 self.db.delete("settings", where={"name": attribute_name, "tag": tag}) 498 updated_rows = self.db.cursor.rowcount 499 500 if self.memcache: 501 self.memcache.delete(self._get_memcache_id(attribute_name, tag)) 502 503 return updated_rows 504 505 def clear_cache(self): 506 """ 507 Clear cached configuration values 508 509 Called when the backend restarts - helps start with a blank slate. 510 """ 511 if not self.memcache: 512 return 513 514 self.memcache.flush_all() 515 516 def uncache_user_tags(self, users): 517 """ 518 Clear cached user tags 519 520 User tags are cached with memcache if possible to avoid unnecessary 521 database roundtrips. This method clears the cached user tags, in case 522 a tag is added/deleted from a user. 523 524 :param list users: List of users, as usernames or User objects 525 """ 526 if self.memcache: 527 for user in users: 528 user = self._normalise_user(user) 529 self.memcache.delete(f"_usertags-{user}") 530 531 def _normalise_user(self, user): 532 """ 533 Normalise user object 534 535 Users may be passed as a username, a user object, or a proxy of such an 536 object. This method normalises this to a string (the username), or 537 `None` if no user is provided. 538 539 :param user: User value to normalise 540 :return str|None: Normalised value 541 """ 542 543 # can provide either a string or user object 544 if type(user) is not str: 545 if type(user).__name__ == "LocalProxy": 546 # passed on from Flask 547 user = user._get_current_object() 548 549 if hasattr(user, "get_id"): 550 user = user.get_id() 551 elif user != None: # noqa: E711 552 # werkzeug.local.LocalProxy (e.g., user not yet logged in) wraps None; use '!=' instead of 'is not' 553 raise TypeError( 554 f"_normalise_user() expects None, a User object or a string for argument 'user', {type(user).__name__} given" 555 ) 556 557 return user 558 559 def _get_memcache_id(self, attribute_name, tags=None): 560 """ 561 Generate a memcache key for a config setting request 562 563 This includes the relevant user name/tags because the value may be 564 different depending on the value of these parameters. 565 566 :param str attribute_name: 567 :param str|list tags: 568 :return str: 569 """ 570 if tags and isinstance(tags, str): 571 tags = [tags] 572 573 tag_bit = [] 574 if tags: 575 tag_bit.append("|".join(tags)) 576 577 memcache_id = attribute_name 578 if tag_bit: 579 memcache_id += f"-{'-'.join(tag_bit)}" 580 581 return memcache_id.encode("ascii") 582 583 def __getattr__(self, attr): 584 """ 585 Getter so we can directly request values 586 587 :param attr: Config setting to get 588 :return: Value 589 """ 590 591 if attr in dir(self): 592 # an explicitly defined attribute should always be called in favour 593 # of this passthrough 594 attribute = getattr(self, attr) 595 return attribute 596 else: 597 return self.get(attr) 598 599 600class ConfigWrapper: 601 """ 602 Wrapper for the config manager 603 604 Allows setting a default set of tags or user, so that all subsequent calls 605 to `get()` are done for those tags or that user. Can also adjust tags based 606 on the HTTP request, if used in a Flask context. 607 """ 608 def __init__(self, config, user=None, tags=None, request=None): 609 """ 610 Initialise config wrapper 611 612 :param ConfigManager config: Initialised config manager 613 :param user: User to get settings for 614 :param tags: Tags to get settings for 615 :param request: Request to get headers from. This can be used to set 616 a particular tag based on the HTTP headers of the request, e.g. to 617 serve 4CAT with a different configuration based on the proxy server 618 used. 619 """ 620 if type(config) is ConfigWrapper: 621 # let's not do nested wrappers, but copy properties unless 622 # provided explicitly 623 self.user = user if user else config.user 624 self.tags = tags if tags else config.tags 625 self.request = request if request else config.request 626 self.config = config.config 627 self.memcache = config.memcache 628 else: 629 self.config = config 630 self.user = user 631 self.tags = tags 632 self.request = request 633 634 # this ensures we use our own memcache client, important in threaded 635 # contexts because pymemcache is not thread-safe. Unless we get a 636 # prepared connection... 637 self.memcache = self.config.load_memcache() 638 639 # this ensures the user object in turn reads from the wrapper 640 if self.user: 641 self.user.with_config(self, rewrap=False) 642 643 644 def set(self, *args, **kwargs): 645 """ 646 Wrap `set()` 647 648 :param args: 649 :param kwargs: 650 :return: 651 """ 652 if "tag" not in kwargs and self.tags: 653 kwargs["tag"] = self.tags 654 655 kwargs["memcache"] = self.memcache 656 657 return self.config.set(*args, **kwargs) 658 659 def get_all(self, *args, **kwargs): 660 """ 661 Wrap `get_all()` 662 663 Takes the `user`, `tags` and `request` given when initialised into 664 account. If `tags` is set explicitly, the HTTP header-based override 665 is not applied. 666 667 :param args: 668 :param kwargs: 669 :return: 670 """ 671 if "user" not in kwargs and self.user: 672 kwargs["user"] = self.user 673 674 if "tags" not in kwargs: 675 kwargs["tags"] = self.tags if self.tags else [] 676 kwargs["tags"] = self.request_override(kwargs["tags"]) 677 678 kwargs["memcache"] = self.memcache 679 680 return self.config.get_all(*args, **kwargs) 681 682 def get(self, *args, **kwargs): 683 """ 684 Wrap `get()` 685 686 Takes the `user`, `tags` and `request` given when initialised into 687 account. If `tags` is set explicitly, the HTTP header-based override 688 is not applied. 689 690 :param args: 691 :param kwargs: 692 :return: 693 """ 694 if "user" not in kwargs: 695 kwargs["user"] = self.user 696 697 if "tags" not in kwargs: 698 kwargs["tags"] = self.tags if self.tags else [] 699 kwargs["tags"] = self.request_override(kwargs["tags"]) 700 701 kwargs["memcache"] = self.memcache 702 703 return self.config.get(*args, **kwargs) 704 705 def get_active_tags(self, user=None, tags=None): 706 """ 707 Wrap `get_active_tags()` 708 709 Takes the `user`, `tags` and `request` given when initialised into 710 account. If `tags` is set explicitly, the HTTP header-based override 711 is not applied. 712 713 :param user: 714 :param tags: 715 :return list: 716 """ 717 active_tags = self.config.get_active_tags(user, tags, self.memcache) 718 if not tags: 719 active_tags = self.request_override(active_tags) 720 721 return active_tags 722 723 def request_override(self, tags): 724 """ 725 Force tag via HTTP request headers 726 727 To facilitate loading different configurations based on the HTTP 728 request, the request object can be passed to the ConfigWrapper and 729 if a certain request header is set, the value of that header will be 730 added to the list of tags to consider when retrieving settings. 731 732 See the flask.proxy_secret config setting; this is used to prevent 733 users from changing configuration by forging the header. 734 735 :param list|str tags: List of tags to extend based on request 736 :return list: Amended list of tags 737 """ 738 if type(tags) is str: 739 tags = [tags] 740 741 # use self.config.get here, not self.get, because else we get infinite 742 # recursion (since self.get can call this method) 743 if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \ 744 self.config.get("flask.proxy_secret", memcache=self.memcache) and \ 745 self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret", memcache=self.memcache): 746 # need to ensure not just anyone can add this header to their 747 # request! 748 # to this end, the second header must be set to the secret value; 749 # if it is not set, assume the headers are not being configured by 750 # the proxy server 751 if not tags: 752 tags = [] 753 754 # can never set admin tag via headers (should always be user-based) 755 forbidden_overrides = ("admin",) 756 tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides] 757 758 return tags 759 760 761 def __getattr__(self, item): 762 """ 763 Generic wrapper 764 765 Just pipe everything through to the config object 766 767 :param item: 768 :return: 769 """ 770 if hasattr(self.config, item): 771 return getattr(self.config, item) 772 elif hasattr(self, item): 773 return getattr(self, item) 774 else: 775 raise AttributeError(f"'{self.__name__}' object has no attribute '{item}'") 776 777 778class CoreConfigManager(ConfigManager): 779 """ 780 A configuration reader that can only read from core settings 781 782 Can be used in thread-unsafe context and when no database is present. 783 """ 784 def with_db(self, db=None): 785 """ 786 Raise a RuntimeError when trying to link a database connection 787 788 :param db: 789 """ 790 raise RuntimeError("Trying to read non-core configuration value from a CoreConfigManager")
20class CacheMiss: 21 """ 22 Helper class to distinguish memcache misses from true `None` values 23 """ 24 pass
Helper class to distinguish memcache misses from true None
values
27class ConfigManager: 28 db = None 29 dbconn = None 30 cache = {} 31 memcache = None 32 33 core_settings = {} 34 config_definition = {} 35 36 def __init__(self, db=None): 37 # ensure core settings (including database config) are loaded 38 self.load_core_settings() 39 self.load_user_settings() 40 self.memcache = self.load_memcache() 41 42 # establish database connection if none available 43 if db: 44 self.with_db(db) 45 46 def with_db(self, db=None): 47 """ 48 Initialise database 49 50 Not done on init, because something may need core settings before the 51 database can be initialised 52 53 :param db: Database object. If None, initialise it using the core config 54 """ 55 if db or not self.db: 56 # Replace w/ db if provided else only initialise if not already 57 self.db = db if db else Database(logger=None, dbname=self.get("DB_NAME"), user=self.get("DB_USER"), 58 password=self.get("DB_PASSWORD"), host=self.get("DB_HOST"), 59 port=self.get("DB_PORT"), appname="config-reader") 60 else: 61 # self.db already initialized and no db provided 62 pass 63 64 def load_user_settings(self): 65 """ 66 Load settings configurable by the user 67 68 Does not load the settings themselves, but rather the definition so 69 values can be validated, etc 70 """ 71 # basic 4CAT settings 72 self.config_definition.update(config_definition) 73 74 # module settings can't be loaded directly because modules need the 75 # config manager to load, so that becomes circular 76 # instead, this is cached on startup and then loaded here 77 module_config_path = self.get("PATH_ROOT").joinpath("config/module_config.bin") 78 if module_config_path.exists(): 79 try: 80 with module_config_path.open("rb") as infile: 81 retries = 0 82 module_config = None 83 # if 4CAT is being run in two different containers 84 # (front-end and back-end) they might both be running this 85 # bit of code at the same time. If the file is half-written 86 # loading it will fail, so allow for a few retries 87 while retries < 3: 88 try: 89 module_config = pickle.load(infile) 90 break 91 except Exception: # this can be a number of exceptions, all with the same recovery path 92 time.sleep(0.1) 93 retries += 1 94 continue 95 96 if module_config is None: 97 # not really a way to gracefully recover from this, but 98 # we can at least describe the error 99 raise RuntimeError("Could not read module_config.bin. The 4CAT developers did a bad job of " 100 "preventing this. Shame on them!") 101 102 self.config_definition.update(module_config) 103 except (ValueError, TypeError): 104 pass 105 106 def load_core_settings(self): 107 """ 108 Load 4CAT core settings 109 110 These are (mostly) stored in config.ini and cannot be changed from the 111 web interface. 112 113 :return: 114 """ 115 config_file = Path(__file__).parent.parent.joinpath("config/config.ini") 116 117 config_reader = configparser.ConfigParser() 118 in_docker = False 119 if config_file.exists(): 120 config_reader.read(config_file) 121 if config_reader["DOCKER"].getboolean("use_docker_config"): 122 # Can use throughtout 4CAT to know if Docker environment 123 in_docker = True 124 else: 125 # config should be created! 126 raise ConfigException("No config/config.ini file exists! Update and rename the config.ini-example file.") 127 128 self.core_settings.update({ 129 "CONFIG_FILE": config_file.resolve(), 130 "USING_DOCKER": in_docker, 131 "DB_HOST": config_reader["DATABASE"].get("db_host"), 132 "DB_PORT": config_reader["DATABASE"].get("db_port"), 133 "DB_USER": config_reader["DATABASE"].get("db_user"), 134 "DB_NAME": config_reader["DATABASE"].get("db_name"), 135 "DB_PASSWORD": config_reader["DATABASE"].get("db_password"), 136 137 "API_HOST": config_reader["API"].get("api_host"), 138 "API_PORT": config_reader["API"].getint("api_port"), 139 140 "MEMCACHE_SERVER": config_reader.get("MEMCACHE", option="memcache_host", fallback={}), 141 142 "PATH_ROOT": Path(os.path.abspath(os.path.dirname(__file__))).joinpath( 143 "..").resolve(), # better don"t change this 144 "PATH_LOGS": Path(config_reader["PATHS"].get("path_logs", "")), 145 "PATH_IMAGES": Path(config_reader["PATHS"].get("path_images", "")), 146 "PATH_DATA": Path(config_reader["PATHS"].get("path_data", "")), 147 "PATH_LOCKFILE": Path(config_reader["PATHS"].get("path_lockfile", "")), 148 "PATH_SESSIONS": Path(config_reader["PATHS"].get("path_sessions", "")), 149 150 "ANONYMISATION_SALT": config_reader["GENERATE"].get("anonymisation_salt"), 151 "SECRET_KEY": config_reader["GENERATE"].get("secret_key") 152 }) 153 154 155 def load_memcache(self): 156 """ 157 Initialise memcache client 158 159 The config reader can optionally use Memcache to keep fetched values in 160 memory. 161 """ 162 if self.get("MEMCACHE_SERVER"): 163 try: 164 # do one test fetch to test if connection is valid 165 memcache = MemcacheClient(self.get("MEMCACHE_SERVER"), serde=serde.pickle_serde, key_prefix=b"4cat-config") 166 memcache.set("4cat-init-dummy", time.time()) 167 memcache.init_thread_id = threading.get_ident() 168 return memcache 169 except (SystemError, ValueError, MemcacheError, ConnectionError, OSError): 170 # we have no access to the logger here so we simply pass 171 # later we can detect elsewhere that a memcache address is 172 # configured but no connection is there - then we can log 173 # config reader still works without memcache 174 pass 175 176 return None 177 178 def ensure_database(self): 179 """ 180 Ensure the database is in sync with the config definition 181 182 Deletes all stored settings not defined in 4CAT, and creates a global 183 setting for all settings not yet in the database. 184 """ 185 self.with_db() 186 187 # create global values for known keys with the default 188 known_settings = self.get_all_setting_names() 189 for setting, parameters in self.config_definition.items(): 190 if setting in known_settings: 191 continue 192 193 self.db.log.debug(f"Creating setting: {setting} with default value {parameters.get('default', '')}") 194 self.set(setting, parameters.get("default", "")) 195 196 # make sure settings and user table are in sync 197 user_tags = list(set(itertools.chain(*[u["tags"] for u in self.db.fetchall("SELECT DISTINCT tags FROM users")]))) 198 known_tags = [t["tag"] for t in self.db.fetchall("SELECT DISTINCT tag FROM settings")] 199 tag_order = self.get("flask.tag_order") 200 201 for tag in known_tags: 202 # add tags used by a setting to tag order 203 if tag and tag not in tag_order: 204 tag_order.append(tag) 205 206 for tag in user_tags: 207 # add tags used by a user to tag order 208 if tag and tag not in tag_order: 209 tag_order.append(tag) 210 211 # admin tag should always be first in order 212 if "admin" in tag_order: 213 tag_order.remove("admin") 214 215 tag_order.insert(0, "admin") 216 217 self.set("flask.tag_order", tag_order) 218 self.db.commit() 219 220 def get_all_setting_names(self, with_core=True): 221 """ 222 Get names of all settings 223 224 For when the value doesn't matter! 225 226 :param bool with_core: Also include core (i.e. config.ini) settings 227 :return list: List of setting names known by the database and core settings 228 """ 229 # attempt to initialise the database connection so we can include 230 # user settings 231 if not self.db: 232 self.with_db() 233 234 settings = list(self.core_settings.keys()) if with_core else [] 235 settings.extend([s["name"] for s in self.db.fetchall("SELECT DISTINCT name FROM settings")]) 236 237 return settings 238 239 def get_all(self, is_json=False, user=None, tags=None, with_core=True, memcache=None): 240 """ 241 Get all known settings 242 243 This is *not optimised* but used rarely enough that that doesn't 244 matter so much. 245 246 :param bool is_json: if True, the value is returned as stored and not 247 interpreted as JSON if it comes from the database 248 :param user: User object or name. Adds a tag `user:[username]` in 249 front of the tag list. 250 :param tags: Tag or tags for the required setting. If a tag is 251 provided, the method checks if a special value for the setting exists 252 with the given tag, and returns that if one exists. First matching tag 253 wins. 254 :param bool with_core: Also include core (i.e. config.ini) settings 255 :param MemcacheClient memcache: Memcache client. If `None` and 256 `self.memcache` exists, use that instead. 257 258 :return dict: Setting value, as a dictionary with setting names as keys 259 and setting values as values. 260 """ 261 for setting in self.get_all_setting_names(with_core=with_core): 262 yield setting, self.get(setting, None, is_json, user, tags, memcache) 263 264 265 def get(self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None): 266 """ 267 Get a setting's value from the database 268 269 If the setting does not exist, the provided fallback value is returned. 270 271 :param str attribute_name: Setting to return. 272 :param default: Value to return if setting does not exist 273 :param bool is_json: if True, the value is returned as stored and not 274 interpreted as JSON if it comes from the database 275 :param user: User object or name. Adds a tag `user:[username]` in 276 front of the tag list. 277 :param tags: Tag or tags for the required setting. If a tag is 278 provided, the method checks if a special value for the setting exists 279 with the given tag, and returns that if one exists. First matching tag 280 wins. 281 :param MemcacheClient memcache: Memcache client. If `None` and 282 `self.memcache` exists, use that instead. 283 284 :return: Setting value, or the provided fallback, or `None`. 285 """ 286 # core settings are not from the database 287 # they are therefore also not memcached - too little gain 288 if type(attribute_name) is not str: 289 raise TypeError(f"attribute_name must be a str, {attribute_name.__class__.__name__} given") 290 291 if attribute_name in self.core_settings: 292 # we never get to the database or memcache part of this method if 293 # this is a core setting we already know 294 return self.core_settings[attribute_name] 295 296 # if trying to access a setting that's not a core setting, attempt to 297 # initialise the database connection 298 if not self.db: 299 self.with_db() 300 301 # get tags to look for 302 # copy() because else we keep adding onto the same list, which 303 # interacts badly with get_all() 304 if tags: 305 tags = tags.copy() 306 tags = self.get_active_tags(user, tags, memcache) 307 308 # now we have all tags - get the config values for each (if available) 309 # and then return the first matching one. Add the 'empty' tag at the 310 # end to fall back to the global value if no specific one exists. 311 tags.append("") 312 313 # short-circuit via memcache if appropriate 314 if not memcache and self.memcache: 315 memcache = self.memcache 316 317 # first check if we have all the values in memcache, in which case we 318 # do not need a database query 319 if memcache: 320 if threading.get_ident() != memcache.init_thread_id: 321 raise RuntimeError("Thread-unsafe use of memcache! Please make sure you are using a configuration " 322 "wrapper to read with a thread-local memcache connection.") 323 324 cached_values = {tag: memcache.get(self._get_memcache_id(attribute_name, tag), default=CacheMiss) for tag in tags} 325 326 else: 327 cached_values = {t: CacheMiss for t in tags} 328 329 # for the tags we could not get from memcache, run a database query 330 # (and save to cache if possible) 331 missing_tags = [t for t in cached_values if cached_values[t] is CacheMiss] 332 if missing_tags: 333 # query database for any values within the required tags 334 query = "SELECT * FROM settings WHERE name = %s AND tag IN %s" 335 replacements = (attribute_name, tuple(missing_tags)) 336 queried_settings = {setting["tag"]: setting["value"] for setting in self.db.fetchall(query, replacements)} 337 338 if memcache: 339 for tag, value in queried_settings.items(): 340 memcache.set(self._get_memcache_id(attribute_name, tag), value) 341 342 cached_values.update(queried_settings) 343 344 # there may be some tags for which we still do not have a value at 345 # this point. these simply do not have a tag-specific value but that in 346 # itself is worth caching, otherwise we're going to query for a 347 # non-existent value each time. 348 # so: cache a magic value for such setting/tag combinations, and 349 # replace the magic value with a CacheMiss in the dict that will be 350 # parsed 351 unconfigured_magic = "__unconfigured__" 352 if memcache: 353 for tag in [t for t in cached_values if cached_values[t] is CacheMiss]: 354 # should this be more magic? 355 memcache.set(self._get_memcache_id(attribute_name, tag), unconfigured_magic) 356 357 for tag in [t for t in cached_values if cached_values[t] == unconfigured_magic]: 358 cached_values[tag] = CacheMiss 359 360 # now we may still have some CacheMisses in the values dict, if there 361 # was no setting in the database with that tag. So, find the first 362 # value that is not a CacheMiss. If nothing matches, try the global tag 363 # and if even that does not match (no setting saved at all) return the 364 # default 365 for tag in tags: 366 if tag in cached_values and cached_values.get(tag) is not CacheMiss: 367 value = cached_values[tag] 368 break 369 else: 370 value = None 371 372 # parse some values... 373 if not is_json and value is not None: 374 value = json.loads(value) 375 # TODO: Which default should have priority? The provided default feels like it should be the highest priority, but I think that is an old implementation and perhaps should be removed. - Dale 376 elif value is None and attribute_name in self.config_definition and "default" in self.config_definition[attribute_name]: 377 value = self.config_definition[attribute_name]["default"] 378 elif value is None and default is not None: 379 value = default 380 381 return value 382 383 def get_active_tags(self, user=None, tags=None, memcache=None): 384 """ 385 Get active tags for given user/tag list 386 387 Used internally to harmonize tag setting for various methods, but can 388 also be called directly to verify tag activation. 389 390 :param user: User object or name. Adds a tag `user:[username]` in 391 front of the tag list. 392 :param tags: Tag or tags for the required setting. If a tag is 393 provided, the method checks if a special value for the setting exists 394 with the given tag, and returns that if one exists. First matching tag 395 wins. 396 :param MemcacheClient memcache: Memcache client. If `None` and 397 `self.memcache` exists, use that instead. 398 :return list: List of tags 399 """ 400 # be flexible about the input types here 401 if tags is None: 402 tags = [] 403 elif type(tags) is str: 404 tags = [tags] 405 406 user = self._normalise_user(user) 407 408 # user-specific settings are just a special type of tag (which takes 409 # precedence), same goes for user groups. so if a user was passed, get 410 # that user's tags (including the 'special' user: tag) and add them 411 # to the list 412 if user: 413 user_tags = CacheMiss 414 415 if not memcache and self.memcache: 416 memcache = self.memcache 417 418 if memcache: 419 memcache_id = f"_usertags-{user}" 420 user_tags = memcache.get(memcache_id, default=CacheMiss) 421 422 if user_tags is CacheMiss: 423 user_tags = self.db.fetchone("SELECT tags FROM users WHERE name = %s", (user,)) 424 if user_tags and memcache: 425 memcache.set(memcache_id, user_tags) 426 427 if user_tags: 428 try: 429 tags.extend(user_tags["tags"]) 430 except (TypeError, ValueError): 431 # should be a JSON list, but isn't 432 pass 433 434 tags.insert(0, f"user:{user}") 435 436 return tags 437 438 def set(self, attribute_name, value, is_json=False, tag="", overwrite_existing=True, memcache=None): 439 """ 440 Insert OR set value for a setting 441 442 If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the 443 setting exists the setting is not updated. 444 445 :param str attribute_name: Attribute to set 446 :param value: Value to set (will be serialised as JSON) 447 :param bool is_json: True for a value that is already a serialised JSON string; False if value is object that needs to 448 be serialised into a JSON string 449 :param bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists 450 :param str tag: Tag to write setting for 451 :param MemcacheClient memcache: Memcache client. If `None` and 452 `self.memcache` exists, use that instead. 453 454 :return int: number of updated rows 455 """ 456 # Check value is valid JSON 457 if is_json: 458 try: 459 json.dumps(json.loads(value)) 460 except json.JSONDecodeError: 461 return None 462 else: 463 try: 464 value = json.dumps(value) 465 except json.JSONDecodeError: 466 return None 467 468 if attribute_name in self.config_definition and self.config_definition.get(attribute_name).get("global"): 469 tag = "" 470 471 if overwrite_existing: 472 query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT (name, tag) DO UPDATE SET value = EXCLUDED.value" 473 else: 474 query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING" 475 476 self.db.execute(query, (attribute_name, value, tag)) 477 updated_rows = self.db.cursor.rowcount 478 self.db.log.debug(f"Updated setting for {attribute_name}: {value} (tag: {tag})") 479 480 if not memcache and self.memcache: 481 memcache = self.memcache 482 483 if memcache: 484 # invalidate any cached value for this setting 485 memcache_id = self._get_memcache_id(attribute_name, tag) 486 memcache.delete(memcache_id) 487 488 return updated_rows 489 490 def delete_for_tag(self, attribute_name, tag): 491 """ 492 Delete config override for a given tag 493 494 :param str attribute_name: 495 :param str tag: 496 :return int: number of deleted rows 497 """ 498 self.db.delete("settings", where={"name": attribute_name, "tag": tag}) 499 updated_rows = self.db.cursor.rowcount 500 501 if self.memcache: 502 self.memcache.delete(self._get_memcache_id(attribute_name, tag)) 503 504 return updated_rows 505 506 def clear_cache(self): 507 """ 508 Clear cached configuration values 509 510 Called when the backend restarts - helps start with a blank slate. 511 """ 512 if not self.memcache: 513 return 514 515 self.memcache.flush_all() 516 517 def uncache_user_tags(self, users): 518 """ 519 Clear cached user tags 520 521 User tags are cached with memcache if possible to avoid unnecessary 522 database roundtrips. This method clears the cached user tags, in case 523 a tag is added/deleted from a user. 524 525 :param list users: List of users, as usernames or User objects 526 """ 527 if self.memcache: 528 for user in users: 529 user = self._normalise_user(user) 530 self.memcache.delete(f"_usertags-{user}") 531 532 def _normalise_user(self, user): 533 """ 534 Normalise user object 535 536 Users may be passed as a username, a user object, or a proxy of such an 537 object. This method normalises this to a string (the username), or 538 `None` if no user is provided. 539 540 :param user: User value to normalise 541 :return str|None: Normalised value 542 """ 543 544 # can provide either a string or user object 545 if type(user) is not str: 546 if type(user).__name__ == "LocalProxy": 547 # passed on from Flask 548 user = user._get_current_object() 549 550 if hasattr(user, "get_id"): 551 user = user.get_id() 552 elif user != None: # noqa: E711 553 # werkzeug.local.LocalProxy (e.g., user not yet logged in) wraps None; use '!=' instead of 'is not' 554 raise TypeError( 555 f"_normalise_user() expects None, a User object or a string for argument 'user', {type(user).__name__} given" 556 ) 557 558 return user 559 560 def _get_memcache_id(self, attribute_name, tags=None): 561 """ 562 Generate a memcache key for a config setting request 563 564 This includes the relevant user name/tags because the value may be 565 different depending on the value of these parameters. 566 567 :param str attribute_name: 568 :param str|list tags: 569 :return str: 570 """ 571 if tags and isinstance(tags, str): 572 tags = [tags] 573 574 tag_bit = [] 575 if tags: 576 tag_bit.append("|".join(tags)) 577 578 memcache_id = attribute_name 579 if tag_bit: 580 memcache_id += f"-{'-'.join(tag_bit)}" 581 582 return memcache_id.encode("ascii") 583 584 def __getattr__(self, attr): 585 """ 586 Getter so we can directly request values 587 588 :param attr: Config setting to get 589 :return: Value 590 """ 591 592 if attr in dir(self): 593 # an explicitly defined attribute should always be called in favour 594 # of this passthrough 595 attribute = getattr(self, attr) 596 return attribute 597 else: 598 return self.get(attr)
46 def with_db(self, db=None): 47 """ 48 Initialise database 49 50 Not done on init, because something may need core settings before the 51 database can be initialised 52 53 :param db: Database object. If None, initialise it using the core config 54 """ 55 if db or not self.db: 56 # Replace w/ db if provided else only initialise if not already 57 self.db = db if db else Database(logger=None, dbname=self.get("DB_NAME"), user=self.get("DB_USER"), 58 password=self.get("DB_PASSWORD"), host=self.get("DB_HOST"), 59 port=self.get("DB_PORT"), appname="config-reader") 60 else: 61 # self.db already initialized and no db provided 62 pass
Initialise database
Not done on init, because something may need core settings before the database can be initialised
Parameters
- db: Database object. If None, initialise it using the core config
64 def load_user_settings(self): 65 """ 66 Load settings configurable by the user 67 68 Does not load the settings themselves, but rather the definition so 69 values can be validated, etc 70 """ 71 # basic 4CAT settings 72 self.config_definition.update(config_definition) 73 74 # module settings can't be loaded directly because modules need the 75 # config manager to load, so that becomes circular 76 # instead, this is cached on startup and then loaded here 77 module_config_path = self.get("PATH_ROOT").joinpath("config/module_config.bin") 78 if module_config_path.exists(): 79 try: 80 with module_config_path.open("rb") as infile: 81 retries = 0 82 module_config = None 83 # if 4CAT is being run in two different containers 84 # (front-end and back-end) they might both be running this 85 # bit of code at the same time. If the file is half-written 86 # loading it will fail, so allow for a few retries 87 while retries < 3: 88 try: 89 module_config = pickle.load(infile) 90 break 91 except Exception: # this can be a number of exceptions, all with the same recovery path 92 time.sleep(0.1) 93 retries += 1 94 continue 95 96 if module_config is None: 97 # not really a way to gracefully recover from this, but 98 # we can at least describe the error 99 raise RuntimeError("Could not read module_config.bin. The 4CAT developers did a bad job of " 100 "preventing this. Shame on them!") 101 102 self.config_definition.update(module_config) 103 except (ValueError, TypeError): 104 pass
Load settings configurable by the user
Does not load the settings themselves, but rather the definition so values can be validated, etc
106 def load_core_settings(self): 107 """ 108 Load 4CAT core settings 109 110 These are (mostly) stored in config.ini and cannot be changed from the 111 web interface. 112 113 :return: 114 """ 115 config_file = Path(__file__).parent.parent.joinpath("config/config.ini") 116 117 config_reader = configparser.ConfigParser() 118 in_docker = False 119 if config_file.exists(): 120 config_reader.read(config_file) 121 if config_reader["DOCKER"].getboolean("use_docker_config"): 122 # Can use throughtout 4CAT to know if Docker environment 123 in_docker = True 124 else: 125 # config should be created! 126 raise ConfigException("No config/config.ini file exists! Update and rename the config.ini-example file.") 127 128 self.core_settings.update({ 129 "CONFIG_FILE": config_file.resolve(), 130 "USING_DOCKER": in_docker, 131 "DB_HOST": config_reader["DATABASE"].get("db_host"), 132 "DB_PORT": config_reader["DATABASE"].get("db_port"), 133 "DB_USER": config_reader["DATABASE"].get("db_user"), 134 "DB_NAME": config_reader["DATABASE"].get("db_name"), 135 "DB_PASSWORD": config_reader["DATABASE"].get("db_password"), 136 137 "API_HOST": config_reader["API"].get("api_host"), 138 "API_PORT": config_reader["API"].getint("api_port"), 139 140 "MEMCACHE_SERVER": config_reader.get("MEMCACHE", option="memcache_host", fallback={}), 141 142 "PATH_ROOT": Path(os.path.abspath(os.path.dirname(__file__))).joinpath( 143 "..").resolve(), # better don"t change this 144 "PATH_LOGS": Path(config_reader["PATHS"].get("path_logs", "")), 145 "PATH_IMAGES": Path(config_reader["PATHS"].get("path_images", "")), 146 "PATH_DATA": Path(config_reader["PATHS"].get("path_data", "")), 147 "PATH_LOCKFILE": Path(config_reader["PATHS"].get("path_lockfile", "")), 148 "PATH_SESSIONS": Path(config_reader["PATHS"].get("path_sessions", "")), 149 150 "ANONYMISATION_SALT": config_reader["GENERATE"].get("anonymisation_salt"), 151 "SECRET_KEY": config_reader["GENERATE"].get("secret_key") 152 })
Load 4CAT core settings
These are (mostly) stored in config.ini and cannot be changed from the web interface.
Returns
155 def load_memcache(self): 156 """ 157 Initialise memcache client 158 159 The config reader can optionally use Memcache to keep fetched values in 160 memory. 161 """ 162 if self.get("MEMCACHE_SERVER"): 163 try: 164 # do one test fetch to test if connection is valid 165 memcache = MemcacheClient(self.get("MEMCACHE_SERVER"), serde=serde.pickle_serde, key_prefix=b"4cat-config") 166 memcache.set("4cat-init-dummy", time.time()) 167 memcache.init_thread_id = threading.get_ident() 168 return memcache 169 except (SystemError, ValueError, MemcacheError, ConnectionError, OSError): 170 # we have no access to the logger here so we simply pass 171 # later we can detect elsewhere that a memcache address is 172 # configured but no connection is there - then we can log 173 # config reader still works without memcache 174 pass 175 176 return None
Initialise memcache client
The config reader can optionally use Memcache to keep fetched values in memory.
178 def ensure_database(self): 179 """ 180 Ensure the database is in sync with the config definition 181 182 Deletes all stored settings not defined in 4CAT, and creates a global 183 setting for all settings not yet in the database. 184 """ 185 self.with_db() 186 187 # create global values for known keys with the default 188 known_settings = self.get_all_setting_names() 189 for setting, parameters in self.config_definition.items(): 190 if setting in known_settings: 191 continue 192 193 self.db.log.debug(f"Creating setting: {setting} with default value {parameters.get('default', '')}") 194 self.set(setting, parameters.get("default", "")) 195 196 # make sure settings and user table are in sync 197 user_tags = list(set(itertools.chain(*[u["tags"] for u in self.db.fetchall("SELECT DISTINCT tags FROM users")]))) 198 known_tags = [t["tag"] for t in self.db.fetchall("SELECT DISTINCT tag FROM settings")] 199 tag_order = self.get("flask.tag_order") 200 201 for tag in known_tags: 202 # add tags used by a setting to tag order 203 if tag and tag not in tag_order: 204 tag_order.append(tag) 205 206 for tag in user_tags: 207 # add tags used by a user to tag order 208 if tag and tag not in tag_order: 209 tag_order.append(tag) 210 211 # admin tag should always be first in order 212 if "admin" in tag_order: 213 tag_order.remove("admin") 214 215 tag_order.insert(0, "admin") 216 217 self.set("flask.tag_order", tag_order) 218 self.db.commit()
Ensure the database is in sync with the config definition
Deletes all stored settings not defined in 4CAT, and creates a global setting for all settings not yet in the database.
220 def get_all_setting_names(self, with_core=True): 221 """ 222 Get names of all settings 223 224 For when the value doesn't matter! 225 226 :param bool with_core: Also include core (i.e. config.ini) settings 227 :return list: List of setting names known by the database and core settings 228 """ 229 # attempt to initialise the database connection so we can include 230 # user settings 231 if not self.db: 232 self.with_db() 233 234 settings = list(self.core_settings.keys()) if with_core else [] 235 settings.extend([s["name"] for s in self.db.fetchall("SELECT DISTINCT name FROM settings")]) 236 237 return settings
Get names of all settings
For when the value doesn't matter!
Parameters
- bool with_core: Also include core (i.e. config.ini) settings
Returns
List of setting names known by the database and core settings
239 def get_all(self, is_json=False, user=None, tags=None, with_core=True, memcache=None): 240 """ 241 Get all known settings 242 243 This is *not optimised* but used rarely enough that that doesn't 244 matter so much. 245 246 :param bool is_json: if True, the value is returned as stored and not 247 interpreted as JSON if it comes from the database 248 :param user: User object or name. Adds a tag `user:[username]` in 249 front of the tag list. 250 :param tags: Tag or tags for the required setting. If a tag is 251 provided, the method checks if a special value for the setting exists 252 with the given tag, and returns that if one exists. First matching tag 253 wins. 254 :param bool with_core: Also include core (i.e. config.ini) settings 255 :param MemcacheClient memcache: Memcache client. If `None` and 256 `self.memcache` exists, use that instead. 257 258 :return dict: Setting value, as a dictionary with setting names as keys 259 and setting values as values. 260 """ 261 for setting in self.get_all_setting_names(with_core=with_core): 262 yield setting, self.get(setting, None, is_json, user, tags, memcache)
Get all known settings
This is not optimised but used rarely enough that that doesn't matter so much.
Parameters
- bool is_json: if True, the value is returned as stored and not interpreted as JSON if it comes from the database
- **user: User object or name. Adds a tag
user**: [username]
in front of the tag list. - tags: Tag or tags for the required setting. If a tag is provided, the method checks if a special value for the setting exists with the given tag, and returns that if one exists. First matching tag wins.
- bool with_core: Also include core (i.e. config.ini) settings
- MemcacheClient memcache: Memcache client. If
None
andself.memcache
exists, use that instead.
Returns
Setting value, as a dictionary with setting names as keys and setting values as values.
265 def get(self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None): 266 """ 267 Get a setting's value from the database 268 269 If the setting does not exist, the provided fallback value is returned. 270 271 :param str attribute_name: Setting to return. 272 :param default: Value to return if setting does not exist 273 :param bool is_json: if True, the value is returned as stored and not 274 interpreted as JSON if it comes from the database 275 :param user: User object or name. Adds a tag `user:[username]` in 276 front of the tag list. 277 :param tags: Tag or tags for the required setting. If a tag is 278 provided, the method checks if a special value for the setting exists 279 with the given tag, and returns that if one exists. First matching tag 280 wins. 281 :param MemcacheClient memcache: Memcache client. If `None` and 282 `self.memcache` exists, use that instead. 283 284 :return: Setting value, or the provided fallback, or `None`. 285 """ 286 # core settings are not from the database 287 # they are therefore also not memcached - too little gain 288 if type(attribute_name) is not str: 289 raise TypeError(f"attribute_name must be a str, {attribute_name.__class__.__name__} given") 290 291 if attribute_name in self.core_settings: 292 # we never get to the database or memcache part of this method if 293 # this is a core setting we already know 294 return self.core_settings[attribute_name] 295 296 # if trying to access a setting that's not a core setting, attempt to 297 # initialise the database connection 298 if not self.db: 299 self.with_db() 300 301 # get tags to look for 302 # copy() because else we keep adding onto the same list, which 303 # interacts badly with get_all() 304 if tags: 305 tags = tags.copy() 306 tags = self.get_active_tags(user, tags, memcache) 307 308 # now we have all tags - get the config values for each (if available) 309 # and then return the first matching one. Add the 'empty' tag at the 310 # end to fall back to the global value if no specific one exists. 311 tags.append("") 312 313 # short-circuit via memcache if appropriate 314 if not memcache and self.memcache: 315 memcache = self.memcache 316 317 # first check if we have all the values in memcache, in which case we 318 # do not need a database query 319 if memcache: 320 if threading.get_ident() != memcache.init_thread_id: 321 raise RuntimeError("Thread-unsafe use of memcache! Please make sure you are using a configuration " 322 "wrapper to read with a thread-local memcache connection.") 323 324 cached_values = {tag: memcache.get(self._get_memcache_id(attribute_name, tag), default=CacheMiss) for tag in tags} 325 326 else: 327 cached_values = {t: CacheMiss for t in tags} 328 329 # for the tags we could not get from memcache, run a database query 330 # (and save to cache if possible) 331 missing_tags = [t for t in cached_values if cached_values[t] is CacheMiss] 332 if missing_tags: 333 # query database for any values within the required tags 334 query = "SELECT * FROM settings WHERE name = %s AND tag IN %s" 335 replacements = (attribute_name, tuple(missing_tags)) 336 queried_settings = {setting["tag"]: setting["value"] for setting in self.db.fetchall(query, replacements)} 337 338 if memcache: 339 for tag, value in queried_settings.items(): 340 memcache.set(self._get_memcache_id(attribute_name, tag), value) 341 342 cached_values.update(queried_settings) 343 344 # there may be some tags for which we still do not have a value at 345 # this point. these simply do not have a tag-specific value but that in 346 # itself is worth caching, otherwise we're going to query for a 347 # non-existent value each time. 348 # so: cache a magic value for such setting/tag combinations, and 349 # replace the magic value with a CacheMiss in the dict that will be 350 # parsed 351 unconfigured_magic = "__unconfigured__" 352 if memcache: 353 for tag in [t for t in cached_values if cached_values[t] is CacheMiss]: 354 # should this be more magic? 355 memcache.set(self._get_memcache_id(attribute_name, tag), unconfigured_magic) 356 357 for tag in [t for t in cached_values if cached_values[t] == unconfigured_magic]: 358 cached_values[tag] = CacheMiss 359 360 # now we may still have some CacheMisses in the values dict, if there 361 # was no setting in the database with that tag. So, find the first 362 # value that is not a CacheMiss. If nothing matches, try the global tag 363 # and if even that does not match (no setting saved at all) return the 364 # default 365 for tag in tags: 366 if tag in cached_values and cached_values.get(tag) is not CacheMiss: 367 value = cached_values[tag] 368 break 369 else: 370 value = None 371 372 # parse some values... 373 if not is_json and value is not None: 374 value = json.loads(value) 375 # TODO: Which default should have priority? The provided default feels like it should be the highest priority, but I think that is an old implementation and perhaps should be removed. - Dale 376 elif value is None and attribute_name in self.config_definition and "default" in self.config_definition[attribute_name]: 377 value = self.config_definition[attribute_name]["default"] 378 elif value is None and default is not None: 379 value = default 380 381 return value
Get a setting's value from the database
If the setting does not exist, the provided fallback value is returned.
Parameters
- str attribute_name: Setting to return.
- default: Value to return if setting does not exist
- bool is_json: if True, the value is returned as stored and not interpreted as JSON if it comes from the database
- **user: User object or name. Adds a tag
user**: [username]
in front of the tag list. - tags: Tag or tags for the required setting. If a tag is provided, the method checks if a special value for the setting exists with the given tag, and returns that if one exists. First matching tag wins.
- MemcacheClient memcache: Memcache client. If
None
andself.memcache
exists, use that instead.
Returns
Setting value, or the provided fallback, or
None
.
438 def set(self, attribute_name, value, is_json=False, tag="", overwrite_existing=True, memcache=None): 439 """ 440 Insert OR set value for a setting 441 442 If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the 443 setting exists the setting is not updated. 444 445 :param str attribute_name: Attribute to set 446 :param value: Value to set (will be serialised as JSON) 447 :param bool is_json: True for a value that is already a serialised JSON string; False if value is object that needs to 448 be serialised into a JSON string 449 :param bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists 450 :param str tag: Tag to write setting for 451 :param MemcacheClient memcache: Memcache client. If `None` and 452 `self.memcache` exists, use that instead. 453 454 :return int: number of updated rows 455 """ 456 # Check value is valid JSON 457 if is_json: 458 try: 459 json.dumps(json.loads(value)) 460 except json.JSONDecodeError: 461 return None 462 else: 463 try: 464 value = json.dumps(value) 465 except json.JSONDecodeError: 466 return None 467 468 if attribute_name in self.config_definition and self.config_definition.get(attribute_name).get("global"): 469 tag = "" 470 471 if overwrite_existing: 472 query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT (name, tag) DO UPDATE SET value = EXCLUDED.value" 473 else: 474 query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING" 475 476 self.db.execute(query, (attribute_name, value, tag)) 477 updated_rows = self.db.cursor.rowcount 478 self.db.log.debug(f"Updated setting for {attribute_name}: {value} (tag: {tag})") 479 480 if not memcache and self.memcache: 481 memcache = self.memcache 482 483 if memcache: 484 # invalidate any cached value for this setting 485 memcache_id = self._get_memcache_id(attribute_name, tag) 486 memcache.delete(memcache_id) 487 488 return updated_rows
Insert OR set value for a setting
If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the setting exists the setting is not updated.
Parameters
- str attribute_name: Attribute to set
- value: Value to set (will be serialised as JSON)
- bool is_json: True for a value that is already a serialised JSON string; False if value is object that needs to be serialised into a JSON string
- bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists
- str tag: Tag to write setting for
- MemcacheClient memcache: Memcache client. If
None
andself.memcache
exists, use that instead.
Returns
number of updated rows
490 def delete_for_tag(self, attribute_name, tag): 491 """ 492 Delete config override for a given tag 493 494 :param str attribute_name: 495 :param str tag: 496 :return int: number of deleted rows 497 """ 498 self.db.delete("settings", where={"name": attribute_name, "tag": tag}) 499 updated_rows = self.db.cursor.rowcount 500 501 if self.memcache: 502 self.memcache.delete(self._get_memcache_id(attribute_name, tag)) 503 504 return updated_rows
Delete config override for a given tag
Parameters
- str attribute_name:
- str tag:
Returns
number of deleted rows
506 def clear_cache(self): 507 """ 508 Clear cached configuration values 509 510 Called when the backend restarts - helps start with a blank slate. 511 """ 512 if not self.memcache: 513 return 514 515 self.memcache.flush_all()
Clear cached configuration values
Called when the backend restarts - helps start with a blank slate.
601class ConfigWrapper: 602 """ 603 Wrapper for the config manager 604 605 Allows setting a default set of tags or user, so that all subsequent calls 606 to `get()` are done for those tags or that user. Can also adjust tags based 607 on the HTTP request, if used in a Flask context. 608 """ 609 def __init__(self, config, user=None, tags=None, request=None): 610 """ 611 Initialise config wrapper 612 613 :param ConfigManager config: Initialised config manager 614 :param user: User to get settings for 615 :param tags: Tags to get settings for 616 :param request: Request to get headers from. This can be used to set 617 a particular tag based on the HTTP headers of the request, e.g. to 618 serve 4CAT with a different configuration based on the proxy server 619 used. 620 """ 621 if type(config) is ConfigWrapper: 622 # let's not do nested wrappers, but copy properties unless 623 # provided explicitly 624 self.user = user if user else config.user 625 self.tags = tags if tags else config.tags 626 self.request = request if request else config.request 627 self.config = config.config 628 self.memcache = config.memcache 629 else: 630 self.config = config 631 self.user = user 632 self.tags = tags 633 self.request = request 634 635 # this ensures we use our own memcache client, important in threaded 636 # contexts because pymemcache is not thread-safe. Unless we get a 637 # prepared connection... 638 self.memcache = self.config.load_memcache() 639 640 # this ensures the user object in turn reads from the wrapper 641 if self.user: 642 self.user.with_config(self, rewrap=False) 643 644 645 def set(self, *args, **kwargs): 646 """ 647 Wrap `set()` 648 649 :param args: 650 :param kwargs: 651 :return: 652 """ 653 if "tag" not in kwargs and self.tags: 654 kwargs["tag"] = self.tags 655 656 kwargs["memcache"] = self.memcache 657 658 return self.config.set(*args, **kwargs) 659 660 def get_all(self, *args, **kwargs): 661 """ 662 Wrap `get_all()` 663 664 Takes the `user`, `tags` and `request` given when initialised into 665 account. If `tags` is set explicitly, the HTTP header-based override 666 is not applied. 667 668 :param args: 669 :param kwargs: 670 :return: 671 """ 672 if "user" not in kwargs and self.user: 673 kwargs["user"] = self.user 674 675 if "tags" not in kwargs: 676 kwargs["tags"] = self.tags if self.tags else [] 677 kwargs["tags"] = self.request_override(kwargs["tags"]) 678 679 kwargs["memcache"] = self.memcache 680 681 return self.config.get_all(*args, **kwargs) 682 683 def get(self, *args, **kwargs): 684 """ 685 Wrap `get()` 686 687 Takes the `user`, `tags` and `request` given when initialised into 688 account. If `tags` is set explicitly, the HTTP header-based override 689 is not applied. 690 691 :param args: 692 :param kwargs: 693 :return: 694 """ 695 if "user" not in kwargs: 696 kwargs["user"] = self.user 697 698 if "tags" not in kwargs: 699 kwargs["tags"] = self.tags if self.tags else [] 700 kwargs["tags"] = self.request_override(kwargs["tags"]) 701 702 kwargs["memcache"] = self.memcache 703 704 return self.config.get(*args, **kwargs) 705 706 def get_active_tags(self, user=None, tags=None): 707 """ 708 Wrap `get_active_tags()` 709 710 Takes the `user`, `tags` and `request` given when initialised into 711 account. If `tags` is set explicitly, the HTTP header-based override 712 is not applied. 713 714 :param user: 715 :param tags: 716 :return list: 717 """ 718 active_tags = self.config.get_active_tags(user, tags, self.memcache) 719 if not tags: 720 active_tags = self.request_override(active_tags) 721 722 return active_tags 723 724 def request_override(self, tags): 725 """ 726 Force tag via HTTP request headers 727 728 To facilitate loading different configurations based on the HTTP 729 request, the request object can be passed to the ConfigWrapper and 730 if a certain request header is set, the value of that header will be 731 added to the list of tags to consider when retrieving settings. 732 733 See the flask.proxy_secret config setting; this is used to prevent 734 users from changing configuration by forging the header. 735 736 :param list|str tags: List of tags to extend based on request 737 :return list: Amended list of tags 738 """ 739 if type(tags) is str: 740 tags = [tags] 741 742 # use self.config.get here, not self.get, because else we get infinite 743 # recursion (since self.get can call this method) 744 if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \ 745 self.config.get("flask.proxy_secret", memcache=self.memcache) and \ 746 self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret", memcache=self.memcache): 747 # need to ensure not just anyone can add this header to their 748 # request! 749 # to this end, the second header must be set to the secret value; 750 # if it is not set, assume the headers are not being configured by 751 # the proxy server 752 if not tags: 753 tags = [] 754 755 # can never set admin tag via headers (should always be user-based) 756 forbidden_overrides = ("admin",) 757 tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides] 758 759 return tags 760 761 762 def __getattr__(self, item): 763 """ 764 Generic wrapper 765 766 Just pipe everything through to the config object 767 768 :param item: 769 :return: 770 """ 771 if hasattr(self.config, item): 772 return getattr(self.config, item) 773 elif hasattr(self, item): 774 return getattr(self, item) 775 else: 776 raise AttributeError(f"'{self.__name__}' object has no attribute '{item}'")
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. Can also adjust tags based
on the HTTP request, if used in a Flask context.
609 def __init__(self, config, user=None, tags=None, request=None): 610 """ 611 Initialise config wrapper 612 613 :param ConfigManager config: Initialised config manager 614 :param user: User to get settings for 615 :param tags: Tags to get settings for 616 :param request: Request to get headers from. This can be used to set 617 a particular tag based on the HTTP headers of the request, e.g. to 618 serve 4CAT with a different configuration based on the proxy server 619 used. 620 """ 621 if type(config) is ConfigWrapper: 622 # let's not do nested wrappers, but copy properties unless 623 # provided explicitly 624 self.user = user if user else config.user 625 self.tags = tags if tags else config.tags 626 self.request = request if request else config.request 627 self.config = config.config 628 self.memcache = config.memcache 629 else: 630 self.config = config 631 self.user = user 632 self.tags = tags 633 self.request = request 634 635 # this ensures we use our own memcache client, important in threaded 636 # contexts because pymemcache is not thread-safe. Unless we get a 637 # prepared connection... 638 self.memcache = self.config.load_memcache() 639 640 # this ensures the user object in turn reads from the wrapper 641 if self.user: 642 self.user.with_config(self, rewrap=False)
Initialise config wrapper
Parameters
- ConfigManager config: Initialised config manager
- user: User to get settings for
- tags: Tags to get settings for
- 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.
660 def get_all(self, *args, **kwargs): 661 """ 662 Wrap `get_all()` 663 664 Takes the `user`, `tags` and `request` given when initialised into 665 account. If `tags` is set explicitly, the HTTP header-based override 666 is not applied. 667 668 :param args: 669 :param kwargs: 670 :return: 671 """ 672 if "user" not in kwargs and self.user: 673 kwargs["user"] = self.user 674 675 if "tags" not in kwargs: 676 kwargs["tags"] = self.tags if self.tags else [] 677 kwargs["tags"] = self.request_override(kwargs["tags"]) 678 679 kwargs["memcache"] = self.memcache 680 681 return self.config.get_all(*args, **kwargs)
Wrap get_all()
Takes the user
, tags
and request
given when initialised into
account. If tags
is set explicitly, the HTTP header-based override
is not applied.
Parameters
- args:
- kwargs:
Returns
683 def get(self, *args, **kwargs): 684 """ 685 Wrap `get()` 686 687 Takes the `user`, `tags` and `request` given when initialised into 688 account. If `tags` is set explicitly, the HTTP header-based override 689 is not applied. 690 691 :param args: 692 :param kwargs: 693 :return: 694 """ 695 if "user" not in kwargs: 696 kwargs["user"] = self.user 697 698 if "tags" not in kwargs: 699 kwargs["tags"] = self.tags if self.tags else [] 700 kwargs["tags"] = self.request_override(kwargs["tags"]) 701 702 kwargs["memcache"] = self.memcache 703 704 return self.config.get(*args, **kwargs)
Wrap get()
Takes the user
, tags
and request
given when initialised into
account. If tags
is set explicitly, the HTTP header-based override
is not applied.
Parameters
- args:
- kwargs:
Returns
724 def request_override(self, tags): 725 """ 726 Force tag via HTTP request headers 727 728 To facilitate loading different configurations based on the HTTP 729 request, the request object can be passed to the ConfigWrapper and 730 if a certain request header is set, the value of that header will be 731 added to the list of tags to consider when retrieving settings. 732 733 See the flask.proxy_secret config setting; this is used to prevent 734 users from changing configuration by forging the header. 735 736 :param list|str tags: List of tags to extend based on request 737 :return list: Amended list of tags 738 """ 739 if type(tags) is str: 740 tags = [tags] 741 742 # use self.config.get here, not self.get, because else we get infinite 743 # recursion (since self.get can call this method) 744 if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \ 745 self.config.get("flask.proxy_secret", memcache=self.memcache) and \ 746 self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret", memcache=self.memcache): 747 # need to ensure not just anyone can add this header to their 748 # request! 749 # to this end, the second header must be set to the secret value; 750 # if it is not set, assume the headers are not being configured by 751 # the proxy server 752 if not tags: 753 tags = [] 754 755 # can never set admin tag via headers (should always be user-based) 756 forbidden_overrides = ("admin",) 757 tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides] 758 759 return 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.
Parameters
- list|str tags: List of tags to extend based on request
Returns
Amended list of tags
779class CoreConfigManager(ConfigManager): 780 """ 781 A configuration reader that can only read from core settings 782 783 Can be used in thread-unsafe context and when no database is present. 784 """ 785 def with_db(self, db=None): 786 """ 787 Raise a RuntimeError when trying to link a database connection 788 789 :param db: 790 """ 791 raise RuntimeError("Trying to read non-core configuration value from a CoreConfigManager")
A configuration reader that can only read from core settings
Can be used in thread-unsafe context and when no database is present.
785 def with_db(self, db=None): 786 """ 787 Raise a RuntimeError when trying to link a database connection 788 789 :param db: 790 """ 791 raise RuntimeError("Trying to read non-core configuration value from a CoreConfigManager")
Raise a RuntimeError when trying to link a database connection
Parameters
- db: