This is an automated email from the git hooks/post-receive script. tille pushed a commit to branch master in repository gnumed-server.
commit c270004c84fc413676f6ed3a5bf3f1560cd0ab63 Author: Andreas Tille <[email protected]> Date: Tue Sep 12 16:31:37 2017 +0200 New upstream version 21.14 --- server/bootstrap/bootstrap_gm_db_system.py | 151 ++++++---- server/doc/schema/gnumed-entire_schema.html | 2 +- server/pycommon/gmPG2.py | 330 +++++++++++++++------ server/pycommon/gmPsql.py | 61 ++-- server/pycommon/x-test-default_ro.py | 65 ++++ server/pycommon/x-test-psy.py | 203 +++++++++++++ .../v20-v21/dynamic/v21-release_notes-dynamic.sql | 32 +- 7 files changed, 654 insertions(+), 190 deletions(-) diff --git a/server/bootstrap/bootstrap_gm_db_system.py b/server/bootstrap/bootstrap_gm_db_system.py index ac3beed..a67a1c0 100755 --- a/server/bootstrap/bootstrap_gm_db_system.py +++ b/server/bootstrap/bootstrap_gm_db_system.py @@ -24,6 +24,8 @@ further details. --quiet --log-file= --conf-file= + +Requires psycopg 2.7 ! """ #================================================================== # TODO @@ -36,7 +38,14 @@ __author__ = "[email protected]" __license__ = "GPL v2 or later" # standard library -import sys, string, os.path, fileinput, os, time, getpass, glob, re as regex, tempfile +import sys +import os.path +import fileinput +import os +import getpass +import glob +import re as regex +import tempfile import io import logging @@ -239,7 +248,7 @@ def create_db_group(cursor=None, group=None): return True #================================================================== -def connect(host, port, db, user, passwd): +def connect(host, port, db, user, passwd, conn_name=None): """ This is a wrapper to the database connect function. Will try to recover gracefully from connection errors where possible @@ -259,7 +268,7 @@ def connect(host, port, db, user, passwd): dsn = gmPG2.make_psycopg2_dsn(database=db, host=host, port=port, user=user, password=passwd) _log.info("trying DB connection to %s on %s as %s", db, host or 'localhost', user) try: - conn = gmPG2.get_connection(dsn=dsn, readonly=False, pooled=False, verbose=True) + conn = gmPG2.get_connection(dsn=dsn, readonly=False, pooled=False, verbose=True, connection_name = conn_name) except: _log.exception(u'connection failed') raise @@ -270,6 +279,7 @@ def connect(host, port, db, user, passwd): _log.info('successfully connected') return conn + #================================================================== class user: def __init__(self, anAlias = None, aPassword = None): @@ -372,7 +382,7 @@ class db_server: if self.conn.closed == 0: self.conn.close() - self.conn = connect(self.name, self.port, self.template_db, self.superuser.name, self.superuser.password) + self.conn = connect(self.name, self.port, self.template_db, self.superuser.name, self.superuser.password, conn_name = u'[email protected]') if self.conn is None: _log.error('Cannot connect.') return None @@ -578,6 +588,7 @@ class database: return None #-------------------------------------------------------------- def __bootstrap(self): + global _dbowner # get owner @@ -654,6 +665,7 @@ class database: #self.conn.close() return True + #-------------------------------------------------------------- def __connect_superuser_to_template(self): if self.conn is not None: @@ -665,7 +677,8 @@ class database: self.server.port, self.template_db, self.server.superuser.name, - self.server.superuser.password + self.server.superuser.password, + conn_name = u'[email protected]' ) self.conn.cookie = 'database.__connect_superuser_to_template' @@ -675,6 +688,7 @@ class database: curs.close() return self.conn and 1 + #-------------------------------------------------------------- def __connect_superuser_to_db(self): if self.conn is not None: @@ -686,7 +700,8 @@ class database: self.server.port, self.name, self.server.superuser.name, - self.server.superuser.password + self.server.superuser.password, + conn_name = u'postgres@gnumed_vX' ) self.conn.cookie = 'database.__connect_superuser_to_db' @@ -749,7 +764,8 @@ class database: return self.conn and 1 #-------------------------------------------------------------- def __db_exists(self): - cmd = "BEGIN; SELECT datname FROM pg_database WHERE datname='%s'" % self.name + #cmd = "BEGIN; SELECT datname FROM pg_database WHERE datname='%s'" % self.name + cmd = "SELECT datname FROM pg_database WHERE datname='%s'" % self.name aCursor = self.conn.cursor() try: @@ -790,16 +806,23 @@ class database: print_msg("==> dropping pre-existing target database [%s] ..." % self.name) _log.info('trying to drop target database') cmd = 'DROP DATABASE "%s"' % self.name - self.conn.set_isolation_level(0) + _log.debug('committing existing connection before setting autocommit') + self.conn.commit() + _log.debug('setting autocommit to TRUE') + self.conn.autocommit = True + self.conn.readonly = False cursor = self.conn.cursor() try: + cursor.execute(u'SET default_transaction_read_only TO OFF') + _log.debug('running SQL: %s', cmd) cursor.execute(cmd) except: _log.exception(">>>[%s]<<< failed" % cmd) - cursor.close() + _log.debug(u'conn state after failed DROP: %s', gmPG2.capture_conn_state(self.conn)) return False - cursor.close() - self.conn.commit() + finally: + cursor.close() + self.conn.set_session(readonly = False, autocommit = False) else: use_existing = bool(int(cfg_get(self.section, 'use existing target database'))) if use_existing: @@ -828,35 +851,36 @@ class database: tablespace = '%s' ;""" % (self.name, self.owner.name, self.template_db, tablespace) - # create DB must be run outside transactions - old_iso = self.conn.isolation_level - self.conn.set_isolation_level(0) - cursor = self.conn.cursor() - # get size + cursor = self.conn.cursor() size_cmd = "SELECT pg_size_pretty(pg_database_size('%s'))" % self.template_db cursor.execute(size_cmd) size = cursor.fetchone()[0] + cursor.close() # create database by cloning print_msg("==> cloning [%s] (%s) as target database [%s] ..." % (self.template_db, size, self.name)) + # create DB must be run outside transactions + self.conn.commit() + self.conn.autocommit = True + self.conn.readonly = False + cursor = self.conn.cursor() try: + cursor.execute(u'SET default_transaction_read_only TO OFF') cursor.execute(create_db_cmd) except: _log.exception(">>>[%s]<<< failed" % create_db_cmd) - cursor.close() - self.conn.set_isolation_level(old_iso) return False - cursor.close() - - self.conn.commit() - self.conn.set_isolation_level(old_iso) + finally: + cursor.close() + self.conn.set_session(readonly = False, autocommit = False) if not self.__db_exists(): return None _log.info("Successfully created GNUmed database [%s]." % self.name) return True + #-------------------------------------------------------------- def check_data_plausibility(self): @@ -905,8 +929,8 @@ class database: try: tag, old_query = check_def.split('::::') except: - _log.exception('error in plausibility check, aborting') - _log.error('check definition: %s', check_def) + _log.exception(u'error in plausibility check, aborting') + _log.error(u'check definition: %s', check_def) print_msg(" ... failed (check definition error)") all_tests_successful = False continue @@ -919,8 +943,8 @@ class database: ) old_val = rows[0][0] except: - _log.exception('error in plausibility check [%s] (old), aborting' % tag) - _log.error('SQL: %s', old_query) + _log.exception(u'error in plausibility check [%s] (old), aborting' % tag) + _log.error(u'SQL: %s', old_query) print_msg(" ... failed (SQL error)") all_tests_successful = False continue @@ -932,21 +956,21 @@ class database: ) new_val = rows[0][0] except: - _log.exception('error in plausibility check [%s] (new), aborting' % tag) - _log.error('SQL: %s', new_query) + _log.exception(u'error in plausibility check [%s] (new), aborting' % tag) + _log.error(u'SQL: %s', new_query) print_msg(" ... failed (SQL error)") all_tests_successful = False continue if new_val != old_val: - _log.error('plausibility check [%s] failed, expected [%s], found [%s]' % (tag, old_val, new_val)) - _log.error('SQL (old DB): %s', old_query) - _log.error('SQL (new DB): %s', new_query) + _log.error(u'plausibility check [%s] failed, expected: %s (in old DB), found: %s (in new DB)' % (tag, old_val, new_val)) + _log.error(u'SQL (old DB): %s', old_query) + _log.error(u'SQL (new DB): %s', new_query) print_msg(" ... failed (data error, check [%s])" % tag) all_tests_successful = False continue - _log.info('plausibility check [%s] succeeded' % tag) + _log.info(u'plausibility check [%s] succeeded' % tag) template_conn.close() target_conn.close() @@ -1028,8 +1052,10 @@ class database: print_msg('') print_msg(' http://wiki.gnumed.de/bin/view/Gnumed/ConfigurePostgreSQL') print_msg('') + #-------------------------------------------------------------- def import_data(self): + print_msg("==> upgrading reference data sets ...") import_scripts = cfg_get(self.section, "data import scripts") @@ -1073,7 +1099,9 @@ class database: #-------------------------------------------------------------- def verify_result_hash(self): + print_msg("==> verifying target database schema ...") + target_version = cfg_get(self.section, 'target version') if target_version == 'devel': print_msg(" ... skipped (devel version)") @@ -1096,32 +1124,46 @@ class database: def reindex_all(self): print_msg("==> reindexing target database (can take a while) ...") + + do_reindex = cfg_get(self.section, 'reindex') + if do_reindex is None: + do_reindex = True + else: + do_reindex = (int(do_reindex) == 1) + if not do_reindex: + _log.warning('skipping REINDEXing') + print_msg(" ... skipped") + return True + _log.info('REINDEXing cloned target database so upgrade does not fail in case of a broken index') _log.info('this may potentially take "quite a long time" depending on how much data there is in the database') _log.info('you may want to monitor the PostgreSQL log for signs of progress') - old_iso = self.conn.isolation_level - self.conn.set_isolation_level(0) - curs = self.conn.cursor() + self.conn.commit() + self.conn.set_session(readonly = False, autocommit = True) + curs_outer = self.conn.cursor() + curs_outer.execute(u'SET default_transaction_read_only TO OFF') cmd = 'REINDEX (VERBOSE) DATABASE %s' % self.name try: - curs.execute(cmd) + curs_outer.execute(cmd) except: _log.exception(">>>[%s]<<< failed" % cmd) - curs.close() # re-attempt w/o VERBOSE _log.info('attempting REINDEXing without VERBOSE') - curs = self.conn.cursor() + curs_inner = self.conn.cursor() cmd = 'REINDEX DATABASE %s' % self.name try: - curs.execute(cmd) + curs_inner.execute(cmd) except: _log.exception(">>>[%s]<<< failed" % cmd) - curs.close() - self.conn.set_isolation_level(old_iso) return False - curs.close() - self.conn.set_isolation_level(old_iso) + finally: + curs_inner.close() + self.conn.set_session(readonly = False, autocommit = False) + finally: + curs_outer.close() + self.conn.set_session(readonly = False, autocommit = False) + return True #-------------------------------------------------------------- @@ -1335,6 +1377,7 @@ class database: self.conn.commit() return True + #================================================================== class gmBundle: def __init__(self, aBundleAlias = None): @@ -1494,6 +1537,7 @@ def ask_for_confirmation(): else: return None return True + #-------------------------------------------------------------- def _import_schema (group=None, schema_opt="schema", conn=None): # load schema @@ -1517,14 +1561,21 @@ def _import_schema (group=None, schema_opt="schema", conn=None): # and import them psql = gmPsql.Psql(conn) - for file in schema_files: - the_file = os.path.join(schema_base_dir, file) - if psql.run(the_file) == 0: - _log.info('successfully imported [%s]' % the_file) - else: - _log.error('failed to import [%s]' % the_file) - return None + for filename in schema_files: + if filename.strip() == u'': + continue # skip empty line + if filename.startswith(u'# '): + _log.info(filename) # log as comment + continue + full_path = os.path.join(schema_base_dir, filename) + if psql.run(full_path) == 0: + #_log.info('success') + continue + _log.error(u'failure') + return None + return True + #------------------------------------------------------------------ def exit_with_msg(aMsg = None): if aMsg is not None: diff --git a/server/doc/schema/gnumed-entire_schema.html b/server/doc/schema/gnumed-entire_schema.html index 581eaa2..6a174d8 100644 --- a/server/doc/schema/gnumed-entire_schema.html +++ b/server/doc/schema/gnumed-entire_schema.html @@ -112,7 +112,7 @@ <body> <!-- Primary Index --> - <p><br><br>Dumped on 2017-05-14</p> + <p><br><br>Dumped on 2017-08-31</p> <h1><a name="index">Index of database - gnumed_v21</a></h1> <ul> diff --git a/server/pycommon/gmPG2.py b/server/pycommon/gmPG2.py index 4eb2bd6..36d2f87 100644 --- a/server/pycommon/gmPG2.py +++ b/server/pycommon/gmPG2.py @@ -35,7 +35,7 @@ from Gnumed.pycommon import gmDateTime from Gnumed.pycommon import gmBorg from Gnumed.pycommon import gmI18N from Gnumed.pycommon import gmLog2 -from Gnumed.pycommon.gmTools import prompted_input, u_replacement_character +from Gnumed.pycommon.gmTools import prompted_input, u_replacement_character, format_dict_like _log = logging.getLogger('gm.db') @@ -169,6 +169,30 @@ map_client_branch2required_db_version = { u'1.6': 21 } +map_psyco_tx_status2str = [ + u'TRANSACTION_STATUS_IDLE', + u'TRANSACTION_STATUS_ACTIVE', + u'TRANSACTION_STATUS_INTRANS', + u'TRANSACTION_STATUS_INERROR', + u'TRANSACTION_STATUS_UNKNOWN' +] + +map_psyco_conn_status2str = [ + u'0 - ?', + u'STATUS_READY', + u'STATUS_BEGIN_ALIAS_IN_TRANSACTION', + u'STATUS_PREPARED' +] + +map_psyco_iso_level2str = { + None: u'ISOLATION_LEVEL_DEFAULT (configured on server)', + 0: u'ISOLATION_LEVEL_AUTOCOMMIT', + 1: u'ISOLATION_LEVEL_READ_UNCOMMITTED', + 2: u'ISOLATION_LEVEL_REPEATABLE_READ', + 3: u'ISOLATION_LEVEL_SERIALIZABLE', + 4: u'ISOLATION_LEVEL_READ_UNCOMMITTED' +} + # get columns and data types for a given table query_table_col_defs = u"""select cols.column_name, @@ -442,6 +466,7 @@ def __request_login_params_tui(): raise gmExceptions.ConnectionError(_("Cannot connect to database without login information!")) return login + #--------------------------------------------------- def __request_login_params_gui_wx(): """GUI (wx) input request for database login parameters. @@ -511,6 +536,7 @@ def make_psycopg2_dsn(database=None, host=None, port=5432, user=None, password=N dsn_parts.append('password=%s' % password) dsn_parts.append('sslmode=prefer') + dsn_parts.append('fallback_application_name=GNUmed') return ' '.join(dsn_parts) @@ -878,10 +904,13 @@ def delete_translation_from_database(link_obj=None, language=None, original=None return True #------------------------------------------------------------------------ -def update_translation_in_database(language=None, original=None, translation=None): - cmd = u'SELECT i18n.upd_tx(%(lang)s, %(orig)s, %(trans)s)' +def update_translation_in_database(language=None, original=None, translation=None, link_obj=None): + if language is None: + cmd = u'SELECT i18n.upd_tx(%(orig)s, %(trans)s)' + else: + cmd = u'SELECT i18n.upd_tx(%(lang)s, %(orig)s, %(trans)s)' args = {'lang': language, 'orig': original, 'trans': translation} - run_rw_queries(queries = [{'cmd': cmd, 'args': args}], return_data = False) + run_rw_queries(queries = [{'cmd': cmd, 'args': args}], return_data = False, link_obj = link_obj) return args #------------------------------------------------------------------------ @@ -1022,10 +1051,12 @@ def force_user_language(language=None): def send_maintenance_notification(): cmd = u'notify "db_maintenance_warning"' run_rw_queries(queries = [{'cmd': cmd}], return_data = False) + #------------------------------------------------------------------------ def send_maintenance_shutdown(): cmd = u'notify "db_maintenance_disconnect"' run_rw_queries(queries = [{'cmd': cmd}], return_data = False) + #------------------------------------------------------------------------ def is_pg_interval(candidate=None): cmd = u'SELECT %(candidate)s::interval' @@ -1161,15 +1192,6 @@ def bytea2file_object(data_query=None, file_obj=None, chunk_size=0, data_size=No needed_chunks, remainder = divmod(data_size, chunk_size) _log.debug('# of chunks: %s; remainder: %s bytes', needed_chunks, remainder) -# # since we now require PG 9.1 we can disable this workaround: -# # try setting "bytea_output" -# # - fails if not necessary -# # - succeeds if necessary -# try: -# run_ro_queries(link_obj = conn, queries = [{'cmd': u"set bytea_output to 'escape'"}]) -# except dbapi.ProgrammingError: -# _log.debug('failed to set bytea_output to "escape", not necessary') - # retrieve chunks, skipped if data size < chunk size, # does this not carry the danger of cutting up multi-byte escape sequences ? # no, since bytea is binary, @@ -1468,6 +1490,27 @@ def file2bytea_overlay(query=None, args=None, filename=None, conn=None, md5_quer _log.error('MD5 sums of data file and database BYTEA field do not match: [file::%s] <> [DB::%s]', file_md5, db_md5) return False +#--------------------------------------------------------------------------- +def run_sql_script(sql_script, conn=None): + + if conn is None: + conn = get_connection(readonly = False) + + from Gnumed.pycommon import gmPsql + psql = gmPsql.Psql(conn) + + if psql.run(sql_script) == 0: + query = { + 'cmd': u'select gm.log_script_insertion(%(name)s, %(ver)s)', + 'args': {'name': sql_script, 'ver': u'current'} + } + run_rw_queries(link_obj = conn, queries = [query]) + conn.commit() + return True + + _log.error('error running sql script: %s', sql_script) + return False + #------------------------------------------------------------------------ def sanitize_pg_regex(expression=None, escape_all=False): """Escape input for use in a PostgreSQL regular expression. @@ -1496,6 +1539,56 @@ def sanitize_pg_regex(expression=None, escape_all=False): #']', '\]', # not needed #------------------------------------------------------------------------ +def capture_conn_state(conn=None): + + tx_status = conn.get_transaction_status() + if tx_status in [ psycopg2.extensions.TRANSACTION_STATUS_INERROR, psycopg2.extensions.TRANSACTION_STATUS_UNKNOWN ]: + isolation_level = u'%s (tx aborted or unknown, cannot retrieve)' % conn.isolation_level + else: + isolation_level = u'%s (%s)' % (conn.isolation_level, map_psyco_iso_level2str[conn.isolation_level]) + conn_status = u'%s (%s)' % (conn.status, map_psyco_conn_status2str[conn.status]) + if conn.closed != 0: + conn_status = u'undefined (%s)' % conn_status + + d = { + u'identity': id(conn), + u'backend PID': conn.get_backend_pid(), + u'protocol version': conn.protocol_version, + u'encoding': conn.encoding, + u'closed': conn.closed, + u'readonly': conn.readonly, + u'autocommit': conn.autocommit, + u'isolation level (psyco)': isolation_level, + u'async': conn.async, + u'deferrable': conn.deferrable, + u'transaction status': u'%s (%s)' % (tx_status, map_psyco_tx_status2str[tx_status]), + u'connection status': conn_status, + u'executing async op': conn.isexecuting(), + u'type': type(conn) + } + return u'%s\n' % conn + format_dict_like ( + d, + relevant_keys = [ + u'type', + u'identity', + u'backend PID', + u'protocol version', + u'encoding', + u'isolation level (psyco)', + u'readonly', + u'autocommit', + u'closed', + u'connection status', + u'transaction status', + u'deferrable', + u'async', + u'executing async op' + ], + tabular = True, + value_delimiters = None + ) + +#------------------------------------------------------------------------ def capture_cursor_state(cursor=None): conn = cursor.connection @@ -1505,6 +1598,11 @@ def capture_cursor_state(cursor=None): else: isolation_level = conn.isolation_level + if cursor.query is None: + query = u'<no query>' + else: + query = unicode(cursor.query, 'utf8', 'replace') + txt = u"""Link state: Cursor identity: %s; name: %s @@ -1514,7 +1612,7 @@ Cursor statusmessage: %s Connection identity: %s; backend pid: %s; protocol version: %s; - closed: %s; autocommit: %s; isolation level: %s; encoding: %s; async: %s; + closed: %s; autocommit: %s; isolation level: %s; encoding: %s; async: %s; deferrable: %s; readonly: %s; TX status: %s; CX status: %s; executing async op: %s; Query %s @@ -1540,11 +1638,13 @@ Query isolation_level, conn.encoding, conn.async, - tx_status, - conn.status, + conn.deferrable, + conn.readonly, + map_psyco_tx_status2str[tx_status], + map_psyco_conn_status2str[conn.status], conn.isexecuting(), - unicode(cursor.query, 'utf8', 'replace'), + query ) return txt @@ -1602,6 +1702,14 @@ def run_ro_queries(link_obj=None, queries=None, verbose=False, return_data=True, except dbapi.Error as pg_exc: _log.error('query failed in RO connection') _log.error(capture_cursor_state(curs)) + if hasattr(pg_exc, 'diag'): + for prop in dir(pg_exc.diag): + if prop.startswith(u'__'): + continue + val = getattr(pg_exc.diag, prop) + if val is None: + continue + _log.error(u'PG diags %s: %s', prop, val) pg_exc = make_pg_exception_fields_unicode(pg_exc) _log.error('PG error code: %s', pg_exc.pgcode) if pg_exc.pgerror is not None: @@ -1650,6 +1758,9 @@ def run_ro_queries(link_obj=None, queries=None, verbose=False, return_data=True, col_idx = get_col_indices(curs) curs_close() + # so we can see data committed meanwhile if the + # link object had been passed in and thusly might + # be part of a long-running read-only transaction readonly_rollback_just_in_case() return (data, col_idx) @@ -1748,6 +1859,14 @@ def run_rw_queries(link_obj=None, queries=None, end_tx=False, return_data=None, except dbapi.Error as pg_exc: _log.error('query failed in RW connection') _log.error(capture_cursor_state(curs)) + if hasattr(pg_exc, 'diag'): + for prop in dir(pg_exc.diag): + if prop.startswith(u'__'): + continue + val = getattr(pg_exc.diag, prop) + if val is None: + continue + _log.error(u'PG diags %s: %s', prop, val) for notice in notices_accessor.notices: _log.error(notice.strip(u'\n').strip(u'\r')) del notices_accessor.notices[:] @@ -1906,7 +2025,7 @@ class cConnectionPool(psycopg2.pool.PersistentConnectionPool): self._used[conn_key].original_close() # ----------------------------------------------------------------------- -def get_raw_connection(dsn=None, verbose=False, readonly=True): +def get_raw_connection(dsn=None, verbose=False, readonly=True, connection_name=None, autocommit=False): """Get a raw, unadorned connection. - this will not set any parameters such as encoding, timezone, datestyle @@ -1921,9 +2040,21 @@ def get_raw_connection(dsn=None, verbose=False, readonly=True): if u'host=salaam.homeunix' in dsn: raise ValueError('The public database is not hosted by <salaam.homeunix.com> anymore.\n\nPlease point your configuration files to <publicdb.gnumed.de>.') + # try to enforce a useful encoding early on so that we + # have a good chance of decoding authentication errors + # containing foreign language characters + if u' client_encoding=' not in dsn: + dsn += u' client_encoding=utf8' + + if connection_name is None: + if u' application_name' not in dsn: + dsn += u" application_name=GNUmed" + else: + if u' application_name' not in dsn: + dsn += u" application_name=%s" % connection_name + try: conn = dbapi.connect(dsn=dsn, connection_factory=psycopg2.extras.DictConnection) - #conn = dbapi.connect(dsn=dsn, cursor_factory=psycopg2.extras.RealDictCursor) except dbapi.OperationalError, e: t, v, tb = sys.exc_info() @@ -1932,22 +2063,27 @@ def get_raw_connection(dsn=None, verbose=False, readonly=True): except (AttributeError, IndexError, TypeError): raise - msg = unicode(msg, gmI18N.get_encoding(), 'replace') + #msg = unicode(msg, gmI18N.get_encoding(), 'replace') + msg = unicode(msg, u'utf8', 'replace') - if msg.find('fe_sendauth') != -1: + if u'fe_sendauth' in msg: raise cAuthenticationError, (dsn, msg), tb if regex.search('user ".*" does not exist', msg) is not None: raise cAuthenticationError, (dsn, msg), tb - if msg.find('uthenti') != -1: + if u'uthenti' in msg: raise cAuthenticationError, (dsn, msg), tb raise - _log.debug('new database connection, backend PID: %s, readonly: %s', conn.get_backend_pid(), readonly) + if connection_name is None: + _log.debug('established anonymous database connection, backend PID: %s', conn.get_backend_pid()) + else: + _log.debug('established database connection "%s", backend PID: %s', connection_name, conn.get_backend_pid()) - # do first-time stuff + # do first-connection-only stuff + # - verify PG version global postgresql_version if postgresql_version is None: curs = conn.cursor() @@ -1967,44 +2103,30 @@ def get_raw_connection(dsn=None, verbose=False, readonly=True): except: pass if verbose: - _log_PG_settings(curs=curs) + _log_PG_settings(curs = curs) curs.close() conn.commit() + # - verify PG understands client time zone if _default_client_timezone is None: __detect_client_timezone(conn = conn) - curs = conn.cursor() - - # set access mode - conn.set_session(readonly = readonly) - conn.set_session(autocommit = readonly) - if readonly: - _log.debug('access mode [READ ONLY]') - #conn.set_session(readonly = True) - _log.debug('readonly: autocommit=True to avoid <IDLE IN TRANSACTION>') -# conn.autocommit = True -# cmd = 'set session characteristics as transaction READ ONLY' -# curs.execute(cmd) -# cmd = 'set default_transaction_read_only to on' -# curs.execute(cmd) + # - set access mode + if readonly is True: + _log.debug('readonly: forcing autocommit=True to avoid <IDLE IN TRANSACTION>') + autocommit = True else: - _log.debug('access mode [READ WRITE]') -# conn.set_session(readonly = False) - _log.debug('readwrite: autocommit=False') -# cmd = 'set session characteristics as transaction READ WRITE' -# curs.execute(cmd) -# cmd = 'set default_transaction_read_only to off' -# curs.execute(cmd) - - curs.close() + _log.debug('autocommit is desired to be: %s', autocommit) conn.commit() + conn.autocommit = autocommit + conn.readonly = readonly conn.is_decorated = False return conn + # ======================================================================= -def get_connection(dsn=None, readonly=True, encoding=None, verbose=False, pooled=True): +def get_connection(dsn=None, readonly=True, encoding=None, verbose=False, pooled=True, connection_name=None, autocommit=False): """Get a new connection. This assumes the locale system has been initialized @@ -2015,15 +2137,20 @@ def get_connection(dsn=None, readonly=True, encoding=None, verbose=False, pooled if pooled and readonly and (dsn is None): global __ro_conn_pool if __ro_conn_pool is None: + log_ro_conn = True __ro_conn_pool = cConnectionPool ( minconn = 1, maxconn = 2, dsn = dsn, verbose = verbose ) + else: + log_ro_conn = False conn = __ro_conn_pool.getconn() + if log_ro_conn: + [ _log.debug(line) for line in capture_conn_state(conn = conn).split(u'\n') ] else: - conn = get_raw_connection(dsn=dsn, verbose=verbose, readonly=False) + conn = get_raw_connection(dsn = dsn, verbose = verbose, readonly = readonly, connection_name = connection_name, autocommit = autocommit) if conn.is_decorated: return conn @@ -2048,57 +2175,42 @@ def get_connection(dsn=None, readonly=True, encoding=None, verbose=False, pooled # - transaction isolation level if readonly: - # alter-database default, checked at connect, no need to set now - iso_level = u'read committed' + # alter-database default, checked at connect, no need to set here + pass else: conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE) - iso_level = u'serializable' - - _log.debug('client string encoding [%s], isolation level [%s], time zone [%s]', encoding, iso_level, _default_client_timezone) - curs = conn.cursor() + _log.debug('client time zone [%s]', _default_client_timezone) # - client time zone + curs = conn.cursor() curs.execute(_sql_set_timezone, [_default_client_timezone]) - - conn.commit() - -# # FIXME: remove this whole affair once either 9.0 is standard (Ubuntu 10 LTS is -# # FIXME: PG 8.4, however!) or else when psycopg2 supports a workaround -# # -# # - bytea data format -# # PG 9.0 switched to - by default - using "hex" rather than "escape", -# # however, psycopg2's linked with a pre-9.0 libpq do assume "escape" -# # as the transmission mode for bytea output, -# # so try to set this setting back to "escape", -# # if that's not possible the reason will be that PG < 9.0 does not support -# # that setting - which also means we don't need it and can ignore the -# # failure -# cmd = "set bytea_output to 'escape'" -# try: -# curs.execute(cmd) -# except dbapi.ProgrammingError: -# _log.error('cannot set bytea_output format') - curs.close() conn.commit() conn.is_decorated = True + if verbose: + [ _log.debug(line) for line in capture_conn_state(conn = conn).split(u'\n') ] + return conn + #----------------------------------------------------------------------- def shutdown(): if __ro_conn_pool is None: return __ro_conn_pool.shutdown() + # ====================================================================== # internal helpers #----------------------------------------------------------------------- def __noop(): pass + #----------------------------------------------------------------------- def _raise_exception_on_ro_conn_close(): raise TypeError(u'close() called on read-only connection') + #----------------------------------------------------------------------- def log_database_access(action=None): run_insert ( @@ -2107,6 +2219,7 @@ def log_database_access(action=None): values = {u'user_action': action}, end_tx = True ) + #----------------------------------------------------------------------- def sanity_check_time_skew(tolerance=60): """Check server time and local time to be within @@ -2150,6 +2263,7 @@ def sanity_check_time_skew(tolerance=60): return False return True + #----------------------------------------------------------------------- def sanity_check_database_settings(): """Checks database settings. @@ -2237,22 +2351,43 @@ def sanity_check_database_settings(): return 1, u'\n'.join(msg) return 0, u'' + #------------------------------------------------------------------------ def _log_PG_settings(curs=None): - # don't use any of the run_*()s since that might - # create a loop if we fail here - # FIXME: use pg_settings + # don't use any of the run_*()s helper functions + # since that might create a loop if we fail here try: - curs.execute(u'show all') + # .pending_restart does not exist in PG 9.4 yet + #curs.execute(u'SELECT name, setting, unit, source, reset_val, sourcefile, sourceline, pending_restart FROM pg_settings') + curs.execute(u'SELECT name, setting, unit, source, reset_val, sourcefile, sourceline FROM pg_settings') except: - _log.exception(u'cannot log PG settings (>>>show all<<< failed)') + _log.exception(u'cannot log PG settings ("SELECT ... FROM pg_settings" failed)') return False settings = curs.fetchall() - if settings is None: - _log.error(u'cannot log PG settings (>>>show all<<< did not return rows)') - return False for setting in settings: - _log.debug(u'PG option [%s]: %s', setting['name'], setting['setting']) + if setting['unit'] is None: + unit = u'' + else: + unit = u' %s' % setting['unit'] + if setting['sourcefile'] is None: + sfile = u'' + else: + sfile = u'// %s @ %s' % (setting['sourcefile'], setting['sourceline']) +# # .pending_restart does not exist in PG 9.4 yet +# if setting['pending_restart'] is False: +# pending_restart = u'' +# else: +# pending_restart = u'// needs restart' +# _log.debug(u'%s: %s%s (set from: [%s] // sess RESET will set to: [%s]%s%s)', + _log.debug(u'%s: %s%s (set from: [%s] // sess RESET will set to: [%s]%s)', + setting['name'], + setting['setting'], + unit, + setting['source'], + setting['reset_val'], +# pending_restart, + sfile + ) try: curs.execute(u'select pg_available_extensions()') @@ -2266,7 +2401,19 @@ def _log_PG_settings(curs=None): for ext in extensions: _log.debug(u'PG extension: %s', ext['pg_available_extensions']) + # not really that useful because: + # - clusterwide + # - not retained across server restart (fixed in 9.6.1 - really ?) +# try: +# curs.execute(u'SELECT pg_last_committed_xact()') +# except: +# _log.exception(u'cannot retrieve last committed xact') +# xact = curs.fetchall() +# if xact is not None: +# _log.debug(u'last committed transaction in cluster: %s', xact[0]) + return True + #======================================================================== def make_pg_exception_fields_unicode(exc): @@ -2286,6 +2433,7 @@ def make_pg_exception_fields_unicode(exc): exc.u_pgerror = unicode(exc.pgerror, gmI18N.get_encoding(), 'replace').strip().strip(u'\n').strip().strip(u'\n') return exc + #------------------------------------------------------------------------ def extract_msg_from_pg_exception(exc=None): @@ -2296,6 +2444,7 @@ def extract_msg_from_pg_exception(exc=None): # assumption return unicode(msg, gmI18N.get_encoding(), 'replace') + # ======================================================================= class cAuthenticationError(dbapi.OperationalError): @@ -2719,6 +2868,7 @@ if __name__ == "__main__": #-------------------------------------------------------------------- def test_sanity_check_time_skew(): sanity_check_time_skew() + #-------------------------------------------------------------------- def test_get_foreign_key_names(): print get_foreign_key_names ( @@ -2729,6 +2879,7 @@ if __name__ == "__main__": target_table = u'episode', target_column = u'pk' ) + #-------------------------------------------------------------------- def test_get_foreign_key_details(): for row in get_foreign_keys2column ( @@ -2743,6 +2894,7 @@ if __name__ == "__main__": row['referenced_table'], row['referenced_column'] ) + #-------------------------------------------------------------------- def test_set_user_language(): # (user, language, result, exception type) @@ -2772,10 +2924,12 @@ if __name__ == "__main__": print "test:", test print "expected exception" print "result:", e + #-------------------------------------------------------------------- def test_get_schema_revision_history(): for line in get_schema_revision_history(): print u' - '.join(line) + #-------------------------------------------------------------------- def test_run_query(): gmDateTime.init() @@ -2856,6 +3010,11 @@ SELECT to_timestamp (foofoo,'YYMMDD.HH24MI') FROM ( run_rw_queries(queries = [{'cmd': u'SELEC 1'}]) #-------------------------------------------------------------------- + def test_log_settings(): + conn = conn = get_connection() + _log_PG_settings(curs = conn.cursor()) + + #-------------------------------------------------------------------- # run tests #test_get_connection() #test_exceptions() @@ -2881,5 +3040,6 @@ SELECT to_timestamp (foofoo,'YYMMDD.HH24MI') FROM ( #test_file2bytea_copy_from() #test_file2bytea_lo() test_faulty_SQL() + #test_log_settings() # ====================================================================== diff --git a/server/pycommon/gmPsql.py b/server/pycommon/gmPsql.py index 46df8c1..ad1b963 100644 --- a/server/pycommon/gmPsql.py +++ b/server/pycommon/gmPsql.py @@ -41,6 +41,7 @@ class Psql: """ self.conn = conn self.vars = {'ON_ERROR_STOP':None} + #--------------------------------------------------------------- def match (self, str): match = re.match (str, self.line) @@ -50,6 +51,7 @@ class Psql: ret = 1 self.groups = match.groups () return ret + #--------------------------------------------------------------- def fmt_msg(self, aMsg): try: @@ -65,6 +67,7 @@ class Psql: except: pass unformattable_error_id += 1 return tmp + #--------------------------------------------------------------- def run (self, filename): """ @@ -89,8 +92,8 @@ class Psql: in_string = False bracketlevel = 0 curr_cmd = '' - curs = self.conn.cursor () -## transaction_started = False + curs = self.conn.cursor() + for self.line in self.file.readlines(): self.lineno += 1 if len(self.line.strip()) == 0: @@ -100,20 +103,24 @@ class Psql: if self.match (r"^\\echo (.*)"): _log.info(self.fmt_msg(shell(self.groups[0]))) continue + # \qecho if self.match (r"^\\qecho (.*)"): _log.info(self.fmt_msg(shell (self.groups[0]))) continue + # \q if self.match (r"^\\q"): _log.warning(self.fmt_msg(u"script terminated by \\q")) return 0 + # \set if self.match (r"^\\set (\S+) (\S+)"): self.vars[self.groups[0]] = shell (self.groups[1]) if self.groups[0] == 'ON_ERROR_STOP': self.vars['ON_ERROR_STOP'] = int (self.vars['ON_ERROR_STOP']) continue + # \unset if self.match (r"^\\unset (\S+)"): self.vars[self.groups[0]] = None @@ -150,46 +157,26 @@ class Psql: curr_cmd += this_char else: try: -# if curr_cmd.strip ().upper () == 'COMMIT': -# if transaction_started: -# self.conn.commit () -# curs.close () -# curs = self.conn.cursor () -# _log.debug(self.fmt_msg ("transaction committed")) -# else: -# _log.warning(self.fmt_msg ("COMMIT without BEGIN: no actual transaction happened!")) -# transaction_started = False - -# elif curr_cmd.strip ().upper () == 'BEGIN': -# if transaction_started: -# _log.warning(self.fmt_msg ("BEGIN inside transaction")) -# else: -# transaction_started = True -# _log.debug(self.fmt_msg ("starting transaction")) - -# else: if curr_cmd.strip() != '': - if curr_cmd.find('vacuum'): - self.conn.commit(); - curs.close() - old_iso_level = self.conn.isolation_level - self.conn.set_isolation_level(0) - curs = self.conn.cursor() - curs.execute (curr_cmd) - self.conn.set_isolation_level(old_iso_level) - else: - curs.execute (curr_cmd) -# if not transaction_started: - except Exception, error: - _log.debug(curr_cmd) + curs.execute (curr_cmd) + except Exception as error: + _log.exception(curr_cmd) if re.match (r"^NOTICE:.*", str(error)): _log.warning(self.fmt_msg(error)) else: + _log.error(self.fmt_msg(error)) + if hasattr(error, 'diag'): + for prop in dir(error.diag): + if prop.startswith(u'__'): + continue + val = getattr(error.diag, prop) + if val is None: + continue + _log.error(u'PG diags %s: %s', prop, val) if self.vars['ON_ERROR_STOP']: - _log.error(self.fmt_msg(error)) + self.conn.commit() + curs.close() return 1 - else: - _log.debug(self.fmt_msg(error)) self.conn.commit() curs.close() @@ -204,6 +191,7 @@ class Psql: self.conn.commit() curs.close() return 0 + #=================================================================== # testing code if __name__ == '__main__': @@ -219,4 +207,3 @@ if __name__ == '__main__': psql = Psql (conn) psql.run (sys.argv[1]) conn.close () -#=================================================================== diff --git a/server/pycommon/x-test-default_ro.py b/server/pycommon/x-test-default_ro.py new file mode 100644 index 0000000..ef3df0c --- /dev/null +++ b/server/pycommon/x-test-default_ro.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# +# please run this script against a database which is configured to be readonly by: +# +# alter databaes <NAME> set default_transaction_read_only to on +# +# if cmd line argument is "show_problem" -> exhibit the problem + +db = u'gnumed_v20' # a database configured "alter database %s set default_transaction_read_only to on" +user = 'gm-dbo' # a user with CREATE DATABASE powers + +#-------------------------------------------------------------------------------- +import sys +import psycopg2 + + +cmd_def_tx_ro = "SELECT upper(source), name, upper(setting) FROM pg_settings WHERE name = 'default_transaction_read_only'" +cmd_create_db = "create database %s_copy template %s" % (db, db) +cmd_drop_db = "drop database %s_copy" % db + +show_problem = False +if len(sys.argv) > 1: + if sys.argv[1] == 'show_problem': + show_problem = True + +conn = psycopg2.connect(dbname = db, user = user) +print 'conn:', conn +print 'readonly:', conn.readonly +print 'autocommit:', conn.autocommit +print 'setting autocommit to False' +conn.autocommit = False +print 'autocommit now:', conn.autocommit +if show_problem: + print 'vvvvv this creates the problem vvvvv' + print ' setting readonly to False' + conn.readonly = False + print ' readonly now:', conn.readonly + print '^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^' +print 'setting autocommit to True' +conn.autocommit = True +print 'autocommit now:', conn.autocommit +print 'setting readonly to False' +conn.readonly = False +print 'readonly now:', conn.readonly +curs = conn.cursor() +curs.execute(cmd_def_tx_ro) +print 'querying DEFAULT_TRANSACTION_READ_ONLY state (should show "ON")' +print curs.fetchall() +curs.close() +conn.commit() +print 'the following SQL will fail:', cmd_create_db +print '(note that the transaction being talked about is implicit to PostgreSQL, due to autocommit mode)' +curs = conn.cursor() +try: + curs.execute(cmd_create_db) + curs.execute(cmd_drop_db) +except psycopg2.InternalError as ex: + print 'SQL failed:' + print ex + +print 'shutting down' + +curs.close() +conn.rollback() +conn.close() diff --git a/server/pycommon/x-test-psy.py b/server/pycommon/x-test-psy.py new file mode 100644 index 0000000..f030df2 --- /dev/null +++ b/server/pycommon/x-test-psy.py @@ -0,0 +1,203 @@ + +db = u'gnumed_v20' # a database configured "alter database %s set default_transaction_read_only to on" +user = 'gm-dbo' # a user with CREATE DATABASE powers + + + +cmd_def_tx_ro = "SELECT upper(source), name, upper(setting) FROM pg_settings WHERE name = 'default_transaction_read_only'" +cmd_create_db = "create database %s_copy template %s" % (db, db) + + +import sys +import psycopg2 + + +conn = psycopg2.connect(dbname = db, user = user) +print 'readonly:', conn.readonly +print 'autocommit:', conn.autocommit +conn.readonly = False +print 'readonly now:', conn.readonly +#curs = conn.cursor() +#curs.execute(cmd_def_tx_ro) +#print 'should show DEFAULT_TRANSACTION_READ_ONLY set to ON' +#print curs.fetchall() +#curs.close() +#conn.commit() +conn.autocommit = True +print 'readonly:', conn.readonly +print 'autocommit:', conn.autocommit +print 'the following CREATE DATABASE should fail' +curs = conn.cursor() +curs.execute(cmd_create_db) +curs.close() +conn.rollback() +conn.close() + +sys.exit() + + + + + +curs = conn.cursor() +#cmd_def_tx_ro = u'show default_transaction_read_only;' +cmd_def_tx_ro = "SELECT upper(source), name, upper(setting) FROM pg_settings WHERE name = 'default_transaction_read_only'" +cmd_tx_ro = u'show transaction_read_only;' +cmd_DEL = u'DELETE FROM dem.identity where pk is NULL' + +print conn +print 'initial RO state:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() + +#print '' +print 'setting <conn.readonly = False> ...' +conn.readonly = False +print 'RO state in same TX:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() +print 'RO state in next TX:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() + +print '' +print 'setting <conn.autocommit = True> (conn.readonly still False) ...' +print '-> means exiting psyco TX handling, needed for some DDL such as CREATE DATABASE ...' +conn.autocommit = True +print 'RO state in same TX:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() +print 'RO state in next TX:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() + +print '' +print 'setting <conn.autocommit = False> (conn.readonly still False) ...' +print '-> means exiting psyco TX handling, needed for some DDL such as CREATE DATABASE ...' +conn.autocommit = False +print 'RO state in same TX:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() +print 'RO state in same TX:' +print ' psyco (conn.readonly):', conn.readonly +print ' psyco (conn.autocommit):', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_def_tx_ro +curs.execute(cmd_tx_ro) +print ' PG:', curs.fetchall(), u'- %s' % cmd_tx_ro +print ' running DELETE:', cmd_DEL +try: + curs.execute(cmd_DEL) + print ' success' +except Exception as e: + print ' failed:', e +conn.commit() + + + +sys.exit() + + + + + + +print 'RO state in same TX:' +print ' psyco - conn.readonly:', conn.readonly +print ' psyco - conn.autocommit:', conn.autocommit +curs.execute(cmd_def_tx_ro) +print ' PG - default_transaction_read_only:', curs.fetchall() +curs.execute(cmd_tx_ro) +print ' PG - transaction_read_only:', curs.fetchall() +conn.commit() +print 'RO state in next TX:' +print ' psyco - conn.readonly:', conn.readonly +curs.execute(cmd_def_tx_ro) +print ' PG - default_transaction_read_only:', curs.fetchall() +curs.execute(cmd_tx_ro) +print ' PG - transaction_read_only:', curs.fetchall() +conn.commit() + +print '' + +print 'PG/psyco split brain because of:' +cmd = "SELECT upper(source), name, upper(setting) FROM pg_settings WHERE name = 'default_transaction_read_only'" +print ' SQL:', cmd +curs.execute(cmd) +print ' PG:', curs.fetchall() + + + +conn.commit() +curs.execute(u'DELETE FROM dem.identity where pk is NULL') + + +curs.close() +conn.commit() +conn.close() diff --git a/server/sql/v20-v21/dynamic/v21-release_notes-dynamic.sql b/server/sql/v20-v21/dynamic/v21-release_notes-dynamic.sql index cce6ee1..253a1d8 100644 --- a/server/sql/v20-v21/dynamic/v21-release_notes-dynamic.sql +++ b/server/sql/v20-v21/dynamic/v21-release_notes-dynamic.sql @@ -17,26 +17,24 @@ INSERT INTO dem.message_inbox ( ) VALUES ( (select pk from dem.staff where db_user = 'any-doc'), (select pk_type from dem.v_inbox_item_type where type = 'memo' and category = 'administrative'), - 'Release Notes for GNUmed 1.6.13 (database v21.13)', - 'GNUmed 1.6.13 Release Notes: + 'Release Notes for GNUmed 1.6.14 (database v21.14)', + 'GNUmed 1.6.14 Release Notes: - 1.6.13 + 1.6.14 -FIX: editing of drug products -FIX: formatting of intervals with seconds [thanks Rickard] -FIX: robustify backend listener against change notification trigger errors -FIX: backport once-only detection of unicode char selector -FIX: improper handling of notebook page change events -FIX: error handling on uploading DICOM to Orthanc +FIX: exception when having issues with calculating eGFR in medication plugin +FIX: exception on disabling identity [thanks Marc] +FIX: exception on adding archived documents to export area +FIX: Orthanc DICOM patient ID modification +FIX: faulty file drop target declarations -IMPROVED: more fully prevent logfile based password leaks -IMPROVED: add listing of latest vaccination per indication -IMPROVED: export area change listening and sortability -IMPROVED: episode edit area behaviour -IMPROVED: add measurement by clicking empty cell in grid - -NEW: add Constans algorithm for upper extremity DVT +IMPROVED: saving of export area items +IMPROVED: patient display in provider inbox +IMPROVED: copy document to export area from document plugin +IMPROVED: Orthanc modification dialog title +IMPROVED: imported documents deletion confirmation +IMPROVED: patient media metadata '); -- -------------------------------------------------------------- -select gm.log_script_insertion('v21-release_notes-dynamic.sql', '21.13'); +select gm.log_script_insertion('v21-release_notes-dynamic.sql', '21.14'); -- Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/debian-med/gnumed-server.git _______________________________________________ debian-med-commit mailing list [email protected] http://lists.alioth.debian.org/cgi-bin/mailman/listinfo/debian-med-commit
