From 81502588146f9944a897ec8273d9b1d59681abf3 Mon Sep 17 00:00:00 2001 From: Boernsman Date: Wed, 17 Feb 2021 20:59:45 +0100 Subject: [PATCH 1/8] added Tempo plugin --- debian/control | 9 ++ debian/nymea-plugin-tempo.install.in | 1 + nymea-plugins.pro | 1 + tempo/README.md | 17 +++ tempo/integrationplugintempo.cpp | 181 +++++++++++++++++++++++++++ tempo/integrationplugintempo.h | 68 ++++++++++ tempo/integrationplugintempo.json | 172 +++++++++++++++++++++++++ tempo/meta.json | 0 tempo/tempo.cpp | 155 +++++++++++++++++++++++ tempo/tempo.h | 135 ++++++++++++++++++++ tempo/tempo.png | Bin 0 -> 29508 bytes tempo/tempo.pro | 14 +++ 12 files changed, 753 insertions(+) create mode 100644 debian/nymea-plugin-tempo.install.in create mode 100644 tempo/README.md create mode 100644 tempo/integrationplugintempo.cpp create mode 100644 tempo/integrationplugintempo.h create mode 100644 tempo/integrationplugintempo.json create mode 100644 tempo/meta.json create mode 100644 tempo/tempo.cpp create mode 100644 tempo/tempo.h create mode 100644 tempo/tempo.png create mode 100644 tempo/tempo.pro diff --git a/debian/control b/debian/control index db1cc724..03b23488 100644 --- a/debian/control +++ b/debian/control @@ -700,6 +700,15 @@ Depends: ${shlibs:Depends}, nymea-plugins-translations, Description: nymea.io plugin for Telegram This plugin allows nymea to send messages to telegram via the bot API. + + +Package: nymea-plugin-tempo +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Description: nymea.io plugin for Tempo time tracking + This package will install the nymea.io plugin for Tempo time tracking. Package: nymea-plugin-tplink diff --git a/debian/nymea-plugin-tempo.install.in b/debian/nymea-plugin-tempo.install.in new file mode 100644 index 00000000..c63e05ef --- /dev/null +++ b/debian/nymea-plugin-tempo.install.in @@ -0,0 +1 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginstempo.so diff --git a/nymea-plugins.pro b/nymea-plugins.pro index eb037d01..a92e5782 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -58,6 +58,7 @@ PLUGIN_DIRS = \ tasmota \ tcpcommander \ telegram \ + tempo \ texasinstruments \ tplink \ tuya \ diff --git a/tempo/README.md b/tempo/README.md new file mode 100644 index 00000000..cc12441a --- /dev/null +++ b/tempo/README.md @@ -0,0 +1,17 @@ +# Tempo + +Add Tempo time tracking accounts to nymea. + +## Supported Things + +* Tempo Atlassian Account + * Time Accounts + +## Requirements + +* Package 'nymea-plugin-tempo' must be installed +* A atlassian account and client credentials + +## More + +https://apidocs.tempo.io/ diff --git a/tempo/integrationplugintempo.cpp b/tempo/integrationplugintempo.cpp new file mode 100644 index 00000000..9cc92cb4 --- /dev/null +++ b/tempo/integrationplugintempo.cpp @@ -0,0 +1,181 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "integrationplugintempo.h" +#include "plugininfo.h" + +#include "network/networkaccessmanager.h" + +#include +#include +#include +#include +#include + +IntegrationPluginTempo::IntegrationPluginTempo() +{ +} + +void IntegrationPluginTempo::startPairing(ThingPairingInfo *info) +{ + qCDebug(dcTempo()) << "Start pairing"; + + if (info->thingClassId() == tempoConnectionThingClassId) { + + QByteArray clientId = configValue(tempoPluginCustomClientIdParamTypeId).toByteArray(); + QByteArray clientSecret = configValue(tempoPluginCustomClientSecretParamTypeId).toByteArray(); + if (clientId.isEmpty() || clientSecret.isEmpty()) { + clientId = apiKeyStorage()->requestKey("tempo").data("clientId"); + clientSecret = apiKeyStorage()->requestKey("tempo").data("clientSecret"); + } else { + qCDebug(dcTempo()) << "Using custom client secret and id"; + } + if (clientId.isEmpty() || clientSecret.isEmpty()) { + info->finish(Thing::ThingErrorAuthenticationFailure, tr("Client id and/or seceret is not available.")); + return; + } + + QString jiraCloudInstanceName = info->params().paramValue(tempoConnectionAtlassianAccountParamTypeId).toString(); + QUrl url = Tempo::getLoginUrl(QUrl("https://127.0.0.1:8888"), jiraCloudInstanceName, clientId); + qCDebug(dcTempo()) << "Checking if the Tempo server is reachable"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [reply, info, url, this] { + + if (reply->error() != QNetworkReply::NetworkError::HostNotFoundError) { + qCDebug(dcTempo()) << "Tempo server is reachable"; + info->setOAuthUrl(url); + info->finish(Thing::ThingErrorNoError); + } else { + qCWarning(dcTempo()) << "Got online check error" << reply->error() << reply->errorString(); + info->finish(Thing::ThingErrorSetupFailed, tr("Tempo server not reachable, please check the internet connection")); + } + }); + } else { + qCWarning(dcTempo()) << "Unhandled pairing method!"; + info->finish(Thing::ThingErrorCreationMethodNotSupported); + } +} + +void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) +{ + Q_UNUSED(username); + qCDebug(dcTempo()) << "Confirm pairing"; + if (info->thingClassId() == tempoConnectionThingClassId) { + + QUrl url(secret); + QUrlQuery query(url); + QByteArray authorizationCode = query.queryItemValue("code").toLocal8Bit(); + if (authorizationCode.isEmpty()) { + qCWarning(dcTempo()) << "No authorization code received."; + return info->finish(Thing::ThingErrorAuthenticationFailure); + } + + Tempo *tempo = m_setupTempoConnections.value(info->thingId()); + if (!tempo) { + qWarning(dcTempo()) << "No Tempo connection found for device:" << info->thingName(); + m_setupTempoConnections.remove(info->thingId()); + return info->finish(Thing::ThingErrorHardwareFailure); + } + qCDebug(dcTempo()) << "Authorization code" << authorizationCode.mid(0, 4)+QString().fill('*', authorizationCode.length()-4) ; + tempo->getAccessTokenFromAuthorizationCode(authorizationCode); + connect(tempo, &Tempo::receivedRefreshToken, info, [info, this](const QByteArray &refreshToken){ + qCDebug(dcTempo()) << "Token:" << refreshToken.mid(0, 4)+QString().fill('*', refreshToken.length()-4) ; + + pluginStorage()->beginGroup(info->thingId().toString()); + pluginStorage()->setValue("refresh_token", refreshToken); + pluginStorage()->endGroup(); + + info->finish(Thing::ThingErrorNoError); + }); + } else { + Q_ASSERT_X(false, "confirmPairing", QString("Unhandled thingClassId: %1").arg(info->thingClassId().toString()).toUtf8()); + } +} + +void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + qCDebug(dcTempo()) << "Setup thing"; + + if (thing->thingClassId() == tempoConnectionThingClassId) { + + } +} + +void IntegrationPluginTempo::executeAction(ThingActionInfo *info) +{ + Thing *thing = info->thing(); + Action action = info->action(); + + if (thing->thingClassId() == tempoConnectionThingClassId) { + + } else if (thing->thingClassId() == accountThingClassId) { + + } +} + +void IntegrationPluginTempo::thingRemoved(Thing *thing) +{ + qCDebug(dcTempo()) << "Thing removed" << thing->name(); +} + +void IntegrationPluginTempo::onReceivedAccounts(const QList &accounts) +{ + qCDebug(dcTempo()) << "Received" << accounts.count() << "accounts"; + + Tempo *tempoConnection = static_cast(sender()); + Thing *parentThing = m_tempoConnections.key(tempoConnection); + if (!parentThing) + return; + + ThingDescriptors desciptors; + Q_FOREACH(Tempo::Account account, accounts) { + ThingClassId thingClassId; + + Thing * existingThing = myThings().findByParams(ParamList() << Param(m_idParamTypeIds.value(thingClassId), appliance.homeApplianceId)); + if (existingThing) { + qCDebug(dcTempo()) << "Thing is already added to system" << existingThing->name(); + //Set connected state; + //existingThing->setStateValue(m_connectedStateTypeIds.value(thingClassId), appliance.connected); + continue; + } + qCDebug(dcTempo()) << "Found new account:" << account.name << "key:" << account.key << "id:" << account.id; + ThingDescriptor descriptor(thingClassId, account.name, account.key, parentThing->id()); + + ParamList params; + //params << Param(m_idParamTypeIds.value(thingClassId), appliance.homeApplianceId); + descriptor.setParams(params); + desciptors.append(descriptor); + } + if (!desciptors.isEmpty()) + emit autoThingsAppeared(desciptors); +} + diff --git a/tempo/integrationplugintempo.h b/tempo/integrationplugintempo.h new file mode 100644 index 00000000..5ad3bd88 --- /dev/null +++ b/tempo/integrationplugintempo.h @@ -0,0 +1,68 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINTEMPO_H +#define INTEGRATIONPLUGINTEMPO_H + +#include "integrations/integrationplugin.h" +#include "plugintimer.h" + +#include "tempo.h" + +class IntegrationPluginTempo : public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationplugintempo.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginTempo(); + + void startPairing(ThingPairingInfo *info) override; + void confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) override; + + void setupThing(ThingSetupInfo *info) override; + void executeAction(ThingActionInfo *info) override; + void thingRemoved(Thing *thing) override; + +private: + PluginTimer *m_pluginTimer15min = nullptr; + + QHash m_setupTempoConnections; + QHash m_tempoConnections; + +private slots: + void onConnectionChanged(bool connected); + void onAuthenticationStatusChanged(bool authenticated); + void onRequestExecuted(QUuid requestId, bool success); + void onReceivedAccounts(const QList &accounts); +}; +#endif // INTEGRATIONPLUGINTEMPO_H diff --git a/tempo/integrationplugintempo.json b/tempo/integrationplugintempo.json new file mode 100644 index 00000000..1aaede7d --- /dev/null +++ b/tempo/integrationplugintempo.json @@ -0,0 +1,172 @@ +{ + "id": "809bc4ca-d1cd-4279-9e0d-7324537ccb5a", + "name": "tempo", + "displayName": "Tempo", + "apiKeys": ["tempo"], + "paramTypes": [ + { + "id": "c130b2b7-6d30-406e-899b-669a065daee3", + "name": "customClientId", + "displayName": "Custom client id", + "defaultValue": "", + "type": "QString" + }, + { + "id": "9c759711-e772-44ce-9d86-6a3af89c2d94", + "name": "customClientSecret", + "displayName": "Custom client secret", + "defaultValue": "", + "type": "QString" + } + ], + "vendors": [ + { + "id": "58fc1ab7-b8b5-4e52-8388-72957ce5852d", + "name": "tempo", + "displayName": "Tempo", + "thingClasses": [ + { + "id": "878eae0a-6217-4b36-bd46-72c911e52e73", + "name": "tempoConnection", + "displayName": "Tempo connection", + "interfaces": ["account"], + "createMethods": ["user"], + "setupMethod": "oauth", + "paramTypes": [ + { + "id": "b4110c37-8331-4057-8e9f-12f34c2623fe", + "name": "atlassianAccountName", + "displayName": "Atlassian account name", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "15f45315-5419-4e1b-ace3-fc21503d3b70", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": true, + "cached": false, + "type": "bool" + }, + { + "id": "e4b5be87-dbc9-481e-88da-608c71be8bda", + "name": "loggedIn", + "displayName": "Logged in", + "displayNameEvent": "Logged in changed", + "defaultValue": true, + "type": "bool" + }, + { + "id": "f3b9581b-7828-4fbe-be5f-3e8aad78a71e", + "name": "userDisplayName", + "displayName": "User name", + "displayNameEvent": "User name changed", + "defaultValue": "", + "type": "QString" + }, + { + "id": "db70444d-bf67-4133-b2de-54aefbdd7149", + "name": "autoAddAccounts", + "displayName": "Auto add accounts", + "displayNameEvent": "Auto add accounts", + "defaultValue": true, + "type": "bool" + } + ] + }, + { + "id": "8be71352-bdfd-450b-903e-79a4ed203701", + "name": "account", + "displayName": "Account", + "interfaces": ["connectable"], + "createMethods": ["auto"], + "browsable": true, + "paramTypes": [ + { + "id": "c6aeddae-56af-496d-a419-1635ff9bae50", + "name": "id", + "displayName": "ID", + "defaultValue": "-", + "type": "QString" + } + ], + "stateTypes": [ + { + "id": "0b776bc1-9e56-4205-9bc3-b356026f5b64", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": true, + "cached": false, + "type": "bool" + }, + { + "id": "7948f15b-7243-404e-9e67-18e915e8b328", + "name": "status", + "displayName": "Status", + "displayNameEvent": "Status changed", + "defaultValue": "OPEN", + "possibleValues": [ + "OPEN", + "CLOSED", + "ARCHIVED" + ], + "type": "QString" + }, + { + "id": "abd55ea0-ad4e-413e-bc77-3e8b7f0a9be4", + "name": "global", + "displayName": "Global", + "displayNameEvent": "Global changed", + "defaultValue": false, + "type": "bool" + }, + { + "id": "44ebbc18-7511-48c0-860b-c4de5f634ed6", + "name": "monthlyBudget", + "displayName": "Monthly budget", + "displayNameEvent": "Monthly budget changed", + "defaultValue": 0, + "type": "int" + }, + { + "id": "f1f2af66-d09a-4242-9058-401145f662c4", + "name": "lead", + "displayName": "Lead", + "displayNameEvent": "Lead changed", + "defaultValue": "", + "type": "QString" + }, + { + "id": "ece43b12-4a0d-4e25-b811-b1aca610bea8", + "name": "contact", + "displayName": "Contact", + "displayNameEvent": "Contact changed", + "defaultValue": "", + "type": "QString" + }, + { + "id": "3af6d1c0-bb0a-406f-809b-2c367e1a16bb", + "name": "category", + "displayName": "Category", + "displayNameEvent": "Category changed", + "defaultValue": "", + "type": "QString" + }, + { + "id": "3dcc1426-51f8-46fa-9967-5a93d7bb2633", + "name": "Customer", + "displayName": "Customer", + "displayNameEvent": "Customer changed", + "defaultValue": "", + "type": "QString" + } + ] + } + ] + } + ] +} diff --git a/tempo/meta.json b/tempo/meta.json new file mode 100644 index 00000000..e69de29b diff --git a/tempo/tempo.cpp b/tempo/tempo.cpp new file mode 100644 index 00000000..fc497ce9 --- /dev/null +++ b/tempo/tempo.cpp @@ -0,0 +1,155 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include +#include + +#include "tempo.h" +#include "extern-plugininfo.h" + +Tempo::Tempo(NetworkAccessManager *networkmanager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent) : +QObject(parent), + m_clientId(clientId), + m_clientSecret(clientSecret), + m_networkManager(networkmanager) + +{ + m_tokenRefreshTimer = new QTimer(this); + m_tokenRefreshTimer->setSingleShot(true); + connect(m_tokenRefreshTimer, &QTimer::timeout, this, &Tempo::onRefreshTimer); +} + +QByteArray Tempo::accessToken() +{ + return m_accessToken; +} + +QByteArray Tempo::refreshToken() +{ + return m_refreshToken; +} + +QUrl Tempo::getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName, const QByteArray &clientId) +{ + QUrl url; + url.setScheme("https"); + url.setHost(jiraCloudInstanceName+"atlassian.net"); + url.setPath("/plugins/servlet/ac/io.tempo.jira/oauth-authorize/"); + QUrlQuery query; + query.addQueryItem("client_id", clientId); + query.addQueryItem("redirect_uri", redirectUrl.toString()); + query.addQueryItem("access_type", "tenant_user"); + url.setQuery(query); + return url; +} + +void Tempo::getAccounts() +{ + QUrl url = QUrl(m_baseControlUrl+"/accounts"); + + QNetworkRequest request(url); + request.setRawHeader("Authorization", "Bearer "+m_accessToken); + + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [this, reply]{ + + QByteArray rawData = reply->readAll(); + if (!checkStatusCode(reply, rawData)) { + return; + } + QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); + QVariantList accountList = dataMap.value("results").toList(); + QList accounts; + Q_FOREACH(QVariant var, accountList) { + QVariantMap map = var.toMap(); + Account account; + account.self = map["self"].toString(); + account.key = map["key"].toString(); + account.id = map["id"].toInt(); + account.name = map["name"].toString(); + + if (map["status"] == "OPEN") { + account.status = Status::Open; + } else if (map["status"] == "CLOSED") { + account.status = Status::Closed; + } else if (map["status"] == "ARCHIVED") { + account.status = Status::Archived; + } + account.global = map["global"].toBool(); + account.monthlyBudget = map["monthlyBudget"].toInt(); + + QVariantMap lead = map["lead"].toMap(); + account.lead.self = lead["self"].toString(); + account.lead.accountId = lead["accountId"].toString(); + account.lead.displayName = lead["displayName"].toString(); + //TODO Customer + QVariantMap customer = map["customer"].toMap(); + account.customer.self = lead["self"].toString(); + //TODO Category + QVariantMap category = map["category"].toMap(); + account.category.self = lead["self"].toString(); + //TODO Contact + QVariantMap contact = map["contact"].toMap(); + account.contact.self = lead["self"].toString(); + + accounts.append(account); + } + if (!accounts.isEmpty()) { + + } + }); +} + +void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to) +{ + +} + +void Tempo::onRefreshTimer() +{ + +} + +void Tempo::setAuthenticated(bool state) +{ + if (state != m_authenticated) { + m_authenticated = state; + emit authenticationStatusChanged(state); + } +} + +void Tempo::setConnected(bool state) +{ + if (state != m_connected) { + m_connected = state; + emit connectionChanged(state); + } +} diff --git a/tempo/tempo.h b/tempo/tempo.h new file mode 100644 index 00000000..c6316c1f --- /dev/null +++ b/tempo/tempo.h @@ -0,0 +1,135 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef TEMPO_H +#define TEMPO_H + +#include +#include +#include + +#include "network/networkaccessmanager.h" + +class Tempo : public QObject +{ + Q_OBJECT +public: + + enum Status { + Open, + Closed, + Archived + }; + Q_ENUM(Status) + struct Lead { + QUrl self; + QString accountId; + QString displayName; + }; + + struct Contact { + QUrl self; + QString accountId; + QString displayName; + QString type; + }; + + struct Category { + QUrl self; + QString accountId; + QString displayName; + }; + + struct Customer { + QUrl self; + QString key; + int id; + QString name; + }; + + struct Account { + QUrl self; + QString key; + int id; + QString name; + Status status; + bool global; + int monthlyBudget; + Lead lead; + Contact contact; + Category category; + Customer customer; + }; + + explicit Tempo(NetworkAccessManager *networkmanager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent = nullptr); + QByteArray accessToken(); + QByteArray refreshToken(); + + static QUrl getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName, const QByteArray &clientId); + + void getAccessTokenFromRefreshToken(const QByteArray &refreshToken); + void getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode); + + void getAccounts(); + void getWorkloadByAccount(const QString &accountKey, QDate from, QDate to); + +private: + + QByteArray m_baseTokenUrl = "https://api.tempo.io/oauth/token/"; + QByteArray m_baseControlUrl = "https://api.tempo.io/core/3/"; + QByteArray m_clientId; + QByteArray m_clientSecret; + + QByteArray m_accessToken; + QByteArray m_refreshToken; + QByteArray m_redirectUri = "https://127.0.0.1:8888"; + + NetworkAccessManager *m_networkManager = nullptr; + QTimer *m_tokenRefreshTimer = nullptr; + + void setAuthenticated(bool state); + void setConnected(bool state); + + bool m_authenticated = false; + bool m_connected = false; + + bool checkStatusCode(QNetworkReply *reply, const QByteArray &rawData); +private slots: + void onRefreshTimer(); + +signals: + void authenticationStatusChanged(bool state); + void connectionChanged(bool connected); + void receivedRefreshToken(const QByteArray &refreshToken); + void receivedAccessToken(const QByteArray &accessToken); + void accountsReceived(const QList accounts); +}; + +#endif // TEMPO_H diff --git a/tempo/tempo.png b/tempo/tempo.png new file mode 100644 index 0000000000000000000000000000000000000000..28852c46470f550e03691369f7d403e3813cf2d4 GIT binary patch literal 29508 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�YCo6s7k)bF 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 W Date: Thu, 18 Feb 2021 09:49:05 +0100 Subject: [PATCH 2/8] added tempo integration --- homeconnect/homeconnect.h | 1 + tempo/integrationplugintempo.cpp | 182 ++++++++++++++++++-- tempo/integrationplugintempo.h | 4 +- tempo/integrationplugintempo.json | 24 ++- tempo/tempo.cpp | 268 ++++++++++++++++++++++++++++-- tempo/tempo.h | 18 +- 6 files changed, 465 insertions(+), 32 deletions(-) diff --git a/homeconnect/homeconnect.h b/homeconnect/homeconnect.h index ae2f757f..486bb651 100644 --- a/homeconnect/homeconnect.h +++ b/homeconnect/homeconnect.h @@ -166,6 +166,7 @@ private: bool m_connected = false; bool checkStatusCode(QNetworkReply *reply, const QByteArray &rawData); + private slots: void onRefreshTimeout(); diff --git a/tempo/integrationplugintempo.cpp b/tempo/integrationplugintempo.cpp index 9cc92cb4..f4621b0b 100644 --- a/tempo/integrationplugintempo.cpp +++ b/tempo/integrationplugintempo.cpp @@ -62,15 +62,26 @@ void IntegrationPluginTempo::startPairing(ThingPairingInfo *info) return; } - QString jiraCloudInstanceName = info->params().paramValue(tempoConnectionAtlassianAccountParamTypeId).toString(); - QUrl url = Tempo::getLoginUrl(QUrl("https://127.0.0.1:8888"), jiraCloudInstanceName, clientId); - qCDebug(dcTempo()) << "Checking if the Tempo server is reachable"; - QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + QString jiraCloudInstanceName = info->params().paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); + Tempo *tempo = new Tempo(hardwareManager()->networkManager(), clientId, clientSecret, this); + + QUrl url = tempo->getLoginUrl(QUrl("https://127.0.0.1:8888"), jiraCloudInstanceName); + qCDebug(dcTempo()) << "Checking if the Tempo server is reachable: https://api.tempo.io/core/3"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(QUrl("https://api.tempo.io/core/3"))); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [reply, info, url, this] { + connect(reply, &QNetworkReply::finished, info, [reply, info, tempo, url, this] { if (reply->error() != QNetworkReply::NetworkError::HostNotFoundError) { qCDebug(dcTempo()) << "Tempo server is reachable"; + ThingId thingId = info->thingId(); + m_setupTempoConnections.insert(info->thingId(), tempo); + connect(info, &ThingPairingInfo::aborted, this, [thingId, this] { + qCWarning(dcTempo()) << "ThingPairingInfo aborted, cleaning up"; + Tempo *tempo = m_setupTempoConnections.take(thingId); + if (tempo) + tempo->deleteLater(); + }); + qCDebug(dcTempo()) << "OAuthUrl" << url.toString(); info->setOAuthUrl(url); info->finish(Thing::ThingErrorNoError); } else { @@ -87,9 +98,9 @@ void IntegrationPluginTempo::startPairing(ThingPairingInfo *info) void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) { Q_UNUSED(username); - qCDebug(dcTempo()) << "Confirm pairing"; - if (info->thingClassId() == tempoConnectionThingClassId) { + if (info->thingClassId() == tempoConnectionThingClassId) { + qCDebug(dcTempo()) << "Confirm pairing" << info->thingName(); QUrl url(secret); QUrlQuery query(url); QByteArray authorizationCode = query.queryItemValue("code").toLocal8Bit(); @@ -100,7 +111,7 @@ void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QStrin Tempo *tempo = m_setupTempoConnections.value(info->thingId()); if (!tempo) { - qWarning(dcTempo()) << "No Tempo connection found for device:" << info->thingName(); + qWarning(dcTempo()) << "No tempo connection found for device:" << info->thingName(); m_setupTempoConnections.remove(info->thingId()); return info->finish(Thing::ThingErrorHardwareFailure); } @@ -127,6 +138,99 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) if (thing->thingClassId() == tempoConnectionThingClassId) { + Tempo *tempo; + if (m_tempoConnections.contains(thing)) { + qCDebug(dcTempo()) << "Setup after reconfiguration, cleaning up"; + m_tempoConnections.take(thing)->deleteLater(); + } + if (m_setupTempoConnections.keys().contains(thing->id())) { + // This thing setup is after a pairing process + qCDebug(dcTempo()) << "Tempo OAuth setup complete"; + tempo = m_setupTempoConnections.take(thing->id()); + if (!tempo) { + qCWarning(dcTempo()) << "Tempo connection object not found for thing" << thing->name(); + } + m_tempoConnections.insert(thing, tempo); + info->finish(Thing::ThingErrorNoError); + } else { + //device loaded from the device database, needs a new access token; + pluginStorage()->beginGroup(thing->id().toString()); + QByteArray refreshToken = pluginStorage()->value("refresh_token").toByteArray(); + pluginStorage()->endGroup(); + if (refreshToken.isEmpty()) { + info->finish(Thing::ThingErrorAuthenticationFailure, tr("Refresh token is not available.")); + return; + } + + QByteArray clientId = configValue(tempoPluginCustomClientIdParamTypeId).toByteArray(); + QByteArray clientSecret = configValue(tempoPluginCustomClientSecretParamTypeId).toByteArray(); + if (clientId.isEmpty() || clientSecret.isEmpty()) { + clientId = apiKeyStorage()->requestKey("tempo").data("clientId"); + clientSecret = apiKeyStorage()->requestKey("tempo").data("clientSecret"); + } else { + qCDebug(dcTempo()) << "Using custom API id and secret."; + } + if (clientId.isEmpty() || clientSecret.isEmpty()) { + info->finish(Thing::ThingErrorAuthenticationFailure, tr("Client id and/or secret is not available.")); + return; + } + Tempo *tempo = new Tempo(hardwareManager()->networkManager(), clientId, clientSecret, this); + tempo->getAccessTokenFromRefreshToken(refreshToken); + connect(tempo, &Tempo::receivedAccessToken, info, [info] { + info->finish(Thing::ThingErrorNoError); + }); + connect(info, &ThingSetupInfo::aborted, tempo, &Tempo::deleteLater); + } + connect(tempo, &Tempo::connectionChanged, this, &IntegrationPluginTempo::onConnectionChanged); + connect(tempo, &Tempo::authenticationStatusChanged, this, &IntegrationPluginTempo::onAuthenticationStatusChanged); + connect(tempo, &Tempo::accountsReceived, this, &IntegrationPluginTempo::onReceivedAccounts); + + } else if (thing->thingClassId() == accountThingClassId) { + Thing *parentThing = myThings().findById(thing->parentId()); + if (parentThing->setupComplete()) { + info->finish(Thing::ThingErrorNoError); + } else { + connect(parentThing, &Thing::setupStatusChanged, info, [parentThing, info]{ + if (parentThing->setupComplete()) { + info->finish(Thing::ThingErrorNoError); + } + }); + } + } else { + Q_ASSERT_X(false, "setupThing", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8()); + } +} + +void IntegrationPluginTempo::postSetupThing(Thing *thing) +{ + qCDebug(dcTempo()) << "Post setup thing" << thing->name(); + + if (!m_pluginTimer15min) { + m_pluginTimer15min = hardwareManager()->pluginTimerManager()->registerTimer(60*15); + connect(m_pluginTimer15min, &PluginTimer::timeout, this, [this]() { + qCDebug(dcTempo()) << "Refresh timer timout, polling all Tempo accounts."; + Q_FOREACH (Thing *thing, myThings().filterByThingClassId(tempoConnectionThingClassId)) { + Tempo *tempo = m_tempoConnections.value(thing); + if (!tempo) { + qWarning(dcTempo()) << "No Tempo connection found for" << thing->name(); + continue; + } + tempo->getAccounts(); + Q_FOREACH (Thing *childThing, myThings().filterByParentId(thing->id())) { + QString key = childThing->paramValue(accountThingKeyParamTypeId).toString(); + QDate from(1970, 1, 1); + tempo->getWorkloadByAccount(key, from, QDate::currentDate()); + } + } + }); + } + + if (thing->thingClassId() == tempoConnectionThingClassId) { + Tempo *tempo = m_tempoConnections.value(thing); + tempo->getAccounts(); + + } else if (thing->thingClassId() == accountThingClassId) { + } } @@ -147,6 +251,41 @@ void IntegrationPluginTempo::thingRemoved(Thing *thing) qCDebug(dcTempo()) << "Thing removed" << thing->name(); } +void IntegrationPluginTempo::onConnectionChanged(bool connected) +{ + Tempo *tempo = static_cast(sender()); + Thing *thing = m_tempoConnections.key(tempo); + if (!thing) + return; + thing->setStateValue(tempoConnectionConnectedStateTypeId, connected); + if (!connected) { + Q_FOREACH(Thing *child, myThings().filterByParentId(thing->id())) { + child->setStateValue(accountConnectedStateTypeId, connected); + } + } +} + +void IntegrationPluginTempo::onAuthenticationStatusChanged(bool authenticated) +{ + qCDebug(dcTempo()) << "Authentication changed" << authenticated; + + Tempo *tempoConnection = static_cast(sender()); + + Thing *thing = m_tempoConnections.key(tempoConnection); + if (!thing) + return; + + thing->setStateValue(tempoConnectionLoggedInStateTypeId, authenticated); + if (!authenticated) { + //refresh access token needs to be refreshed + pluginStorage()->beginGroup(thing->id().toString()); + QByteArray refreshToken = pluginStorage()->value("refresh_token").toByteArray(); + pluginStorage()->endGroup(); + tempoConnection->getAccessTokenFromRefreshToken(refreshToken); + } +} + + void IntegrationPluginTempo::onReceivedAccounts(const QList &accounts) { qCDebug(dcTempo()) << "Received" << accounts.count() << "accounts"; @@ -160,13 +299,13 @@ void IntegrationPluginTempo::onReceivedAccounts(const QList &acc Q_FOREACH(Tempo::Account account, accounts) { ThingClassId thingClassId; - Thing * existingThing = myThings().findByParams(ParamList() << Param(m_idParamTypeIds.value(thingClassId), appliance.homeApplianceId)); - if (existingThing) { - qCDebug(dcTempo()) << "Thing is already added to system" << existingThing->name(); - //Set connected state; - //existingThing->setStateValue(m_connectedStateTypeIds.value(thingClassId), appliance.connected); - continue; - } + //Thing * existingThing = myThings().findByParams(ParamList() << Param(m_idParamTypeIds.value(thingClassId), appliance.homeApplianceId)); + //if (existingThing) { + // qCDebug(dcTempo()) << "Thing is already added to system" << existingThing->name(); + //Set connected state; + //existingThing->setStateValue(m_connectedStateTypeIds.value(thingClassId), appliance.connected); + // continue; + // } qCDebug(dcTempo()) << "Found new account:" << account.name << "key:" << account.key << "id:" << account.id; ThingDescriptor descriptor(thingClassId, account.name, account.key, parentThing->id()); @@ -179,3 +318,16 @@ void IntegrationPluginTempo::onReceivedAccounts(const QList &acc emit autoThingsAppeared(desciptors); } +void IntegrationPluginTempo::onAccountWorkloadReceived(const QString &accountKey, QList workloads) +{ + Thing *thing = myThings().findByParams(ParamList() << Param(accountThingKeyParamTypeId, accountKey)); + if (!thing) + return; + + uint totalTimeSpentSeconds = 0; + Q_FOREACH(Tempo::Worklog workload, workloads) { + totalTimeSpentSeconds += workload.timeSpentSeconds; + } + thing->setStateValue(accountTotalTimeSpentStateTypeId, totalTimeSpentSeconds/60); +} + diff --git a/tempo/integrationplugintempo.h b/tempo/integrationplugintempo.h index 5ad3bd88..4139452e 100644 --- a/tempo/integrationplugintempo.h +++ b/tempo/integrationplugintempo.h @@ -50,6 +50,7 @@ public: void confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) override; void setupThing(ThingSetupInfo *info) override; + void postSetupThing(Thing *thing) override; void executeAction(ThingActionInfo *info) override; void thingRemoved(Thing *thing) override; @@ -62,7 +63,8 @@ private: private slots: void onConnectionChanged(bool connected); void onAuthenticationStatusChanged(bool authenticated); - void onRequestExecuted(QUuid requestId, bool success); void onReceivedAccounts(const QList &accounts); + + void onAccountWorkloadReceived(const QString &accountKey, QList workloads); }; #endif // INTEGRATIONPLUGINTEMPO_H diff --git a/tempo/integrationplugintempo.json b/tempo/integrationplugintempo.json index 1aaede7d..c20b4cc7 100644 --- a/tempo/integrationplugintempo.json +++ b/tempo/integrationplugintempo.json @@ -87,9 +87,9 @@ "paramTypes": [ { "id": "c6aeddae-56af-496d-a419-1635ff9bae50", - "name": "id", - "displayName": "ID", - "defaultValue": "-", + "name": "key", + "displayName": "Key", + "defaultValue": "", "type": "QString" } ], @@ -163,6 +163,24 @@ "displayNameEvent": "Customer changed", "defaultValue": "", "type": "QString" + }, + { + "id": "1ac39002-56a1-4911-aa68-9d14e142edae", + "name": "totalTimeSpent", + "displayName": "Total time spent", + "displayNameEvent": "Total time spent changed", + "defaultValue": 0, + "type": "uint", + "unit": "Minutes" + }, + { + "id": "81bec4e8-9fd3-43d1-b339-2a7fdd83e8cb", + "name": "monthTimeSpent", + "displayName": "This month time spent", + "displayNameEvent": "This month time spent changed", + "defaultValue": 0, + "type": "uint", + "unit": "Minutes" } ] } diff --git a/tempo/tempo.cpp b/tempo/tempo.cpp index fc497ce9..adc5af28 100644 --- a/tempo/tempo.cpp +++ b/tempo/tempo.cpp @@ -35,15 +35,15 @@ #include "extern-plugininfo.h" Tempo::Tempo(NetworkAccessManager *networkmanager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent) : -QObject(parent), - m_clientId(clientId), - m_clientSecret(clientSecret), - m_networkManager(networkmanager) + QObject(parent), + m_clientId(clientId), + m_clientSecret(clientSecret), + m_networkManager(networkmanager) { - m_tokenRefreshTimer = new QTimer(this); - m_tokenRefreshTimer->setSingleShot(true); - connect(m_tokenRefreshTimer, &QTimer::timeout, this, &Tempo::onRefreshTimer); + m_tokenRefreshTimer = new QTimer(this); + m_tokenRefreshTimer->setSingleShot(true); + connect(m_tokenRefreshTimer, &QTimer::timeout, this, &Tempo::onRefreshTimer); } QByteArray Tempo::accessToken() @@ -56,20 +56,140 @@ QByteArray Tempo::refreshToken() return m_refreshToken; } -QUrl Tempo::getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName, const QByteArray &clientId) +QUrl Tempo::getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName) { + if (m_clientId.isEmpty()) { + qWarning(dcTempo) << "Client Id not defined!"; + return QUrl(""); + } + + if (redirectUrl.isEmpty()){ + qWarning(dcTempo) << "No redirect uri defined!"; + } + m_redirectUri = QUrl::toPercentEncoding(redirectUrl.toString()); + QUrl url; url.setScheme("https"); - url.setHost(jiraCloudInstanceName+"atlassian.net"); + url.setHost(jiraCloudInstanceName+".atlassian.net"); url.setPath("/plugins/servlet/ac/io.tempo.jira/oauth-authorize/"); QUrlQuery query; - query.addQueryItem("client_id", clientId); - query.addQueryItem("redirect_uri", redirectUrl.toString()); + query.addQueryItem("client_id", m_clientId); + query.addQueryItem("redirect_uri", m_redirectUri); query.addQueryItem("access_type", "tenant_user"); url.setQuery(query); return url; } +void Tempo::getAccessTokenFromRefreshToken(const QByteArray &refreshToken) +{ + if (refreshToken.isEmpty()) { + qWarning(dcTempo) << "No refresh token given!"; + setAuthenticated(false); + return; + } + + QUrl url(m_baseTokenUrl); + QUrlQuery query; + query.clear(); + query.addQueryItem("grant_type", "refresh_token"); + query.addQueryItem("refresh_token", refreshToken); + query.addQueryItem("client_secret", m_clientSecret); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QNetworkReply *reply = m_networkManager->post(request, query.toString().toUtf8()); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [this, reply](){ + + QByteArray rawData = reply->readAll(); + if (!checkStatusCode(reply, rawData)) { + return; + } + QJsonDocument data = QJsonDocument::fromJson(rawData); + + if(!data.toVariant().toMap().contains("access_token")) { + setAuthenticated(false); + return; + } + m_accessToken = data.toVariant().toMap().value("access_token").toByteArray(); + emit receivedAccessToken(m_accessToken); + + if (data.toVariant().toMap().contains("expires_in")) { + int expireTime = data.toVariant().toMap().value("expires_in").toInt(); + qCDebug(dcTempo) << "Access token expires int" << expireTime << "s, at" << QDateTime::currentDateTime().addSecs(expireTime).toString(); + if (!m_tokenRefreshTimer) { + qWarning(dcTempo()) << "Access token refresh timer not initialized"; + return; + } + if (expireTime < 20) { + qCWarning(dcTempo()) << "Expire time too short"; + return; + } + m_tokenRefreshTimer->start((expireTime - 20) * 1000); + } + }); +} + +void Tempo::getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode) +{ + // Obtaining access token + if(authorizationCode.isEmpty()) + qWarning(dcTempo()) << "No authorization code given!"; + if(m_clientId.isEmpty()) + qWarning(dcTempo()) << "Client key not set!"; + if(m_clientSecret.isEmpty()) + qWarning(dcTempo()) << "Client secret not set!"; + + QUrl url = QUrl(m_baseTokenUrl); + QUrlQuery query; url.setQuery(query); + + query.clear(); + query.addQueryItem("client_id", m_clientId); + query.addQueryItem("client_secret", m_clientSecret); + query.addQueryItem("redirect_uri", m_redirectUri); + query.addQueryItem("grant_type", "authorization_code"); + query.addQueryItem("code", authorizationCode); + // query.addQueryItem("code_verifier", m_codeChallenge); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QNetworkReply *reply = m_networkManager->post(request, query.toString().toUtf8()); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [this, reply](){ + + QByteArray rawData = reply->readAll(); + if (!checkStatusCode(reply, rawData)) { + return; + } + QJsonDocument jsonDoc = QJsonDocument::fromJson(rawData); + if(!jsonDoc.toVariant().toMap().contains("access_token") || !jsonDoc.toVariant().toMap().contains("refresh_token") ) { + setAuthenticated(false); + return; + } + m_accessToken = jsonDoc.toVariant().toMap().value("access_token").toByteArray(); + receivedAccessToken(m_accessToken); + m_refreshToken = jsonDoc.toVariant().toMap().value("refresh_token").toByteArray(); + receivedRefreshToken(m_refreshToken); + + if (jsonDoc.toVariant().toMap().contains("expires_in")) { + int expireTime = jsonDoc.toVariant().toMap().value("expires_in").toInt(); + qCDebug(dcTempo()) << "Token expires in" << expireTime << "s, at" << QDateTime::currentDateTime().addSecs(expireTime).toString(); + if (!m_tokenRefreshTimer) { + qWarning(dcTempo()) << "Token refresh timer not initialized"; + setAuthenticated(false); + return; + } + if (expireTime < 20) { + qCWarning(dcTempo()) << "Expire time too short"; + return; + } + m_tokenRefreshTimer->start((expireTime - 20) * 1000); + } + }); +} + void Tempo::getAccounts() { QUrl url = QUrl(m_baseControlUrl+"/accounts"); @@ -130,12 +250,52 @@ void Tempo::getAccounts() void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to) { + QUrl url = QUrl(m_baseControlUrl+"/worklogs/account/"+accountKey); + QUrlQuery query; + query.addQueryItem("from", from.toString(Qt::DateFormat::ISODate)); + query.addQueryItem("to", to.toString(Qt::DateFormat::ISODate)); + url.setQuery(query); + QNetworkRequest request(url); + request.setRawHeader("Authorization", "Bearer "+m_accessToken); + + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [this, accountKey, reply]{ + + QByteArray rawData = reply->readAll(); + if (!checkStatusCode(reply, rawData)) { + return; + } + QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); + QVariantList worklogList = dataMap.value("results").toList(); + QList worklogs; + Q_FOREACH(QVariant var, worklogList) { + QVariantMap map = var.toMap(); + Worklog worklog; + worklog.self = map["self"].toString(); + worklog.tempoWorklogId = map["tempoWorklogId"].toInt(); + worklog.jiraWorklogId = map["jiraWorklogId"].toInt(); + worklog.issue = map["issue"].toMap().value("key").toString(); + worklog.timeSpentSeconds = map["timeSpentSeconds"].toInt(); + //TODO startDate: required (date-only) + //TODO startTime: required (time-only) + worklog.description = map["description"].toString(); + //TODO createdAt: required (datetime) + //TODO updatedAt: required (datetime) + worklog.authorAccountId = map["author"].toMap().value("accountId").toString(); + worklog.authorDisplayName = map["author"].toMap().value("displayName").toString(); + worklogs.append(worklog); + } + if (!worklogs.isEmpty()) + emit accountWorklogsReceived(accountKey, worklogs); + }); } void Tempo::onRefreshTimer() { - + qCDebug(dcTempo()) << "Refresh authentication token"; + getAccessTokenFromRefreshToken(m_refreshToken); } void Tempo::setAuthenticated(bool state) @@ -153,3 +313,87 @@ void Tempo::setConnected(bool state) emit connectionChanged(state); } } + +bool Tempo::checkStatusCode(QNetworkReply *reply, const QByteArray &rawData) +{ + // Check for the internet connection + if (reply->error() == QNetworkReply::NetworkError::HostNotFoundError || + reply->error() == QNetworkReply::NetworkError::UnknownNetworkError || + reply->error() == QNetworkReply::NetworkError::TemporaryNetworkFailureError) { + qCWarning(dcTempo()) << "Connection error" << reply->errorString(); + setConnected(false); + setAuthenticated(false); + return false; + } else { + setConnected(true); + } + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(rawData, &error); + + switch (status){ + case 200: //The request was successful. Typically returned for successful GET requests. + case 204: //The request was successful. Typically returned for successful PUT/DELETE requests with no payload. + break; + case 400: //Error occurred (e.g. validation error - value is out of range) + if(!jsonDoc.toVariant().toMap().contains("error")) { + if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_client") { + qWarning(dcTempo()) << "Client token provided doesn’t correspond to client that generated auth code."; + } + if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_redirect_uri") { + qWarning(dcTempo()) << "Missing redirect_uri parameter."; + } + if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_code") { + qWarning(dcTempo()) << "Expired authorization code."; + } + } + setAuthenticated(false); + return false; + case 401: + qWarning(dcTempo()) << "Client does not have permission to use this API."; + setAuthenticated(false); + return false; + case 403: + qCWarning(dcTempo()) << "Forbidden, Scope has not been granted or home appliance is not assigned to HC account"; + setAuthenticated(false); + return false; + case 404: + qCWarning(dcTempo()) << "Not Found. This resource is not available (e.g. no images on washing machine)"; + return false; + case 405: + qWarning(dcTempo()) << "Wrong HTTP method used."; + setAuthenticated(false); + return false; + case 408: + qCWarning(dcTempo())<< "Request Timeout, API Server failed to produce an answer or has no connection to backend service"; + return false; + case 409: + qCWarning(dcTempo()) << "Conflict - Command/Query cannot be executed for the home appliance, the error response contains the error details"; + qCWarning(dcTempo()) << "Error" << jsonDoc; + return false; + case 415: + qCWarning(dcTempo())<< "Unsupported Media Type. The request's Content-Type is not supported"; + return false; + case 429: + qCWarning(dcTempo())<< "Too Many Requests, the number of requests for a specific endpoint exceeded the quota of the client"; + return false; + case 500: + qCWarning(dcTempo())<< "Internal Server Error, in case of a server configuration error or any errors in resource files"; + return false; + case 503: + qCWarning(dcTempo())<< "Service Unavailable,if a required backend service is not available"; + return false; + default: + break; + } + + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTempo()) << "Received invalide JSON object" << rawData; + qCWarning(dcTempo()) << "Status" << status; + setAuthenticated(false); + return false; + } + + setAuthenticated(true); + return true; +} diff --git a/tempo/tempo.h b/tempo/tempo.h index c6316c1f..9bfd7d51 100644 --- a/tempo/tempo.h +++ b/tempo/tempo.h @@ -88,11 +88,25 @@ public: Customer customer; }; + struct Worklog { + QUrl self; + int tempoWorklogId; + int jiraWorklogId; + QString issue; + int timeSpentSeconds; + QDateTime startedAt; + QString description; + QDateTime createdAt; + QDateTime updatedAt; + QString authorAccountId; + QString authorDisplayName; + }; + explicit Tempo(NetworkAccessManager *networkmanager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent = nullptr); QByteArray accessToken(); QByteArray refreshToken(); - static QUrl getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName, const QByteArray &clientId); + QUrl getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName); void getAccessTokenFromRefreshToken(const QByteArray &refreshToken); void getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode); @@ -130,6 +144,8 @@ signals: void receivedRefreshToken(const QByteArray &refreshToken); void receivedAccessToken(const QByteArray &accessToken); void accountsReceived(const QList accounts); + + void accountWorklogsReceived(const QString &accountKey, QList worklogs); }; #endif // TEMPO_H From 9e3d4184980dc1149d46e475469afe89a18e602e Mon Sep 17 00:00:00 2001 From: Boernsman Date: Thu, 18 Feb 2021 20:33:10 +0100 Subject: [PATCH 3/8] removed oauth and using token instead --- tempo/integrationplugintempo.cpp | 164 +++++++++++----------------- tempo/integrationplugintempo.h | 1 + tempo/integrationplugintempo.json | 49 +++++---- tempo/tempo.cpp | 176 +++++++----------------------- tempo/tempo.h | 35 +++--- 5 files changed, 148 insertions(+), 277 deletions(-) diff --git a/tempo/integrationplugintempo.cpp b/tempo/integrationplugintempo.cpp index f4621b0b..e341a80a 100644 --- a/tempo/integrationplugintempo.cpp +++ b/tempo/integrationplugintempo.cpp @@ -49,40 +49,16 @@ void IntegrationPluginTempo::startPairing(ThingPairingInfo *info) if (info->thingClassId() == tempoConnectionThingClassId) { - QByteArray clientId = configValue(tempoPluginCustomClientIdParamTypeId).toByteArray(); - QByteArray clientSecret = configValue(tempoPluginCustomClientSecretParamTypeId).toByteArray(); - if (clientId.isEmpty() || clientSecret.isEmpty()) { - clientId = apiKeyStorage()->requestKey("tempo").data("clientId"); - clientSecret = apiKeyStorage()->requestKey("tempo").data("clientSecret"); - } else { - qCDebug(dcTempo()) << "Using custom client secret and id"; - } - if (clientId.isEmpty() || clientSecret.isEmpty()) { - info->finish(Thing::ThingErrorAuthenticationFailure, tr("Client id and/or seceret is not available.")); - return; - } QString jiraCloudInstanceName = info->params().paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); - Tempo *tempo = new Tempo(hardwareManager()->networkManager(), clientId, clientSecret, this); - QUrl url = tempo->getLoginUrl(QUrl("https://127.0.0.1:8888"), jiraCloudInstanceName); qCDebug(dcTempo()) << "Checking if the Tempo server is reachable: https://api.tempo.io/core/3"; QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(QUrl("https://api.tempo.io/core/3"))); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [reply, info, tempo, url, this] { + connect(reply, &QNetworkReply::finished, info, [reply, info, this] { if (reply->error() != QNetworkReply::NetworkError::HostNotFoundError) { qCDebug(dcTempo()) << "Tempo server is reachable"; - ThingId thingId = info->thingId(); - m_setupTempoConnections.insert(info->thingId(), tempo); - connect(info, &ThingPairingInfo::aborted, this, [thingId, this] { - qCWarning(dcTempo()) << "ThingPairingInfo aborted, cleaning up"; - Tempo *tempo = m_setupTempoConnections.take(thingId); - if (tempo) - tempo->deleteLater(); - }); - qCDebug(dcTempo()) << "OAuthUrl" << url.toString(); - info->setOAuthUrl(url); info->finish(Thing::ThingErrorNoError); } else { qCWarning(dcTempo()) << "Got online check error" << reply->error() << reply->errorString(); @@ -101,36 +77,61 @@ void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QStrin if (info->thingClassId() == tempoConnectionThingClassId) { qCDebug(dcTempo()) << "Confirm pairing" << info->thingName(); - QUrl url(secret); - QUrlQuery query(url); - QByteArray authorizationCode = query.queryItemValue("code").toLocal8Bit(); - if (authorizationCode.isEmpty()) { + if (secret.isEmpty()) { qCWarning(dcTempo()) << "No authorization code received."; return info->finish(Thing::ThingErrorAuthenticationFailure); } - - Tempo *tempo = m_setupTempoConnections.value(info->thingId()); - if (!tempo) { - qWarning(dcTempo()) << "No tempo connection found for device:" << info->thingName(); - m_setupTempoConnections.remove(info->thingId()); - return info->finish(Thing::ThingErrorHardwareFailure); - } - qCDebug(dcTempo()) << "Authorization code" << authorizationCode.mid(0, 4)+QString().fill('*', authorizationCode.length()-4) ; - tempo->getAccessTokenFromAuthorizationCode(authorizationCode); - connect(tempo, &Tempo::receivedRefreshToken, info, [info, this](const QByteArray &refreshToken){ - qCDebug(dcTempo()) << "Token:" << refreshToken.mid(0, 4)+QString().fill('*', refreshToken.length()-4) ; - - pluginStorage()->beginGroup(info->thingId().toString()); - pluginStorage()->setValue("refresh_token", refreshToken); - pluginStorage()->endGroup(); - - info->finish(Thing::ThingErrorNoError); + QString atlassianAccountName = info->params().paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); + Tempo *tempo = new Tempo(hardwareManager()->networkManager(), atlassianAccountName, secret, this); + tempo->getAccounts(); + connect(info, &ThingPairingInfo::aborted, tempo, &Tempo::deleteLater); + connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, this] (bool authenticated){ + if (authenticated) { + m_setupTempoConnections.insert(info->thingId(), tempo); + info->finish(Thing::ThingErrorNoError); + } }); } else { Q_ASSERT_X(false, "confirmPairing", QString("Unhandled thingClassId: %1").arg(info->thingClassId().toString()).toUtf8()); } } +void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) +{ + qCDebug(dcTempo()) << "Discover things"; + if (info->thingClassId() == accountThingClassId) { + Q_FOREACH(Tempo *tempo, m_tempoConnections) { + tempo->getAccounts(); + Thing *parentThing = m_tempoConnections.key(tempo); + if (!parentThing) { + qCWarning(dcTempo()) << "Parent not found"; + return; + } + connect(tempo, &Tempo::accountsReceived, info, [info, parentThing, this, tempo] (const QList &accounts) { + Q_FOREACH(Tempo::Account account, accounts) { + ThingDescriptor descriptor(accountThingClassId, account.name, account.customer.name, parentThing->id()); + ParamList params; + params << Param(accountThingKeyParamTypeId, account.key); + descriptor.setParams(params); + info->addThingDescriptor(descriptor); + } + }); + } + } else if (info->thingClassId() == teamThingClassId) { + Q_FOREACH(Tempo *tempo, m_tempoConnections) { + tempo->getTeams(); + Thing *parentThing = m_tempoConnections.key(tempo); + if (!parentThing) { + qCWarning(dcTempo()) << "Parent not found"; + return; + } + } + } + QTimer::singleShot(5000, info, [info] { + info->finish(Thing::ThingErrorNoError); + }); +} + void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) { Thing *thing = info->thing(); @@ -155,37 +156,30 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) } else { //device loaded from the device database, needs a new access token; pluginStorage()->beginGroup(thing->id().toString()); - QByteArray refreshToken = pluginStorage()->value("refresh_token").toByteArray(); + QByteArray token = pluginStorage()->value("token").toByteArray(); pluginStorage()->endGroup(); - if (refreshToken.isEmpty()) { - info->finish(Thing::ThingErrorAuthenticationFailure, tr("Refresh token is not available.")); + if (token.isEmpty()) { + info->finish(Thing::ThingErrorAuthenticationFailure, tr("Token is not available.")); return; } + QString jiraInstanceName = thing->paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); + Tempo *tempo = new Tempo(hardwareManager()->networkManager(), jiraInstanceName, token, this); + tempo->getAccounts(); - QByteArray clientId = configValue(tempoPluginCustomClientIdParamTypeId).toByteArray(); - QByteArray clientSecret = configValue(tempoPluginCustomClientSecretParamTypeId).toByteArray(); - if (clientId.isEmpty() || clientSecret.isEmpty()) { - clientId = apiKeyStorage()->requestKey("tempo").data("clientId"); - clientSecret = apiKeyStorage()->requestKey("tempo").data("clientSecret"); - } else { - qCDebug(dcTempo()) << "Using custom API id and secret."; - } - if (clientId.isEmpty() || clientSecret.isEmpty()) { - info->finish(Thing::ThingErrorAuthenticationFailure, tr("Client id and/or secret is not available.")); - return; - } - Tempo *tempo = new Tempo(hardwareManager()->networkManager(), clientId, clientSecret, this); - tempo->getAccessTokenFromRefreshToken(refreshToken); - connect(tempo, &Tempo::receivedAccessToken, info, [info] { - info->finish(Thing::ThingErrorNoError); - }); connect(info, &ThingSetupInfo::aborted, tempo, &Tempo::deleteLater); + connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, this] (bool authenticated){ + if (authenticated) { + m_tempoConnections.insert(info->thing(), tempo); + info->finish(Thing::ThingErrorNoError); + } + }); } connect(tempo, &Tempo::connectionChanged, this, &IntegrationPluginTempo::onConnectionChanged); connect(tempo, &Tempo::authenticationStatusChanged, this, &IntegrationPluginTempo::onAuthenticationStatusChanged); connect(tempo, &Tempo::accountsReceived, this, &IntegrationPluginTempo::onReceivedAccounts); - } else if (thing->thingClassId() == accountThingClassId) { + } else if (thing->thingClassId() == accountThingClassId || + thing->thingClassId() == teamThingClassId){ Thing *parentThing = myThings().findById(thing->parentId()); if (parentThing->setupComplete()) { info->finish(Thing::ThingErrorNoError); @@ -231,6 +225,8 @@ void IntegrationPluginTempo::postSetupThing(Thing *thing) } else if (thing->thingClassId() == accountThingClassId) { + } else if (thing->thingClassId() == teamThingClassId) { + } } @@ -276,46 +272,12 @@ void IntegrationPluginTempo::onAuthenticationStatusChanged(bool authenticated) return; thing->setStateValue(tempoConnectionLoggedInStateTypeId, authenticated); - if (!authenticated) { - //refresh access token needs to be refreshed - pluginStorage()->beginGroup(thing->id().toString()); - QByteArray refreshToken = pluginStorage()->value("refresh_token").toByteArray(); - pluginStorage()->endGroup(); - tempoConnection->getAccessTokenFromRefreshToken(refreshToken); - } } void IntegrationPluginTempo::onReceivedAccounts(const QList &accounts) { qCDebug(dcTempo()) << "Received" << accounts.count() << "accounts"; - - Tempo *tempoConnection = static_cast(sender()); - Thing *parentThing = m_tempoConnections.key(tempoConnection); - if (!parentThing) - return; - - ThingDescriptors desciptors; - Q_FOREACH(Tempo::Account account, accounts) { - ThingClassId thingClassId; - - //Thing * existingThing = myThings().findByParams(ParamList() << Param(m_idParamTypeIds.value(thingClassId), appliance.homeApplianceId)); - //if (existingThing) { - // qCDebug(dcTempo()) << "Thing is already added to system" << existingThing->name(); - //Set connected state; - //existingThing->setStateValue(m_connectedStateTypeIds.value(thingClassId), appliance.connected); - // continue; - // } - qCDebug(dcTempo()) << "Found new account:" << account.name << "key:" << account.key << "id:" << account.id; - ThingDescriptor descriptor(thingClassId, account.name, account.key, parentThing->id()); - - ParamList params; - //params << Param(m_idParamTypeIds.value(thingClassId), appliance.homeApplianceId); - descriptor.setParams(params); - desciptors.append(descriptor); - } - if (!desciptors.isEmpty()) - emit autoThingsAppeared(desciptors); } void IntegrationPluginTempo::onAccountWorkloadReceived(const QString &accountKey, QList workloads) diff --git a/tempo/integrationplugintempo.h b/tempo/integrationplugintempo.h index 4139452e..b1ee298c 100644 --- a/tempo/integrationplugintempo.h +++ b/tempo/integrationplugintempo.h @@ -49,6 +49,7 @@ public: void startPairing(ThingPairingInfo *info) override; void confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) override; + void discoverThings(ThingDiscoveryInfo *info) override; void setupThing(ThingSetupInfo *info) override; void postSetupThing(Thing *thing) override; void executeAction(ThingActionInfo *info) override; diff --git a/tempo/integrationplugintempo.json b/tempo/integrationplugintempo.json index c20b4cc7..349b00d4 100644 --- a/tempo/integrationplugintempo.json +++ b/tempo/integrationplugintempo.json @@ -2,23 +2,6 @@ "id": "809bc4ca-d1cd-4279-9e0d-7324537ccb5a", "name": "tempo", "displayName": "Tempo", - "apiKeys": ["tempo"], - "paramTypes": [ - { - "id": "c130b2b7-6d30-406e-899b-669a065daee3", - "name": "customClientId", - "displayName": "Custom client id", - "defaultValue": "", - "type": "QString" - }, - { - "id": "9c759711-e772-44ce-9d86-6a3af89c2d94", - "name": "customClientSecret", - "displayName": "Custom client secret", - "defaultValue": "", - "type": "QString" - } - ], "vendors": [ { "id": "58fc1ab7-b8b5-4e52-8388-72957ce5852d", @@ -31,7 +14,7 @@ "displayName": "Tempo connection", "interfaces": ["account"], "createMethods": ["user"], - "setupMethod": "oauth", + "setupMethod": "displayPin", "paramTypes": [ { "id": "b4110c37-8331-4057-8e9f-12f34c2623fe", @@ -82,7 +65,7 @@ "name": "account", "displayName": "Account", "interfaces": ["connectable"], - "createMethods": ["auto"], + "createMethods": ["discovery"], "browsable": true, "paramTypes": [ { @@ -183,6 +166,34 @@ "unit": "Minutes" } ] + }, + { + "id": "11c85176-e7fe-44b4-995a-24757273f3af", + "name": "team", + "displayName": "Team", + "interfaces": ["connectable"], + "createMethods": ["discovery"], + "browsable": true, + "paramTypes": [ + { + "id": "bb90e986-fcfa-47e8-8783-f2b5a887314a", + "name": "key", + "displayName": "Key", + "defaultValue": "", + "type": "QString" + } + ], + "stateTypes": [ + { + "id": "a125d3b5-676f-49eb-bb93-feae233c2e91", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": true, + "cached": false, + "type": "bool" + } + ] } ] } diff --git a/tempo/tempo.cpp b/tempo/tempo.cpp index adc5af28..7f042070 100644 --- a/tempo/tempo.cpp +++ b/tempo/tempo.cpp @@ -34,158 +34,61 @@ #include "tempo.h" #include "extern-plugininfo.h" -Tempo::Tempo(NetworkAccessManager *networkmanager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent) : +Tempo::Tempo(NetworkAccessManager *networkmanager, const QString &jiraCloudInstanceName, const QString &token, QObject *parent) : QObject(parent), - m_clientId(clientId), - m_clientSecret(clientSecret), + m_token(token), + m_jiraCloudInstanceName(jiraCloudInstanceName), m_networkManager(networkmanager) - { - m_tokenRefreshTimer = new QTimer(this); - m_tokenRefreshTimer->setSingleShot(true); - connect(m_tokenRefreshTimer, &QTimer::timeout, this, &Tempo::onRefreshTimer); + qCDebug(dcTempo()) << "Creating tempo connection to" << m_jiraCloudInstanceName; } -QByteArray Tempo::accessToken() +Tempo::~Tempo() { - return m_accessToken; + qCDebug(dcTempo()) << "Deleting tempo connection to" << m_jiraCloudInstanceName; } -QByteArray Tempo::refreshToken() +QString Tempo::token() const { - return m_refreshToken; + return m_token; } -QUrl Tempo::getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName) +void Tempo::getTeams() { - if (m_clientId.isEmpty()) { - qWarning(dcTempo) << "Client Id not defined!"; - return QUrl(""); - } - - if (redirectUrl.isEmpty()){ - qWarning(dcTempo) << "No redirect uri defined!"; - } - m_redirectUri = QUrl::toPercentEncoding(redirectUrl.toString()); - - QUrl url; - url.setScheme("https"); - url.setHost(jiraCloudInstanceName+".atlassian.net"); - url.setPath("/plugins/servlet/ac/io.tempo.jira/oauth-authorize/"); - QUrlQuery query; - query.addQueryItem("client_id", m_clientId); - query.addQueryItem("redirect_uri", m_redirectUri); - query.addQueryItem("access_type", "tenant_user"); - url.setQuery(query); - return url; -} - -void Tempo::getAccessTokenFromRefreshToken(const QByteArray &refreshToken) -{ - if (refreshToken.isEmpty()) { - qWarning(dcTempo) << "No refresh token given!"; - setAuthenticated(false); - return; - } - - QUrl url(m_baseTokenUrl); - QUrlQuery query; - query.clear(); - query.addQueryItem("grant_type", "refresh_token"); - query.addQueryItem("refresh_token", refreshToken); - query.addQueryItem("client_secret", m_clientSecret); + QUrl url = QUrl(m_baseControlUrl+"/teams"); + qCDebug(dcTempo()) << "Get teams, url" << url.toString(); QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + request.setRawHeader("Authorization", "Bearer "+m_token.toUtf8()); - QNetworkReply *reply = m_networkManager->post(request, query.toString().toUtf8()); + QNetworkReply *reply = m_networkManager->get(request); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, this, [this, reply](){ + connect(reply, &QNetworkReply::finished, this, [this, reply]{ QByteArray rawData = reply->readAll(); if (!checkStatusCode(reply, rawData)) { return; } - QJsonDocument data = QJsonDocument::fromJson(rawData); + QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); + QVariantList teamList = dataMap.value("results").toList(); + QList teams; + Q_FOREACH(QVariant var, teamList) { + QVariantMap map = var.toMap(); + Team team; + team.self = map["self"].toString(); + team.id = map["id"].toInt(); + team.name = map["name"].toString(); + team.summary = map["summery"].toString(); - if(!data.toVariant().toMap().contains("access_token")) { - setAuthenticated(false); - return; + QVariantMap lead = map["lead"].toMap(); + team.lead.self = lead["self"].toString(); + team.lead.accountId = lead["accountId"].toString(); + team.lead.displayName = lead["displayName"].toString(); + + teams.append(team); } - m_accessToken = data.toVariant().toMap().value("access_token").toByteArray(); - emit receivedAccessToken(m_accessToken); - - if (data.toVariant().toMap().contains("expires_in")) { - int expireTime = data.toVariant().toMap().value("expires_in").toInt(); - qCDebug(dcTempo) << "Access token expires int" << expireTime << "s, at" << QDateTime::currentDateTime().addSecs(expireTime).toString(); - if (!m_tokenRefreshTimer) { - qWarning(dcTempo()) << "Access token refresh timer not initialized"; - return; - } - if (expireTime < 20) { - qCWarning(dcTempo()) << "Expire time too short"; - return; - } - m_tokenRefreshTimer->start((expireTime - 20) * 1000); - } - }); -} - -void Tempo::getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode) -{ - // Obtaining access token - if(authorizationCode.isEmpty()) - qWarning(dcTempo()) << "No authorization code given!"; - if(m_clientId.isEmpty()) - qWarning(dcTempo()) << "Client key not set!"; - if(m_clientSecret.isEmpty()) - qWarning(dcTempo()) << "Client secret not set!"; - - QUrl url = QUrl(m_baseTokenUrl); - QUrlQuery query; url.setQuery(query); - - query.clear(); - query.addQueryItem("client_id", m_clientId); - query.addQueryItem("client_secret", m_clientSecret); - query.addQueryItem("redirect_uri", m_redirectUri); - query.addQueryItem("grant_type", "authorization_code"); - query.addQueryItem("code", authorizationCode); - // query.addQueryItem("code_verifier", m_codeChallenge); - - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - - QNetworkReply *reply = m_networkManager->post(request, query.toString().toUtf8()); - connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, this, [this, reply](){ - - QByteArray rawData = reply->readAll(); - if (!checkStatusCode(reply, rawData)) { - return; - } - QJsonDocument jsonDoc = QJsonDocument::fromJson(rawData); - if(!jsonDoc.toVariant().toMap().contains("access_token") || !jsonDoc.toVariant().toMap().contains("refresh_token") ) { - setAuthenticated(false); - return; - } - m_accessToken = jsonDoc.toVariant().toMap().value("access_token").toByteArray(); - receivedAccessToken(m_accessToken); - m_refreshToken = jsonDoc.toVariant().toMap().value("refresh_token").toByteArray(); - receivedRefreshToken(m_refreshToken); - - if (jsonDoc.toVariant().toMap().contains("expires_in")) { - int expireTime = jsonDoc.toVariant().toMap().value("expires_in").toInt(); - qCDebug(dcTempo()) << "Token expires in" << expireTime << "s, at" << QDateTime::currentDateTime().addSecs(expireTime).toString(); - if (!m_tokenRefreshTimer) { - qWarning(dcTempo()) << "Token refresh timer not initialized"; - setAuthenticated(false); - return; - } - if (expireTime < 20) { - qCWarning(dcTempo()) << "Expire time too short"; - return; - } - m_tokenRefreshTimer->start((expireTime - 20) * 1000); + if (!teams.isEmpty()) { + emit teamsReceived(teams); } }); } @@ -193,9 +96,10 @@ void Tempo::getAccessTokenFromAuthorizationCode(const QByteArray &authorizationC void Tempo::getAccounts() { QUrl url = QUrl(m_baseControlUrl+"/accounts"); + qCDebug(dcTempo()) << "Get accounts. Url" << url.toString(); QNetworkRequest request(url); - request.setRawHeader("Authorization", "Bearer "+m_accessToken); + request.setRawHeader("Authorization", "Bearer "+m_token.toUtf8()); QNetworkReply *reply = m_networkManager->get(request); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); @@ -243,7 +147,7 @@ void Tempo::getAccounts() accounts.append(account); } if (!accounts.isEmpty()) { - + emit accountsReceived(accounts); } }); } @@ -256,8 +160,10 @@ void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to query.addQueryItem("to", to.toString(Qt::DateFormat::ISODate)); url.setQuery(query); + qCDebug(dcTempo()) << "Get workload by account. Url" << url.toString(); + QNetworkRequest request(url); - request.setRawHeader("Authorization", "Bearer "+m_accessToken); + request.setRawHeader("Authorization", "Bearer "+m_token.toUtf8()); QNetworkReply *reply = m_networkManager->get(request); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); @@ -292,12 +198,6 @@ void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to }); } -void Tempo::onRefreshTimer() -{ - qCDebug(dcTempo()) << "Refresh authentication token"; - getAccessTokenFromRefreshToken(m_refreshToken); -} - void Tempo::setAuthenticated(bool state) { if (state != m_authenticated) { diff --git a/tempo/tempo.h b/tempo/tempo.h index 9bfd7d51..1492ec87 100644 --- a/tempo/tempo.h +++ b/tempo/tempo.h @@ -102,31 +102,29 @@ public: QString authorDisplayName; }; - explicit Tempo(NetworkAccessManager *networkmanager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent = nullptr); - QByteArray accessToken(); - QByteArray refreshToken(); + struct Team { + QUrl self; + int id; + QString name; + QString summary; + Lead lead; + }; - QUrl getLoginUrl(const QUrl &redirectUrl, const QString &jiraCloudInstanceName); - - void getAccessTokenFromRefreshToken(const QByteArray &refreshToken); - void getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode); + explicit Tempo(NetworkAccessManager *networkmanager, const QString &jiraCloudInstanceName, const QString &token, QObject *parent = nullptr); + ~Tempo() override; + QString token() const; + void getTeams(); void getAccounts(); void getWorkloadByAccount(const QString &accountKey, QDate from, QDate to); private: - QByteArray m_baseTokenUrl = "https://api.tempo.io/oauth/token/"; QByteArray m_baseControlUrl = "https://api.tempo.io/core/3/"; - QByteArray m_clientId; - QByteArray m_clientSecret; - - QByteArray m_accessToken; - QByteArray m_refreshToken; - QByteArray m_redirectUri = "https://127.0.0.1:8888"; + QString m_token; + QString m_jiraCloudInstanceName; NetworkAccessManager *m_networkManager = nullptr; - QTimer *m_tokenRefreshTimer = nullptr; void setAuthenticated(bool state); void setConnected(bool state); @@ -135,16 +133,15 @@ private: bool m_connected = false; bool checkStatusCode(QNetworkReply *reply, const QByteArray &rawData); + private slots: - void onRefreshTimer(); signals: void authenticationStatusChanged(bool state); void connectionChanged(bool connected); - void receivedRefreshToken(const QByteArray &refreshToken); - void receivedAccessToken(const QByteArray &accessToken); - void accountsReceived(const QList accounts); + void teamsReceived(const QList teams); + void accountsReceived(const QList accounts); void accountWorklogsReceived(const QString &accountKey, QList worklogs); }; From ac17e1a8de633a388c656dc39c6b112ba00d2d3b Mon Sep 17 00:00:00 2001 From: Boernsman Date: Thu, 18 Feb 2021 20:47:20 +0100 Subject: [PATCH 4/8] added team discovery and fixed token storage --- tempo/integrationplugintempo.cpp | 25 ++++++++++++++++++++++--- tempo/integrationplugintempo.json | 10 ++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/tempo/integrationplugintempo.cpp b/tempo/integrationplugintempo.cpp index e341a80a..9eb16918 100644 --- a/tempo/integrationplugintempo.cpp +++ b/tempo/integrationplugintempo.cpp @@ -85,8 +85,11 @@ void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QStrin Tempo *tempo = new Tempo(hardwareManager()->networkManager(), atlassianAccountName, secret, this); tempo->getAccounts(); connect(info, &ThingPairingInfo::aborted, tempo, &Tempo::deleteLater); - connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, this] (bool authenticated){ + connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, secret, this] (bool authenticated){ if (authenticated) { + pluginStorage()->beginGroup(info->thingId().toString()); + pluginStorage()->setValue("token", secret); + pluginStorage()->endGroup(); m_setupTempoConnections.insert(info->thingId(), tempo); info->finish(Thing::ThingErrorNoError); } @@ -99,6 +102,10 @@ void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QStrin void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) { qCDebug(dcTempo()) << "Discover things"; + if (m_tempoConnections.isEmpty()) { + return info->finish(Thing::ThingErrorHardwareNotAvailable, tr("Create a Tempo connection first")); + } + if (info->thingClassId() == accountThingClassId) { Q_FOREACH(Tempo *tempo, m_tempoConnections) { tempo->getAccounts(); @@ -109,6 +116,9 @@ void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) } connect(tempo, &Tempo::accountsReceived, info, [info, parentThing, this, tempo] (const QList &accounts) { Q_FOREACH(Tempo::Account account, accounts) { + if (account.status == Tempo::Status::Archived) + continue; + ThingDescriptor descriptor(accountThingClassId, account.name, account.customer.name, parentThing->id()); ParamList params; params << Param(accountThingKeyParamTypeId, account.key); @@ -125,6 +135,16 @@ void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) qCWarning(dcTempo()) << "Parent not found"; return; } + connect(tempo, &Tempo::teamsReceived, info, [info, parentThing, this, tempo] (const QList &teams) { + Q_FOREACH(Tempo::Team team, teams) { + + ThingDescriptor descriptor(teamThingClassId, team.name, team.summary, parentThing->id()); + ParamList params; + params << Param(teamThingIdParamTypeId, team.id); + descriptor.setParams(params); + info->addThingDescriptor(descriptor); + } + }); } } QTimer::singleShot(5000, info, [info] { @@ -164,8 +184,6 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) } QString jiraInstanceName = thing->paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); Tempo *tempo = new Tempo(hardwareManager()->networkManager(), jiraInstanceName, token, this); - tempo->getAccounts(); - connect(info, &ThingSetupInfo::aborted, tempo, &Tempo::deleteLater); connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, this] (bool authenticated){ if (authenticated) { @@ -173,6 +191,7 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) info->finish(Thing::ThingErrorNoError); } }); + tempo->getAccounts(); } connect(tempo, &Tempo::connectionChanged, this, &IntegrationPluginTempo::onConnectionChanged); connect(tempo, &Tempo::authenticationStatusChanged, this, &IntegrationPluginTempo::onAuthenticationStatusChanged); diff --git a/tempo/integrationplugintempo.json b/tempo/integrationplugintempo.json index 349b00d4..ea56685c 100644 --- a/tempo/integrationplugintempo.json +++ b/tempo/integrationplugintempo.json @@ -66,7 +66,6 @@ "displayName": "Account", "interfaces": ["connectable"], "createMethods": ["discovery"], - "browsable": true, "paramTypes": [ { "id": "c6aeddae-56af-496d-a419-1635ff9bae50", @@ -173,14 +172,13 @@ "displayName": "Team", "interfaces": ["connectable"], "createMethods": ["discovery"], - "browsable": true, "paramTypes": [ { "id": "bb90e986-fcfa-47e8-8783-f2b5a887314a", - "name": "key", - "displayName": "Key", - "defaultValue": "", - "type": "QString" + "name": "id", + "displayName": "Id", + "defaultValue": 0, + "type": "int" } ], "stateTypes": [ From fe9b604c04077d3df3f94a247f6f01f83497fb21 Mon Sep 17 00:00:00 2001 From: Boernsman Date: Fri, 19 Feb 2021 11:24:20 +0100 Subject: [PATCH 5/8] fixed account parsing --- tempo/integrationplugintempo.cpp | 180 +++++++++++++++++++++++------- tempo/integrationplugintempo.h | 8 +- tempo/integrationplugintempo.json | 55 ++++----- tempo/tempo.cpp | 98 +++++++++++----- tempo/tempo.h | 13 ++- 5 files changed, 251 insertions(+), 103 deletions(-) diff --git a/tempo/integrationplugintempo.cpp b/tempo/integrationplugintempo.cpp index 9eb16918..fa163975 100644 --- a/tempo/integrationplugintempo.cpp +++ b/tempo/integrationplugintempo.cpp @@ -109,17 +109,17 @@ void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) if (info->thingClassId() == accountThingClassId) { Q_FOREACH(Tempo *tempo, m_tempoConnections) { tempo->getAccounts(); - Thing *parentThing = m_tempoConnections.key(tempo); - if (!parentThing) { + ThingId parentThingId = m_tempoConnections.key(tempo); + if (parentThingId.isNull()) { qCWarning(dcTempo()) << "Parent not found"; return; } - connect(tempo, &Tempo::accountsReceived, info, [info, parentThing, this, tempo] (const QList &accounts) { + connect(tempo, &Tempo::accountsReceived, info, [info, parentThingId] (const QList &accounts) { Q_FOREACH(Tempo::Account account, accounts) { if (account.status == Tempo::Status::Archived) continue; - ThingDescriptor descriptor(accountThingClassId, account.name, account.customer.name, parentThing->id()); + ThingDescriptor descriptor(accountThingClassId, account.name, account.customer.name, parentThingId); ParamList params; params << Param(accountThingKeyParamTypeId, account.key); descriptor.setParams(params); @@ -130,15 +130,15 @@ void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) } else if (info->thingClassId() == teamThingClassId) { Q_FOREACH(Tempo *tempo, m_tempoConnections) { tempo->getTeams(); - Thing *parentThing = m_tempoConnections.key(tempo); - if (!parentThing) { + ThingId parentThingId = m_tempoConnections.key(tempo); + if (parentThingId.isNull()) { qCWarning(dcTempo()) << "Parent not found"; return; } - connect(tempo, &Tempo::teamsReceived, info, [info, parentThing, this, tempo] (const QList &teams) { + connect(tempo, &Tempo::teamsReceived, info, [info, parentThingId] (const QList &teams) { Q_FOREACH(Tempo::Team team, teams) { - ThingDescriptor descriptor(teamThingClassId, team.name, team.summary, parentThing->id()); + ThingDescriptor descriptor(teamThingClassId, team.name, team.summary, parentThingId); ParamList params; params << Param(teamThingIdParamTypeId, team.id); descriptor.setParams(params); @@ -160,9 +160,9 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) if (thing->thingClassId() == tempoConnectionThingClassId) { Tempo *tempo; - if (m_tempoConnections.contains(thing)) { + if (m_tempoConnections.contains(thing->id())) { qCDebug(dcTempo()) << "Setup after reconfiguration, cleaning up"; - m_tempoConnections.take(thing)->deleteLater(); + m_tempoConnections.take(thing->id())->deleteLater(); } if (m_setupTempoConnections.keys().contains(thing->id())) { // This thing setup is after a pairing process @@ -171,7 +171,7 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) if (!tempo) { qCWarning(dcTempo()) << "Tempo connection object not found for thing" << thing->name(); } - m_tempoConnections.insert(thing, tempo); + m_tempoConnections.insert(thing->id(), tempo); info->finish(Thing::ThingErrorNoError); } else { //device loaded from the device database, needs a new access token; @@ -187,15 +187,18 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) connect(info, &ThingSetupInfo::aborted, tempo, &Tempo::deleteLater); connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, this] (bool authenticated){ if (authenticated) { - m_tempoConnections.insert(info->thing(), tempo); + m_tempoConnections.insert(info->thing()->id(), tempo); + connect(tempo, &Tempo::connectionChanged, this, &IntegrationPluginTempo::onConnectionChanged); + connect(tempo, &Tempo::authenticationStatusChanged, this, &IntegrationPluginTempo::onAuthenticationStatusChanged); + connect(tempo, &Tempo::accountsReceived, this, &IntegrationPluginTempo::onAccountsReceived); + connect(tempo, &Tempo::teamsReceived, this, &IntegrationPluginTempo::onTeamsReceived); + connect(tempo, &Tempo::accountWorklogsReceived, this, &IntegrationPluginTempo::onAccountWorkloadReceived); + connect(tempo, &Tempo::teamWorklogsReceived, this, &IntegrationPluginTempo::onTeamWorkloadReceived); info->finish(Thing::ThingErrorNoError); } }); tempo->getAccounts(); } - connect(tempo, &Tempo::connectionChanged, this, &IntegrationPluginTempo::onConnectionChanged); - connect(tempo, &Tempo::authenticationStatusChanged, this, &IntegrationPluginTempo::onAuthenticationStatusChanged); - connect(tempo, &Tempo::accountsReceived, this, &IntegrationPluginTempo::onReceivedAccounts); } else if (thing->thingClassId() == accountThingClassId || thing->thingClassId() == teamThingClassId){ @@ -223,53 +226,68 @@ void IntegrationPluginTempo::postSetupThing(Thing *thing) connect(m_pluginTimer15min, &PluginTimer::timeout, this, [this]() { qCDebug(dcTempo()) << "Refresh timer timout, polling all Tempo accounts."; Q_FOREACH (Thing *thing, myThings().filterByThingClassId(tempoConnectionThingClassId)) { - Tempo *tempo = m_tempoConnections.value(thing); + Tempo *tempo = m_tempoConnections.value(thing->id()); if (!tempo) { qWarning(dcTempo()) << "No Tempo connection found for" << thing->name(); continue; } tempo->getAccounts(); + tempo->getTeams(); Q_FOREACH (Thing *childThing, myThings().filterByParentId(thing->id())) { - QString key = childThing->paramValue(accountThingKeyParamTypeId).toString(); - QDate from(1970, 1, 1); - tempo->getWorkloadByAccount(key, from, QDate::currentDate()); + if (childThing->thingClassId() == accountThingClassId) { + QString key = childThing->paramValue(accountThingKeyParamTypeId).toString(); + QDate from(1970, 1, 1); + tempo->getWorkloadByAccount(key, from, QDate::currentDate()); + } else if (childThing->thingClassId() == teamThingClassId) { + int id = childThing->paramValue(teamThingIdParamTypeId).toInt(); + QDate from(1970, 1, 1); + tempo->getWorkloadByTeam(id, from, QDate::currentDate()); + } } } }); } if (thing->thingClassId() == tempoConnectionThingClassId) { - Tempo *tempo = m_tempoConnections.value(thing); + Tempo *tempo = m_tempoConnections.value(thing->id()); + if (!tempo) { + qCWarning(dcTempo()) << "Tempo connection not found for" << thing->name(); + return; + } tempo->getAccounts(); } else if (thing->thingClassId() == accountThingClassId) { - + Tempo *tempo = m_tempoConnections.value(thing->parentId()); + QString key = thing->paramValue(accountThingKeyParamTypeId).toString(); + QDate from(1970, 1, 1); + tempo->getWorkloadByAccount(key, from, QDate::currentDate()); + tempo->getAccounts(); } else if (thing->thingClassId() == teamThingClassId) { - - } -} - -void IntegrationPluginTempo::executeAction(ThingActionInfo *info) -{ - Thing *thing = info->thing(); - Action action = info->action(); - - if (thing->thingClassId() == tempoConnectionThingClassId) { - - } else if (thing->thingClassId() == accountThingClassId) { - + Tempo *tempo = m_tempoConnections.value(thing->parentId()); + int id = thing->paramValue(teamThingIdParamTypeId).toInt(); + QDate from(1970, 1, 1); + tempo->getWorkloadByTeam(id, from, QDate::currentDate()); + tempo->getTeams(); } } void IntegrationPluginTempo::thingRemoved(Thing *thing) { qCDebug(dcTempo()) << "Thing removed" << thing->name(); + if (thing->thingClassId() == tempoConnectionThingClassId) { + m_tempoConnections.take(thing->id())->deleteLater(); + } + if (myThings().isEmpty()) { + qCDebug(dcTempo()) << "Stopping plugin timer"; + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer15min); + m_pluginTimer15min = nullptr; + } } void IntegrationPluginTempo::onConnectionChanged(bool connected) { Tempo *tempo = static_cast(sender()); - Thing *thing = m_tempoConnections.key(tempo); + Thing *thing = myThings().findById(m_tempoConnections.key(tempo)); if (!thing) return; thing->setStateValue(tempoConnectionConnectedStateTypeId, connected); @@ -284,31 +302,107 @@ void IntegrationPluginTempo::onAuthenticationStatusChanged(bool authenticated) { qCDebug(dcTempo()) << "Authentication changed" << authenticated; - Tempo *tempoConnection = static_cast(sender()); - - Thing *thing = m_tempoConnections.key(tempoConnection); + Tempo *tempo = static_cast(sender()); + Thing *thing = myThings().findById(m_tempoConnections.key(tempo)); if (!thing) return; thing->setStateValue(tempoConnectionLoggedInStateTypeId, authenticated); } - -void IntegrationPluginTempo::onReceivedAccounts(const QList &accounts) +void IntegrationPluginTempo::onAccountsReceived(const QList accounts) { - qCDebug(dcTempo()) << "Received" << accounts.count() << "accounts"; + qCDebug(dcTempo()) << "Accounts received"; + + Q_FOREACH(Tempo::Account account, accounts) { + qCDebug(dcTempo()) << " - Account" << account.name; + qCDebug(dcTempo()) << " - Key" << account.key; + qCDebug(dcTempo()) << " - Monthly budget" << account.monthlyBudget; + qCDebug(dcTempo()) << " - Lead" << account.lead.displayName; + qCDebug(dcTempo()) << " - Is Global" << account.global; + qCDebug(dcTempo()) << " - Contact type" << account.contact.type; + qCDebug(dcTempo()) << " - Contact account id" << account.contact.accountId; + qCDebug(dcTempo()) << " - Contact" << account.contact.displayName; + qCDebug(dcTempo()) << " - Category Id" << account.category.id; + qCDebug(dcTempo()) << " - Category name" << account.category.name; + qCDebug(dcTempo()) << " - Category key" << account.category.key; + qCDebug(dcTempo()) << " - Customer id" << account.customer.id; + qCDebug(dcTempo()) << " - Customer key" << account.customer.key; + qCDebug(dcTempo()) << " - Customer name" << account.customer.name; + + Thing *thing = myThings().findByParams(ParamList() << Param(accountThingKeyParamTypeId, account.key)); + if (!thing) { + continue; + } + thing->setName(account.name); + thing->setStateValue(accountConnectedStateTypeId, (account.status != Tempo::Status::Archived)); + thing->setStateValue(accountLeadStateTypeId, account.lead.displayName); + thing->setStateValue(accountGlobalStateTypeId, account.global); + thing->setStateValue(accountCategoryStateTypeId, account.category.name); + thing->setStateValue(accountCustomerStateTypeId, account.customer.name); + thing->setStateValue(accountContactStateTypeId, account.contact.displayName); + thing->setStateValue(accountMonthlyBudgetStateTypeId, account.monthlyBudget); + + if (account.status == Tempo::Status::Open) { + thing->setStateValue(accountStatusStateTypeId, "Open"); + } else if (account.status == Tempo::Status::Closed) { + thing->setStateValue(accountStatusStateTypeId, "Closed"); + } else if (account.status == Tempo::Status::Archived) { + thing->setStateValue(accountStatusStateTypeId, "Archived"); + } + } +} + +void IntegrationPluginTempo::onTeamsReceived(const QList teams) +{ + qCDebug(dcTempo()) << "Teams received"; + + Q_FOREACH(Tempo::Team team, teams) { + Thing *thing = myThings().findByParams(ParamList() << Param(teamThingIdParamTypeId, team.id)); + if (!thing) { + continue; + } + thing->setName(team.name); + thing->setStateValue(teamConnectedStateTypeId, true); + thing->setStateValue(teamLeadStateTypeId, team.lead.displayName); + } } void IntegrationPluginTempo::onAccountWorkloadReceived(const QString &accountKey, QList workloads) { + qCDebug(dcTempo()) << "Account workload received, account key:" << accountKey << "Worklog etries: "<< workloads.count(); Thing *thing = myThings().findByParams(ParamList() << Param(accountThingKeyParamTypeId, accountKey)); - if (!thing) + if (!thing) { + qCWarning(dcTempo()) << "Could not find account thing for account key" << accountKey; return; + } + + uint totalTimeSpentSeconds = 0; + uint thisMonthTimeSpentSeconds = 0; + QDate today = QDate::currentDate(); + Q_FOREACH(Tempo::Worklog workload, workloads) { + if (workload.createdAt.date().month() == today.month()) { + thisMonthTimeSpentSeconds += workload.timeSpentSeconds; + } + totalTimeSpentSeconds += workload.timeSpentSeconds; + } + thing->setStateValue(accountTotalTimeSpentStateTypeId, totalTimeSpentSeconds/3600.00); + thing->setStateValue(accountMonthTimeSpentStateTypeId, thisMonthTimeSpentSeconds/3600.00); +} + +void IntegrationPluginTempo::onTeamWorkloadReceived(int teamId, QList workloads) +{ + qCDebug(dcTempo()) << "Team workload received, team ID:" << teamId << "Worklog etries: "<< workloads.count(); + Thing *thing = myThings().findByParams(ParamList() << Param(teamThingIdParamTypeId, teamId)); + if (!thing) { + qCWarning(dcTempo()) << "Could not find team thing for account key" << teamId; + return; + } uint totalTimeSpentSeconds = 0; Q_FOREACH(Tempo::Worklog workload, workloads) { totalTimeSpentSeconds += workload.timeSpentSeconds; } - thing->setStateValue(accountTotalTimeSpentStateTypeId, totalTimeSpentSeconds/60); + thing->setStateValue(teamTotalTimeSpentStateTypeId, totalTimeSpentSeconds/3600.00); } diff --git a/tempo/integrationplugintempo.h b/tempo/integrationplugintempo.h index b1ee298c..2be13f5a 100644 --- a/tempo/integrationplugintempo.h +++ b/tempo/integrationplugintempo.h @@ -52,20 +52,22 @@ public: void discoverThings(ThingDiscoveryInfo *info) override; void setupThing(ThingSetupInfo *info) override; void postSetupThing(Thing *thing) override; - void executeAction(ThingActionInfo *info) override; void thingRemoved(Thing *thing) override; private: PluginTimer *m_pluginTimer15min = nullptr; QHash m_setupTempoConnections; - QHash m_tempoConnections; + QHash m_tempoConnections; private slots: void onConnectionChanged(bool connected); void onAuthenticationStatusChanged(bool authenticated); - void onReceivedAccounts(const QList &accounts); + + void onAccountsReceived(const QList accounts); + void onTeamsReceived(const QList teams); void onAccountWorkloadReceived(const QString &accountKey, QList workloads); + void onTeamWorkloadReceived(int teamId, QList workloads); }; #endif // INTEGRATIONPLUGINTEMPO_H diff --git a/tempo/integrationplugintempo.json b/tempo/integrationplugintempo.json index ea56685c..4999dacd 100644 --- a/tempo/integrationplugintempo.json +++ b/tempo/integrationplugintempo.json @@ -41,22 +41,6 @@ "displayNameEvent": "Logged in changed", "defaultValue": true, "type": "bool" - }, - { - "id": "f3b9581b-7828-4fbe-be5f-3e8aad78a71e", - "name": "userDisplayName", - "displayName": "User name", - "displayNameEvent": "User name changed", - "defaultValue": "", - "type": "QString" - }, - { - "id": "db70444d-bf67-4133-b2de-54aefbdd7149", - "name": "autoAddAccounts", - "displayName": "Auto add accounts", - "displayNameEvent": "Auto add accounts", - "defaultValue": true, - "type": "bool" } ] }, @@ -72,7 +56,8 @@ "name": "key", "displayName": "Key", "defaultValue": "", - "type": "QString" + "type": "QString", + "readOnly": true } ], "stateTypes": [ @@ -90,11 +75,11 @@ "name": "status", "displayName": "Status", "displayNameEvent": "Status changed", - "defaultValue": "OPEN", + "defaultValue": "Open", "possibleValues": [ - "OPEN", - "CLOSED", - "ARCHIVED" + "Open", + "Closed", + "Archived" ], "type": "QString" }, @@ -152,8 +137,8 @@ "displayName": "Total time spent", "displayNameEvent": "Total time spent changed", "defaultValue": 0, - "type": "uint", - "unit": "Minutes" + "type": "double", + "unit": "Hours" }, { "id": "81bec4e8-9fd3-43d1-b339-2a7fdd83e8cb", @@ -161,8 +146,8 @@ "displayName": "This month time spent", "displayNameEvent": "This month time spent changed", "defaultValue": 0, - "type": "uint", - "unit": "Minutes" + "type": "double", + "unit": "Hours" } ] }, @@ -178,7 +163,8 @@ "name": "id", "displayName": "Id", "defaultValue": 0, - "type": "int" + "type": "int", + "readOnly": true } ], "stateTypes": [ @@ -190,6 +176,23 @@ "defaultValue": true, "cached": false, "type": "bool" + }, + { + "id": "667a9d8d-4e80-4c7c-938c-d698853fa4b1", + "name": "lead", + "displayName": "Lead", + "displayNameEvent": "Lead changed", + "defaultValue": "", + "type": "QString" + }, + { + "id": "a694682e-3c2a-4146-aa56-9e75fd82bcab", + "name": "totalTimeSpent", + "displayName": "Total time spent", + "displayNameEvent": "Total time spent changed", + "defaultValue": 0, + "type": "double", + "unit": "Hours" } ] } diff --git a/tempo/tempo.cpp b/tempo/tempo.cpp index 7f042070..eaa95a48 100644 --- a/tempo/tempo.cpp +++ b/tempo/tempo.cpp @@ -134,15 +134,24 @@ void Tempo::getAccounts() account.lead.self = lead["self"].toString(); account.lead.accountId = lead["accountId"].toString(); account.lead.displayName = lead["displayName"].toString(); - //TODO Customer + QVariantMap customer = map["customer"].toMap(); - account.customer.self = lead["self"].toString(); - //TODO Category + account.customer.self = customer["self"].toString(); + account.customer.key = customer["key"].toString(); + account.customer.id = customer["id"].toInt(); + account.customer.name = customer["name"].toString(); + QVariantMap category = map["category"].toMap(); - account.category.self = lead["self"].toString(); - //TODO Contact + account.category.self = category["self"].toString(); + account.category.key = category["key"].toString(); + account.category.name = category["name"].toString(); + account.category.id = category["id"].toInt(); + QVariantMap contact = map["contact"].toMap(); - account.contact.self = lead["self"].toString(); + account.contact.self = contact["self"].toString(); + account.contact.type = contact["type"].toString(); + account.contact.accountId = contact["accountId"].toString(); + account.contact.displayName = contact["displayName"].toString(); accounts.append(account); } @@ -152,12 +161,14 @@ void Tempo::getAccounts() }); } -void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to) +void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to, int offset, int limit) { QUrl url = QUrl(m_baseControlUrl+"/worklogs/account/"+accountKey); QUrlQuery query; query.addQueryItem("from", from.toString(Qt::DateFormat::ISODate)); query.addQueryItem("to", to.toString(Qt::DateFormat::ISODate)); + query.addQueryItem("offset", QString::number(offset)); + query.addQueryItem("limit", QString::number(limit)); url.setQuery(query); qCDebug(dcTempo()) << "Get workload by account. Url" << url.toString(); @@ -174,30 +185,41 @@ void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to return; } QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); - QVariantList worklogList = dataMap.value("results").toList(); - QList worklogs; - Q_FOREACH(QVariant var, worklogList) { - QVariantMap map = var.toMap(); - Worklog worklog; - worklog.self = map["self"].toString(); - worklog.tempoWorklogId = map["tempoWorklogId"].toInt(); - worklog.jiraWorklogId = map["jiraWorklogId"].toInt(); - worklog.issue = map["issue"].toMap().value("key").toString(); - worklog.timeSpentSeconds = map["timeSpentSeconds"].toInt(); - //TODO startDate: required (date-only) - //TODO startTime: required (time-only) - worklog.description = map["description"].toString(); - //TODO createdAt: required (datetime) - //TODO updatedAt: required (datetime) - worklog.authorAccountId = map["author"].toMap().value("accountId").toString(); - worklog.authorDisplayName = map["author"].toMap().value("displayName").toString(); - worklogs.append(worklog); - } + QList worklogs = parseJsonForWorklog(dataMap); if (!worklogs.isEmpty()) emit accountWorklogsReceived(accountKey, worklogs); }); } +void Tempo::getWorkloadByTeam(int teamId, QDate from, QDate to, int offset, int limit) +{ + QUrl url = QUrl(m_baseControlUrl+"/worklogs/team/"+QString::number(teamId)); + QUrlQuery query; + query.addQueryItem("from", from.toString(Qt::DateFormat::ISODate)); + query.addQueryItem("to", to.toString(Qt::DateFormat::ISODate)); + query.addQueryItem("offset", QString::number(offset)); + query.addQueryItem("limit", QString::number(limit)); + url.setQuery(query); + + qCDebug(dcTempo()) << "Get workload by account. Url" << url.toString(); + + QNetworkRequest request(url); + request.setRawHeader("Authorization", "Bearer "+m_token.toUtf8()); + + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [this, teamId, reply]{ + QByteArray rawData = reply->readAll(); + if (!checkStatusCode(reply, rawData)) { + return; + } + QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); + QList worklogs = parseJsonForWorklog(dataMap); + if (!worklogs.isEmpty()) + emit teamWorklogsReceived(teamId, worklogs); + }); +} + void Tempo::setAuthenticated(bool state) { if (state != m_authenticated) { @@ -214,6 +236,30 @@ void Tempo::setConnected(bool state) } } +QList Tempo::parseJsonForWorklog(const QVariantMap &data) +{ + QVariantList worklogList = data.value("results").toList(); + QList worklogs; + Q_FOREACH(QVariant var, worklogList) { + QVariantMap map = var.toMap(); + Worklog worklog; + worklog.self = map["self"].toString(); + worklog.tempoWorklogId = map["tempoWorklogId"].toInt(); + worklog.jiraWorklogId = map["jiraWorklogId"].toInt(); + worklog.issue = map["issue"].toMap().value("key").toString(); + worklog.timeSpentSeconds = map["timeSpentSeconds"].toInt(); + //TODO startDate: required (date-only) + //TODO startTime: required (time-only) + worklog.description = map["description"].toString(); + worklog.createdAt = QDateTime::fromString(map["createdAt"].toString(), Qt::ISODate); + worklog.updatedAt = QDateTime::fromString(map["updatedAt"].toString(), Qt::ISODate); + worklog.authorAccountId = map["author"].toMap().value("accountId").toString(); + worklog.authorDisplayName = map["author"].toMap().value("displayName").toString(); + worklogs.append(worklog); + } + return worklogs; +} + bool Tempo::checkStatusCode(QNetworkReply *reply, const QByteArray &rawData) { // Check for the internet connection diff --git a/tempo/tempo.h b/tempo/tempo.h index 1492ec87..71a431ae 100644 --- a/tempo/tempo.h +++ b/tempo/tempo.h @@ -63,8 +63,9 @@ public: struct Category { QUrl self; - QString accountId; - QString displayName; + QString key; + int id; + QString name; }; struct Customer { @@ -116,11 +117,11 @@ public: void getTeams(); void getAccounts(); - void getWorkloadByAccount(const QString &accountKey, QDate from, QDate to); + void getWorkloadByAccount(const QString &accountKey, QDate from, QDate to, int offset = 0, int limit = 50); + void getWorkloadByTeam(int teamId, QDate from, QDate to, int offset = 0, int limit = 50); private: - QByteArray m_baseTokenUrl = "https://api.tempo.io/oauth/token/"; - QByteArray m_baseControlUrl = "https://api.tempo.io/core/3/"; + QByteArray m_baseControlUrl = "https://api.tempo.io/core/3"; QString m_token; QString m_jiraCloudInstanceName; @@ -132,6 +133,7 @@ private: bool m_authenticated = false; bool m_connected = false; + QList parseJsonForWorklog(const QVariantMap &data); bool checkStatusCode(QNetworkReply *reply, const QByteArray &rawData); private slots: @@ -143,6 +145,7 @@ signals: void teamsReceived(const QList teams); void accountsReceived(const QList accounts); void accountWorklogsReceived(const QString &accountKey, QList worklogs); + void teamWorklogsReceived(int teamId, QList worklogs); }; #endif // TEMPO_H From c77b46a2f523a7405f3da44d8271956c8d75f83d Mon Sep 17 00:00:00 2001 From: Boernsman Date: Tue, 23 Feb 2021 19:17:46 +0100 Subject: [PATCH 6/8] fixed worklog counting on exceeding API limit --- tempo/integrationplugintempo.cpp | 156 +++++++--- tempo/integrationplugintempo.h | 5 +- tempo/integrationplugintempo.json | 27 +- tempo/tempo.cpp | 24 +- tempo/tempo.h | 10 +- ...bc4ca-d1cd-4279-9e0d-7324537ccb5a-en_US.ts | 285 ++++++++++++++++++ 6 files changed, 434 insertions(+), 73 deletions(-) create mode 100644 tempo/translations/809bc4ca-d1cd-4279-9e0d-7324537ccb5a-en_US.ts diff --git a/tempo/integrationplugintempo.cpp b/tempo/integrationplugintempo.cpp index fa163975..06378a46 100644 --- a/tempo/integrationplugintempo.cpp +++ b/tempo/integrationplugintempo.cpp @@ -49,20 +49,17 @@ void IntegrationPluginTempo::startPairing(ThingPairingInfo *info) if (info->thingClassId() == tempoConnectionThingClassId) { - - QString jiraCloudInstanceName = info->params().paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); - qCDebug(dcTempo()) << "Checking if the Tempo server is reachable: https://api.tempo.io/core/3"; QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(QUrl("https://api.tempo.io/core/3"))); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [reply, info, this] { + connect(reply, &QNetworkReply::finished, info, [reply, info] { if (reply->error() != QNetworkReply::NetworkError::HostNotFoundError) { qCDebug(dcTempo()) << "Tempo server is reachable"; - info->finish(Thing::ThingErrorNoError); + info->finish(Thing::ThingErrorNoError, QT_TR_NOOP("Please enter your Tempo API integration token.")); } else { qCWarning(dcTempo()) << "Got online check error" << reply->error() << reply->errorString(); - info->finish(Thing::ThingErrorSetupFailed, tr("Tempo server not reachable, please check the internet connection")); + info->finish(Thing::ThingErrorSetupFailed, tr("Tempo server not reachable, please check the internet connection.")); } }); } else { @@ -81,8 +78,7 @@ void IntegrationPluginTempo::confirmPairing(ThingPairingInfo *info, const QStrin qCWarning(dcTempo()) << "No authorization code received."; return info->finish(Thing::ThingErrorAuthenticationFailure); } - QString atlassianAccountName = info->params().paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); - Tempo *tempo = new Tempo(hardwareManager()->networkManager(), atlassianAccountName, secret, this); + Tempo *tempo = new Tempo(hardwareManager()->networkManager(), secret, this); tempo->getAccounts(); connect(info, &ThingPairingInfo::aborted, tempo, &Tempo::deleteLater); connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, secret, this] (bool authenticated){ @@ -155,7 +151,7 @@ void IntegrationPluginTempo::discoverThings(ThingDiscoveryInfo *info) void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) { Thing *thing = info->thing(); - qCDebug(dcTempo()) << "Setup thing"; + qCDebug(dcTempo()) << "Setup thing" << thing->name(); if (thing->thingClassId() == tempoConnectionThingClassId) { @@ -172,6 +168,12 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) qCWarning(dcTempo()) << "Tempo connection object not found for thing" << thing->name(); } m_tempoConnections.insert(thing->id(), tempo); + connect(tempo, &Tempo::connectionChanged, this, &IntegrationPluginTempo::onConnectionChanged); + connect(tempo, &Tempo::authenticationStatusChanged, this, &IntegrationPluginTempo::onAuthenticationStatusChanged); + connect(tempo, &Tempo::accountsReceived, this, &IntegrationPluginTempo::onAccountsReceived); + connect(tempo, &Tempo::teamsReceived, this, &IntegrationPluginTempo::onTeamsReceived); + connect(tempo, &Tempo::accountWorklogsReceived, this, &IntegrationPluginTempo::onAccountWorkloadReceived); + connect(tempo, &Tempo::teamWorklogsReceived, this, &IntegrationPluginTempo::onTeamWorkloadReceived); info->finish(Thing::ThingErrorNoError); } else { //device loaded from the device database, needs a new access token; @@ -182,8 +184,7 @@ void IntegrationPluginTempo::setupThing(ThingSetupInfo *info) info->finish(Thing::ThingErrorAuthenticationFailure, tr("Token is not available.")); return; } - QString jiraInstanceName = thing->paramValue(tempoConnectionThingAtlassianAccountNameParamTypeId).toString(); - Tempo *tempo = new Tempo(hardwareManager()->networkManager(), jiraInstanceName, token, this); + Tempo *tempo = new Tempo(hardwareManager()->networkManager(), token, this); connect(info, &ThingSetupInfo::aborted, tempo, &Tempo::deleteLater); connect(tempo, &Tempo::authenticationStatusChanged, info, [info, tempo, this] (bool authenticated){ if (authenticated) { @@ -236,12 +237,10 @@ void IntegrationPluginTempo::postSetupThing(Thing *thing) Q_FOREACH (Thing *childThing, myThings().filterByParentId(thing->id())) { if (childThing->thingClassId() == accountThingClassId) { QString key = childThing->paramValue(accountThingKeyParamTypeId).toString(); - QDate from(1970, 1, 1); - tempo->getWorkloadByAccount(key, from, QDate::currentDate()); + tempo->getWorkloadByAccount(key, QDate(1970, 1, 1), QDate::currentDate(), 0, 1000); } else if (childThing->thingClassId() == teamThingClassId) { int id = childThing->paramValue(teamThingIdParamTypeId).toInt(); - QDate from(1970, 1, 1); - tempo->getWorkloadByTeam(id, from, QDate::currentDate()); + tempo->getWorkloadByTeam(id, QDate(1970, 1, 1), QDate::currentDate(), 0, 1000); } } } @@ -260,13 +259,13 @@ void IntegrationPluginTempo::postSetupThing(Thing *thing) Tempo *tempo = m_tempoConnections.value(thing->parentId()); QString key = thing->paramValue(accountThingKeyParamTypeId).toString(); QDate from(1970, 1, 1); - tempo->getWorkloadByAccount(key, from, QDate::currentDate()); + tempo->getWorkloadByAccount(key, from, QDate::currentDate(), 0, 1000); tempo->getAccounts(); } else if (thing->thingClassId() == teamThingClassId) { Tempo *tempo = m_tempoConnections.value(thing->parentId()); int id = thing->paramValue(teamThingIdParamTypeId).toInt(); QDate from(1970, 1, 1); - tempo->getWorkloadByTeam(id, from, QDate::currentDate()); + tempo->getWorkloadByTeam(id, from, QDate::currentDate(), 0, 1000); tempo->getTeams(); } } @@ -276,7 +275,11 @@ void IntegrationPluginTempo::thingRemoved(Thing *thing) qCDebug(dcTempo()) << "Thing removed" << thing->name(); if (thing->thingClassId() == tempoConnectionThingClassId) { m_tempoConnections.take(thing->id())->deleteLater(); + } else if (thing->thingClassId() == teamThingClassId || + thing->thingClassId() == accountThingClassId) { + m_worklogBuffer.remove(thing->id()); } + if (myThings().isEmpty()) { qCDebug(dcTempo()) << "Stopping plugin timer"; hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer15min); @@ -315,20 +318,20 @@ void IntegrationPluginTempo::onAccountsReceived(const QList acco qCDebug(dcTempo()) << "Accounts received"; Q_FOREACH(Tempo::Account account, accounts) { - qCDebug(dcTempo()) << " - Account" << account.name; - qCDebug(dcTempo()) << " - Key" << account.key; - qCDebug(dcTempo()) << " - Monthly budget" << account.monthlyBudget; - qCDebug(dcTempo()) << " - Lead" << account.lead.displayName; - qCDebug(dcTempo()) << " - Is Global" << account.global; - qCDebug(dcTempo()) << " - Contact type" << account.contact.type; - qCDebug(dcTempo()) << " - Contact account id" << account.contact.accountId; - qCDebug(dcTempo()) << " - Contact" << account.contact.displayName; - qCDebug(dcTempo()) << " - Category Id" << account.category.id; - qCDebug(dcTempo()) << " - Category name" << account.category.name; - qCDebug(dcTempo()) << " - Category key" << account.category.key; - qCDebug(dcTempo()) << " - Customer id" << account.customer.id; - qCDebug(dcTempo()) << " - Customer key" << account.customer.key; - qCDebug(dcTempo()) << " - Customer name" << account.customer.name; + // qCDebug(dcTempo()) << " - Account" << account.name; + // qCDebug(dcTempo()) << " - Key" << account.key; + // qCDebug(dcTempo()) << " - Monthly budget" << account.monthlyBudget; + // qCDebug(dcTempo()) << " - Lead" << account.lead.displayName; + // qCDebug(dcTempo()) << " - Is Global" << account.global; + // qCDebug(dcTempo()) << " - Contact type" << account.contact.type; + // qCDebug(dcTempo()) << " - Contact account id" << account.contact.accountId; + // qCDebug(dcTempo()) << " - Contact" << account.contact.displayName; + // qCDebug(dcTempo()) << " - Category Id" << account.category.id; + // qCDebug(dcTempo()) << " - Category name" << account.category.name; + // qCDebug(dcTempo()) << " - Category key" << account.category.key; + // qCDebug(dcTempo()) << " - Customer id" << account.customer.id; + // qCDebug(dcTempo()) << " - Customer key" << account.customer.key; + // qCDebug(dcTempo()) << " - Customer name" << account.customer.name; Thing *thing = myThings().findByParams(ParamList() << Param(accountThingKeyParamTypeId, account.key)); if (!thing) { @@ -368,7 +371,7 @@ void IntegrationPluginTempo::onTeamsReceived(const QList teams) } } -void IntegrationPluginTempo::onAccountWorkloadReceived(const QString &accountKey, QList workloads) +void IntegrationPluginTempo::onAccountWorkloadReceived(const QString &accountKey, QList workloads, int limit, int offset) { qCDebug(dcTempo()) << "Account workload received, account key:" << accountKey << "Worklog etries: "<< workloads.count(); Thing *thing = myThings().findByParams(ParamList() << Param(accountThingKeyParamTypeId, accountKey)); @@ -377,32 +380,87 @@ void IntegrationPluginTempo::onAccountWorkloadReceived(const QString &accountKey return; } - uint totalTimeSpentSeconds = 0; - uint thisMonthTimeSpentSeconds = 0; - QDate today = QDate::currentDate(); - Q_FOREACH(Tempo::Worklog workload, workloads) { - if (workload.createdAt.date().month() == today.month()) { - thisMonthTimeSpentSeconds += workload.timeSpentSeconds; - } - totalTimeSpentSeconds += workload.timeSpentSeconds; + if (offset == 0) { + m_worklogBuffer.remove(thing->id()); + } + if (workloads.count() >= limit) { + //limit is reached + if (m_worklogBuffer.contains(thing->id())) { + m_worklogBuffer[thing->id()].append(workloads); + } else { + m_worklogBuffer.insert(thing->id(), workloads); + } + Tempo *tempo = m_tempoConnections.value(thing->parentId()); + if (tempo) { + tempo->getWorkloadByAccount(accountKey, QDate(1970, 1, 1), QDate::currentDate(), offset+workloads.count(), limit); + } + + } else { + uint totalTimeSpentSeconds = 0; + uint thisMonthTimeSpentSeconds = 0; + QDate today = QDate::currentDate(); + Q_FOREACH(Tempo::Worklog workload, workloads) { + if ((workload.startDate.month() == today.month()) && (workload.startDate.year() == today.year())) { + thisMonthTimeSpentSeconds += workload.timeSpentSeconds; + } + totalTimeSpentSeconds += workload.timeSpentSeconds; + } + if (m_worklogBuffer.contains(thing->id())) { + Q_FOREACH(Tempo::Worklog workload, m_worklogBuffer.take(thing->id())) { + if ((workload.startDate.month() == today.month()) && (workload.startDate.year() == today.year())) { + thisMonthTimeSpentSeconds += workload.timeSpentSeconds; + } + totalTimeSpentSeconds += workload.timeSpentSeconds; + } + } + thing->setStateValue(accountTotalTimeSpentStateTypeId, totalTimeSpentSeconds/3600.00); + thing->setStateValue(accountMonthTimeSpentStateTypeId, thisMonthTimeSpentSeconds/3600.00); } - thing->setStateValue(accountTotalTimeSpentStateTypeId, totalTimeSpentSeconds/3600.00); - thing->setStateValue(accountMonthTimeSpentStateTypeId, thisMonthTimeSpentSeconds/3600.00); } -void IntegrationPluginTempo::onTeamWorkloadReceived(int teamId, QList workloads) +void IntegrationPluginTempo::onTeamWorkloadReceived(int teamId, QList workloads, int limit, int offset) { - qCDebug(dcTempo()) << "Team workload received, team ID:" << teamId << "Worklog etries: "<< workloads.count(); + qCDebug(dcTempo()) << "Team workload received, team ID:" << teamId << "Worklog entries: "<< workloads.count(); Thing *thing = myThings().findByParams(ParamList() << Param(teamThingIdParamTypeId, teamId)); if (!thing) { qCWarning(dcTempo()) << "Could not find team thing for account key" << teamId; return; } - - uint totalTimeSpentSeconds = 0; - Q_FOREACH(Tempo::Worklog workload, workloads) { - totalTimeSpentSeconds += workload.timeSpentSeconds; + if (offset == 0) { + m_worklogBuffer.remove(thing->id()); + } + if (workloads.count() >= limit) { + //limit is reached# + if (m_worklogBuffer.contains(thing->id())) { + m_worklogBuffer[thing->id()].append(workloads); + } else { + m_worklogBuffer.insert(thing->id(), workloads); + } + Tempo *tempo = m_tempoConnections.value(thing->parentId()); + if (tempo) { + tempo->getWorkloadByTeam(teamId, QDate(1970, 1, 1), QDate::currentDate(), offset+workloads.count(), limit); + } + + } else { + uint totalTimeSpentSeconds = 0; + uint thisMonthTimeSpentSeconds = 0; + QDate today = QDate::currentDate(); + Q_FOREACH(Tempo::Worklog workload, workloads) { + if ((workload.startDate.month() == today.month()) && (workload.startDate.year() == today.year())) { + thisMonthTimeSpentSeconds += workload.timeSpentSeconds; + } + totalTimeSpentSeconds += workload.timeSpentSeconds; + } + if (m_worklogBuffer.contains(thing->id())) { + Q_FOREACH(Tempo::Worklog workload, m_worklogBuffer.take(thing->id())) { + if ((workload.startDate.month() == today.month()) && (workload.startDate.year() == today.year())) { + thisMonthTimeSpentSeconds += workload.timeSpentSeconds; + } + totalTimeSpentSeconds += workload.timeSpentSeconds; + } + } + thing->setStateValue(teamTotalTimeSpentStateTypeId, totalTimeSpentSeconds/3600.00); + thing->setStateValue(teamMonthTimeSpentStateTypeId, thisMonthTimeSpentSeconds/3600.00); } - thing->setStateValue(teamTotalTimeSpentStateTypeId, totalTimeSpentSeconds/3600.00); } diff --git a/tempo/integrationplugintempo.h b/tempo/integrationplugintempo.h index 2be13f5a..17d73b22 100644 --- a/tempo/integrationplugintempo.h +++ b/tempo/integrationplugintempo.h @@ -57,6 +57,7 @@ public: private: PluginTimer *m_pluginTimer15min = nullptr; + QHash> m_worklogBuffer; QHash m_setupTempoConnections; QHash m_tempoConnections; @@ -67,7 +68,7 @@ private slots: void onAccountsReceived(const QList accounts); void onTeamsReceived(const QList teams); - void onAccountWorkloadReceived(const QString &accountKey, QList workloads); - void onTeamWorkloadReceived(int teamId, QList workloads); + void onAccountWorkloadReceived(const QString &accountKey, QList workloads, int limit, int offset); + void onTeamWorkloadReceived(int teamId, QList workloads, int limit, int offset); }; #endif // INTEGRATIONPLUGINTEMPO_H diff --git a/tempo/integrationplugintempo.json b/tempo/integrationplugintempo.json index 4999dacd..4040a3df 100644 --- a/tempo/integrationplugintempo.json +++ b/tempo/integrationplugintempo.json @@ -15,15 +15,6 @@ "interfaces": ["account"], "createMethods": ["user"], "setupMethod": "displayPin", - "paramTypes": [ - { - "id": "b4110c37-8331-4057-8e9f-12f34c2623fe", - "name": "atlassianAccountName", - "displayName": "Atlassian account name", - "type": "QString", - "defaultValue": "" - } - ], "stateTypes": [ { "id": "15f45315-5419-4e1b-ace3-fc21503d3b70", @@ -50,6 +41,15 @@ "displayName": "Account", "interfaces": ["connectable"], "createMethods": ["discovery"], + "settingsTypes": [ + { + "id": "56c460b2-37d8-453a-b4f4-8e58be348f85", + "name": "startDate", + "displayName": "Start date", + "defaultValue": "", + "type": "QDate" + } + ], "paramTypes": [ { "id": "c6aeddae-56af-496d-a419-1635ff9bae50", @@ -193,6 +193,15 @@ "defaultValue": 0, "type": "double", "unit": "Hours" + }, + { + "id": "f5ec7b30-3074-41e9-b1fc-62b6307ddbe1", + "name": "monthTimeSpent", + "displayName": "This month time spent", + "displayNameEvent": "This month time spent changed", + "defaultValue": 0, + "type": "double", + "unit": "Hours" } ] } diff --git a/tempo/tempo.cpp b/tempo/tempo.cpp index eaa95a48..93f40062 100644 --- a/tempo/tempo.cpp +++ b/tempo/tempo.cpp @@ -34,18 +34,17 @@ #include "tempo.h" #include "extern-plugininfo.h" -Tempo::Tempo(NetworkAccessManager *networkmanager, const QString &jiraCloudInstanceName, const QString &token, QObject *parent) : +Tempo::Tempo(NetworkAccessManager *networkmanager, const QString &token, QObject *parent) : QObject(parent), m_token(token), - m_jiraCloudInstanceName(jiraCloudInstanceName), m_networkManager(networkmanager) { - qCDebug(dcTempo()) << "Creating tempo connection to" << m_jiraCloudInstanceName; + qCDebug(dcTempo()) << "Creating tempo connection"; } Tempo::~Tempo() { - qCDebug(dcTempo()) << "Deleting tempo connection to" << m_jiraCloudInstanceName; + qCDebug(dcTempo()) << "Deleting tempo connection"; } QString Tempo::token() const @@ -185,9 +184,11 @@ void Tempo::getWorkloadByAccount(const QString &accountKey, QDate from, QDate to return; } QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); + int offset = dataMap.value("metadata").toMap().value("offset").toInt(); + int limit = dataMap.value("metadata").toMap().value("limit").toInt(); QList worklogs = parseJsonForWorklog(dataMap); if (!worklogs.isEmpty()) - emit accountWorklogsReceived(accountKey, worklogs); + emit accountWorklogsReceived(accountKey, worklogs, limit, offset); }); } @@ -214,9 +215,11 @@ void Tempo::getWorkloadByTeam(int teamId, QDate from, QDate to, int offset, int return; } QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap(); + int offset = dataMap.value("metadata").toMap().value("offset").toInt(); + int limit = dataMap.value("metadata").toMap().value("limit").toInt(); QList worklogs = parseJsonForWorklog(dataMap); if (!worklogs.isEmpty()) - emit teamWorklogsReceived(teamId, worklogs); + emit teamWorklogsReceived(teamId, worklogs, limit, offset); }); } @@ -239,6 +242,11 @@ void Tempo::setConnected(bool state) QList Tempo::parseJsonForWorklog(const QVariantMap &data) { QVariantList worklogList = data.value("results").toList(); + qCDebug(dcTempo()) << "Worklog received"; + qCDebug(dcTempo()) << " - Count:" << data.value("metadata").toMap().value("count"); + qCDebug(dcTempo()) << " - Offset:" << data.value("metadata").toMap().value("offset"); + qCDebug(dcTempo()) << " - Limit:" << data.value("metadata").toMap().value("limit"); + QList worklogs; Q_FOREACH(QVariant var, worklogList) { QVariantMap map = var.toMap(); @@ -248,8 +256,8 @@ QList Tempo::parseJsonForWorklog(const QVariantMap &data) worklog.jiraWorklogId = map["jiraWorklogId"].toInt(); worklog.issue = map["issue"].toMap().value("key").toString(); worklog.timeSpentSeconds = map["timeSpentSeconds"].toInt(); - //TODO startDate: required (date-only) - //TODO startTime: required (time-only) + worklog.startDate = QDate::fromString(map["startDate"].toString(), Qt::ISODate); + worklog.startTime = QTime::fromString(map["startTime"].toString(), Qt::ISODate); worklog.description = map["description"].toString(); worklog.createdAt = QDateTime::fromString(map["createdAt"].toString(), Qt::ISODate); worklog.updatedAt = QDateTime::fromString(map["updatedAt"].toString(), Qt::ISODate); diff --git a/tempo/tempo.h b/tempo/tempo.h index 71a431ae..f3a24a02 100644 --- a/tempo/tempo.h +++ b/tempo/tempo.h @@ -95,7 +95,8 @@ public: int jiraWorklogId; QString issue; int timeSpentSeconds; - QDateTime startedAt; + QDate startDate; + QTime startTime; QString description; QDateTime createdAt; QDateTime updatedAt; @@ -111,7 +112,7 @@ public: Lead lead; }; - explicit Tempo(NetworkAccessManager *networkmanager, const QString &jiraCloudInstanceName, const QString &token, QObject *parent = nullptr); + explicit Tempo(NetworkAccessManager *networkmanager, const QString &token, QObject *parent = nullptr); ~Tempo() override; QString token() const; @@ -123,7 +124,6 @@ public: private: QByteArray m_baseControlUrl = "https://api.tempo.io/core/3"; QString m_token; - QString m_jiraCloudInstanceName; NetworkAccessManager *m_networkManager = nullptr; @@ -144,8 +144,8 @@ signals: void teamsReceived(const QList teams); void accountsReceived(const QList accounts); - void accountWorklogsReceived(const QString &accountKey, QList worklogs); - void teamWorklogsReceived(int teamId, QList worklogs); + void accountWorklogsReceived(const QString &accountKey, QList worklogs, int limit, int offset); + void teamWorklogsReceived(int teamId, QList worklogs, int limit, int offset); }; #endif // TEMPO_H diff --git a/tempo/translations/809bc4ca-d1cd-4279-9e0d-7324537ccb5a-en_US.ts b/tempo/translations/809bc4ca-d1cd-4279-9e0d-7324537ccb5a-en_US.ts new file mode 100644 index 00000000..6e5bf7fd --- /dev/null +++ b/tempo/translations/809bc4ca-d1cd-4279-9e0d-7324537ccb5a-en_US.ts @@ -0,0 +1,285 @@ + + + + + IntegrationPluginTempo + + + Please enter your Tempo API integration token. + + + + + Tempo server not reachable, please check the internet connection. + + + + + Create a Tempo connection first + + + + + Token is not available. + + + + + tempo + + + Account + The name of the ThingClass ({8be71352-bdfd-450b-903e-79a4ed203701}) + + + + + + Category + The name of the ParamType (ThingClass: account, EventType: category, ID: {3af6d1c0-bb0a-406f-809b-2c367e1a16bb}) +---------- +The name of the StateType ({3af6d1c0-bb0a-406f-809b-2c367e1a16bb}) of ThingClass account + + + + + Category changed + The name of the EventType ({3af6d1c0-bb0a-406f-809b-2c367e1a16bb}) of ThingClass account + + + + + + + + + + Connected + The name of the ParamType (ThingClass: team, EventType: connected, ID: {a125d3b5-676f-49eb-bb93-feae233c2e91}) +---------- +The name of the StateType ({a125d3b5-676f-49eb-bb93-feae233c2e91}) of ThingClass team +---------- +The name of the ParamType (ThingClass: account, EventType: connected, ID: {0b776bc1-9e56-4205-9bc3-b356026f5b64}) +---------- +The name of the StateType ({0b776bc1-9e56-4205-9bc3-b356026f5b64}) of ThingClass account +---------- +The name of the ParamType (ThingClass: tempoConnection, EventType: connected, ID: {15f45315-5419-4e1b-ace3-fc21503d3b70}) +---------- +The name of the StateType ({15f45315-5419-4e1b-ace3-fc21503d3b70}) of ThingClass tempoConnection + + + + + + + Connected changed + The name of the EventType ({a125d3b5-676f-49eb-bb93-feae233c2e91}) of ThingClass team +---------- +The name of the EventType ({0b776bc1-9e56-4205-9bc3-b356026f5b64}) of ThingClass account +---------- +The name of the EventType ({15f45315-5419-4e1b-ace3-fc21503d3b70}) of ThingClass tempoConnection + + + + + + Contact + The name of the ParamType (ThingClass: account, EventType: contact, ID: {ece43b12-4a0d-4e25-b811-b1aca610bea8}) +---------- +The name of the StateType ({ece43b12-4a0d-4e25-b811-b1aca610bea8}) of ThingClass account + + + + + Contact changed + The name of the EventType ({ece43b12-4a0d-4e25-b811-b1aca610bea8}) of ThingClass account + + + + + + Customer + The name of the ParamType (ThingClass: account, EventType: Customer, ID: {3dcc1426-51f8-46fa-9967-5a93d7bb2633}) +---------- +The name of the StateType ({3dcc1426-51f8-46fa-9967-5a93d7bb2633}) of ThingClass account + + + + + Customer changed + The name of the EventType ({3dcc1426-51f8-46fa-9967-5a93d7bb2633}) of ThingClass account + + + + + + Global + The name of the ParamType (ThingClass: account, EventType: global, ID: {abd55ea0-ad4e-413e-bc77-3e8b7f0a9be4}) +---------- +The name of the StateType ({abd55ea0-ad4e-413e-bc77-3e8b7f0a9be4}) of ThingClass account + + + + + Global changed + The name of the EventType ({abd55ea0-ad4e-413e-bc77-3e8b7f0a9be4}) of ThingClass account + + + + + Id + The name of the ParamType (ThingClass: team, Type: thing, ID: {bb90e986-fcfa-47e8-8783-f2b5a887314a}) + + + + + Key + The name of the ParamType (ThingClass: account, Type: thing, ID: {c6aeddae-56af-496d-a419-1635ff9bae50}) + + + + + + + + Lead + The name of the ParamType (ThingClass: team, EventType: lead, ID: {667a9d8d-4e80-4c7c-938c-d698853fa4b1}) +---------- +The name of the StateType ({667a9d8d-4e80-4c7c-938c-d698853fa4b1}) of ThingClass team +---------- +The name of the ParamType (ThingClass: account, EventType: lead, ID: {f1f2af66-d09a-4242-9058-401145f662c4}) +---------- +The name of the StateType ({f1f2af66-d09a-4242-9058-401145f662c4}) of ThingClass account + + + + + + Lead changed + The name of the EventType ({667a9d8d-4e80-4c7c-938c-d698853fa4b1}) of ThingClass team +---------- +The name of the EventType ({f1f2af66-d09a-4242-9058-401145f662c4}) of ThingClass account + + + + + + Logged in + The name of the ParamType (ThingClass: tempoConnection, EventType: loggedIn, ID: {e4b5be87-dbc9-481e-88da-608c71be8bda}) +---------- +The name of the StateType ({e4b5be87-dbc9-481e-88da-608c71be8bda}) of ThingClass tempoConnection + + + + + Logged in changed + The name of the EventType ({e4b5be87-dbc9-481e-88da-608c71be8bda}) of ThingClass tempoConnection + + + + + + Monthly budget + The name of the ParamType (ThingClass: account, EventType: monthlyBudget, ID: {44ebbc18-7511-48c0-860b-c4de5f634ed6}) +---------- +The name of the StateType ({44ebbc18-7511-48c0-860b-c4de5f634ed6}) of ThingClass account + + + + + Monthly budget changed + The name of the EventType ({44ebbc18-7511-48c0-860b-c4de5f634ed6}) of ThingClass account + + + + + Start date + The name of the ParamType (ThingClass: account, Type: settings, ID: {56c460b2-37d8-453a-b4f4-8e58be348f85}) + + + + + + Status + The name of the ParamType (ThingClass: account, EventType: status, ID: {7948f15b-7243-404e-9e67-18e915e8b328}) +---------- +The name of the StateType ({7948f15b-7243-404e-9e67-18e915e8b328}) of ThingClass account + + + + + Status changed + The name of the EventType ({7948f15b-7243-404e-9e67-18e915e8b328}) of ThingClass account + + + + + Team + The name of the ThingClass ({11c85176-e7fe-44b4-995a-24757273f3af}) + + + + + + Tempo + The name of the vendor ({58fc1ab7-b8b5-4e52-8388-72957ce5852d}) +---------- +The name of the plugin tempo ({809bc4ca-d1cd-4279-9e0d-7324537ccb5a}) + + + + + Tempo connection + The name of the ThingClass ({878eae0a-6217-4b36-bd46-72c911e52e73}) + + + + + + + + This month time spent + The name of the ParamType (ThingClass: team, EventType: monthTimeSpent, ID: {f5ec7b30-3074-41e9-b1fc-62b6307ddbe1}) +---------- +The name of the StateType ({f5ec7b30-3074-41e9-b1fc-62b6307ddbe1}) of ThingClass team +---------- +The name of the ParamType (ThingClass: account, EventType: monthTimeSpent, ID: {81bec4e8-9fd3-43d1-b339-2a7fdd83e8cb}) +---------- +The name of the StateType ({81bec4e8-9fd3-43d1-b339-2a7fdd83e8cb}) of ThingClass account + + + + + + This month time spent changed + The name of the EventType ({f5ec7b30-3074-41e9-b1fc-62b6307ddbe1}) of ThingClass team +---------- +The name of the EventType ({81bec4e8-9fd3-43d1-b339-2a7fdd83e8cb}) of ThingClass account + + + + + + + + Total time spent + The name of the ParamType (ThingClass: team, EventType: totalTimeSpent, ID: {a694682e-3c2a-4146-aa56-9e75fd82bcab}) +---------- +The name of the StateType ({a694682e-3c2a-4146-aa56-9e75fd82bcab}) of ThingClass team +---------- +The name of the ParamType (ThingClass: account, EventType: totalTimeSpent, ID: {1ac39002-56a1-4911-aa68-9d14e142edae}) +---------- +The name of the StateType ({1ac39002-56a1-4911-aa68-9d14e142edae}) of ThingClass account + + + + + + Total time spent changed + The name of the EventType ({a694682e-3c2a-4146-aa56-9e75fd82bcab}) of ThingClass team +---------- +The name of the EventType ({1ac39002-56a1-4911-aa68-9d14e142edae}) of ThingClass account + + + + From fa31611d22aae76e3077d80a79e18b256e2fae32 Mon Sep 17 00:00:00 2001 From: Boernsman Date: Tue, 23 Feb 2021 19:21:40 +0100 Subject: [PATCH 7/8] fixed debian install --- debian/control | 1 + debian/nymea-plugin-tempo.install.in | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 03b23488..32d75ec9 100644 --- a/debian/control +++ b/debian/control @@ -1173,6 +1173,7 @@ Depends: nymea-plugin-anel, nymea-plugin-pushnotifications, nymea-plugin-wakeonlan, nymea-plugin-tasmota, + nymea-plugin-tempo, nymea-plugin-tplink, nymea-plugin-wemo, nymea-plugin-elgato, diff --git a/debian/nymea-plugin-tempo.install.in b/debian/nymea-plugin-tempo.install.in index c63e05ef..00a44b17 100644 --- a/debian/nymea-plugin-tempo.install.in +++ b/debian/nymea-plugin-tempo.install.in @@ -1 +1 @@ -usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginstempo.so +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationplugintempo.so From c4124ed7f70f75a397aeda0ea435ac835754f39e Mon Sep 17 00:00:00 2001 From: Boernsman Date: Sat, 6 Mar 2021 10:58:17 +0100 Subject: [PATCH 8/8] added meta.json --- tempo/meta.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tempo/meta.json b/tempo/meta.json index e69de29b..ed17d2af 100644 --- a/tempo/meta.json +++ b/tempo/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Tempo", + "tagline": "Get logged hours from Tempo.", + "icon": "tempo.svg", + "stability": "consumer", + "offline": false, + "technologies": [ + "cloud" + ], + "categories": [ + "online-service" + ] +}