/*
Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
*/
// #define DEBUG_NETWORK_REQUESTS
// #define DEBUG_NETWORK_REPLIES_RAW
// #define DEBUG_NETWORK_REPLIES_DICT
// #define DEBUG_ACTIVITY
// #define DEBUG_JSON
#define USE_BACKGROUND_DATABASE
#include "networkmanager.h"
#include <functional>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QObject>
#include <QSqlQuery>
#include <QtGlobal>
#include <QtNetwork/QNetworkAccessManager>
#include <QtNetwork/QNetworkRequest>
#include <QtNetwork/QNetworkReply>
#include <QtNetwork/QSslConfiguration>
#include <QUrl>
#include <QUrlQuery>
#include "common/preprocessor_aid.h"
#include "common/varconst.h"
#include "core/camcopsapp.h"
#include "db/dbnestabletransaction.h"
#include "dbobjects/idnumdescription.h"
#include "dbobjects/patientidnum.h"
#include "dialogs/passwordentrydialog.h"
#include "dialogs/logbox.h"
#include "dbobjects/blob.h"
#include "lib/containers.h"
#include "lib/convert.h"
#include "lib/datetime.h"
#include "lib/idpolicy.h"
#include "lib/uifunc.h"
#include "tasklib/task.h"
#include "tasklib/taskfactory.h"
#include "tasklib/taskschedule.h"
#include "tasklib/taskscheduleitem.h"
#include "version/camcopsversion.h"
using dbfunc::delimit;
// Keys used by server or client (S server, C client, B bidirectional)
// SEE ALSO patient.cpp, for the JSON ones.
const QString KEY_CAMCOPS_VERSION("camcops_version"); // C->S
const QString KEY_DATABASE_TITLE("databaseTitle"); // S->C
const QString KEY_DATEVALUES("datevalues"); // C->S
const QString KEY_DBDATA("dbdata"); // C->S, new in v2.3.0
const QString KEY_DEVICE("device"); // C->S
const QString KEY_DEVICE_FRIENDLY_NAME("devicefriendlyname"); // C->S
const QString KEY_ERROR("error"); // S->C
const QString KEY_FIELDS("fields"); // B; fieldnames
const QString KEY_FINALIZING("finalizing"); // C->S, in JSON, v2.3.0
const QString KEY_ID_POLICY_UPLOAD("idPolicyUpload"); // S->C
const QString KEY_ID_POLICY_FINALIZE("idPolicyFinalize"); // S->C
const QString KEY_IP_USE_INFO("ip_use_info"); // S->C, new in v2.4.0
const QString KEY_IP_USE_COMMERCIAL("ip_use_commercial"); // S->C, new in v2.4.0
const QString KEY_IP_USE_CLINICAL("ip_use_clinical"); // S->C, new in v2.4.0
const QString KEY_IP_USE_EDUCATIONAL("ip_use_educational"); // S->C, new in v2.4.0
const QString KEY_IP_USE_RESEARCH("ip_use_research"); // S->C, new in v2.4.0
const QString KEY_MOVE_OFF_TABLET_VALUES("move_off_tablet_values"); // C->S, v2.3.0
const QString KEY_NFIELDS("nfields"); // B
const QString KEY_NRECORDS("nrecords"); // B
const QString KEY_OPERATION("operation"); // C->S
const QString KEY_PASSWORD("password"); // C->S
const QString KEY_PATIENT_INFO("patient_info"); // C->S, new in v2.3.0
const QString KEY_PATIENT_PROQUINT("patient_proquint"); // C->S, new in v2.4.0
const QString KEY_PKNAME("pkname"); // C->S
const QString KEY_PKNAMEINFO("pknameinfo"); // C->S
const QString KEY_PKVALUES("pkvalues"); // C->S
const QString KEY_RESULT("result"); // S->C
const QString KEY_SERVER_CAMCOPS_VERSION("serverCamcopsVersion"); // S->C
const QString KEY_SESSION_ID("session_id"); // B
const QString KEY_SESSION_TOKEN("session_token"); // B
const QString KEY_SUCCESS("success"); // S->C
const QString KEY_TABLE("table"); // C->S
const QString KEY_TABLES("tables"); // C->S
const QString KEY_TASK_SCHEDULES("task_schedules"); // S->C, new in v2.4.0
const QString KEY_TASK_SCHEDULE_ITEMS("task_schedule_items");
const QString KEY_USER("user"); // C->S
const QString KEY_VALUES("values"); // C->S
const QString KEYPREFIX_ID_DESCRIPTION("idDescription"); // S->C
const QString KEYSPEC_ID_DESCRIPTION(KEYPREFIX_ID_DESCRIPTION + "%1"); // S->C
const QString KEYPREFIX_ID_SHORT_DESCRIPTION("idShortDescription"); // S->C
const QString KEYSPEC_ID_SHORT_DESCRIPTION(KEYPREFIX_ID_SHORT_DESCRIPTION + "%1"); // S->C
const QString KEYPREFIX_ID_VALIDATION_METHOD("idValidationMethod"); // S->C, new in v2.2.8
const QString KEYSPEC_ID_VALIDATION_METHOD(KEYPREFIX_ID_VALIDATION_METHOD + "%1"); // S->C, new in v2.2.8
const QString KEYSPEC_RECORD("record%1"); // B
// Operations for server:
const QString OP_CHECK_DEVICE_REGISTERED("check_device_registered");
const QString OP_CHECK_UPLOAD_USER_DEVICE("check_upload_user_and_device");
const QString OP_DELETE_WHERE_KEY_NOT("delete_where_key_not");
const QString OP_END_UPLOAD("end_upload");
const QString OP_GET_EXTRA_STRINGS("get_extra_strings");
const QString OP_GET_ID_INFO("get_id_info");
const QString OP_GET_ALLOWED_TABLES("get_allowed_tables"); // v2.2.0
const QString OP_GET_TASK_SCHEDULES("get_task_schedules"); // v2.4.0
const QString OP_REGISTER("register");
const QString OP_REGISTER_PATIENT("register_patient"); // v2.4.0
const QString OP_START_PRESERVATION("start_preservation");
const QString OP_START_UPLOAD("start_upload");
const QString OP_UPLOAD_ENTIRE_DATABASE("upload_entire_database"); // v2.3.0
const QString OP_UPLOAD_TABLE("upload_table");
const QString OP_UPLOAD_RECORD("upload_record");
const QString OP_UPLOAD_EMPTY_TABLES("upload_empty_tables");
const QString OP_VALIDATE_PATIENTS("validate_patients"); // v2.3.0
const QString OP_WHICH_KEYS_TO_SEND("which_keys_to_send");
const Version MIN_SERVER_VERSION_FOR_VALIDATE_PATIENTS("2.3.0");
const Version MIN_SERVER_VERSION_FOR_ONE_STEP_UPLOAD("2.3.0");
const QString ENCODE_TRUE("1");
const QString ENCODE_FALSE("0");
// ============================================================================
// NetworkManager
// ============================================================================
// - MAIN COMMUNICATION METHOD:
// serverPost(dict, &callbackfunction);
// CALLBACK LIFETIME SAFETY in this class:
// - There is only one NetworkManager in the whole app, owned by the
// CamcopsApp.
// - The QNetworkAccessManager lives as long as the NetworkManager.
// - Therefore, any callbacks to this class are lifetime-safe and can use
// std::bind.
// - HOWEVER, callbacks to something transient may not be (e.g. another object
// sets up a callback to itself but with std::bind rather than to a QObject;
// network function is called; object is deleted; network replies; boom).
// So BEWARE there.
// - Since we have a single set of principal network access functions relating
// to upload/server interaction, the simplest thing is to build them all into
// this class, and then we don't have to worry about lifetime problems.
NetworkManager::NetworkManager(CamcopsApp& app,
DatabaseManager& db,
TaskFactoryPtr p_task_factory,
QWidget* parent) :
m_app(app),
m_db(db),
m_p_task_factory(p_task_factory),
m_parent(parent),
m_offer_cancel(true),
m_silent(parent == nullptr),
m_logbox(nullptr),
m_mgr(new QNetworkAccessManager(this)), // will be autodeleted by QObject
m_upload_method(UploadMethod::Copy),
m_upload_next_stage(NextUploadStage::Invalid),
m_upload_current_record_index(0),
m_recordwise_prune_req_sent(false),
m_recordwise_pks_pruned(false),
m_upload_n_records(0),
m_register_next_stage(NextRegisterStage::Invalid)
{
}
NetworkManager::~NetworkManager()
{
deleteLogBox();
}
// ============================================================================
// User interface
// ============================================================================
void NetworkManager::ensureLogBox() const
{
if (!m_logbox) {
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO << "creating logbox";
#endif
m_logbox = new LogBox(m_parent, m_title, m_offer_cancel);
m_logbox->setStyleSheet(
m_app.getSubstitutedCss(uiconst::CSS_CAMCOPS_MAIN));
connect(m_logbox.data(), &LogBox::accepted,
this, &NetworkManager::logboxFinished,
Qt::UniqueConnection);
connect(m_logbox.data(), &LogBox::rejected,
this, &NetworkManager::logboxCancelled,
Qt::UniqueConnection);
m_logbox->open();
}
}
void NetworkManager::deleteLogBox()
{
if (!m_logbox) {
return;
}
m_logbox->deleteLater();
m_logbox = nullptr;
}
void NetworkManager::enableLogging()
{
m_silent = false;
}
void NetworkManager::disableLogging()
{
m_silent = true;
}
bool NetworkManager::isLogging() const
{
return !m_silent;
}
void NetworkManager::setTitle(const QString& title)
{
m_title = title;
if (m_logbox) {
m_logbox->setWindowTitle(title);
}
}
void NetworkManager::statusMessage(const QString& msg) const
{
qInfo().noquote() << "Network:" << msg;
if (m_silent) {
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO << "silent";
#endif
return;
}
ensureLogBox();
m_logbox->statusMessage(
QString("%1: %2").arg(datetime::nowTimestamp(), msg));
}
void NetworkManager::htmlStatusMessage(const QString& html) const
{
if (m_silent) {
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO << "silent";
#endif
return;
}
ensureLogBox();
m_logbox->statusMessage(html, true);
}
void NetworkManager::logboxCancelled()
{
// User has hit cancel
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO;
#endif
cleanup();
deleteLogBox();
emit cancelled(ErrorCode::NoError, QString());
}
void NetworkManager::logboxFinished()
{
// User has acknowledged finish
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO;
#endif
cleanup();
deleteLogBox();
emit finished();
}
// ============================================================================
// Basic connection management
// ============================================================================
void NetworkManager::disconnectManager()
{
m_mgr->disconnect();
}
QNetworkRequest NetworkManager::createRequest(const QUrl& url,
const bool offer_cancel,
const bool ssl,
const bool ignore_ssl_errors,
QSsl::SslProtocol ssl_protocol)
{
// Clear any previous callbacks
disconnectManager();
m_offer_cancel = offer_cancel;
QNetworkRequest request;
#ifdef DEBUG_NETWORK_REQUESTS
qDebug().nospace().noquote()
<< Q_FUNC_INFO
<< ": offer_cancel=" << offer_cancel
<< ", ssl=" << ssl
<< ", ignore_ssl_errors=" << ignore_ssl_errors
<< ", ssl_protocol=" << convert::describeSslProtocol(ssl_protocol);
#endif
if (ssl) {
QSslConfiguration config = QSslConfiguration::defaultConfiguration();
config.setProtocol(ssl_protocol);
// NB the OpenSSL version must also support the protocol (e.g. TLSv2);
// ... see also https://bugreports.qt.io/browse/QTBUG-31230
// ... but TLSv2 working fine with manually compiled OpenSSL
request.setSslConfiguration(config);
if (ignore_ssl_errors) {
QObject::connect(
m_mgr, &QNetworkAccessManager::sslErrors,
std::bind(&NetworkManager::sslIgnoringErrorHandler,
this, std::placeholders::_1,
std::placeholders::_2));
}
}
// URL
request.setUrl(url);
return request;
}
QUrl NetworkManager::serverUrl(bool& success) const
{
QUrl url;
#ifdef DEBUG_OFFER_HTTP_TO_SERVER
url.setScheme(m_app.varBool(varconst::DEBUG_USE_HTTPS_TO_SERVER) ? "https"
: "http");
#else
url.setScheme("https");
#endif
url.setHost(m_app.varString(varconst::SERVER_ADDRESS));
url.setPort(m_app.varInt(varconst::SERVER_PORT));
QString path = m_app.varString(varconst::SERVER_PATH);
if (!path.startsWith('/')) {
path = "/" + path;
}
url.setPath(path);
success = !url.host().isEmpty();
return url;
}
QString NetworkManager::serverUrlDisplayString() const
{
bool success = false; // we don't care about the result
QUrl url = serverUrl(success);
const QString str = url.toDisplayString();
return str;
}
QNetworkRequest NetworkManager::createServerRequest(bool& success)
{
QSsl::SslProtocol ssl_protocol = convert::sslProtocolFromDescription(
m_app.varString(varconst::SSL_PROTOCOL));
return createRequest(
serverUrl(success),
true, // always offer cancel
true, // always use SSL
!m_app.validateSslCertificates(), // ignore SSL errors?
ssl_protocol);
}
void NetworkManager::serverPost(Dict dict, ReplyFuncPtr reply_func,
const bool include_user)
{
// Request (URL, SSL, etc.).
bool success = true;
QNetworkRequest request = createServerRequest(success);
if (!success) {
statusMessage(tr("Server host details not specified; see Settings"));
fail();
return;
}
// Complete the dictionary
// dict[KEY_CAMCOPS_VERSION] = camcopsversion::CAMCOPS_VERSION.toFloatString(); // outdated
dict[KEY_CAMCOPS_VERSION] = camcopsversion::CAMCOPS_CLIENT_VERSION.toString(); // server copes as of v2.0.0
dict[KEY_DEVICE] = m_app.deviceId();
if (include_user) {
QString user = m_app.varString(varconst::SERVER_USERNAME);
if (user.isEmpty()) {
statusMessage(tr("User information required but you have not yet "
"specified it; see Settings"));
fail();
return;
}
dict[KEY_USER] = user;
if (!ensurePasswordKnown()) {
statusMessage(tr("Password not specified"));
fail();
return;
}
dict[KEY_PASSWORD] = m_tmp_password;
}
if (!m_tmp_session_id.isEmpty() && !m_tmp_session_token.isEmpty()) {
dict[KEY_SESSION_ID] = m_tmp_session_id;
dict[KEY_SESSION_TOKEN] = m_tmp_session_token;
}
// Clean up the reply storage objects
m_reply_data.clear();
m_reply_dict.clear();
// Connect up the reply signals
QObject::connect(m_mgr, &QNetworkAccessManager::finished,
this, reply_func);
// Send the request
const QUrlQuery postdata = convert::getPostDataAsUrlQuery(dict);
request.setHeader(QNetworkRequest::ContentTypeHeader,
"application/x-www-form-urlencoded");
const QByteArray final_data = postdata.toString(QUrl::FullyEncoded).toUtf8();
// See discussion of encoding in Convert::getPostDataAsUrlQuery
#ifdef DEBUG_NETWORK_REQUESTS
qDebug() << "Request to server: " << final_data;
#endif
statusMessage(tr("... sending ") + sizeBytes(final_data.length()));
m_mgr->post(request, final_data);
}
bool NetworkManager::processServerReply(QNetworkReply* reply)
{
if (!reply) {
statusMessage("Bug: null pointer to processServerReply");
fail();
return false;
}
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
statusMessage(tr("Network failure: ") + reply->errorString());
fail(convertQtNetworkCode(reply->error()), reply->errorString());
return false;
}
m_reply_data = reply->readAll(); // can probably do this only once
statusMessage(tr("... received ") + sizeBytes(m_reply_data.length()));
#ifdef DEBUG_NETWORK_REPLIES_RAW
qDebug() << "Network reply (raw): " << m_reply_data;
#endif
m_reply_dict = convert::getReplyDict(m_reply_data);
#ifdef DEBUG_NETWORK_REPLIES_DICT
qInfo() << "Network reply (dictionary): " << m_reply_dict;
#endif
if (!replyFormatCorrect()) {
statusMessage(tr(
"Reply is not from CamCOPS API. Are your server settings "
"misconfigured? Reply is below."));
htmlStatusMessage(convert::getReplyString(m_reply_data));
fail(ErrorCode::IncorrectReplyFormat,
tr("Reply is not from CamCOPS API. Are your server settings "
"misconfigured?"));
return false;
}
m_tmp_session_id = m_reply_dict[KEY_SESSION_ID];
m_tmp_session_token = m_reply_dict[KEY_SESSION_TOKEN];
if (replyReportsSuccess()) {
return true;
}
// If the server's reporting success=0, it should provide an
// error too:
statusMessage(tr("Server reported an error: ") +
m_reply_dict[KEY_ERROR]);
fail(ErrorCode::ServerError, QString(m_reply_dict[KEY_ERROR]));
return false;
}
NetworkManager::ErrorCode NetworkManager::convertQtNetworkCode(
const QNetworkReply::NetworkError error_code)
{
Q_UNUSED(error_code)
// There doesn't seem to be a way to correctly identify the
// source of the problem. So for now just return the same error code and
// in the app produce a list of things for the user to check.
return NetworkManager::GenericNetworkError;
}
QString NetworkManager::sizeBytes(const qint64 size) const
{
return convert::prettySize(size, true, false, true, "bytes");
}
bool NetworkManager::replyFormatCorrect() const
{
// Characteristics of a reply that has come from the CamCOPS API, not
// (for example) a "page not found" error from Apache:
return m_reply_dict.contains(KEY_SUCCESS) &&
m_reply_dict.contains(KEY_SESSION_ID) &&
m_reply_dict.contains(KEY_SESSION_TOKEN);
}
bool NetworkManager::replyReportsSuccess() const
{
return m_reply_dict[KEY_SUCCESS].toInt();
}
RecordList NetworkManager::getRecordList() const
{
RecordList recordlist;
if (!m_reply_dict.contains(KEY_NRECORDS) ||
!m_reply_dict.contains(KEY_NFIELDS) ||
!m_reply_dict.contains(KEY_FIELDS)) {
statusMessage(tr("ERROR: missing field or record information"));
return RecordList();
}
const int nrecords = m_reply_dict[KEY_NRECORDS].toInt();
if (nrecords <= 0) {
statusMessage(tr("ERROR: No records"));
return RecordList();
}
int nfields = m_reply_dict[KEY_NFIELDS].toInt();
const QString fields = m_reply_dict[KEY_FIELDS];
const QStringList fieldnames = fields.split(',');
if (nfields != fieldnames.length()) {
statusMessage(
tr("WARNING: nfields (%1) doesn't match number of actual "
"fields (%2); field list is: %3")
.arg(nfields)
.arg(fieldnames.length())
.arg(fields));
nfields = fieldnames.length();
}
if (nfields <= 0) {
statusMessage(tr("ERROR: No fields"));
return RecordList();
}
for (int r = 0; r < nrecords; ++r) {
QMap<QString, QVariant> record;
const QString recordname = KEYSPEC_RECORD.arg(r);
if (!m_reply_dict.contains(recordname)) {
statusMessage(tr("ERROR: missing record: ") + recordname);
return RecordList();
}
const QString valuelist = m_reply_dict[recordname];
const QVector<QVariant> values = convert::csvSqlLiteralsToValues(valuelist);
if (values.length() != nfields) {
statusMessage(tr("ERROR: #values not equal to #fields"));
return RecordList();
}
for (int f = 0; f < nfields; ++f) {
record[fieldnames[f]] = values[f];
}
recordlist.push_back(record);
}
return recordlist;
}
bool NetworkManager::ensurePasswordKnown()
{
if (!m_tmp_password.isEmpty()) {
// We already have it, from whatever source
return true;
}
if (m_app.storingServerPassword()) {
m_tmp_password = m_app.getPlaintextServerPassword();
if (!m_tmp_password.isEmpty()) {
return true;
}
}
// If we get here, either we're not storing the password or it hasn't been
// entered.
const QString text = tr("Enter password for user <b>%1</b> on server %2")
.arg(m_app.varString(varconst::SERVER_USERNAME),
serverUrlDisplayString());
const QString title = tr("Enter server password");
QWidget* parent = m_logbox ? m_logbox : m_parent;
PasswordEntryDialog dlg(text, title, parent);
const int reply = dlg.exec();
if (reply != QDialog::Accepted) {
return false;
}
// fetch/write back password
m_tmp_password = dlg.password();
return true;
}
void NetworkManager::cleanup()
{
disconnectManager();
m_tmp_password = "";
m_tmp_session_id = "";
m_tmp_session_token = "";
m_register_next_stage = NextRegisterStage::Invalid;
m_reply_data.clear();
m_reply_dict.clear();
m_upload_next_stage = NextUploadStage::Invalid;
m_upload_patient_ids_to_move_off.clear();
m_upload_empty_tables.clear();
m_upload_tables_to_send_whole.clear();
m_upload_tables_to_send_recordwise.clear();
m_upload_recordwise_table_in_progress = "";
m_upload_recordwise_fieldnames.clear();
m_upload_current_record_index = -1;
m_upload_recordwise_pks_to_send.clear();
m_upload_n_records = 0;
m_upload_tables_to_wipe.clear();
m_upload_patient_info_json = "";
}
void NetworkManager::sslIgnoringErrorHandler(QNetworkReply* reply,
const QList<QSslError> & errlist)
{
// Error handle that ignores SSL certificate errors and continues
statusMessage(tr("+++ Ignoring %1 SSL error(s):").arg(errlist.length()));
for (const QSslError& err : errlist) {
statusMessage(" " + err.errorString());
}
reply->ignoreSslErrors();
}
void NetworkManager::cancel()
{
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO;
#endif
cleanup();
if (m_logbox) {
return m_logbox->reject(); // its rejected() signal calls our logboxCancelled()
}
emit cancelled(ErrorCode::NoError, QString());
}
void NetworkManager::fail(const ErrorCode error_code,
const QString& error_string)
{
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO;
#endif
cleanup();
if (m_logbox) {
return m_logbox->finish(false); // its signals call our logboxCancelled() or logboxFinished()
}
emit cancelled(error_code, error_string);
}
void NetworkManager::succeed()
{
#ifdef DEBUG_ACTIVITY
qDebug() << Q_FUNC_INFO;
#endif
cleanup();
if (m_logbox) {
return m_logbox->finish(true); // its signals call our logboxCancelled() or logboxFinished()
}
emit finished();
}
// ============================================================================
// Testing
// ============================================================================
void NetworkManager::testHttpGet(const QString& url, const bool offer_cancel)
{
QNetworkRequest request = createRequest(QUrl(url), offer_cancel,
false, false);
statusMessage(tr("Testing HTTP GET connection to:") + " " + url);
// Safe object lifespan signal: can use std::bind
QObject::connect(m_mgr, &QNetworkAccessManager::finished,
std::bind(&NetworkManager::testReplyFinished,
this, std::placeholders::_1));
// GET
m_mgr->get(request);
statusMessage(tr("... sent request to:") + " " + url);
}
void NetworkManager::testHttpsGet(const QString& url, const bool offer_cancel,
const bool ignore_ssl_errors)
{
QNetworkRequest request = createRequest(QUrl(url), offer_cancel,
true, ignore_ssl_errors,
QSsl::AnyProtocol);
statusMessage(tr("Testing HTTPS GET connection to:") + " " + url);
// Safe object lifespan signal: can use std::bind
QObject::connect(m_mgr, &QNetworkAccessManager::finished,
std::bind(&NetworkManager::testReplyFinished, this,
std::placeholders::_1));
// Note: the reply callback arrives on the main (GUI) thread.
// GET
m_mgr->get(request);
statusMessage(tr("... sent request to:") + " " + url);
}
void NetworkManager::testReplyFinished(QNetworkReply* reply)
{
if (reply->error() == QNetworkReply::NoError) {
statusMessage(tr("Result:"));
statusMessage(reply->readAll());
} else {
statusMessage(tr("Network error:") + " " + reply->errorString());
}
reply->deleteLater(); // http://doc.qt.io/qt-5/qnetworkaccessmanager.html#details
succeed();
}
// ============================================================================
// Server registration
// ============================================================================
void NetworkManager::registerWithServer()
{
registerNext();
}
void NetworkManager::registerNext(QNetworkReply* reply)
{
if (reply) {
if (!processServerReply(reply)) {
return;
}
statusMessage(tr("... OK"));
}
Dict dict;
switch (m_register_next_stage) {
case NextRegisterStage::Invalid:
m_register_next_stage = NextRegisterStage::Register;
registerNext();
break;
case NextRegisterStage::Register:
statusMessage(
//: Server URL
tr("Registering with %1 and receiving identification information")
.arg(serverUrlDisplayString())
);
dict[KEY_OPERATION] = OP_REGISTER;
dict[KEY_DEVICE_FRIENDLY_NAME] = m_app.varString(
varconst::DEVICE_FRIENDLY_NAME
);
m_register_next_stage = NextRegisterStage::StoreServerIdentification;
serverPost(dict, &NetworkManager::registerNext);
break;
case NextRegisterStage::StoreServerIdentification:
storeServerIdentificationInfo();
m_register_next_stage = NextRegisterStage::GetAllowedTables;
registerNext();
break;
case NextRegisterStage::GetAllowedTables:
statusMessage(tr("Requesting allowed tables"));
dict[KEY_OPERATION] = OP_GET_ALLOWED_TABLES;
m_register_next_stage = NextRegisterStage::StoreAllowedTables;
serverPost(dict, &NetworkManager::registerNext);
break;
case NextRegisterStage::StoreAllowedTables:
storeAllowedTables();
m_register_next_stage = NextRegisterStage::GetExtraStrings;
registerNext();
break;
case NextRegisterStage::GetExtraStrings:
statusMessage(tr("Requesting extra strings"));
dict[KEY_OPERATION] = OP_GET_EXTRA_STRINGS;
m_register_next_stage = NextRegisterStage::StoreExtraStrings;
serverPost(dict, &NetworkManager::registerNext);
break;
case NextRegisterStage::StoreExtraStrings:
storeExtraStrings();
m_register_next_stage = NextRegisterStage::Finished;
if (m_app.isSingleUserMode()) {
m_register_next_stage = NextRegisterStage::GetTaskSchedules;
}
registerNext();
break;
case NextRegisterStage::GetTaskSchedules:
dict[KEY_OPERATION] = OP_GET_TASK_SCHEDULES;
dict[KEY_PATIENT_PROQUINT] = m_app.varString(
varconst::SINGLE_PATIENT_PROQUINT
);
m_register_next_stage = NextRegisterStage::StoreTaskSchedules;
serverPost(dict, &NetworkManager::registerNext);
break;
case NextRegisterStage::StoreTaskSchedules:
storeTaskSchedulesAndPatientDetails();
m_register_next_stage = NextRegisterStage::Finished;
registerNext();
break;
case NextRegisterStage::Finished:
statusMessage(tr("Completed successfully."));
succeed();
break;
default:
uifunc::stopApp("Bug: unknown m_register_next_stage");
}
}
void NetworkManager::updateTaskSchedulesAndPatientDetails()
{
Dict dict;
dict[KEY_OPERATION] = OP_GET_TASK_SCHEDULES;
dict[KEY_PATIENT_PROQUINT] = m_app.varString(
varconst::SINGLE_PATIENT_PROQUINT
);
statusMessage(tr("Getting task schedules from") + " " +
serverUrlDisplayString());
serverPost(dict, &NetworkManager::receivedTaskSchedulesAndPatientDetails);
}
void NetworkManager::receivedTaskSchedulesAndPatientDetails(QNetworkReply* reply)
{
if (!processServerReply(reply)) {
return;
}
storeTaskSchedulesAndPatientDetails();
succeed();
}
void NetworkManager::storeTaskSchedulesAndPatientDetails()
{
statusMessage(tr("... received task schedules"));
QJsonParseError error;
// ------------------------------------------------------------------------
// Patient
// ------------------------------------------------------------------------
// Note: Unlike in createSinglePatient(), our patient object already
// exists. We're just checking that the details match (in case there's been
// a change on the server).
const QJsonDocument patient_doc = QJsonDocument::fromJson(
m_reply_dict[KEY_PATIENT_INFO].toUtf8(), &error
);
if (patient_doc.isNull()) {
const QString message = tr("Failed to parse patient info: %1").arg(
error.errorString()
);
statusMessage(message);
fail(ErrorCode::JsonParseError, message);
return;
}
const QJsonArray patients_json_array = patient_doc.array();
const QJsonObject patient_json = patients_json_array.first().toObject();
Patient* patient = m_app.selectedPatient();
if (patient) {
patient->setPatientDetailsFromJson(patient_json);
patient->setIdNums(patient_json);
patient->save();
} else {
const QString message = tr(
"No patient selected! Unexpected in single-patient mode.");
statusMessage(message);
// ... but continue.
}
// ------------------------------------------------------------------------
// Schedules
// ------------------------------------------------------------------------
const QJsonDocument schedule_doc = QJsonDocument::fromJson(
m_reply_dict[KEY_TASK_SCHEDULES].toUtf8(), &error
);
if (schedule_doc.isNull()) {
const QString message = tr("Failed to parse task schedules: %1").arg(
error.errorString()
);
statusMessage(message);
fail(ErrorCode::JsonParseError, message);
return;
}
const TaskSchedulePtrList old_schedules = m_app.getTaskSchedules();
const QJsonArray schedules_array = schedule_doc.array();
TaskSchedulePtrList new_schedules;
for (QJsonArray::const_iterator it = schedules_array.constBegin();
it != schedules_array.constEnd();
it++) {
QJsonObject schedule_json = it->toObject();
TaskSchedulePtr schedule = TaskSchedulePtr(
new TaskSchedule(m_app, m_app.sysdb(), schedule_json)
);
schedule->save();
schedule->addItems(
schedule_json.value(KEY_TASK_SCHEDULE_ITEMS).toArray()
);
new_schedules.append(schedule);
}
if (old_schedules.size() > 0) {
updateCompleteStatusForAnonymousTasks(old_schedules, new_schedules);
}
for (const TaskSchedulePtr& old_schedule : old_schedules) {
old_schedule->deleteFromDatabase();
}
}
void NetworkManager::updateCompleteStatusForAnonymousTasks(
TaskSchedulePtrList old_schedules,
TaskSchedulePtrList new_schedules)
{
// When updating the schedule, the server does not know which anonymous
// tasks have been completed so we use any existing data on the tablet.
// The new task schedule item has to match the old one exactly in terms
// of table name, date etc
QMap<QString, TaskSchedulePtr> old_schedule_map;
for (const TaskSchedulePtr& old_schedule : old_schedules) {
old_schedule_map[old_schedule->name()] = old_schedule;
}
for (const TaskSchedulePtr& new_schedule : new_schedules) {
const QString schedule_name = new_schedule->name();
if (old_schedule_map.contains(schedule_name)) {
TaskSchedulePtr old_schedule = old_schedule_map[schedule_name];
for (const TaskScheduleItemPtr& old_item: old_schedule->items()) {
if (old_item->isAnonymous()) {
TaskScheduleItemPtr new_item = new_schedule->findItem(
old_item
);
if (new_item != nullptr) {
new_item->setComplete(old_item->isComplete(),
old_item->whenCompleted());
new_item->save();
}
}
}
}
}
}
void NetworkManager::fetchIdDescriptions()
{
statusMessage(tr("Getting ID info from") + " " + serverUrlDisplayString());
Dict dict;
dict[KEY_OPERATION] = OP_GET_ID_INFO;
serverPost(dict, &NetworkManager::fetchIdDescriptionsSub1);
}
void NetworkManager::fetchIdDescriptionsSub1(QNetworkReply* reply)
{
if (!processServerReply(reply)) {
return;
}
statusMessage(tr("... registered and received identification information"));
storeServerIdentificationInfo();
succeed();
}
void NetworkManager::fetchExtraStrings()
{
statusMessage(tr("Getting extra strings from") + " " +
serverUrlDisplayString());
Dict dict;
dict[KEY_OPERATION] = OP_GET_EXTRA_STRINGS;
serverPost(dict, &NetworkManager::fetchExtraStringsSub1);
}
void NetworkManager::fetchExtraStringsSub1(QNetworkReply* reply)
{
if (!processServerReply(reply)) {
return;
}
statusMessage(tr("... received extra strings"));
storeExtraStrings();
succeed();
}
void NetworkManager::fetchAllServerInfo()
{
statusMessage(tr("Fetching server info from ") + serverUrlDisplayString());
statusMessage(tr("Requesting ID info"));
Dict dict;
dict[KEY_OPERATION] = OP_GET_ID_INFO;
serverPost(dict, &NetworkManager::fetchAllServerInfoSub1);
}
void NetworkManager::fetchAllServerInfoSub1(QNetworkReply* reply)
{
if (!processServerReply(reply)) {
return;
}
statusMessage(tr("... received identification information"));
storeServerIdentificationInfo();
// Now we move across to the "registration" chain of functions:
m_register_next_stage = NextRegisterStage::GetAllowedTables;
registerNext();
}
void NetworkManager::storeServerIdentificationInfo()
{
m_app.setVar(varconst::SERVER_DATABASE_TITLE, m_reply_dict[KEY_DATABASE_TITLE]);
m_app.setVar(varconst::SERVER_CAMCOPS_VERSION, m_reply_dict[KEY_SERVER_CAMCOPS_VERSION]);
m_app.setVar(varconst::ID_POLICY_UPLOAD, m_reply_dict[KEY_ID_POLICY_UPLOAD]);
m_app.setVar(varconst::ID_POLICY_FINALIZE, m_reply_dict[KEY_ID_POLICY_FINALIZE]);
m_app.deleteAllIdDescriptions();
for (const QString& keydesc : m_reply_dict.keys()) {
if (keydesc.startsWith(KEYPREFIX_ID_DESCRIPTION)) {
const QString number = keydesc.right(keydesc.length() -
KEYPREFIX_ID_DESCRIPTION.length());
bool ok = false;
const int which_idnum = number.toInt(&ok);
if (ok) {
const QString desc = m_reply_dict[keydesc];
const QString key_shortdesc = KEYSPEC_ID_SHORT_DESCRIPTION.arg(which_idnum);
const QString shortdesc = m_reply_dict[key_shortdesc];
const QString key_validation = KEYSPEC_ID_VALIDATION_METHOD.arg(which_idnum);
const QString validation_method = m_reply_dict[key_validation];
m_app.setIdDescription(which_idnum, desc, shortdesc, validation_method);
} else {
qWarning() << "Bad ID description key:" << keydesc;
}
}
}
m_app.setVar(varconst::LAST_SERVER_REGISTRATION, datetime::now());
m_app.setVar(varconst::LAST_SUCCESSFUL_UPLOAD, QVariant());
// ... because we might have registered with a different server, we set
// this to NULL, so it doesn't give the impression that we have uploaded
// our data to the new server.
// Deselect patient or reload single user mode patient as its description
// text may be out of date
m_app.setDefaultPatient(true);
}
void NetworkManager::storeAllowedTables()
{
RecordList recordlist = getRecordList();
m_app.setAllowedServerTables(recordlist);
statusMessage(tr("Saved %1 allowed tables").arg(recordlist.length()));
}
void NetworkManager::storeExtraStrings()
{
RecordList recordlist = getRecordList();
if (!recordlist.isEmpty()) {
m_app.setAllExtraStrings(recordlist);
statusMessage(tr("Saved %1 extra strings").arg(recordlist.length()));
}
}
// ============================================================================
// Upload
// ============================================================================
// ----------------------------------------------------------------------------
// Upload: CORE
// ----------------------------------------------------------------------------
void NetworkManager::upload(const UploadMethod method)
{
statusMessage(tr("Preparing to upload to:") + " " +
serverUrlDisplayString());
// ... in part so uploadNext() status message looks OK
// The GUI doesn't get a chance to respond until after this function
// has completed.
// SlowGuiGuard guard(); // not helpful
m_app.processEvents(); // these, scattered around, are very helpful.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 1. Internal database checks/flag-setting
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
cleanup();
m_upload_method = method;
// Offline things first:
if (!isPatientInfoComplete()) { // also sets m_patient_info_json
fail();
return;
}
m_app.processEvents();
statusMessage(tr("Removing any defunct binary large objects"));
if (!pruneDeadBlobs()) {
fail();
return;
}
statusMessage(tr("... done"));
m_app.processEvents();
statusMessage("Setting move-off flags for tasks, where applicable");
if (!applyPatientMoveOffTabletFlagsToTasks()) {
fail();
return;
}
statusMessage(tr("... done"));
m_app.processEvents();
m_app.processEvents();
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 2. Begin comms with the server by checking device is registered.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
checkDeviceRegistered();
m_upload_next_stage = NextUploadStage::CheckUser;
// ... will end up at uploadNext().
}
void NetworkManager::uploadNext(QNetworkReply* reply)
{
// This function imposes an order on the upload sequence, which makes
// everything else work.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Whatever happens next, check the server was happy with our last request.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The option for reply to be nullptr is so we can do a no-op.
if (reply && !processServerReply(reply)) {
return;
}
if (m_upload_next_stage == NextUploadStage::Invalid) {
// stage might be Invalid if user hit cancel while messages still
// inbound
return;
}
statusMessage(tr("... OK"));
switch (m_upload_next_stage) {
case NextUploadStage::CheckUser:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: check device registration. (Checked implicitly.)
// TO: check user OK.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
checkUploadUser();
m_upload_next_stage = NextUploadStage::FetchServerIdInfo;
break;
case NextUploadStage::FetchServerIdInfo:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: check user OK. (Checked implicitly.)
// TO: fetch server ID info (server version, database title,
// which ID numbers, ID policies)
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
uploadFetchServerIdInfo();
m_upload_next_stage = NextUploadStage::ValidatePatients;
break;
case NextUploadStage::StoreExtraStrings:
// The server version changed so we fetch any new extra strings
storeExtraStrings();
// now we go back to trying to fetch the server info
m_upload_next_stage = NextUploadStage::FetchServerIdInfo;
uploadNext(nullptr);
break;
case NextUploadStage::ValidatePatients:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: fetch server ID info
// TO: ask server to validate patients
// ... or if the server doesn't support that, move on another step
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if (m_app.isSingleUserMode()) {
// In single user mode, if the server has been updated, we overwrite
// the stored server version and refetch all server info without
// warning or prompting the user to refetch.
if (!serverVersionMatchesStored()) {
storeServerIdentificationInfo();
statusMessage(tr("Requesting extra strings"));
Dict dict;
dict[KEY_OPERATION] = OP_GET_EXTRA_STRINGS;
m_upload_next_stage = NextUploadStage::StoreExtraStrings;
serverPost(dict, &NetworkManager::uploadNext);
return;
}
}
if (!isServerVersionOK() || !arePoliciesOK() || !areDescriptionsOK()) {
fail();
return;
}
if (serverSupportsValidatePatients()) {
uploadValidatePatients(); // v2.3.0
m_upload_next_stage = NextUploadStage::FetchAllowedTables;
break;
} else {
// Otherwise:
// [[fallthrough]];
// ... C++17 syntax!
// - https://en.cppreference.com/w/cpp/language/attributes
// - https://en.cppreference.com/w/cpp/language/attributes/fallthrough
// [[clang::fallthrough]];
// ... compiler-specific
Q_FALLTHROUGH();
// ... well done, Qt
// - http://doc.qt.io/qt-5/qtglobal.html#Q_FALLTHROUGH
}
case NextUploadStage::FetchAllowedTables:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: ask server to validate patients
// TO: fetch allowed tables/minimum client versions
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
uploadFetchAllowedTables();
m_upload_next_stage = NextUploadStage::CheckPoliciesThenStartUpload;
break;
case NextUploadStage::CheckPoliciesThenStartUpload:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: fetch allowed tables/minimum client versions
// TO: start upload or preservation
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
statusMessage("... received allowed tables");
storeAllowedTables();
if (!catalogueTablesForUpload()) { // checks per-table version requirements
fail();
return;
}
if (shouldUseOneStepUpload()) {
uploadOneStep();
m_upload_next_stage = NextUploadStage::Finished;
} else {
startUpload();
if (m_upload_method == UploadMethod::Copy) {
// If we copy, we proceed to uploading
m_upload_next_stage = NextUploadStage::Uploading;
} else {
// If we're moving, we preserve records.
m_upload_next_stage = NextUploadStage::StartPreservation;
}
}
break;
case NextUploadStage::StartPreservation:
startPreservation();
m_upload_next_stage = NextUploadStage::Uploading;
break;
case NextUploadStage::Uploading:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: start upload or preservation
// TO: upload, tablewise then recordwise (CYCLES ROUND here until done)
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if (!m_upload_empty_tables.isEmpty()) {
sendEmptyTables(m_upload_empty_tables);
m_upload_empty_tables.clear();
} else if (!m_upload_tables_to_send_whole.isEmpty()) {
QString table = m_upload_tables_to_send_whole.front();
m_upload_tables_to_send_whole.pop_front();
sendTableWhole(table);
} else if (!m_upload_recordwise_pks_to_send.isEmpty()) {
if (!m_recordwise_prune_req_sent) {
requestRecordwisePkPrune();
} else {
if (!m_recordwise_pks_pruned) {
if (!pruneRecordwisePks()) {
fail();
return;
}
if (m_upload_recordwise_pks_to_send.isEmpty()) {
// Quasi-recursive way of saying "do whatever you would
// have done otherwise", since the server had said "I'm
// not interested in any records from that table".
statusMessage(tr("... server doesn't want anything "
"from this table"));
uploadNext(nullptr);
return;
}
}
sendNextRecord();
}
} else if (!m_upload_tables_to_send_recordwise.isEmpty()) {
QString table = m_upload_tables_to_send_recordwise.front();
m_upload_tables_to_send_recordwise.pop_front();
sendTableRecordwise(table);
} else {
endUpload();
m_upload_next_stage = NextUploadStage::Finished;
}
break;
case NextUploadStage::Finished:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// FROM: upload, or uploadOneStep()
// All done successfully!
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
wipeTables();
statusMessage(tr("Finished"));
m_app.setVar(varconst::LAST_SUCCESSFUL_UPLOAD, datetime::now());
m_app.setNeedsUpload(false);
m_app.setDefaultPatient(true); // even for "copy" method; see changelog
m_app.forceRefreshPatientList();
succeed();
break;
default:
uifunc::stopApp("Bug: unknown m_upload_next_stage");
}
}
// ----------------------------------------------------------------------------
// Upload: COMMS
// ----------------------------------------------------------------------------
void NetworkManager::checkDeviceRegistered()
{
statusMessage(tr("Checking device is registered with server"));
Dict dict;
dict[KEY_OPERATION] = OP_CHECK_DEVICE_REGISTERED;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::checkUploadUser()
{
statusMessage(tr("Checking user/device permitted to upload"));
Dict dict;
dict[KEY_OPERATION] = OP_CHECK_UPLOAD_USER_DEVICE;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::uploadFetchServerIdInfo()
{
statusMessage(tr("Fetching server's version/ID policies/ID descriptions"));
Dict dict;
dict[KEY_OPERATION] = OP_GET_ID_INFO;
serverPost(dict, &NetworkManager::uploadNext);
}
bool NetworkManager::serverSupportsValidatePatients() const
{
return m_app.serverVersion() >= MIN_SERVER_VERSION_FOR_VALIDATE_PATIENTS;
}
void NetworkManager::uploadValidatePatients()
{
// Added in v2.3.0
statusMessage(tr("Validating patients for upload"));
Dict dict;
dict[KEY_OPERATION] = OP_VALIDATE_PATIENTS;
dict[KEY_PATIENT_INFO] = m_upload_patient_info_json;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::uploadFetchAllowedTables()
{
statusMessage(tr("Fetching server's allowed tables/client versions"));
Dict dict;
dict[KEY_OPERATION] = OP_GET_ALLOWED_TABLES;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::startUpload()
{
statusMessage(tr("Starting upload"));
Dict dict;
dict[KEY_OPERATION] = OP_START_UPLOAD;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::startPreservation()
{
statusMessage(tr("Starting preservation"));
Dict dict;
dict[KEY_OPERATION] = OP_START_PRESERVATION;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::sendEmptyTables(const QStringList& tablenames)
{
statusMessage(tr("Uploading empty tables: ") + tablenames.join(", "));
Dict dict;
dict[KEY_OPERATION] = OP_UPLOAD_EMPTY_TABLES;
dict[KEY_TABLES] = tablenames.join(",");
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::sendTableWhole(const QString& tablename)
{
statusMessage(tr("Uploading table: ") + tablename);
Dict dict;
dict[KEY_OPERATION] = OP_UPLOAD_TABLE;
dict[KEY_TABLE] = tablename;
const QStringList fieldnames = m_db.getFieldNames(tablename);
dict[KEY_PKNAME] = dbconst::PK_FIELDNAME; // version 2.0.4
// There was a BUG here before v2.0.4:
// - the old Titanium code gave fieldnames starting with the PK
// - the SQLite reporting order isn't necessarily like that
// - for the upload_table command, the receiving code relied on the PK
// being first
// - So as of tablet v2.0.4, the client explicitly reports PK name (and
// makes no guarantee about field order) and as of server v2.1.0, the
// server takes the PK name if the tablet is >=2.0.4, or "id" otherwise
// (because the client PK name always was "id"!). This allows old tablets
// to work (for which: could use fieldnames[0] or "id") and early buggy
// C++ clients to work (for which: "id" is the only valid option).
dict[KEY_FIELDS] = fieldnames.join(",");
const QString sql = dbfunc::selectColumns(fieldnames, tablename);
const QueryResult result = m_db.query(sql);
if (!result.succeeded()) {
queryFail(sql);
return;
}
const int nrows = result.nRows();
for (int record = 0; record < nrows; ++record) {
dict[KEYSPEC_RECORD.arg(record)] = result.csvRow(record);
}
dict[KEY_NRECORDS] = QString::number(nrows);
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::sendTableRecordwise(const QString& tablename)
{
statusMessage(tr("Preparing to send table (recordwise): ") + tablename);
m_upload_recordwise_table_in_progress = tablename;
m_upload_recordwise_fieldnames = m_db.getFieldNames(tablename);
m_recordwise_prune_req_sent = false;
m_recordwise_pks_pruned = false;
m_upload_recordwise_pks_to_send = m_db.getPKs(tablename,
dbconst::PK_FIELDNAME);
m_upload_n_records = m_upload_recordwise_pks_to_send.size();
m_upload_current_record_index = 0;
// First, DELETE WHERE pk NOT...
const QString pkvalues = convert::numericVectorToCsvString(m_upload_recordwise_pks_to_send);
Dict dict;
dict[KEY_OPERATION] = OP_DELETE_WHERE_KEY_NOT;
dict[KEY_TABLE] = tablename;
dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;
dict[KEY_PKVALUES] = pkvalues;
statusMessage(tr("Sending message: ") + OP_DELETE_WHERE_KEY_NOT);
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::requestRecordwisePkPrune()
{
const QString sql = QString("SELECT %1, %2, %3 FROM %4")
.arg(delimit(dbconst::PK_FIELDNAME), // result column 0
delimit(dbconst::MODIFICATION_TIMESTAMP_FIELDNAME), // result column 1
delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME), // result column 2
delimit(m_upload_recordwise_table_in_progress));
const QueryResult result = m_db.query(sql);
const QStringList pkvalues = result.columnAsStringList(0);
const QStringList datevalues = result.columnAsStringList(1);
const QStringList move_off_tablet_values = result.columnAsStringList(2);
Dict dict;
dict[KEY_OPERATION] = OP_WHICH_KEYS_TO_SEND;
dict[KEY_TABLE] = m_upload_recordwise_table_in_progress;
dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;
dict[KEY_PKVALUES] = pkvalues.join(",");
dict[KEY_DATEVALUES] = datevalues.join(",");
dict[KEY_MOVE_OFF_TABLET_VALUES] = move_off_tablet_values.join(","); // v2.3.0
m_recordwise_prune_req_sent = true;
statusMessage(tr("Sending message: ") + OP_WHICH_KEYS_TO_SEND);
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::sendNextRecord()
{
++m_upload_current_record_index;
statusMessage(tr("Uploading table %1, record %2/%3")
.arg(m_upload_recordwise_table_in_progress)
.arg(m_upload_current_record_index)
.arg(m_upload_n_records));
// Don't use m_upload_recordwise_pks_to_send.size() as the count, as that
// changes during upload.
const int pk = m_upload_recordwise_pks_to_send.front();
m_upload_recordwise_pks_to_send.pop_front();
SqlArgs sqlargs(dbfunc::selectColumns(
m_upload_recordwise_fieldnames,
m_upload_recordwise_table_in_progress));
WhereConditions where;
where.add(dbconst::PK_FIELDNAME, pk);
where.appendWhereClauseTo(sqlargs);
const QueryResult result = m_db.query(sqlargs, QueryResult::FetchMode::FetchFirst);
if (!result.succeeded() || result.nRows() < 1) {
queryFail(sqlargs.sql);
return;
}
const QString values = result.csvRow(0);
Dict dict;
dict[KEY_OPERATION] = OP_UPLOAD_RECORD;
dict[KEY_TABLE] = m_upload_recordwise_table_in_progress;
dict[KEY_FIELDS] = m_upload_recordwise_fieldnames.join(",");
dict[KEY_PKNAME] = dbconst::PK_FIELDNAME;
dict[KEY_VALUES] = values;
serverPost(dict, &NetworkManager::uploadNext);
}
void NetworkManager::endUpload()
{
statusMessage(tr("Finishing upload"));
Dict dict;
dict[KEY_OPERATION] = OP_END_UPLOAD;
serverPost(dict, &NetworkManager::uploadNext);
}
// ----------------------------------------------------------------------------
// Upload: INTERNAL FUNCTIONS
// ----------------------------------------------------------------------------
bool NetworkManager::isPatientInfoComplete()
{
statusMessage(tr("Checking patient information sufficiently complete"));
Patient specimen_patient(m_app, m_db);
const SqlArgs sqlargs = specimen_patient.fetchQuerySql();
const QueryResult result = m_db.query(sqlargs);
if (!result.succeeded()) {
queryFail(sqlargs.sql);
return false;
}
const bool finalizing = m_upload_method != UploadMethod::Copy;
int nfailures_upload = 0;
int nfailures_finalize = 0;
int nfailures_clash = 0;
int nfailures_move_off = 0;
QJsonArray patients_json_array;
const int nrows = result.nRows();
for (int row = 0; row < nrows; ++row) {
Patient patient(m_app, m_db);
patient.setFromQuery(result, row, true);
const bool finalizing_this_pt = patient.shouldMoveOffTablet();
if (!patient.compliesWithUpload()) {
++nfailures_upload;
}
const bool complies_with_finalize = patient.compliesWithFinalize();
if (!complies_with_finalize) {
++nfailures_finalize;
}
if (patient.anyIdClash()) {
// not the most efficient; COUNT DISTINCT...
// However, this gives us the number of patients clashing.
++nfailures_clash;
}
if (m_upload_method != UploadMethod::Move && finalizing_this_pt) {
// To move a patient off, it must comply with the finalize policy.
if (!complies_with_finalize) {
++nfailures_move_off;
} else {
m_upload_patient_ids_to_move_off.append(patient.pkvalueInt());
}
}
// Set JSON too. See below.
QJsonObject ptjson = patient.jsonDescription();
ptjson[KEY_FINALIZING] = finalizing || finalizing_this_pt;
patients_json_array.append(ptjson);
}
if (nfailures_clash > 0) {
statusMessage(tr("Failure: %1 patient(s) having clashing ID numbers")
.arg(nfailures_clash));
return false;
}
if (nfailures_move_off > 0) {
statusMessage(tr(
"You are trying to move off %1 patient(s) using the "
"explicit per-patient move-off flag, but they do not "
"comply with the server's finalize ID policy [%2]")
.arg(nfailures_move_off)
.arg(m_app.finalizePolicy().pretty()));
return false;
}
if (m_upload_method == UploadMethod::Copy && nfailures_upload > 0) {
// Copying; we're allowed not to meet the finalizing requirements,
// but we must meet the uploading requirements
statusMessage(tr("Failure: %1 patient(s) do not meet the "
"server's upload ID policy of: %2")
.arg(nfailures_upload)
.arg(m_app.uploadPolicy().pretty()));
return false;
}
if (finalizing && nfailures_upload + nfailures_finalize > 0) {
// Finalizing; must meet all requirements
statusMessage(tr(
"Failure: %1 patient(s) do not meet the server's upload ID policy "
"[%2]; %3 patient(s) do not meet its finalize ID policy [%4]")
.arg(nfailures_upload)
.arg(m_app.uploadPolicy().pretty())
.arg(nfailures_finalize)
.arg(m_app.finalizePolicy().pretty()));
return false;
}
// We also set the patient info JSON here, so we only iterate through
// patients once.
//
// Compare camcops_server.cc_modules.client_api.validate_patients() on the
// server.
//
// Top-level JSON can be an object or an array.
// - https://stackoverflow.com/questions/3833299/can-an-array-be-top-level-json-text
// - http://www.ietf.org/rfc/rfc4627.txt?number=4627
const QJsonDocument jsondoc(patients_json_array);
m_upload_patient_info_json = jsondoc.toJson(QJsonDocument::Compact);
// ^^^^^^ ... a QByteArray in UTF-8
// - https://stackoverflow.com/questions/28181627/how-to-convert-a-qjsonobject-to-qstring
#ifdef DEBUG_JSON
qDebug().noquote() << "Patient info JSON:" << m_upload_patient_info_json;
#endif
return true;
}
bool NetworkManager::applyPatientMoveOffTabletFlagsToTasks()
{
// If we were uploading, we need to undo our move-off flags (in case the
// user changes their mind about a patient)
// We could use a system of "set before upload, clear afterwards".
// However, failing to clear (for some reason) is a risk.
// Therefore, we set and clear flags here, for all tables.
// That is, we make sure these flags are all correct immediately before
// an upload (which is when we care).
if (m_upload_method != UploadMethod::Copy) {
// if we're not using UploadMethod::Copy, everything is going to be
// moved anyway, by virtue of startPreservation()
statusMessage(tr("... not applicable; all tasks will be moved"));
return true;
}
DbNestableTransaction trans(m_db);
// ========================================================================
// Step 1: clear all move-off flags, except in the source tables (being:
// patient tables and anonymous task primary tables).
// ========================================================================
for (const TaskPtr& specimen : m_p_task_factory->allSpecimens()) {
if (specimen->isAnonymous()) {
// anonymous task: clear the ancillary tables
for (const QString& tablename : specimen->ancillaryTables()) {
if (!clearMoveOffTabletFlag(tablename)) {
queryFailClearingMoveOffFlag(tablename);
return false;
}
}
} else {
// task with patient: clear all tables
for (const QString& tablename : specimen->allTables()) {
if (!clearMoveOffTabletFlag(tablename)) {
queryFailClearingMoveOffFlag(tablename);
return false;
}
}
}
}
// Clear all flags for BLOBs
if (!clearMoveOffTabletFlag(Blob::TABLENAME)) {
queryFailClearingMoveOffFlag(Blob::TABLENAME);
return false;
}
// ========================================================================
// Step 2: Apply flags from patients to their idnums/tasks/ancillary tables.
// ========================================================================
// m_upload_patient_ids_to_move_off has been precalculated for efficiency
const int n_patients = m_upload_patient_ids_to_move_off.length();
if (n_patients > 0) {
QString pt_paramholders = dbfunc::sqlParamHolders(n_patients);
ArgList pt_args = dbfunc::argListFromIntList(m_upload_patient_ids_to_move_off);
// Maximum length of an SQL statement: lots
// https://www.sqlite.org/limits.html
QString sql;
// Patient ID number table
sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
.arg(delimit(PatientIdNum::PATIENT_IDNUM_TABLENAME),
delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
delimit(PatientIdNum::FK_PATIENT),
pt_paramholders);
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sql, pt_args);
#else
if (!m_db.exec(sql, pt_args)) {
queryFail(sql);
return false;
}
#endif
// Task tables
for (const TaskPtr& specimen : m_p_task_factory->allSpecimens()) {
if (specimen->isAnonymous()) {
continue;
}
const QString main_tablename = specimen->tablename();
// (a) main table, with FK to patient
sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
.arg(delimit(main_tablename),
delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
delimit(Task::PATIENT_FK_FIELDNAME),
pt_paramholders);
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sql, pt_args);
#else
if (!m_db.exec(sql, pt_args)) {
queryFail(sql);
return false;
}
#endif
// (b) ancillary tables
const QStringList ancillary_tables = specimen->ancillaryTables();
if (ancillary_tables.isEmpty()) {
// no ancillary tables
continue;
}
WhereConditions where;
where.add(dbconst::MOVE_OFF_TABLET_FIELDNAME, 1);
const QVector<int> task_pks = m_db.getSingleFieldAsIntList(
main_tablename, dbconst::PK_FIELDNAME, where);
if (task_pks.isEmpty()) {
// no tasks to be moved off
continue;
}
const QString fk_task_fieldname = specimen->ancillaryTableFKToTaskFieldname();
if (fk_task_fieldname.isEmpty()) {
uifunc::stopApp(QString(
"Task %1 has ancillary tables but "
"ancillaryTableFKToTaskFieldname() returns empty")
.arg(main_tablename));
}
const QString task_paramholders = dbfunc::sqlParamHolders(task_pks.length());
const ArgList task_args = dbfunc::argListFromIntList(task_pks);
for (const QString& ancillary_table : ancillary_tables) {
sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
.arg(delimit(ancillary_table),
delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
delimit(fk_task_fieldname),
task_paramholders);
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sql, task_args);
#else
if (!m_db.exec(sql, task_args)) {
queryFail(sql);
return false;
}
#endif
}
}
}
// ========================================================================
// Step 3: Apply flags from anonymous tasks to their ancillary tables.
// ========================================================================
for (const TaskPtr& specimen : m_p_task_factory->allSpecimens()) {
if (!specimen->isAnonymous()) {
continue;
}
const QString main_tablename = specimen->tablename();
const QStringList ancillary_tables = specimen->ancillaryTables();
if (ancillary_tables.isEmpty()) {
continue;
}
// Get PKs of all anonymous tasks being moved off
WhereConditions where;
where.add(dbconst::MOVE_OFF_TABLET_FIELDNAME, 1);
const QVector<int> task_pks = m_db.getSingleFieldAsIntList(
main_tablename, dbconst::PK_FIELDNAME, where);
if (task_pks.isEmpty()) {
// no tasks to be moved off
continue;
}
const QString fk_task_fieldname = specimen->ancillaryTableFKToTaskFieldname();
if (fk_task_fieldname.isEmpty()) {
uifunc::stopApp(QString(
"Task %1 has ancillary tables but "
"ancillaryTableFKToTaskFieldname() returns empty")
.arg(main_tablename));
}
const QString task_paramholders = dbfunc::sqlParamHolders(task_pks.length());
const ArgList task_args = dbfunc::argListFromIntList(task_pks);
for (const QString& ancillary_table : ancillary_tables) {
QString sql = QString("UPDATE %1 SET %2 = 1 WHERE %3 IN (%4)")
.arg(delimit(ancillary_table),
delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME),
delimit(fk_task_fieldname),
task_paramholders);
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sql, task_args);
#else
if (!m_db.exec(sql, task_args)) {
queryFail(sql);
return false;
}
#endif
}
}
// ========================================================================
// Step 4. BLOB table.
// ========================================================================
// Options here are:
// - iterate through every task (and ancillary table), loading them from
// SQL to C++, and asking each what BLOB IDs they possess;
// - store patient_id (or NULL) with each BLOB;
// - iterate through each BLOB, looking for the move-off flag on the
// associated task/ancillary record.
// The most efficient and simple is likely to be (3).
// (a) For every BLOB...
const QString sql = dbfunc::selectColumns(
QStringList{dbconst::PK_FIELDNAME,
Blob::SRC_TABLE_FIELDNAME,
Blob::SRC_PK_FIELDNAME},
Blob::TABLENAME);
const QueryResult result = m_db.query(sql);
if (!result.succeeded()) {
queryFail(sql);
return false;
}
const int nrows = result.nRows();
for (int row = 0; row < nrows; ++row) {
// (b) find the table/PK of the linked task (or other table)
const int blob_pk = result.at(row, 0).toInt();
const QString src_table = result.at(row, 1).toString();
const int src_pk = result.at(row, 2).toInt();
// (c) find the move-off flag for that linked task
SqlArgs sub1_sqlargs(
dbfunc::selectColumns(
QStringList{dbconst::MOVE_OFF_TABLET_FIELDNAME},
src_table));
WhereConditions sub1_where;
sub1_where.add(dbconst::PK_FIELDNAME, src_pk);
sub1_where.appendWhereClauseTo(sub1_sqlargs);
const int move_off_int = m_db.fetchInt(sub1_sqlargs, -1);
if (move_off_int == -1) {
// No records matching
qWarning().nospace()
<< "BLOB refers to "
<< src_table
<< "."
<< dbconst::PK_FIELDNAME
<< " = "
<< src_pk
<< " but record doesn't exist!";
continue;
}
if (move_off_int == 0) {
// Record exists; task not marked for move-off
continue;
}
// (d) set the BLOB's move-off flag
const UpdateValues update_values{{dbconst::MOVE_OFF_TABLET_FIELDNAME, true}};
SqlArgs sub2_sqlargs = dbfunc::updateColumns(update_values, Blob::TABLENAME);
WhereConditions sub2_where;
sub2_where.add(dbconst::PK_FIELDNAME, blob_pk);
sub2_where.appendWhereClauseTo(sub2_sqlargs);
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sub2_sqlargs);
#else
if (!m_db.exec(sub2_sqlargs)) {
queryFail(sub2_sqlargs.sql);
return false;
}
#endif
}
return true;
}
bool NetworkManager::catalogueTablesForUpload()
{
statusMessage(tr("Cataloguing tables for upload"));
const QStringList recordwise_tables{Blob::TABLENAME};
const QStringList patient_tables{Patient::TABLENAME,
PatientIdNum::PATIENT_IDNUM_TABLENAME};
const QStringList all_tables = m_db.getAllTables();
const Version server_version = m_app.serverVersion();
bool may_upload;
bool server_has_table; // table present on server
Version min_client_version; // server's requirement for min client version
Version min_server_version; // client's requirement for min server version
for (const QString& table : all_tables) {
const int n_records = m_db.count(table);
may_upload = m_app.mayUploadTable(
table, server_version,
server_has_table, min_client_version, min_server_version);
if (!may_upload) {
if (server_has_table) {
// This table requires a newer client than we are, OR we
// require a newer server than it is.
// If the table is empty, proceed. Otherwise, fail.
if (server_version < min_server_version) {
if (n_records != 0) {
statusMessage(tr(
"ERROR: Table '%1' contains data; it is present "
"on the server but the client requires server "
"version >=%2; the server is version %3"
).arg(table, min_server_version.toString(),
server_version.toString()));
return false;
}
statusMessage(tr(
"WARNING: Table '%1' is present on the server but "
"the client requires server version >=%2; the "
"server is version %3; proceeding ONLY BECAUSE "
"THIS TABLE IS EMPTY."
).arg(table, min_server_version.toString(),
server_version.toString()));
} else {
if (n_records != 0) {
statusMessage(tr(
"ERROR: Table '%1' contains data; it is present "
"on the server but the server requires client "
"version >=%2; you are using version %3"
).arg(table, min_client_version.toString(),
camcopsversion::CAMCOPS_CLIENT_VERSION.toString()));
return false;
}
statusMessage(tr(
"WARNING: Table '%1' is present on the server but "
"the server requires client version >=%2; you are "
"using version %3; proceeding ONLY BECAUSE THIS "
"TABLE IS EMPTY."
).arg(table, min_client_version.toString(),
camcopsversion::CAMCOPS_CLIENT_VERSION.toString()));
}
} else {
// The table isn't on the server.
if (n_records != 0) {
statusMessage(tr(
"ERROR: Table '%1' contains data but is absent on the "
"server. You probably need a newer server version. "
"(Once you have upgraded the server, re-register with "
"it.)").arg(table));
return false;
}
statusMessage(tr(
"WARNING: Table '%1' is absent on the server. You "
"probably need a newer server version. (Once you have "
"upgraded the server, re-register with it.) "
"Proceeding ONLY BECAUSE THIS TABLE IS EMPTY."
).arg(table));
}
}
// How to upload?
if (n_records == 0) {
if (may_upload) {
m_upload_empty_tables.append(table);
}
} else if (recordwise_tables.contains(table)) {
m_upload_tables_to_send_recordwise.append(table);
} else {
m_upload_tables_to_send_whole.append(table);
}
// Whether to clear afterwards?
// (Note that if we get here and may_upload is false, it must be the
// case that the table is empty, in which case it doesn't matter
// whether we clear it or not.)
switch (m_upload_method) {
case UploadMethod::Copy:
case UploadMethod::Invalid:
#ifdef COMPILER_WANTS_DEFAULT_IN_EXHAUSTIVE_SWITCH
default:
#endif
break;
case UploadMethod::MoveKeepingPatients:
if (!patient_tables.contains(table)) {
m_upload_tables_to_wipe.append(table);
}
break;
case UploadMethod::Move:
m_upload_tables_to_wipe.append(table);
break;
}
}
return true;
}
bool NetworkManager::isServerVersionOK() const
{
statusMessage(tr("Checking server CamCOPS version"));
if (!serverVersionNewEnough()) {
return false;
}
if (!serverVersionMatchesStored()) {
return false;
}
statusMessage(tr("... OK"));
return true;
}
bool NetworkManager::serverVersionNewEnough() const
{
const Version server_version = serverVersionFromReply();
bool new_enough = server_version >= camcopsversion::MINIMUM_SERVER_VERSION;
if (!new_enough) {
statusMessage(tr("Server CamCOPS version (%1) is too old; must be >= %2")
.arg(server_version.toString(),
camcopsversion::MINIMUM_SERVER_VERSION.toString()));
}
return new_enough;
}
bool NetworkManager::serverVersionMatchesStored() const
{
const Version server_version = serverVersionFromReply();
const Version stored_server_version = m_app.serverVersion();
bool matches = server_version == stored_server_version;
if (!matches) {
statusMessage(tr("Server version (%1) doesn't match stored version (%2).")
.arg(server_version.toString(),
stored_server_version.toString()) +
txtPleaseRefetchServerInfo());
}
return matches;
}
Version NetworkManager::serverVersionFromReply() const
{
return Version(m_reply_dict[KEY_SERVER_CAMCOPS_VERSION]);
}
bool NetworkManager::arePoliciesOK() const
{
statusMessage(tr("Checking ID policies match server"));
const QString local_upload = m_app.uploadPolicy().pretty();
const QString local_finalize = m_app.finalizePolicy().pretty();
const QString server_upload = IdPolicy(m_reply_dict[KEY_ID_POLICY_UPLOAD]).pretty();
const QString server_finalize = IdPolicy(m_reply_dict[KEY_ID_POLICY_FINALIZE]).pretty();
bool ok = true;
if (local_upload != server_upload) {
statusMessage(tr("Local upload policy [%1] doesn't match server's [%2].")
.arg(local_upload, server_upload) +
txtPleaseRefetchServerInfo());
ok = false;
}
if (local_finalize != server_finalize) {
statusMessage(tr("Local finalize policy [%1] doesn't match server's [%2].")
.arg(local_finalize, server_finalize) +
txtPleaseRefetchServerInfo());
ok = false;
}
if (ok) {
statusMessage(tr("... OK"));
}
return ok;
}
bool NetworkManager::areDescriptionsOK() const
{
statusMessage(tr("Checking ID descriptions match server"));
bool idnums_all_on_server = true;
bool descriptions_match = true;
QVector<int> which_idnums_on_server;
QVector<IdNumDescriptionPtr> iddescriptions = m_app.getAllIdDescriptions();
for (const IdNumDescriptionPtr& iddesc : iddescriptions) {
const int n = iddesc->whichIdNum();
const QString key_desc = KEYSPEC_ID_DESCRIPTION.arg(n);
const QString key_shortdesc = KEYSPEC_ID_SHORT_DESCRIPTION.arg(n);
const QString key_validation = KEYSPEC_ID_VALIDATION_METHOD.arg(n);
if (m_reply_dict.contains(key_desc) &&
m_reply_dict.contains(key_shortdesc)) {
const QString local_desc = iddesc->description();
const QString local_shortdesc = iddesc->shortDescription();
const QString server_desc = m_reply_dict[key_desc];
const QString server_shortdesc = m_reply_dict[key_shortdesc];
descriptions_match = descriptions_match &&
local_desc == server_desc &&
local_shortdesc == server_shortdesc;
which_idnums_on_server.append(n);
// Old servers may not provide the ID number validator info.
// But new ones will (v2.2.8+), in which case we'll check.
if (m_reply_dict.contains(key_validation)) {
const QString local_validation = iddesc->validationMethod();
const QString server_validation = m_reply_dict[key_validation];
descriptions_match = descriptions_match &&
local_validation == server_validation;
}
} else {
idnums_all_on_server = false;
}
}
QVector<int> which_idnums_on_tablet = whichIdnumsUsedOnTablet();
QVector<int> extra_idnums_on_tablet = containers::setSubtract(
which_idnums_on_tablet, which_idnums_on_server);
const bool extra_idnums = !extra_idnums_on_tablet.isEmpty();
const bool ok = descriptions_match && idnums_all_on_server && !extra_idnums;
if (ok) {
statusMessage(tr("... OK"));
} else if (!idnums_all_on_server) {
statusMessage(tr("Some ID numbers defined on the tablet are absent on "
"the server!") + txtPleaseRefetchServerInfo());
} else if (!descriptions_match) {
statusMessage(tr("Descriptions do not match!") + txtPleaseRefetchServerInfo());
} else if (extra_idnums) {
statusMessage(tr(
"ID numbers %1 are used on the tablet but not defined "
"on the server! Please edit patient records to remove "
"them.").arg(convert::numericVectorToCsvString(
extra_idnums_on_tablet)));
} else {
statusMessage("Logic bug: something not OK but don't know why");
}
return ok;
}
QVector<int> NetworkManager::whichIdnumsUsedOnTablet() const
{
const QString sql = QString("SELECT DISTINCT %1 FROM %2 ORDER BY %1")
.arg(delimit(PatientIdNum::FN_WHICH_IDNUM),
delimit(PatientIdNum::PATIENT_IDNUM_TABLENAME));
const QueryResult result = m_db.query(sql);
return result.firstColumnAsIntList();
}
bool NetworkManager::pruneRecordwisePks()
{
if (!m_reply_dict.contains(KEY_RESULT)) {
statusMessage(tr("Server's reply was missing the key: ") + KEY_RESULT);
return false;
}
const QString reply = m_reply_dict[KEY_RESULT];
statusMessage(tr("Server requests only PKs: ") + reply);
m_upload_recordwise_pks_to_send = convert::csvStringToIntVector(reply);
m_upload_n_records = m_upload_recordwise_pks_to_send.size();
m_recordwise_pks_pruned = true;
return true;
}
void NetworkManager::wipeTables()
{
DbNestableTransaction trans(m_db);
// Plain wipes, of entire tables
for (const QString& wipe_table : m_upload_tables_to_wipe) {
// Note: m_upload_tables_to_wipe will contain the patient table if
// we're moving everything; see catalogueTablesForUpload()
statusMessage(tr("Wiping table: ") + wipe_table);
if (!m_db.deleteFrom(wipe_table)) {
statusMessage(tr("... failed to delete!"));
trans.fail();
fail();
}
}
// Selective wipes: tasks, patients, ancillary tables...
// - We wipe: (a) records in tasks whose patient record was marked for
// moving (and whose _move_off_tablet field was propagated through to the
// task, as above); (b) any anonymous tasks specifically marked for
// moving; (c) any ancillary tasks of the above.
// - The simplest way is to go through ALL tables (task + ancillary +
// patient + patient ID...) and delete records for which
// "_move_off_tablet" is set (skipping any tables we've already wiped
// completely, for speed).
if (m_upload_method != UploadMethod::Move) {
// ... if we were doing a Move, *everything* has gone
statusMessage(tr("Wiping any specifically requested patients and/or anonymous tasks"));
WhereConditions where_move_off;
where_move_off.add(dbconst::MOVE_OFF_TABLET_FIELDNAME, 1);
const QStringList all_tables = m_db.getAllTables();
for (const QString& tablename : all_tables) {
if (m_upload_tables_to_wipe.contains(tablename)) {
continue; // Already totally wiped
}
m_db.deleteFrom(tablename, where_move_off);
}
}
}
void NetworkManager::queryFail(const QString& sql)
{
statusMessage(tr("Query failed: ") + sql);
fail();
}
void NetworkManager::queryFailClearingMoveOffFlag(const QString& tablename)
{
queryFail(tr("... trying to clear move-off-tablet flag for table:") +
" " + tablename);
}
bool NetworkManager::clearMoveOffTabletFlag(const QString& tablename)
{
// 1. Clear all
const QString sql = QString("UPDATE %1 SET %2 = 0")
.arg(delimit(tablename),
delimit(dbconst::MOVE_OFF_TABLET_FIELDNAME));
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sql);
return true;
#else
return m_db.exec(sql);
#endif
}
bool NetworkManager::pruneDeadBlobs()
{
using dbfunc::delimit;
const QStringList all_tables = m_db.getAllTables();
QVector<int> bad_blob_pks;
// For all BLOBs...
QString sql = dbfunc::selectColumns(
QStringList{dbconst::PK_FIELDNAME,
Blob::SRC_TABLE_FIELDNAME,
Blob::SRC_PK_FIELDNAME},
Blob::TABLENAME);
const QueryResult result = m_db.query(sql);
if (!result.succeeded()) {
queryFail(sql);
return false;
}
const int nrows = result.nRows();
for (int row = 0; row < nrows; ++row) {
const int blob_pk = result.at(row, 0).toInt();
const QString src_table = result.at(row, 1).toString();
const int src_pk = result.at(row, 2).toInt();
if (src_pk == dbconst::NONEXISTENT_PK) {
continue;
}
// Does our BLOB refer to something non-existent?
if (!all_tables.contains(src_table) ||
!m_db.existsByPk(src_table, dbconst::PK_FIELDNAME, src_pk)) {
bad_blob_pks.append(blob_pk);
}
}
const int n_bad_blobs = bad_blob_pks.length();
statusMessage(tr("... %1 defunct BLOBs").arg(n_bad_blobs));
if (n_bad_blobs == 0) {
return true;
}
qWarning() << "Deleting defunct BLOBs with PKs:" << bad_blob_pks;
const QString paramholders = dbfunc::sqlParamHolders(n_bad_blobs);
sql = QString("DELETE FROM %1 WHERE %2 IN (%3)")
.arg(delimit(Blob::TABLENAME),
delimit(dbconst::PK_FIELDNAME),
paramholders);
ArgList args = dbfunc::argListFromIntList(bad_blob_pks);
#ifdef USE_BACKGROUND_DATABASE
m_db.execNoAnswer(sql, args);
#else
if (!m_db.exec(sql, args)) {
queryFail(sql);
return false;
}
#endif
return true;
}
// ============================================================================
// One-step upload
// ============================================================================
bool NetworkManager::serverSupportsOneStepUpload() const
{
return m_app.serverVersion() >= MIN_SERVER_VERSION_FOR_ONE_STEP_UPLOAD;
}
bool NetworkManager::shouldUseOneStepUpload() const
{
if (m_app.isSingleUserMode()) {
return false;
}
if (!serverSupportsOneStepUpload()) {
return false;
}
const int method = m_app.varInt(varconst::UPLOAD_METHOD);
// Can't use switch; const int is not const enough.
// Can't use enums; have to store in an int field.
if (method == varconst::UPLOAD_METHOD_ONESTEP) {
return true;
} else if (method == varconst::UPLOAD_METHOD_BYSIZE) {
return m_db.approximateDatabaseSize() <= m_app.varLongLong(
varconst::MAX_DBSIZE_FOR_ONESTEP_UPLOAD);
} else {
// e.g. varconst::UPLOAD_METHOD_MULTISTEP or bad value
return false;
}
}
void NetworkManager::uploadOneStep()
{
statusMessage(tr("Starting one-step upload"));
const bool preserving = m_upload_method != UploadMethod::Copy;
Dict dict;
dict[KEY_OPERATION] = OP_UPLOAD_ENTIRE_DATABASE;
dict[KEY_FINALIZING] = preserving ? ENCODE_TRUE : ENCODE_FALSE;
dict[KEY_PKNAMEINFO] = getPkInfoAsJson();
dict[KEY_DBDATA] = m_db.getDatabaseAsJson();
#ifdef DEBUG_JSON
qDebug().noquote() << Q_FUNC_INFO << dict[KEY_DBDATA];
#endif
serverPost(dict, &NetworkManager::uploadNext);
}
QString NetworkManager::getPkInfoAsJson()
{
QJsonObject root;
for (const QString& tablename : m_db.getAllTables()) {
root[tablename] = dbconst::PK_FIELDNAME; // they're all the same...
}
const QJsonDocument jsondoc(root);
return jsondoc.toJson(QJsonDocument::Compact);
}
// ============================================================================
// Translatable text
// ============================================================================
QString NetworkManager::txtPleaseRefetchServerInfo()
{
// return " " + tr("Please re-register with the server.");
return " " + tr("Please re-fetch server information.");
}
// ============================================================================
// Patient registration
// ============================================================================
void NetworkManager::registerPatient()
{
Dict dict;
dict[KEY_OPERATION] = OP_REGISTER_PATIENT;
dict[KEY_PATIENT_PROQUINT] = m_app.varString(
varconst::SINGLE_PATIENT_PROQUINT
);
const bool include_user = false;
serverPost(dict, &NetworkManager::registerPatientSub1, include_user);
}
void NetworkManager::registerPatientSub1(QNetworkReply* reply)
{
if (!processServerReply(reply)) {
return;
}
setUserDetails();
if (!createSinglePatient()) {
return;
}
if (!setIpUseInfo()) {
return;
}
registerWithServer();
}
void NetworkManager::setUserDetails()
{
if (m_reply_dict.contains(KEY_USER)) {
m_app.setEncryptedServerPassword(m_reply_dict[KEY_PASSWORD]);
m_app.setVar(varconst::SERVER_USERNAME, m_reply_dict[KEY_USER]);
}
}
bool NetworkManager::createSinglePatient()
{
QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(
m_reply_dict[KEY_PATIENT_INFO].toUtf8(), &error
);
if (doc.isNull()) {
const QString message = tr("Failed to parse patient info: %1").arg(
error.errorString()
);
statusMessage(message);
fail(ErrorCode::JsonParseError, message);
return false;
}
// Consistent with uploading patients but only one element
// in the array
const QJsonArray patients_json_array = doc.array();
const QJsonObject patient_json = patients_json_array.first().toObject();
PatientPtr patient = PatientPtr(
new Patient(m_app, m_app.db(), patient_json)
);
patient->save();
m_app.setSinglePatientId(patient->id());
patient->addIdNums(patient_json);
return true;
}
bool NetworkManager::setIpUseInfo()
{
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(
m_reply_dict[KEY_IP_USE_INFO].toUtf8(), &error
);
if (doc.isNull()) {
const QString message = tr(
"Failed to parse intellectual property use info: %1"
).arg(error.errorString());
statusMessage(message);
fail(ErrorCode::JsonParseError, message);
return false;
}
const QJsonObject ip_use_info = doc.object();
m_app.setVar(varconst::IP_USE_CLINICAL,
ip_use_info.value(KEY_IP_USE_CLINICAL));
m_app.setVar(varconst::IP_USE_COMMERCIAL,
ip_use_info.value(KEY_IP_USE_COMMERCIAL));
m_app.setVar(varconst::IP_USE_EDUCATIONAL,
ip_use_info.value(KEY_IP_USE_EDUCATIONAL));
m_app.setVar(varconst::IP_USE_RESEARCH,
ip_use_info.value(KEY_IP_USE_RESEARCH));
return true;
}