diff --git a/.github/workflows/qml-tests.yml b/.github/workflows/qml-tests.yml new file mode 100644 index 00000000..d2b18ddb --- /dev/null +++ b/.github/workflows/qml-tests.yml @@ -0,0 +1,56 @@ +name: QML Tests + +on: + push: + branches: + - main + - dev + - 'release/**' + - 'refactor/**' + - 'fix/**' + paths: + - 'qml/**' + - 'tests/qml/**' + - 'models/**' + - '.github/workflows/qml-tests.yml' + pull_request: + branches: + - main + - dev + - 'release/**' + paths: + - 'qml/**' + - 'tests/qml/**' + - 'models/**' + - '.github/workflows/qml-tests.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + qml-component-tests: + name: QML Component Tests + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install Clickable + run: | + python -m pip install --upgrade pip + pip install clickable-ut + + - name: Prepare build directory + run: mkdir -p build/x86_64-linux-gnu/app + + - name: Run QML Component Tests + run: | + clickable test --non-interactive --arch amd64 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..10719210 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: + - main + - dev + - 'release/**' + - 'refactor/**' + - 'fix/**' + pull_request: + branches: + - main + - dev + - 'release/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend-unit-tests: + name: Python Unit Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: ['3.10', '3.12'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest urllib3 requests + + - name: Run Backend Unit Tests + run: | + pytest tests/python -v diff --git a/clickable.yaml b/clickable.yaml index e121c029..c3ab162d 100644 --- a/clickable.yaml +++ b/clickable.yaml @@ -2,6 +2,7 @@ clickable_minimum_required: 8.0.0 builder: cmake kill: qmlscene skip_review: true +test: qmltestrunner -input tests/qml -import tests/qml dependencies_target: - libatomic1 dependencies_host: diff --git a/manifest.json.in b/manifest.json.in index dc8448d6..675e95cb 100644 --- a/manifest.json.in +++ b/manifest.json.in @@ -18,7 +18,7 @@ "push-helper": "ubtms-push-helper.json" } }, - "version": "1.3.3", + "version": "1.3.4", "maintainer": "CIT Services ", "framework" : "@CLICK_FRAMEWORK@" } diff --git a/models/Main.js b/models/Main.js index 59eda064..9c8c2778 100644 --- a/models/Main.js +++ b/models/Main.js @@ -1,16 +1,16 @@ .import "database.js" as DBCommon .import QtQuick.LocalStorage 2.7 as Sql +.import "accounts.js" as Account +.import "../models/dbinit.js" as DbInit /* Name: get_quadrant_current_week - * This function will return total of spent time for current week based on quadrants from timesheet entries - * 4 quadrants are as following - * 0 -> Urgent and Important - * 1 -> Import but not Urgent - * 2 -> Not Important but Urgent - * 3 -> Not Important and Not Urgent - */ - -.import "../models/dbinit.js" as DbInit + * This function will return total of spent time for current week based on quadrants from timesheet entries + * 4 quadrants are as following + * 0 -> Urgent and Important + * 1 -> Import but not Urgent + * 2 -> Not Important but Urgent + * 3 -> Not Important and Not Urgent + */ DbInit.initializeDatabase(); function get_quadrant_difference() { @@ -74,7 +74,7 @@ function get_quadrant_current_month() { * return format {: } */ -function get_projects_spent_hours(account, startDate, endDate) { +function get_projects_spent_hours(account, startDate, endDate, userId) { var project_details = []; try { @@ -121,6 +121,13 @@ function get_projects_spent_hours(account, startDate, endDate) { conditions.push("DATE(a.record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilter = Account.buildUserFilterSQL(userId, account, "a"); + if (userFilter.clause) { + conditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY p.account_id, p.id, p.name, parent.name, s.name ORDER BY total DESC"; @@ -148,7 +155,7 @@ function get_projects_spent_hours(account, startDate, endDate) { * return format {: } */ -function get_tasks_spent_hours(account, startDate, endDate) { +function get_tasks_spent_hours(account, startDate, endDate, userId) { var task_details = {}; try { @@ -186,6 +193,13 @@ function get_tasks_spent_hours(account, startDate, endDate) { conditions.push("DATE(a.record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterTask = Account.buildUserFilterSQL(userId, account, "a"); + if (userFilterTask.clause) { + conditions.push(userFilterTask.clause); + params = params.concat(userFilterTask.params); + } + } query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY a.task_id, t.name ORDER BY total DESC"; diff --git a/models/accounts.js b/models/accounts.js index 257bbc65..5e22304f 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -1,6 +1,6 @@ .import "database.js" as DBCommon -.import "logger.js" as Logger -.import QtQuick.LocalStorage 2.7 as Sql + .import "logger.js" as Logger + .import QtQuick.LocalStorage 2.7 as Sql /** * Retrieves the list of all user accounts from the local SQLite database. @@ -191,20 +191,20 @@ function getUsers(accountId) { try { var db = Sql.LocalStorage.openDatabaseSync( - DBCommon.NAME, - DBCommon.VERSION, - DBCommon.DISPLAY_NAME, - DBCommon.SIZE - ); + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); db.transaction(function (tx) { var result = tx.executeSql( - "SELECT u.id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, u.odoo_record_id, u.account_id, a.name as account_name " + - "FROM res_users_app u LEFT JOIN users a ON u.account_id = a.id " + - "WHERE u.account_id = ? AND (u.active IS NULL OR u.active = 1) AND u.name IS NOT NULL AND TRIM(u.name) != '' " + - "ORDER BY u.name COLLATE NOCASE ASC", - [accountId] - ); + "SELECT u.id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, u.odoo_record_id, u.account_id, a.name as account_name " + + "FROM res_users_app u LEFT JOIN users a ON u.account_id = a.id " + + "WHERE u.account_id = ? AND (u.active IS NULL OR u.active = 1) AND u.name IS NOT NULL AND TRIM(u.name) != '' " + + "ORDER BY u.name COLLATE NOCASE ASC", + [accountId] + ); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -238,9 +238,9 @@ function fetchParsedSyncLog(accountId) { db.transaction(function (tx) { var rs = tx.executeSql( - "SELECT timestamp, message FROM sync_report WHERE account_id = ? ORDER BY timestamp DESC", - [accountId] - ); + "SELECT timestamp, message FROM sync_report WHERE account_id = ? ORDER BY timestamp DESC", + [accountId] + ); for (var i = 0; i < rs.rows.length; i++) { var entry = rs.rows.item(i); @@ -284,18 +284,18 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap message: "", duplicateType: null }; - + try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - + db.transaction(function (tx) { - - + + const nameCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE LOWER(name) = LOWER(?)', [name] ); - + if (nameCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate account name found (case-insensitive): " + name); result.duplicateFound = true; @@ -303,13 +303,13 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap result.message = "An account with this name already exists."; return; } - - + + const connectionCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE link = ? AND database = ? AND username = ? COLLATE BINARY', [link, database, username] ); - + if (connectionCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate connection found for: " + link + "/" + database + "/" + username); result.duplicateFound = true; @@ -317,24 +317,24 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap result.message = "An account with this server connection already exists."; return; } - - + + const apiKeyToStore = (selectedConnectWithId === 1) ? apikey : ''; tx.executeSql( 'INSERT INTO users (name, link, database, username, connectwith_id, api_key) VALUES (?, ?, ?, ?, ?, ?)', [name, link, database, username, selectedConnectWithId, apiKeyToStore] ); - + DBCommon.log("New user account created successfully: " + name); result.message = "Account created successfully."; }); - + } catch (e) { DBCommon.logException("createAccount", e); result.duplicateFound = true; result.message = "Error creating account: " + e.message; } - + return result; } @@ -356,57 +356,57 @@ function updateAccount(accountId, name, link, database, username, selectedConnec message: "", duplicateType: null }; - + try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - + db.transaction(function (tx) { // Check for duplicate account name (excluding current account) const nameCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, accountId] ); - + if (nameCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate account name found (case-insensitive): " + name); result.duplicateType = "name"; result.message = "An account with this name already exists."; return; } - + // Check for duplicate connection (excluding current account) const connectionCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE link = ? AND database = ? AND username = ? COLLATE BINARY AND id != ?', [link, database, username, accountId] ); - + if (connectionCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate connection found for: " + link + "/" + database + "/" + username); result.duplicateType = "connection"; result.message = "An account with this server connection already exists."; return; } - + // Update the account const apiKeyToStore = (selectedConnectWithId === 1) ? apikey : ''; tx.executeSql( 'UPDATE users SET name = ?, link = ?, database = ?, username = ?, connectwith_id = ?, api_key = ? WHERE id = ?', [name, link, database, username, selectedConnectWithId, apiKeyToStore, accountId] ); - + DBCommon.log("User account updated successfully: " + name); result.success = true; result.message = "Account updated successfully."; }); - + } catch (e) { DBCommon.logException("updateAccount", e); result.message = "Error updating account: " + e.message; } - + return result; } - + /** * Deletes a user account and all related records from associated tables in the local SQLite database. * @@ -426,23 +426,23 @@ function deleteAccountAndRelatedData(userId) { db.transaction(function (tx) { const tables = [ - "sync_report", - "project_project_app", - "project_task_app", - "account_analytic_line_app", - "res_users_app", - "mail_activity_type_app", - "ir_model_app", - "mail_activity_app", - "ir_attachment_app", - "project_task_assignee_app", - "project_update_app", - "project_task_type_app", - "project_project_stage_app", - "attachment_download_app", - "form_drafts", - "notification" - ]; + "sync_report", + "project_project_app", + "project_task_app", + "account_analytic_line_app", + "res_users_app", + "mail_activity_type_app", + "ir_model_app", + "mail_activity_app", + "ir_attachment_app", + "project_task_assignee_app", + "project_update_app", + "project_task_type_app", + "project_project_stage_app", + "attachment_download_app", + "form_drafts", + "notification" + ]; var numericUserId = parseInt(userId, 10); for (let i = 0; i < tables.length; i++) { @@ -515,7 +515,7 @@ function getCurrentUserOdooId(accountId) { DBCommon.log(`Found username: ${username}, now checking res_users_app`); - const userResult = tx.executeSql("SELECT odoo_record_id FROM res_users_app WHERE login = ? AND account_id = ?", [username, accountId]); + const userResult = tx.executeSql("SELECT odoo_record_id FROM res_users_app WHERE (login = ? OR email = ? OR work_email = ?) AND account_id = ? LIMIT 1", [username, username, username, accountId]); if (userResult.rows.length > 0) { odooId = userResult.rows.item(0).odoo_record_id; @@ -648,6 +648,7 @@ function getAccountName(accountId) { * * @function getUserNameByOdooId * @param {number} odoo_record_id - The user ID from Odoo (remote system). + * @param {number} [accountId=-1] - The account ID to scope the lookup. * @returns {string} - The user's name if found; otherwise, an empty string. * * @description @@ -656,15 +657,24 @@ function getAccountName(accountId) { * If a match is found, extracts and returns the `name` field. * Logs any exceptions using `DBCommon.logException()` to ensure safe failure handling. */ -function getUserNameByOdooId(odoo_record_id) { +function getUserNameByOdooId(odoo_record_id, accountId) { var userName = ""; try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1"; - var result = tx.executeSql(query, [odoo_record_id, odoo_record_id]); + var numAccId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + var query = ""; + var params = []; + if (numAccId !== -1) { + query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1"; + params = [odoo_record_id, odoo_record_id, numAccId]; + } else { + query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1"; + params = [odoo_record_id, odoo_record_id]; + } + var result = tx.executeSql(query, params); if (result.rows.length > 0) { userName = result.rows.item(0).name; @@ -678,6 +688,952 @@ function getUserNameByOdooId(odoo_record_id) { return userName; } +/** + * Combines user objects that share the same email or login across different accounts. + * + * @param {Array} userList - The list of user objects. + * @param {Object} [tx] - Active SQLite transaction. + * @returns {Array} Combined user list with duplicate accounts merged. + */ +function combineUsersByEmail(userList, tx) { + if (!userList || userList.length <= 1) { + return userList || []; + } + + var groups = {}; + var groupOrder = []; + + for (var i = 0; i < userList.length; i++) { + var u = userList[i]; + var email = (u.email || u.work_email || "").trim().toLowerCase(); + var login = (u.login || "").trim().toLowerCase(); + + var key = ""; + if (email !== "") { + key = "email:" + email; + } else if (login !== "" && login.indexOf("@") !== -1) { + key = "email:" + login; + } else { + var accId = (u.accountId !== undefined && u.accountId !== null) ? u.accountId : + ((u.account_id !== undefined && u.account_id !== null) ? u.account_id : "0"); + key = "account:" + accId + ":id:" + u.id; + } + + if (!groups[key]) { + groups[key] = { + items: [u], + emails: email !== "" ? [email] : [], + logins: login !== "" ? [login] : [], + allUserIds: [u.id], + allAccountIds: (u.accountId !== undefined && u.accountId !== null) ? [u.accountId] : [] + }; + groupOrder.push(key); + } else { + var g = groups[key]; + g.items.push(u); + if (g.allUserIds.indexOf(u.id) === -1) { + g.allUserIds.push(u.id); + } + if (u.accountId !== undefined && u.accountId !== null && g.allAccountIds.indexOf(u.accountId) === -1) { + g.allAccountIds.push(u.accountId); + } + if (email !== "" && g.emails.indexOf(email) === -1) { + g.emails.push(email); + } + if (login !== "" && g.logins.indexOf(login) === -1) { + g.logins.push(login); + } + } + } + + var combined = []; + for (var j = 0; j < groupOrder.length; j++) { + var grp = groups[groupOrder[j]]; + var items = grp.items; + + // Pick best name: longest non-placeholder name + var bestName = items[0].name || ""; + var bestAvatar = items[0].avatar || ""; + for (var k = 0; k < items.length; k++) { + var candName = (items[k].name || "").trim(); + if (candName !== "" && !/^User #\d+$/i.test(candName)) { + if (bestName === "" || /^User #\d+$/i.test(bestName) || candName.length > bestName.length) { + bestName = candName; + } + } + if (!bestAvatar && items[k].avatar) { + bestAvatar = items[k].avatar; + } + } + + // Query res_users_app for any other account/user IDs for these emails + if (tx && grp.emails.length > 0) { + try { + var emPlaceholders = grp.emails.map(function () { return "?"; }).join(","); + var qRes = tx.executeSql( + "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE (LOWER(TRIM(email)) IN (" + emPlaceholders + ") OR LOWER(TRIM(work_email)) IN (" + emPlaceholders + "))", + grp.emails.concat(grp.emails) + ); + for (var m = 0; m < qRes.rows.length; m++) { + var rowUid = Number(qRes.rows.item(m).uid); + var rowAccId = Number(qRes.rows.item(m).account_id); + if (grp.allUserIds.indexOf(rowUid) === -1) { + grp.allUserIds.push(rowUid); + } + if (grp.allAccountIds.indexOf(rowAccId) === -1) { + grp.allAccountIds.push(rowAccId); + } + } + } catch (err) { + // Ignore query error, fall back to known items + } + } + + var accountNames = []; + if (tx && grp.allAccountIds && grp.allAccountIds.length > 0) { + try { + var aPl = grp.allAccountIds.map(function () { return "?"; }).join(","); + var aRes = tx.executeSql("SELECT id, name FROM users WHERE id IN (" + aPl + ")", grp.allAccountIds); + for (var an = 0; an < aRes.rows.length; an++) { + var aRow = aRes.rows.item(an); + if (aRow.name && accountNames.indexOf(aRow.name) === -1) { + accountNames.push(aRow.name); + } + } + } catch (aErr) { + // Ignore query error + } + } + + var primaryItem = items[0]; + combined.push({ + id: primaryItem.id, + name: bestName || primaryItem.name || ("User #" + primaryItem.id), + login: primaryItem.login || (grp.logins.length > 0 ? grp.logins[0] : ""), + email: primaryItem.email || (grp.emails.length > 0 ? grp.emails[0] : ""), + avatar: bestAvatar, + accountId: primaryItem.accountId, + account_id: primaryItem.accountId, + account_names: accountNames, + account_name: accountNames.join(", "), + allUserIds: grp.allUserIds, + allAccountIds: grp.allAccountIds + }); + } + + return combined; +} + +/** + * Resolves all { accountId, userId } pairs corresponding to a user filter. + * When accountId is -1 (All Accounts), users with the same email or login across + * different accounts are combined, and their corresponding user IDs in each account are returned. + * When accountId is specific (> -1), it resolves the user ID valid for that specific account. + * + * @param {number|string} userId - The selected user ID (-1 for all users). + * @param {number|string} [accountId=-1] - The active account ID (-1 for all accounts). + * @returns {Array} List of { accountId: number, userId: number } pairs. + */ +function getUserFilterAccountPairs(userId, accountId) { + var pairs = []; + if (userId === undefined || userId === null || userId === -1 || userId === "-1") { + return pairs; + } + + var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + + // Helper: query res_users_app for matching users in other accounts by email + function findMatchingUsersByEmail(tx, email, excludeAccountId) { + var matches = []; + if (!email || email.trim() === "") { + return matches; + } + var em = email.trim().toLowerCase(); + var sql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) "; + var params = [em, em]; + if (excludeAccountId !== undefined && excludeAccountId !== null && excludeAccountId >= 0) { + sql += "AND account_id != ? "; + params.push(excludeAccountId); + } + var res = tx.executeSql(sql, params); + for (var i = 0; i < res.rows.length; i++) { + matches.push({ + accountId: Number(res.rows.item(i).account_id), + userId: Number(res.rows.item(i).uid) + }); + } + return matches; + } + + // Helper: add pair if not already in list + var seenPairs = {}; + function addPair(accId, uId) { + var a = Number(accId); + var u = Number(uId); + if (isNaN(u) || u < 0) return; + var k = a + ":" + u; + if (!seenPairs[k]) { + seenPairs[k] = true; + pairs.push({ accountId: a, userId: u }); + } + } + + // Normalize single selection object as array + if (typeof userId === "object" && !Array.isArray(userId)) { + var objUid = (userId.user_id !== undefined) ? userId.user_id : (userId.id !== undefined ? userId.id : userId.userId); + if (objUid === -1 || objUid === "-1") { + return pairs; + } + userId = [userId]; + } + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + function resolveScalar(t, sId) { + if (isNaN(sId) || sId < 0) return; + + // If querying a specific account, strictly scope to that account + if (numAccountId !== -1) { + var sRes = t.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [numAccountId, sId, sId] + ); + if (sRes.rows.length > 0) { + addPair(numAccountId, Number(sRes.rows.item(0).uid)); + return; + } + + // Not directly in this account: check if sId exists in another account with an email + var otherRes = t.executeSql( + "SELECT email, work_email FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))", + [sId, sId] + ); + var matchedOther = false; + for (var o = 0; o < otherRes.rows.length; o++) { + var oEmail = (otherRes.rows.item(o).email || otherRes.rows.item(o).work_email || "").trim().toLowerCase(); + if (oEmail !== "") { + var targetMatch = t.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) LIMIT 1", + [numAccountId, oEmail, oEmail] + ); + if (targetMatch.rows.length > 0) { + addPair(numAccountId, Number(targetMatch.rows.item(0).uid)); + matchedOther = true; + break; + } + } + } + if (!matchedOther) { + addPair(numAccountId, sId); + } + return; + } + + // Querying All Accounts (-1): + // Find res_users_app records with this ID + var allRes = t.executeSql( + "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid, email, work_email " + + "FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))", + [sId, sId] + ); + + if (allRes.rows.length === 0) { + addPair(-1, sId); + return; + } + + if (allRes.rows.length === 1) { + var singleRow = allRes.rows.item(0); + var singleAcc = Number(singleRow.account_id); + var singleUid = Number(singleRow.uid); + addPair(singleAcc, singleUid); + + var singleEmail = (singleRow.email || singleRow.work_email || "").trim().toLowerCase(); + if (singleEmail !== "") { + var singleMatches = findMatchingUsersByEmail(t, singleEmail, singleAcc); + for (var sm = 0; sm < singleMatches.length; sm++) { + addPair(singleMatches[sm].accountId, singleMatches[sm].userId); + } + } + return; + } + + // Multiple accounts have a user with this ID number (e.g. ID 2 or 6 on different servers) + // Check if they all share the exact same non-empty email + var distinctEmails = []; + for (var r = 0; r < allRes.rows.length; r++) { + var rEmail = (allRes.rows.item(r).email || allRes.rows.item(r).work_email || "").trim().toLowerCase(); + if (rEmail !== "" && distinctEmails.indexOf(rEmail) === -1) { + distinctEmails.push(rEmail); + } + } + + if (distinctEmails.length === 1) { + var allShareEmail = true; + for (var rr = 0; rr < allRes.rows.length; rr++) { + var rrEmail = (allRes.rows.item(rr).email || allRes.rows.item(rr).work_email || "").trim().toLowerCase(); + if (rrEmail !== distinctEmails[0]) { + allShareEmail = false; + break; + } + } + + if (allShareEmail) { + for (var m = 0; m < allRes.rows.length; m++) { + addPair(Number(allRes.rows.item(m).account_id), Number(allRes.rows.item(m).uid)); + } + var emailMatches = findMatchingUsersByEmail(t, distinctEmails[0], -1); + for (var emIdx = 0; emIdx < emailMatches.length; emIdx++) { + addPair(emailMatches[emIdx].accountId, emailMatches[emIdx].userId); + } + return; + } + } + + // Different users sharing the same ID number on different servers! + // DO NOT mix them up! Scope to the default account (or first record). + var defAccId = -1; + var defRes = t.executeSql("SELECT id FROM users WHERE is_default = 1 LIMIT 1"); + if (defRes.rows.length > 0) { + defAccId = Number(defRes.rows.item(0).id); + } + + var chosenRow = null; + for (var c = 0; c < allRes.rows.length; c++) { + if (Number(allRes.rows.item(c).account_id) === defAccId) { + chosenRow = allRes.rows.item(c); + break; + } + } + if (!chosenRow) { + chosenRow = allRes.rows.item(0); + } + + var chosenAcc = Number(chosenRow.account_id); + var chosenUid = Number(chosenRow.uid); + addPair(chosenAcc, chosenUid); + + var chosenEmail = (chosenRow.email || chosenRow.work_email || "").trim().toLowerCase(); + if (chosenEmail !== "") { + var chosenMatches = findMatchingUsersByEmail(t, chosenEmail, chosenAcc); + for (var cm = 0; cm < chosenMatches.length; cm++) { + addPair(chosenMatches[cm].accountId, chosenMatches[cm].userId); + } + } + } + + // Handle Array of users/selections + if (Array.isArray(userId)) { + if (userId.length === 0) { + return; + } + + for (var a = 0; a < userId.length; a++) { + var item = userId[a]; + if (item === undefined || item === null || item === -1 || item === "-1") { + continue; + } + + if (typeof item === "object") { + var itemUid = (item.user_id !== undefined) ? item.user_id : (item.id !== undefined ? item.id : item.userId); + var itemAccId = (item.account_id !== undefined && item.account_id !== null) ? item.account_id : + ((item.accountId !== undefined && item.accountId !== null) ? item.accountId : -1); + + if (itemUid === undefined || itemUid === null || itemUid === -1 || itemUid === "-1") { + continue; + } + + // Case 1: Pre-combined user with allUserIds and allAccountIds + if (item.allUserIds && item.allAccountIds && item.allUserIds.length > 0) { + for (var uIdx = 0; uIdx < item.allUserIds.length; uIdx++) { + var uAcc = Number(item.allAccountIds[uIdx]); + var uVal = Number(item.allUserIds[uIdx]); + if (numAccountId === -1 || numAccountId === uAcc) { + addPair(uAcc, uVal); + } + } + continue; + } + + var nItemUid = Number(itemUid); + var nItemAccId = (itemAccId !== undefined && itemAccId !== null) ? Number(itemAccId) : -1; + + // Case 2: Object with known account_id + if (nItemAccId >= 0) { + if (numAccountId !== -1) { + if (numAccountId === nItemAccId) { + addPair(nItemAccId, nItemUid); + } else { + // Target account is different from item's account: + // Match in target account by email only + var uRes = tx.executeSql( + "SELECT email, work_email FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [nItemAccId, nItemUid, nItemUid] + ); + if (uRes.rows.length > 0) { + var emSingle = (uRes.rows.item(0).email || uRes.rows.item(0).work_email || "").trim().toLowerCase(); + if (emSingle !== "") { + var targetMatch = tx.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) LIMIT 1", + [numAccountId, emSingle, emSingle] + ); + if (targetMatch.rows.length > 0) { + addPair(numAccountId, Number(targetMatch.rows.item(0).uid)); + } + } + } + } + } else { + // All accounts: add this user, and match other accounts by email ONLY + addPair(nItemAccId, nItemUid); + var uResAll = tx.executeSql( + "SELECT email, work_email FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [nItemAccId, nItemUid, nItemUid] + ); + if (uResAll.rows.length > 0) { + var emAll = (uResAll.rows.item(0).email || uResAll.rows.item(0).work_email || "").trim().toLowerCase(); + if (emAll !== "") { + var otherMatches = findMatchingUsersByEmail(tx, emAll, nItemAccId); + for (var om = 0; om < otherMatches.length; om++) { + addPair(otherMatches[om].accountId, otherMatches[om].userId); + } + } + } + } + continue; + } + + // Case 3: Object without known account_id -> resolve as scalar ID + resolveScalar(tx, nItemUid); + } else { + // Scalar item in array + resolveScalar(tx, Number(item)); + } + } + return; + } + + // Direct scalar userId + var numScalarId = Number(userId); + if (!isNaN(numScalarId) && numScalarId >= 0) { + resolveScalar(tx, numScalarId); + } + }); + } catch (e) { + DBCommon.logException("getUserFilterAccountPairs", e); + } + + if (pairs.length === 0 && numAccountId !== -1) { + var fallbackUid = Number(userId); + if (!isNaN(fallbackUid) && fallbackUid >= 0) { + pairs.push({ accountId: numAccountId, userId: fallbackUid }); + } + } + + return pairs; +} + +/** + * Builds a SQL WHERE clause fragment and parameters array for filtering timesheets by user. + * Supports scalar user ID (-1 for all users), array of user IDs, or array of selection objects. + * + * @param {number|string|Array} userId - The user ID or array of IDs/selection objects. + * @param {number|string} [accountId=-1] - The active account ID (-1 for all accounts). + * @param {string} [tableAlias=""] - Optional table alias (e.g. "aal" or "a"). + * @returns {{ clause: string, params: Array }} + */ +function buildUserFilterSQL(userId, accountId, tableAlias) { + if (userId === undefined || userId === null || userId === -1 || userId === "-1") { + return { clause: "", params: [] }; + } + if (typeof userId === "object" && !Array.isArray(userId)) { + var singleObjUid = (userId.user_id !== undefined) ? userId.user_id : userId.userId; + if (singleObjUid === -1 || singleObjUid === "-1") { + return { clause: "", params: [] }; + } + userId = [userId]; + } + if (Array.isArray(userId)) { + if (userId.length === 0) { + return { clause: "", params: [] }; + } + if (userId.length === 1 && (userId[0] === -1 || userId[0] === "-1" || (typeof userId[0] === "object" && (userId[0].user_id === -1 || userId[0].userId === -1)))) { + return { clause: "", params: [] }; + } + } + + var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + var pairs = getUserFilterAccountPairs(userId, accountId); + var prefix = tableAlias ? (tableAlias + ".") : ""; + + if (pairs.length === 0) { + return { clause: "", params: [] }; + } + + if (numAccountId !== -1) { + var userIdsInAccount = []; + for (var p = 0; p < pairs.length; p++) { + if (userIdsInAccount.indexOf(pairs[p].userId) === -1) { + userIdsInAccount.push(pairs[p].userId); + } + } + if (userIdsInAccount.length === 0) { + return { clause: "", params: [] }; + } + if (userIdsInAccount.length === 1) { + return { clause: prefix + "user_id = ?", params: [userIdsInAccount[0]] }; + } + var placeholders = userIdsInAccount.map(function () { return "?"; }).join(", "); + return { + clause: prefix + "user_id IN (" + placeholders + ")", + params: userIdsInAccount + }; + } + + if (pairs.length === 1) { + if (pairs[0].accountId >= 0) { + return { + clause: "(" + prefix + "account_id = ? AND " + prefix + "user_id = ?)", + params: [pairs[0].accountId, pairs[0].userId] + }; + } else { + return { clause: prefix + "user_id = ?", params: [pairs[0].userId] }; + } + } + + var byAccount = {}; + var accList = []; + for (var i = 0; i < pairs.length; i++) { + var acct = pairs[i].accountId; + var u = pairs[i].userId; + if (!byAccount[acct]) { + byAccount[acct] = []; + accList.push(acct); + } + if (byAccount[acct].indexOf(u) === -1) { + byAccount[acct].push(u); + } + } + + var clauses = []; + var params = []; + for (var aIdx = 0; aIdx < accList.length; aIdx++) { + var aId = accList[aIdx]; + var uIds = byAccount[aId]; + if (aId >= 0) { + if (uIds.length === 1) { + clauses.push("(" + prefix + "account_id = ? AND " + prefix + "user_id = ?)"); + params.push(aId); + params.push(uIds[0]); + } else { + var inPlaceholders = uIds.map(function () { return "?"; }).join(", "); + clauses.push("(" + prefix + "account_id = ? AND " + prefix + "user_id IN (" + inPlaceholders + "))"); + params.push(aId); + params = params.concat(uIds); + } + } else { + if (uIds.length === 1) { + clauses.push(prefix + "user_id = ?"); + params.push(uIds[0]); + } else { + var inPl = uIds.map(function () { return "?"; }).join(", "); + clauses.push(prefix + "user_id IN (" + inPl + ")"); + params = params.concat(uIds); + } + } + } + + return { + clause: "(" + clauses.join(" OR ") + ")", + params: params + }; +} + +/** + * Retrieves active users available for filtering in the dashboard for a given account. + * When accountId is -1 (All Accounts), users with the same email or login across + * different accounts are combined into a single entry with combined metrics. + * + * @param {number} accountId - The account ID (-1 for all accounts). + * @returns {Array} List of users: [{ id, name, login, email, avatar, accountId, allUserIds }] + */ +function getDashboardFilterUsers(accountId) { + var users = []; + var seenIds = {}; + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + // Local Account (account_id = 0) + if (accountId === 0 || accountId === "0") { + users.push({ + id: 1, + name: "Local User", + login: "local_user", + email: "", + avatar: "", + accountId: 0, + allUserIds: [1] + }); + return; + } + + // 1. Find distinct user IDs that have timesheet records in the local database + var tsQuery = "SELECT DISTINCT account_id, user_id FROM account_analytic_line_app WHERE (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0 "; + var tsParams = []; + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + tsQuery += "AND account_id = ? "; + tsParams.push(accountId); + } + + var tsResult = tx.executeSql(tsQuery, tsParams); + var localUsers = []; + for (var i = 0; i < tsResult.rows.length; i++) { + var rowItem = tsResult.rows.item(i); + var uKey = rowItem.account_id + ":" + rowItem.user_id; + if (!seenIds[uKey]) { + seenIds[uKey] = true; + localUsers.push({ accountId: rowItem.account_id, userId: rowItem.user_id }); + } + } + + // 2. Ensure current logged-in user for this account is included ("including own name") + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + var loggedInUid = getCurrentUserOdooId(accountId); + var lKey = accountId + ":" + loggedInUid; + if (loggedInUid && loggedInUid > 0 && !seenIds[lKey]) { + seenIds[lKey] = true; + localUsers.push({ accountId: accountId, userId: loggedInUid }); + } + } else { + // For all accounts, include logged in user of every active account + var accRes = tx.executeSql("SELECT id FROM users WHERE id > 0"); + for (var a = 0; a < accRes.rows.length; a++) { + var instAccId = accRes.rows.item(a).id; + var instLoggedInUid = getCurrentUserOdooId(instAccId); + var instKey = instAccId + ":" + instLoggedInUid; + if (instLoggedInUid && instLoggedInUid > 0 && !seenIds[instKey]) { + seenIds[instKey] = true; + localUsers.push({ accountId: instAccId, userId: instLoggedInUid }); + } + } + } + + // 3. If user records were found from timesheets or logged-in user, enrich from res_users_app + if (localUsers.length > 0) { + for (var j = 0; j < localUsers.length; j++) { + var target = localUsers[j]; + var uQuery = "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS user_id, " + + "name, login, email, work_email, avatar_128, account_id " + + "FROM res_users_app " + + "WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) "; + var uParams = [target.userId, target.userId]; + if (target.accountId !== undefined && target.accountId !== null && target.accountId !== -1) { + uQuery += "AND account_id = ? "; + uParams.push(target.accountId); + } + uQuery += "LIMIT 1"; + + var uRes = tx.executeSql(uQuery, uParams); + if (uRes.rows.length > 0) { + var row = uRes.rows.item(0); + var accIdVal = row.account_id !== undefined ? row.account_id : target.accountId; + users.push({ + id: target.userId, + name: row.name || row.login || getUserNameByOdooId(target.userId, target.accountId) || ("User #" + target.userId), + login: row.login || "", + email: row.email || row.work_email || "", + avatar: row.avatar_128 || "", + accountId: accIdVal, + account_id: accIdVal, + allUserIds: [target.userId] + }); + } else { + users.push({ + id: target.userId, + name: getUserNameByOdooId(target.userId, target.accountId) || ("User #" + target.userId), + login: "", + email: "", + avatar: "", + accountId: target.accountId, + account_id: target.accountId, + allUserIds: [target.userId] + }); + } + } + } else { + // 4. Fallback: If no timesheet records exist yet, fall back to active users from res_users_app + var fallbackQuery = "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS user_id, " + + "name, login, email, work_email, avatar_128, account_id " + + "FROM res_users_app " + + "WHERE (active = 1 OR active IS NULL) "; + var fallbackParams = []; + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + fallbackQuery += "AND account_id = ? "; + fallbackParams.push(accountId); + } + fallbackQuery += "ORDER BY name COLLATE NOCASE ASC"; + + var fallbackRes = tx.executeSql(fallbackQuery, fallbackParams); + for (var k = 0; k < fallbackRes.rows.length; k++) { + var fRow = fallbackRes.rows.item(k); + var fUid = fRow.user_id; + var fbKey = fRow.account_id + ":" + fUid; + if (!seenIds[fbKey]) { + seenIds[fbKey] = true; + users.push({ + id: fUid, + name: fRow.name || fRow.login || ("User #" + fUid), + login: fRow.login || "", + email: fRow.email || fRow.work_email || "", + avatar: fRow.avatar_128 || "", + accountId: fRow.account_id, + account_id: fRow.account_id, + allUserIds: [fUid] + }); + } + } + } + + // 5. If all accounts (-1), combine users by email across accounts + if (accountId === -1 || accountId === "-1" || accountId === undefined || accountId === null) { + users = combineUsersByEmail(users, tx); + } + + // Sort alphabetically by name + users.sort(function (a, b) { + return (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }); + }); + }); + } catch (e) { + DBCommon.logException("getDashboardFilterUsers", e); + } + + return users; +} + +/** + * Resolves the default user to display on the dashboard for a given account. + * By default, the dashboard must display the currently logged-in user's personal metrics upon initial load, + * regardless of access level (standard employee, manager, or admin). + * Aggregated team data ("All Users") is only displayed when explicitly selected from the filter dropdown. + * + * @param {number} accountId - The account ID. + * @param {Array} [availableUsers] - Optional pre-fetched users list. + * @returns {Object|null} The default user object ({ id, name }), or null if none can be resolved. + */ +function getDashboardSingleUserDefault(accountId, availableUsers) { + if (!availableUsers) { + availableUsers = getDashboardFilterUsers(accountId); + } + + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId, 10) : -1; + + // Local Account (0) + if (parsedAccountId === 0) { + if (availableUsers && availableUsers.length > 0) { + return availableUsers[0]; + } + return { id: 1, name: "Local User" }; + } + + // Instance Account (> 0): Always default to currently logged-in user + if (parsedAccountId > 0) { + var loggedInUid = getCurrentUserOdooId(parsedAccountId); + if (loggedInUid && loggedInUid > 0) { + if (availableUsers && availableUsers.length > 0) { + for (var i = 0; i < availableUsers.length; i++) { + if (availableUsers[i].id === loggedInUid) { + return availableUsers[i]; + } + } + } + var userName = getUserNameByOdooId(loggedInUid, parsedAccountId); + return { + id: loggedInUid, + name: userName || ("User #" + loggedInUid) + }; + } + } + + // All Accounts (-1): Try to default to currently logged-in user of default account or any active account + if (parsedAccountId === -1) { + var targetUid = -1; + var sourceAccId = -1; + var defAccId = getDefaultRemoteAccountId(); + if (defAccId > 0) { + targetUid = getCurrentUserOdooId(defAccId); + sourceAccId = defAccId; + } + if (targetUid <= 0) { + var allAccs = getActiveAccounts(); + for (var a = 0; a < allAccs.length; a++) { + var aUid = getCurrentUserOdooId(allAccs[a].id); + if (aUid > 0) { + targetUid = aUid; + sourceAccId = allAccs[a].id; + break; + } + } + } + if (targetUid > 0 && availableUsers && availableUsers.length > 0) { + for (var m = 0; m < availableUsers.length; m++) { + var cand = availableUsers[m]; + if (cand.allUserIds && cand.allAccountIds) { + for (var u = 0; u < cand.allUserIds.length; u++) { + if (cand.allUserIds[u] === targetUid && (sourceAccId <= 0 || cand.allAccountIds[u] === sourceAccId)) { + return cand; + } + } + } else if (cand.id === targetUid) { + var cAcc = (cand.accountId !== undefined) ? cand.accountId : cand.account_id; + if (sourceAccId <= 0 || cAcc === undefined || cAcc === sourceAccId) { + return cand; + } + } + } + } + } + + // Fallback if exactly one user's data exists + if (availableUsers && availableUsers.length === 1) { + return availableUsers[0]; + } + + return null; +} + +/** + * Checks whether the specified account grants manager or admin access. + * + * An account grants manager or admin access if: + * 1. The account is an admin account (username or login is 'admin', or Odoo UID is 2). + * 2. The user is a Project Manager on at least one active project in the account. + * 3. The user has Timesheet/Team Manager access (timesheets belonging to other employees exist in the account). + * + * Standard access accounts (e.g. standard employees with no PM/admin rights, + * and local offline accounts) return false. + * For All Accounts (-1), returns true if any configured remote account grants manager/admin access. + * + * @param {number} accountId - The account ID (-1 for all accounts, 0 for local account). + * @param {Object} [transaction] - Optional SQLite transaction to reuse. + * @returns {boolean} True if manager or admin access is granted, false otherwise. + */ +function hasManagerOrAdminAccess(accountId, transaction) { + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId, 10) : -1; + if (parsedAccountId === 0) { + return false; + } + + if (transaction) { + return _checkManagerOrAdminAccessTx(parsedAccountId, transaction); + } + + var hasAccess = false; + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + hasAccess = _checkManagerOrAdminAccessTx(parsedAccountId, tx); + }); + } catch (e) { + DBCommon.logException("hasManagerOrAdminAccess", e); + } + return hasAccess; +} + +function _checkManagerOrAdminAccessTx(accountId, tx) { + if (accountId === 0) { + return false; + } + + // All Accounts (-1): Check if any active remote account grants manager/admin access + if (accountId === -1) { + var allAccs = tx.executeSql("SELECT id FROM users WHERE id > 0"); + for (var a = 0; a < allAccs.rows.length; a++) { + var instId = allAccs.rows.item(a).id; + if (_checkManagerOrAdminAccessTx(instId, tx)) { + return true; + } + } + return false; + } + + // 1. Check if username on the account is 'admin' + var accRes = tx.executeSql("SELECT username FROM users WHERE id = ?", [accountId]); + var username = ""; + if (accRes.rows.length > 0) { + username = accRes.rows.item(0).username || ""; + if (username.toLowerCase() === "admin") { + return true; + } + } + + // 2. Resolve current user's Odoo UID and check if user login is 'admin' or UID is 2 + var currentUid = null; + if (username) { + var uRes = tx.executeSql( + "SELECT odoo_record_id, login FROM res_users_app WHERE (login = ? OR email = ? OR work_email = ?) AND account_id = ? LIMIT 1", + [username, username, username, accountId] + ); + if (uRes.rows.length > 0) { + var uRow = uRes.rows.item(0); + currentUid = uRow.odoo_record_id; + var uLogin = uRow.login || ""; + if (uLogin.toLowerCase() === "admin") { + return true; + } + } + } + + if (!currentUid) { + currentUid = getCurrentUserOdooId(accountId); + } + + if (currentUid === 2) { + return true; + } + + // 3. Check if user is a Project Manager on any active project for this account + if (currentUid && currentUid > 0) { + var pmRes = tx.executeSql( + "SELECT COUNT(*) AS cnt FROM project_project_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND (user_id = ? OR user_id = CAST(? AS TEXT))", + [accountId, currentUid, currentUid] + ); + if (pmRes.rows.length > 0 && pmRes.rows.item(0).cnt > 0) { + return true; + } + } + + // 4. Check if user has Timesheet/Team Manager access (timesheets of other employees exist) + if (currentUid && currentUid > 0) { + var teamRes = tx.executeSql( + "SELECT COUNT(*) AS cnt FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0 AND user_id != ?", + [accountId, currentUid] + ); + if (teamRes.rows.length > 0 && teamRes.rows.item(0).cnt > 0) { + return true; + } + } else { + var tsRes = tx.executeSql( + "SELECT COUNT(DISTINCT user_id) AS cnt FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0", + [accountId] + ); + if (tsRes.rows.length > 0 && tsRes.rows.item(0).cnt > 1) { + return true; + } + } + + return false; +} + /** * Retrieves the Odoo model ID (`odoo_record_id`) from the local SQLite database * based on the given account ID and technical model name. @@ -707,7 +1663,7 @@ function getOdooModelId(accountId, technicalName) { if (rs.rows.length > 0) { odooRecordId = rs.rows.item(0).odoo_record_id; - // console.log("Found Odoo Model ID:", odooRecordId); + // console.log("Found Odoo Model ID:", odooRecordId); } else { Logger.warn("Accounts", "No matching ir.model found for:", technicalName) } @@ -722,7 +1678,7 @@ function getOdooModelId(accountId, technicalName) { function clearDefaultAccount() { try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - db.transaction(function(tx) { + db.transaction(function (tx) { tx.executeSql("UPDATE users SET is_default = 0"); }); } catch (e) { @@ -749,7 +1705,7 @@ function updateAccountSyncSettings(accountId, intervalMinutes, direction, enable ); }); DBCommon.log("Updated sync settings for account " + accountId + - ": interval=" + intervalMinutes + ", direction=" + direction + ", enabled=" + enabled); + ": interval=" + intervalMinutes + ", direction=" + direction + ", enabled=" + enabled); } catch (e) { DBCommon.logException("updateAccountSyncSettings", e); } diff --git a/models/constants.js b/models/constants.js index bddbff49..42f7468d 100644 --- a/models/constants.js +++ b/models/constants.js @@ -2,7 +2,7 @@ .pragma library -var version="1.3.3" +var version="1.3.4" //fonts var FontSizes = { diff --git a/models/global.js b/models/global.js index ceaff954..766c30ab 100644 --- a/models/global.js +++ b/models/global.js @@ -29,6 +29,23 @@ var dateRangeEndDate = "" // yyyy-MM-dd var dateRangePresetLabel = "This Month" var dateRangeInitialized = false +// Global Dashboard User Filter state +var dashboardUserId = -1; +var dashboardUserName = "All Users"; + +function setDashboardUserFilter(userId, userName) { + dashboardUserId = (userId !== undefined && userId !== null) ? userId : -1; + dashboardUserName = userName || "All Users"; +} + +function getDashboardUserFilter() { + return { + userId: dashboardUserId, + userName: dashboardUserName + }; +} + + // Functions to manage assignee filter state function setAssigneeFilter(enabled, assigneeIds) { assigneeFilterEnabled = enabled; diff --git a/models/project.js b/models/project.js index 7d33f945..4005be8b 100644 --- a/models/project.js +++ b/models/project.js @@ -1,8 +1,8 @@ .import "logger.js" as Logger .import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Account +.import "database.js" as DBCommon +.import "utils.js" as Utils +.import "accounts.js" as Account /** * Get local project ID from Odoo record ID @@ -1217,7 +1217,7 @@ function markProjectUpdateAsDeleted(updateId) { * @param {number|string} [accountId] - Optional account id to filter by. Use -1 for all accounts. * @returns {Array} - A list of objects with `project_id`, `name`, and `spentHours`. */ -function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) { +function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var resultList = []; @@ -1247,7 +1247,7 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) if (isAllAccounts) { Logger.debug("Project", " Aggregating spent hours for ALL accounts") - var sqlAll = "SELECT aal.project_id, aal.account_id, COALESCE(u.name, 'Unknown') AS account_name, " + + var sqlAll = "SELECT COALESCE(p.odoo_record_id, p.id) AS project_id, aal.account_id, COALESCE(u.name, 'Unknown') AS account_name, " + "COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + "FROM account_analytic_line_app aal " + "LEFT JOIN users u ON aal.account_id = u.id " + @@ -1272,8 +1272,15 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) sqlAll += "AND DATE(aal.record_date) <= DATE(?) "; paramsAll.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterAll = Account.buildUserFilterSQL(userId, -1, "aal"); + if (userFilterAll.clause) { + sqlAll += "AND " + userFilterAll.clause + " "; + paramsAll = paramsAll.concat(userFilterAll.params); + } + } - sqlAll += "GROUP BY aal.project_id, aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; + sqlAll += "GROUP BY aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlAll, paramsAll); @@ -1302,7 +1309,7 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) Logger.debug("Project", " Aggregating spent hours for single account:", acctNum) - var sqlSingle = "SELECT aal.project_id, COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + + var sqlSingle = "SELECT COALESCE(p.odoo_record_id, p.id) AS project_id, COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + "FROM account_analytic_line_app aal " + "JOIN project_project_app p ON (" + " (p.odoo_record_id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id) OR " + @@ -1324,8 +1331,15 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) sqlSingle += "AND DATE(aal.record_date) <= DATE(?) "; paramsSingle.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterSingle = Account.buildUserFilterSQL(userId, acctNum, "aal"); + if (userFilterSingle.clause) { + sqlSingle += "AND " + userFilterSingle.clause + " "; + paramsSingle = paramsSingle.concat(userFilterSingle.params); + } + } - sqlSingle += "GROUP BY aal.project_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; + sqlSingle += "GROUP BY p.account_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlSingle, paramsSingle); @@ -1356,9 +1370,10 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) * @param {number} accountId - Account ID, or -1 for all accounts. * @param {string} [startDate] - Optional start date (yyyy-MM-dd). * @param {string} [endDate] - Optional end date (yyyy-MM-dd). + * @param {number} [userId] - Optional user ID to filter timesheet hours by. * @returns {Array} Project summary rows for dashboard charts. */ -function getDashboardProjectTaskSummary(accountId, startDate, endDate) { +function getDashboardProjectTaskSummary(accountId, startDate, endDate, userId) { var resultList = []; try { @@ -1376,6 +1391,13 @@ function getDashboardProjectTaskSummary(accountId, startDate, endDate) { tsConditions.push("DATE(record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilter = Account.buildUserFilterSQL(userId, accountId, ""); + if (userFilter.clause) { + tsConditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } var accountWhere = ""; if (accountId !== -1 && accountId !== undefined && accountId !== null) { diff --git a/models/task.js b/models/task.js index 2915680f..7bf89a00 100644 --- a/models/task.js +++ b/models/task.js @@ -2,17 +2,17 @@ /* * MULTIPLE ASSIGNEES IMPLEMENTATION * ================================= - * - * This module now supports multiple assignees per task by storing them as comma-separated + * + * This module now supports multiple assignees per task by storing them as comma-separated * user IDs in the user_id field of the project_task_app table. - * + * * Key changes: * - Multiple assignee IDs are stored as comma-separated string in user_id field * - getTaskAssignees() parses the comma-separated IDs and returns assignee objects * - setTaskAssignees() updates task with multiple assignees * - saveOrUpdateTask() handles both single and multiple assignees * - migrateTaskAssignees() provides backward compatibility - * + * * Usage: * - For single assignee: store single ID in user_id field (backward compatible) * - For multiple assignees: store comma-separated IDs like "123,456,789" in user_id field @@ -24,6 +24,7 @@ .import "database.js" as DBCommon .import "utils.js" as Utils .import "draft_manager.js" as DraftManager + .import "accounts.js" as Accounts // Helper to handle -1/null function validId(value) { @@ -552,7 +553,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { result.hasChildren = true; result.childTasks = childTasks; - Logger.warn("Task", "Deletion blocked: Task has "+ childTasks.length + "child tasks") + Logger.warn("Task", "Deletion blocked: Task has " + childTasks.length + "child tasks") return; } @@ -566,7 +567,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { deletedTimesheetIds = markRelatedTimesheetsAsDeleted(tx, [taskId], timestamp); if (forceDelete && childTasks.length > 0) { - Logger.warn("Task", "Force delete enabled - deleted parent task with "+ childTasks.length + "children") + Logger.warn("Task", "Force delete enabled - deleted parent task with " + childTasks.length + "children") result.message = "Task '" + taskName + "' deleted (forced deletion with " + childTasks.length + " child tasks remaining)"; } else { result.message = "Task '" + taskName + "' successfully deleted"; @@ -575,7 +576,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { result.success = true; result.deletedTaskIds = [taskId]; - console.info("Task deleted successfully: "+ taskName); + console.info("Task deleted successfully: " + taskName); }); // Clean up any drafts for this deleted task and its related timesheets (outside transaction) @@ -596,7 +597,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { return result; } catch (e) { - Logger.error("Task", "Error marking task as deleted (ID "+ taskId + "): "+ e) + Logger.error("Task", "Error marking task as deleted (ID " + taskId + "): " + e) return { success: false, message: "Failed to delete task: " + e.message, @@ -1985,11 +1986,11 @@ function getFilteredTasksPaginated(filterType, searchQuery, accountId, limit, of var timeMap = {}; if (taskOdooIds.length > 0) { - var placeholders = taskOdooIds.map(function() { return "?"; }).join(","); + var placeholders = taskOdooIds.map(function () { return "?"; }).join(","); var timeQuery = "SELECT task_id, account_id, SUM(unit_amount) as total_hours " + - "FROM account_analytic_line_app " + - "WHERE task_id IN (" + placeholders + ") " + - "GROUP BY task_id, account_id"; + "FROM account_analytic_line_app " + + "WHERE task_id IN (" + placeholders + ") " + + "GROUP BY task_id, account_id"; var timeResult = tx.executeSql(timeQuery, taskOdooIds); for (var k = 0; k < timeResult.rows.length; k++) { var row = timeResult.rows.item(k); @@ -3092,7 +3093,7 @@ function setTaskPriority(taskId, priority, status) { * @param {number} accountId - The account ID * @returns {Array} A list of task objects with color and spentHours for the specified project. */ -function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) { +function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate, userId) { var taskList = []; try { @@ -3102,8 +3103,9 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) var params = [projectOdooRecordId, projectOdooRecordId, accountId]; var dateJoin = ""; var dateCondition = ""; - - if (startDate || endDate) { + var hasUserFilter = (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1"); + + if (startDate || endDate || hasUserFilter) { dateJoin = " INNER JOIN account_analytic_line_app al ON (al.task_id = t.odoo_record_id OR (t.account_id = 0 AND al.task_id = t.id)) AND al.account_id = t.account_id AND (al.status != 'deleted' OR al.status IS NULL) "; var dateFilters = []; if (startDate) { @@ -3114,6 +3116,13 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) dateFilters.push("DATE(al.record_date) <= DATE(?)"); params.push(endDate); } + if (hasUserFilter) { + var userFilterTask = Accounts.buildUserFilterSQL(userId, accountId, "al"); + if (userFilterTask.clause) { + dateFilters.push(userFilterTask.clause); + params = params.concat(userFilterTask.params); + } + } dateCondition = " AND " + dateFilters.join(" AND "); } @@ -3174,10 +3183,17 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) spentCondition += " AND DATE(record_date) <= DATE(?)"; spentParams.push(endDate); } + if (hasUserFilter) { + var userSpentFilter = Accounts.buildUserFilterSQL(userId, accountId, ""); + if (userSpentFilter.clause) { + spentCondition += " AND " + userSpentFilter.clause; + spentParams = spentParams.concat(userSpentFilter.params); + } + } var spentHoursQuery = ` - SELECT COALESCE(SUM(unit_amount), 0) as spent_hours - FROM account_analytic_line_app + SELECT COALESCE(SUM(unit_amount), 0) as spent_hours + FROM account_analytic_line_app WHERE task_id = ? AND account_id = ? AND (status != 'deleted' OR status IS NULL) ${spentCondition} `; @@ -3527,7 +3543,7 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { [accountId] ); - Logger.debug("Task", "getTaskStagesForProject: Found "+ result.rows.length + "PROJECT stages for account "+ accountId + "(before project filter)") + Logger.debug("Task", "getTaskStagesForProject: Found " + result.rows.length + "PROJECT stages for account " + accountId + "(before project filter)") // Filter stages to show only: // 1. Global stages (is_global = 1 or is_global = "1" - available to ALL projects) @@ -3566,7 +3582,7 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { if (isGlobalValue === 1 || isGlobalStr === "1") { // Global stage - available to all projects isAvailable = true; - Logger.debug("Task", "Stage '"+ row.name + "'is GLOBAL (is_global: "+ isGlobalValue + "["+ isGlobalType + "])") + Logger.debug("Task", "Stage '" + row.name + "'is GLOBAL (is_global: " + isGlobalValue + "[" + isGlobalType + "])") } else if (isGlobalStr.indexOf(",") !== -1) { // Project-specific stage - check if this project is in the comma-separated list var projectIds = isGlobalStr.split(","); @@ -3580,18 +3596,18 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { } if (isAvailable) { - Logger.debug("Task", "Stage '"+ row.name + "'is available for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "'contains project ID)") + Logger.debug("Task", "Stage '" + row.name + "'is available for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "'contains project ID)") } else { - Logger.debug("Task", "Stage '"+ row.name + "'NOT available for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "'does not contain project ID)") + Logger.debug("Task", "Stage '" + row.name + "'NOT available for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "'does not contain project ID)") } } else { // Could be a single project ID (no comma) - check if it matches if (isGlobalStr === String(projectOdooRecordId)) { isAvailable = true; - Logger.debug("Task", "Stage '"+ row.name + "'is available ONLY for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "')") + Logger.debug("Task", "Stage '" + row.name + "'is available ONLY for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "')") } else { // This stage is for a different single project - Logger.debug("Task", "Stage '"+ row.name + "'is for a DIFFERENT project (is_global: '"+ isGlobalValue + "', need: "+ projectOdooRecordId + ")") + Logger.debug("Task", "Stage '" + row.name + "'is for a DIFFERENT project (is_global: '" + isGlobalValue + "', need: " + projectOdooRecordId + ")") } } @@ -3608,10 +3624,10 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { } } - Logger.debug("Task", "getTaskStagesForProject: "+ stages.length + "stages available for project "+ projectOdooRecordId) + Logger.debug("Task", "getTaskStagesForProject: " + stages.length + "stages available for project " + projectOdooRecordId) if (stages.length === 0) { - Logger.warn("Task", "No stages available for project "+ projectOdooRecordId + "in account "+ accountId) + Logger.warn("Task", "No stages available for project " + projectOdooRecordId + "in account " + accountId) Logger.warn("Task", " This might indicate the project has no assigned stages in Odoo") } }); @@ -3979,7 +3995,7 @@ function getTasksByPersonalStagePaginated(personalStageOdooRecordId, assigneeIds foldedStageIds.push(foldResult.rows.item(f).odoo_record_id); } if (foldedStageIds.length > 0) { - var placeholders = foldedStageIds.map(function() { return "?"; }).join(","); + var placeholders = foldedStageIds.map(function () { return "?"; }).join(","); whereClauses.push("(t.state IS NULL OR t.state NOT IN (" + placeholders + "))"); for (var fi = 0; fi < foldedStageIds.length; fi++) { params.push(foldedStageIds[fi]); diff --git a/models/timesheet.js b/models/timesheet.js index 6a609e2e..2302dd11 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -1,9 +1,9 @@ .import "logger.js" as Logger .import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Accounts - .import "draft_manager.js" as DraftManager +.import "database.js" as DBCommon +.import "utils.js" as Utils +.import "accounts.js" as Accounts +.import "draft_manager.js" as DraftManager /** @@ -509,7 +509,7 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { * @param {string} status - The status filter: 'all', 'active', 'draft', etc. * @returns {Array} - A list of enriched timesheet entries for the task. */ -function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, endDate) { +function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var timesheetList = []; @@ -529,14 +529,19 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en // Build query based on status and accountId var baseQuery = "SELECT * FROM account_analytic_line_app WHERE task_id = ?"; var dateCondition = ""; - + var hasUserFilter = (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1"); + if (startDate) { dateCondition += " AND DATE(record_date) >= DATE(?)"; } if (endDate) { dateCondition += " AND DATE(record_date) <= DATE(?)"; } - + var userFilter = hasUserFilter ? Accounts.buildUserFilterSQL(userId, accountId, "") : { clause: "", params: [] }; + if (userFilter.clause) { + dateCondition += " AND " + userFilter.clause; + } + if (!status || status.toLowerCase() === "all") { if (accountId && accountId > 0) { query = baseQuery + " AND account_id = ? AND (status IS NULL OR status != 'deleted')" + dateCondition + " ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; @@ -554,9 +559,12 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en params = [taskOdooRecordId, status]; } } - + if (startDate) params.push(startDate); if (endDate) params.push(endDate); + if (userFilter.params && userFilter.params.length > 0) { + params = params.concat(userFilter.params); + } Logger.debug("Timesheet", "Executing getTimesheetsForTask query:", query, "with params:", params) var result = tx.executeSql(query, params); @@ -626,8 +634,12 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en } if (row.user_id !== undefined && row.user_id !== null) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.user_id, row.user_id]); - if (rs_user.rows.length > 0) userName = rs_user.rows.item(0).name; + var rs_user = tx.executeSql("SELECT name, login FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.user_id, row.user_id]); + if (rs_user.rows.length > 0) { + userName = rs_user.rows.item(0).name || rs_user.rows.item(0).login || ""; + } else if (row.account_id === 0) { + userName = "Local User"; + } } timesheetList.push({ diff --git a/pulse-socket b/pulse-socket deleted file mode 120000 index f67f038c..00000000 --- a/pulse-socket +++ /dev/null @@ -1 +0,0 @@ -/run/user/1000/pulse/native \ No newline at end of file diff --git a/qml/TSApp.qml b/qml/TSApp.qml index 02e8cabb..ac6e3e8a 100644 --- a/qml/TSApp.qml +++ b/qml/TSApp.qml @@ -33,6 +33,7 @@ MainView { objectName: "TS" applicationName: "ubtms" property bool init: true + property var rootApp: mainView property alias globalTimerWidget: globalWidgets.globalTimerWidget property alias modelDownloadTimerWidget: globalWidgets.modelDownloadTimerWidget property alias backend_bridge: globalWidgets.backend_bridge @@ -72,6 +73,7 @@ MainView { signal globalAccountChanged(int accountId, string accountName) signal accountDataRefreshRequested(int accountId) signal globalDateRangeChanged(int presetId, string startDate, string endDate, string presetLabel) + signal globalDashboardUserChanged(var userId, string userName) signal projectDataChanged() signal taskDataChanged() // Keep-alive heartbeat: Ensures Qt SceneGraph render thread remains active to safely process diff --git a/qml/app/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 5b5b5df6..a0afa9fd 100644 --- a/qml/app/GlobalWidgets.qml +++ b/qml/app/GlobalWidgets.qml @@ -92,7 +92,14 @@ Item { id: infobar anchors.bottom: parent.bottom anchors.left: parent.left - width: parent.width + anchors.right: parent.right + anchors.bottomMargin: (Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0) + + (modelDownloadTimerWidget.visible ? (modelDownloadTimerWidget.height + units.gu(1)) : 0) + + (globalTimerWidget.visible ? (globalTimerWidget.height + units.gu(1)) : 0) height: units.gu(10) + + Behavior on anchors.bottomMargin { + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } } } diff --git a/qml/app/pages/release_notes.txt b/qml/app/pages/release_notes.txt index c3e1443d..3d2d6b1c 100644 --- a/qml/app/pages/release_notes.txt +++ b/qml/app/pages/release_notes.txt @@ -63,7 +63,7 @@

Time Management - Alpha Draft

- +

Time Management is a native time-tracking and productivity application built exclusively for Ubuntu Touch phones. @@ -141,7 +141,7 @@


- Time Management for Ubuntu Touch • Version 1.3.3 • © 2026 CIT Services + Time Management for Ubuntu Touch • Version 1.3.4 • © 2026 CIT Services

diff --git a/qml/components/feedback/InfoBar.qml b/qml/components/feedback/InfoBar.qml index 5aad9ebd..bac861dc 100644 --- a/qml/components/feedback/InfoBar.qml +++ b/qml/components/feedback/InfoBar.qml @@ -54,10 +54,14 @@ Item { id: container anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: units.gu(10) + anchors.bottomMargin: Qt.inputMethod.visible ? units.gu(1) : units.gu(10) implicitWidth: parent.width implicitHeight: parent.height + Behavior on anchors.bottomMargin { + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } + Rectangle { anchors.fill: parent color: "#2d2d2d" @@ -67,7 +71,13 @@ Item { Text { id: message - anchors.centerIn: parent + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: units.gu(2) + anchors.rightMargin: units.gu(2) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter text: "" wrapMode: Text.WordWrap font.pixelSize: units.gu(2) diff --git a/qml/components/pickers/DateRangeHeaderFilter.qml b/qml/components/pickers/DateRangeHeaderFilter.qml index 5c874f64..51006027 100644 --- a/qml/components/pickers/DateRangeHeaderFilter.qml +++ b/qml/components/pickers/DateRangeHeaderFilter.qml @@ -46,6 +46,7 @@ Item { property bool isFiltered: presetId !== -1 property bool suppressSignal: false property bool showClearButton: true + property alias presetSelector: inlinePresetSelector signal dateRangeChanged(int presetId, string startDate, string endDate, string presetLabel) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index ab210212..e2fa2ac6 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -11,6 +11,7 @@ Item { implicitHeight: layout.implicitHeight height: layout.implicitHeight property alias labelText: presetSelector.labelText + property alias presetSelector: presetSelector property date startDate: new Date() property date endDate: new Date() signal rangeChanged(date start, date end) diff --git a/qml/components/pickers/DaySelector.qml b/qml/components/pickers/DaySelector.qml index 7e76a63d..ac6899ff 100644 --- a/qml/components/pickers/DaySelector.qml +++ b/qml/components/pickers/DaySelector.qml @@ -15,6 +15,7 @@ Item { property date customDate: selectedDate property bool showTomorrow: false property bool readOnly: false + property alias dayCombo: dayCombo signal dateChanged(date selectedDate) function formattedDate() { diff --git a/qml/components/pickers/FilterableHeaderContents.qml b/qml/components/pickers/FilterableHeaderContents.qml index dca0a569..9a97db1a 100644 --- a/qml/components/pickers/FilterableHeaderContents.qml +++ b/qml/components/pickers/FilterableHeaderContents.qml @@ -9,6 +9,7 @@ Item { property bool showDateFilter: false property string title: "" property bool showSubtitle: true + property string userLabel: "" property alias dateFilter: dateFilterItem signal dateRangeChanged() @@ -30,7 +31,7 @@ Item { } Label { - text: dateFilterItem.presetLabel + text: root.userLabel ? (dateFilterItem.presetLabel + " • " + root.userLabel) : dateFilterItem.presetLabel color: "white" fontSize: "small" opacity: 0.8 diff --git a/qml/components/selectors/AssigneeFilterMenu.qml b/qml/components/selectors/AssigneeFilterMenu.qml index faa6e908..3130ec12 100644 --- a/qml/components/selectors/AssigneeFilterMenu.qml +++ b/qml/components/selectors/AssigneeFilterMenu.qml @@ -47,14 +47,47 @@ Item { property bool expanded: false property var selectedAssigneeIds: [] property bool showAccountName: false + property bool showAllUsersOption: false property int maxMenuHeight: units.gu(50) + function isOnlyAllUsersSelected() { + if (!showAllUsersOption) return false; + if (selectedAssigneeIds.length === 0) return false; + for (var i = 0; i < selectedAssigneeIds.length; i++) { + var s = selectedAssigneeIds[i]; + var uid = (typeof s === 'object') ? s.user_id : s; + if (uid !== -1 && uid !== "-1") { + return false; + } + } + return true; + } + + function selectAllUsers() { + selectedAssigneeIds = [createSelection(-1, -1)]; + } + + function clearFilter() { + if (!showAllUsersOption) { + selectedAssigneeIds = []; + } else { + selectAllUsers(); + } + filterModel.update(); + recomputeDisplayAssigneeCache(); + expanded = false; + filterCleared(); + } + // Helper function to check if an assignee is selected (handles both old and new format) function isAssigneeSelected(userId, accountId) { + if (showAllUsersOption && userId === -1) { + return isOnlyAllUsersSelected(); + } for (var i = 0; i < selectedAssigneeIds.length; i++) { var selectedId = selectedAssigneeIds[i]; if (typeof selectedId === 'object') { - if (selectedId.user_id === userId && selectedId.account_id === accountId) { + if (selectedId.user_id === userId && (accountId === undefined || accountId === -1 || selectedId.account_id === -1 || selectedId.account_id === accountId)) { return true; } } else if (selectedId === userId) { @@ -86,6 +119,15 @@ Item { } function addSelectionIfMissing(selection) { + if (showAllUsersOption && selection.user_id !== -1) { + for (var k = selectedAssigneeIds.length - 1; k >= 0; k--) { + var cur = selectedAssigneeIds[k]; + var curUid = (typeof cur === 'object') ? cur.user_id : cur; + if (curUid === -1 || curUid === "-1") { + selectedAssigneeIds.splice(k, 1); + } + } + } if (!hasSelectedAssignee(selection)) { selectedAssigneeIds.push(createSelection(selection.user_id, selection.account_id)); } @@ -95,7 +137,7 @@ Item { for (var i = selectedAssigneeIds.length - 1; i >= 0; i--) { var existingId = selectedAssigneeIds[i]; if (typeof existingId === 'object') { - if (existingId.user_id === selection.user_id && existingId.account_id === selection.account_id) { + if (existingId.user_id === selection.user_id && (selection.account_id === undefined || selection.account_id === -1 || existingId.account_id === -1 || existingId.account_id === selection.account_id)) { selectedAssigneeIds.splice(i, 1); } } else if (existingId === selection.user_id) { @@ -166,7 +208,17 @@ Item { group.account_names.push(accountName); } - group.memberSelections.push(createSelection(assigneeId, accountId)); + if (assignee.memberSelections && assignee.memberSelections.length > 0) { + for (var ms = 0; ms < assignee.memberSelections.length; ms++) { + group.memberSelections.push(assignee.memberSelections[ms]); + } + } else if (assignee.allUserIds && assignee.allAccountIds && assignee.allUserIds.length > 1) { + for (var au = 0; au < assignee.allUserIds.length; au++) { + group.memberSelections.push(createSelection(assignee.allUserIds[au], assignee.allAccountIds[au] || accountId)); + } + } else { + group.memberSelections.push(createSelection(assigneeId, accountId)); + } } var visibleAssignees = []; @@ -199,7 +251,7 @@ Item { } visibleAssignees.sort(function (a, b) { - if (a.selected !== b.selected) { + if (!showAllUsersOption && a.selected !== b.selected) { return a.selected ? -1 : 1; } @@ -212,6 +264,26 @@ Item { return 0; }); + if (showAllUsersOption) { + var allUsersText = i18n.dtr("ubtms", "All Users"); + var isAllUsersSelected = isOnlyAllUsersSelected(); + var matchSearch = !normalizedSearch || allUsersText.toLowerCase().indexOf(normalizedSearch) >= 0; + if (matchSearch) { + visibleAssignees.unshift({ + assigneeId: -1, + name: allUsersText, + email: "", + account_name: "", + titleText: allUsersText, + showAccountChips: false, + accountNamesJson: "[]", + memberSelectionsJson: JSON.stringify([createSelection(-1, -1)]), + selected: isAllUsersSelected, + sectionLabel: isAllUsersSelected ? "selected" : "others" + }); + } + } + return visibleAssignees; } @@ -226,7 +298,7 @@ Item { var selectedCount = 0; for (var i = 0; i < displayAssignees.length; i++) { - if (displayAssignees[i].selected) { + if (displayAssignees[i].selected && displayAssignees[i].assigneeId !== -1) { selectedCount++; } } @@ -264,7 +336,7 @@ Item { Rectangle { id: menuContainer visible: expanded - width: units.gu(40) + width: Math.min(units.gu(40), parent.width - units.gu(2)) height: { // Calculate dynamic height: header + search + assignee list + buttons + margins var baseHeight = units.gu(22); // Header + search + buttons + margins @@ -337,7 +409,7 @@ Item { } Text { - text: i18n.dtr("ubtms", "Filter by Assignees") + text: showAllUsersOption ? i18n.dtr("ubtms", "Filter by Employee") : i18n.dtr("ubtms", "Filter by Assignees") font.bold: true font.pixelSize: units.gu(2.2) color: theme.palette.normal.backgroundText @@ -353,9 +425,10 @@ Item { color: theme.palette.normal.base } - // Search bar for long lists + // Search bar Row { - visible: getDisplayAssigneeCount() > 5 + id: searchRow + visible: true width: parent.width height: units.gu(4) spacing: units.gu(0.5) @@ -364,15 +437,15 @@ Item { id: searchField width: parent.width - clearSearchButton.width - parent.spacing height: parent.height - placeholderText: i18n.dtr("ubtms", "Search assignees...") + placeholderText: showAllUsersOption ? i18n.dtr("ubtms", "Search employees...") : i18n.dtr("ubtms", "Search assignees...") onAccepted: { filterModel.update(); // Handle enter key press } - // onTextChanged: { - // filterModel.update(); - // } + onTextChanged: { + filterModel.update(); + } } // Custom clear search button (needed because native clear doesn't trigger filter update) @@ -428,12 +501,20 @@ Item { } } + Text { + anchors.centerIn: parent + visible: filterModel.count === 0 + text: showAllUsersOption ? i18n.dtr("ubtms", "No employees found") : i18n.dtr("ubtms", "No assignees found") + font.pixelSize: units.gu(1.8) + color: theme.palette.normal.backgroundSecondaryText + } + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded width: units.gu(1) } - section.property: "sectionLabel" + section.property: showAllUsersOption ? "" : "sectionLabel" section.criteria: ViewSection.FullString section.delegate: Item { width: assigneeListView.width @@ -496,32 +577,54 @@ Item { } property bool hasHiddenChips: showsAccountChips && visibleChipCount < accountNames.length - width: parent.width + width: assigneeListView.width height: Math.max(units.gu(6), infoColumn.implicitHeight + contentMargin * 2) color: mouseArea.pressed ? theme.palette.selected.background : "transparent" radius: units.gu(0.5) + property int itemAssigneeId: (typeof model.assigneeId !== "undefined") ? model.assigneeId : assigneeId + property string itemMemberSelectionsJson: (typeof model.memberSelectionsJson !== "undefined") ? model.memberSelectionsJson : memberSelectionsJson + property string itemName: (typeof model.name !== "undefined") ? model.name : name + property string itemTitleText: (typeof model.titleText !== "undefined") ? model.titleText : (typeof titleText !== "undefined" ? titleText : itemName) + property string itemEmail: (typeof model.email !== "undefined" && model.email !== null) ? model.email : (typeof email !== "undefined" && email !== null ? email : "") + property bool itemSelected: (typeof model.selected !== "undefined") ? model.selected : selected + + function toggleSelection() { + if (showAllUsersOption && itemAssigneeId === -1) { + if (isOnlyAllUsersSelected()) { + selectedAssigneeIds = []; + } else { + selectAllUsers(); + } + selectedAssigneeIds = selectedAssigneeIds.slice(); + return; + } + + var memberSelections = []; + if (itemMemberSelectionsJson) { + memberSelections = JSON.parse(itemMemberSelectionsJson); + } + var shouldSelect = !hasAnySelectedAssignee(memberSelections); + + for (var i = 0; i < memberSelections.length; i++) { + if (shouldSelect) { + addSelectionIfMissing(memberSelections[i]); + } else { + removeSelectionIfPresent(memberSelections[i]); + } + } + + selectedAssigneeIds = selectedAssigneeIds.slice(); + } + MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true + cursorShape: Qt.PointingHandCursor onClicked: { - var memberSelections = []; - if (model.memberSelectionsJson) { - memberSelections = JSON.parse(model.memberSelectionsJson); - } - var shouldSelect = !hasAnySelectedAssignee(memberSelections); - - for (var i = 0; i < memberSelections.length; i++) { - if (shouldSelect) { - addSelectionIfMissing(memberSelections[i]); - } else { - removeSelectionIfPresent(memberSelections[i]); - } - } - - selectedAssigneeIds = selectedAssigneeIds.slice(); + delegateRoot.toggleSelection(); } } @@ -531,13 +634,13 @@ Item { anchors.leftMargin: delegateRoot.contentMargin anchors.top: parent.top anchors.topMargin: delegateRoot.contentMargin - checked: model.selected + checked: delegateRoot.itemSelected MouseArea { anchors.fill: parent - propagateComposedEvents: true + cursorShape: Qt.PointingHandCursor onClicked: { - mouseArea.clicked(mouse); + delegateRoot.toggleSelection(); mouse.accepted = true; } } @@ -545,13 +648,22 @@ Item { Icon { id: userIcon - name: "contact" + name: (delegateRoot.itemAssigneeId === -1) ? "contact-group" : "contact" width: units.gu(2.5) height: units.gu(2.5) anchors.left: checkbox.right anchors.leftMargin: delegateRoot.contentSpacing anchors.verticalCenter: checkbox.verticalCenter color: theme.palette.normal.backgroundText + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } Column { @@ -572,11 +684,20 @@ Item { Text { id: nameLabel width: Math.min(headerFlow.width, Math.max(implicitWidth, units.gu(8))) - text: model.titleText || model.name + text: delegateRoot.itemTitleText || delegateRoot.itemName font.pixelSize: units.gu(2) color: theme.palette.normal.backgroundText wrapMode: Text.NoWrap elide: Text.ElideRight + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } Repeater { @@ -601,6 +722,15 @@ Item { elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } } @@ -612,6 +742,7 @@ Item { border.color: theme.palette.selected.background border.width: 1 width: Math.max(units.gu(7), moreChipLabel.implicitWidth + units.gu(2.4)) + z: 2 Text { id: moreChipLabel @@ -623,6 +754,7 @@ Item { MouseArea { anchors.fill: parent + cursorShape: Qt.PointingHandCursor onClicked: { delegateRoot.expandedAccounts = !delegateRoot.expandedAccounts; mouse.accepted = true; @@ -632,13 +764,22 @@ Item { } Text { - visible: model.email !== "" - text: model.email + visible: delegateRoot.itemEmail !== "" + text: delegateRoot.itemEmail font.pixelSize: units.gu(1.5) color: theme.palette.normal.backgroundSecondaryText elide: Text.ElideRight width: infoColumn.width opacity: 0.7 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } } @@ -648,6 +789,7 @@ Item { color: theme.palette.highlighted.background opacity: mouseArea.containsMouse ? 0.1 : 0 radius: parent.radius + enabled: false Behavior on opacity { NumberAnimation { @@ -697,7 +839,7 @@ Item { // Clear Filter Button TSButton { text: i18n.dtr("ubtms", "Clear Filter") - enabled: selectedAssigneeIds.length > 0 + enabled: showAllUsersOption ? true : selectedAssigneeIds.length > 0 width: units.gu(15) height: units.gu(4) bgColor: enabled ? LomiriColors.orange : LomiriColors.ash @@ -706,10 +848,7 @@ Item { Component.onCompleted: {} onClicked: { - selectedAssigneeIds = []; - filterModel.update(); - expanded = false; - filterCleared(); + clearFilter(); } } } @@ -731,7 +870,15 @@ Item { Text { property int selectedCount: getSelectedDisplayAssigneeCount() - text: selectedCount + " assignee" + (selectedCount === 1 ? "" : "s") + " selected" + text: { + if (showAllUsersOption && isOnlyAllUsersSelected()) { + return i18n.dtr("ubtms", "All Users selected"); + } + if (showAllUsersOption) { + return selectedCount + " " + (selectedCount === 1 ? i18n.dtr("ubtms", "employee selected") : i18n.dtr("ubtms", "employees selected")); + } + return selectedCount + " " + (selectedCount === 1 ? i18n.dtr("ubtms", "assignee selected") : i18n.dtr("ubtms", "assignees selected")); + } font.pixelSize: units.gu(1.6) color: theme.palette.normal.backgroundText anchors.centerIn: parent @@ -743,8 +890,8 @@ Item { // Function to load assignees for the current account function loadAssignees(accountId) { - // This will be called from the parent component - // to populate the assigneeModel + // This will be called from the parent component + // to populate the assigneeModel } // Initialize the filter model when assigneeModel changes @@ -766,6 +913,12 @@ Item { } } + onExpandedChanged: { + if (!expanded && searchField) { + searchField.text = ""; + } + } + Component.onCompleted: { if (filterModel) { filterModel.update(); diff --git a/qml/components/visualization/EHower.qml b/qml/components/visualization/EHower.qml index c99a2947..7666e48f 100644 --- a/qml/components/visualization/EHower.qml +++ b/qml/components/visualization/EHower.qml @@ -30,6 +30,7 @@ import Qt.labs.settings 1.0 import "../../../models/constants.js" as AppConst import QtQuick.LocalStorage 2.7 as Sql import "../../../models/accounts.js" as Accounts +import "../../../models/global.js" as Global import ".." Item { @@ -37,15 +38,36 @@ Item { width: parent.width height: parent.height signal quadrantClicked(int quadrant) - property int selectedAccountId: accountPicker.selectedAccountId + property int selectedAccountId: (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property bool autoRefreshOnAccountChange: true property string quadrant1Hours: "0" property string quadrant2Hours: "0" property string quadrant3Hours: "0" property string quadrant4Hours: "0" - function refreshQuadrants(startDate, endDate) { - var result = getQuadrantHoursFromAllInstances(selectedAccountId, startDate, endDate); + function refreshQuadrants(startDate, endDate, userId, accountId) { + if (accountId !== undefined && accountId !== null) { + selectedAccountId = Number(accountId); + } else if (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) { + selectedAccountId = Number(accountPicker.selectedAccountId); + } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } else { + var userFilter = Global.getDashboardUserFilter(); + if (userFilter && userFilter.userId !== undefined && userFilter.userId !== null) { + selectedUserId = userFilter.userId; + } + } + var sDate = startDate; + var eDate = endDate; + if (sDate === undefined || eDate === undefined) { + var filterData = Global.getDateRangeFilter(); + sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + } + var result = getQuadrantHoursFromAllInstances(selectedAccountId, sDate, eDate, selectedUserId); quadrant1Hours = result[1] + "H"; quadrant2Hours = result[2] + "H"; quadrant3Hours = result[3] + "H"; @@ -53,7 +75,7 @@ Item { } // TODO: Move it to Utils - function getQuadrantHoursFromAllInstances(accountId, startDate, endDate) { + function getQuadrantHoursFromAllInstances(accountId, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync("myDatabase", "1.0", "My Database", 1000000); var quadrantHours = { 1: 0.0, @@ -68,9 +90,10 @@ Item { var conditions = ["(status IS NULL OR status != 'deleted')"]; var params = []; - if (accountId !== -1 && accountId !== undefined && accountId !== null) { + var targetAccountId = (accountId !== undefined && accountId !== null) ? accountId : selectedAccountId; + if (targetAccountId !== -1 && targetAccountId !== "-1" && targetAccountId !== undefined && targetAccountId !== null) { conditions.push("account_id = ?"); - params.push(accountId); + params.push(Number(targetAccountId)); } if (startDate) { conditions.push("DATE(record_date) >= DATE(?)"); @@ -80,6 +103,14 @@ Item { conditions.push("DATE(record_date) <= DATE(?)"); params.push(endDate); } + var uid = (userId !== undefined && userId !== null) ? userId : selectedUserId; + if (uid !== -1 && uid !== "-1") { + var userFilter = Accounts.buildUserFilterSQL(uid, targetAccountId, ""); + if (userFilter.clause) { + conditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } var query = "SELECT quadrant_id, SUM(unit_amount) as total FROM account_analytic_line_app WHERE " + conditions.join(" AND ") + " GROUP BY quadrant_id"; rs = tx.executeSql(query, params); @@ -454,18 +485,23 @@ Item { } } + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined" && accountPicker.selectedAccountId !== undefined) ? accountPicker.selectedAccountId : selectedAccountId; + refreshQuadrants(sDate, eDate, userId, accId); + } + } + Component.onCompleted: { - var result = getQuadrantHoursFromAllInstances(selectedAccountId); - quadrant1Hours = result[1] + "H"; - quadrant2Hours = result[2] + "H"; - quadrant3Hours = result[3] + "H"; - quadrant4Hours = result[4] + "H"; + refreshQuadrants(); } onVisibleChanged: { - var result = getQuadrantHoursFromAllInstances(selectedAccountId); - quadrant1Hours = result[1] + "H"; - quadrant2Hours = result[2] + "H"; - quadrant3Hours = result[3] + "H"; - quadrant4Hours = result[4] + "H"; + if (visible) { + refreshQuadrants(); + } } } diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index 662060ac..e1f03ebf 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -225,14 +225,17 @@ Item { function navigateBackInHierarchy() { if (navigationStackModel.count > 0) { var last = navigationStackModel.get(navigationStackModel.count - 1); + var prevParentId = (last && last.parentId !== undefined) ? last.parentId : -1; + var prevAccountId = (last && last.accountId !== undefined) ? last.accountId : -1; + var prevParentName = (last && last.parentName !== undefined) ? last.parentName : ""; navigationStackModel.remove(navigationStackModel.count - 1); - currentParentId = last.parentId !== undefined ? last.parentId : -1; + currentParentId = prevParentId; if (currentParentId === -1) { currentAccountId = getSelectedAccountId(); } else { - currentAccountId = last.accountId !== undefined ? last.accountId : -1; + currentAccountId = prevAccountId; } - currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + currentParentName = prevParentName; } } diff --git a/qml/components/visualization/ProjectPieChart.qml b/qml/components/visualization/ProjectPieChart.qml index 3467edf6..2b1f654f 100644 --- a/qml/components/visualization/ProjectPieChart.qml +++ b/qml/components/visualization/ProjectPieChart.qml @@ -28,6 +28,7 @@ import QtQuick.Controls 2.2 import "../../../models/utils.js" as Utils import "../../../models/project.js" as Project import "../../../models/accounts.js" as Account +import "../../../models/global.js" as Global import ".." Item { @@ -37,8 +38,12 @@ Item { // account used to fetch data; numeric -1 indicates "all accounts" property int selectedAccountId: accountPicker.selectedAccountId + property var selectedUserId: -1 property bool autoRefreshOnAccountChange: true + property alias chartTitleText: chartTitle.text + property alias series: pieSeries + // Custom styled title overlay Text { id: chartTitle @@ -46,7 +51,7 @@ Item { anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter font.pixelSize: units.gu(2) - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "#444" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "#444" padding: units.gu(1.5) } @@ -122,8 +127,8 @@ Item { y: units.gu(2) width: Math.min(hoverText.implicitWidth + units.gu(3), parent.width - units.gu(2)) height: hoverText.implicitHeight + units.gu(1.5) - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#555" : "#FFF" - border.color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#888" : "#ccc" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#555" : "#FFF" + border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#888" : "#ccc" border.width: 1 radius: units.gu(0.5) opacity: hoverText.text !== "" ? 0.95 : 0.0 @@ -139,7 +144,7 @@ Item { horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap text: "" - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "Black" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "Black" font.weight: Font.Light font.pixelSize: units.gu(2) } @@ -187,7 +192,7 @@ Item { id: label text: Utils.truncateText(model.label, 35) font.pixelSize: units.gu(1.8) - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "#333" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "#333" wrapMode: Text.WordWrap elide: Text.ElideRight maximumLineCount: 2 @@ -260,11 +265,19 @@ Item { } // Fetch-and-load helper using the new project API signature - function refreshForAccount(accountId, startDate, endDate) { + function refreshForAccount(accountId, startDate, endDate, userId) { + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } else { + var userFilter = Global.getDashboardUserFilter(); + if (userFilter && userFilter.userId !== undefined) { + selectedUserId = userFilter.userId; + } + } - // call project API with account param and date range + // call project API with account param, date range and userId try { - var data = Project.getProjectSpentHoursList(true, accountId, startDate, endDate); + var data = Project.getProjectSpentHoursList(true, accountId, startDate, endDate, selectedUserId); // data should be an array; load chart load(data || []); } catch (e) { @@ -274,14 +287,35 @@ Item { } Component.onCompleted: { - refreshForAccount(accountPicker.selectedAccountId); + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + refreshForAccount(accountPicker.selectedAccountId, sDate, eDate, uid); } // Re-fetch when the account selector changes Connections { target: autoRefreshOnAccountChange ? accountPicker : null onAccepted: function (accountId, accountName) { - refreshForAccount(accountId); + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + refreshForAccount(accountId, sDate, eDate, uid); + } + } + + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; + refreshForAccount(accId, sDate, eDate, userId); } } } diff --git a/qml/components/workflow/AttachmentManager.qml b/qml/components/workflow/AttachmentManager.qml index cc6013a7..8e39e203 100644 --- a/qml/components/workflow/AttachmentManager.qml +++ b/qml/components/workflow/AttachmentManager.qml @@ -405,6 +405,7 @@ Item { } function openContentPicker() { + Qt.inputMethod.hide(); if (_busy) { return; } diff --git a/qml/features/dashboard/charts/Charts3.qml b/qml/features/dashboard/charts/Charts3.qml index 0a457d7e..31d7e00f 100644 --- a/qml/features/dashboard/charts/Charts3.qml +++ b/qml/features/dashboard/charts/Charts3.qml @@ -40,11 +40,12 @@ Item { property bool autoRefreshOnAccountChange: true property int selectedAccountId: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate, accountId) { + function reloadData(startDate, endDate, accountId, userId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; if (accountId !== undefined && accountId !== null) { @@ -52,10 +53,13 @@ Item { } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { selectedAccountId = accountPicker.selectedAccountId; } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } var t_proj = []; var maxVal = 0; - var data = Model.get_projects_spent_hours(root.selectedAccountId, filterStartDate, filterEndDate); + var data = Model.get_projects_spent_hours(root.selectedAccountId, filterStartDate, filterEndDate, root.selectedUserId); root.totalProjects = data.length; var limit = Math.min(root.displayLimit, data.length); @@ -114,7 +118,9 @@ Item { var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; - reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + reloadData(sDate, eDate, accId, uid); } Connections { @@ -132,15 +138,27 @@ Item { } Connections { - target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { - root.selectedAccountId = accountId; - root.displayLimit = 10; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + root.displayLimit = 10; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } } onAccountDataRefreshRequested: function (accountId) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + onGlobalDashboardUserChanged: function (userId, userName) { + root.selectedUserId = userId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId, userId); } } diff --git a/qml/features/dashboard/charts/Charts4.qml b/qml/features/dashboard/charts/Charts4.qml index c53ef0aa..dcd4cb2a 100644 --- a/qml/features/dashboard/charts/Charts4.qml +++ b/qml/features/dashboard/charts/Charts4.qml @@ -16,12 +16,13 @@ Item { property bool autoRefreshOnAccountChange: true property int selectedAccountId: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property var projectsModel: [] property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate, accountId) { + function reloadData(startDate, endDate, accountId, userId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; if (accountId !== undefined && accountId !== null) { @@ -29,11 +30,17 @@ Item { } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { selectedAccountId = accountPicker.selectedAccountId; } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } projectsModel = buildProjectsModel(filterStartDate, filterEndDate); + if (chartFlow && typeof chartFlow.refreshCurrentView === "function") { + chartFlow.refreshCurrentView(); + } } function buildProjectsModel(startDate, endDate) { - var rows = ProjectModel.getDashboardProjectTaskSummary(selectedAccountId, startDate, endDate); + var rows = ProjectModel.getDashboardProjectTaskSummary(selectedAccountId, startDate, endDate, selectedUserId); for (var i = 0; i < rows.length; i++) { var project = rows[i]; @@ -54,7 +61,7 @@ Item { } var projectRecordId = (project.accountId === 0 || !project.odooRecordId) ? project.localId : project.odooRecordId; - var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate); + var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate, selectedUserId); var mappedTasks = []; for (var i = 0; i < taskRows.length; i++) { @@ -87,14 +94,32 @@ Item { return mappedTasks; } - function loadLogsForTask(projectId, taskId) { + function loadLogsForTask(projectId, taskId, taskObj) { var project = findProject(projectId); + if (!project) { + return []; + } + var task = findTask(project, taskId); - if (!project || !task) { + if (!project.tasks || project.tasks.length === 0 || !task) { + loadTasksForProject(projectId); + task = findTask(project, taskId); + } + var targetTask = task || taskObj; + if (!targetTask) { return []; } - var timesheets = TimesheetModel.getTimesheetsForTask(task.odooRecordId, project.accountId, "all", filterStartDate, filterEndDate); + var targetTaskId = (project.accountId === 0 || !targetTask.odooRecordId) ? targetTask.localId : targetTask.odooRecordId; + if (!targetTaskId && taskId !== undefined && taskId !== null) { + var parts = String(taskId).split(":"); + targetTaskId = Number(parts[parts.length - 1]); + } + if (!targetTaskId) { + return []; + } + + var timesheets = TimesheetModel.getTimesheetsForTask(targetTaskId, project.accountId, "all", filterStartDate, filterEndDate, selectedUserId); var logs = []; for (var i = 0; i < timesheets.length; i++) { @@ -103,19 +128,24 @@ Item { id: entry.id, date: toIsoDate(entry.date), hours: parseHours(entry.spentHours), - note: entry.name || "" + note: entry.name || "", + user: entry.user || "" }); } - task.logs = logs; - task._logsLoaded = true; + if (task) { + task.logs = logs; + task._logsLoaded = true; + } return logs; } function findProject(projectId) { + if (!projectsModel) return null; for (var i = 0; i < projectsModel.length; i++) { - if (projectsModel[i].id === projectId) { - return projectsModel[i]; + var p = projectsModel[i]; + if (p.id === projectId || String(p.odooRecordId) === String(projectId) || String(p.localId) === String(projectId)) { + return p; } } return null; @@ -127,8 +157,9 @@ Item { } for (var i = 0; i < project.tasks.length; i++) { - if (project.tasks[i].id === taskId) { - return project.tasks[i]; + var t = project.tasks[i]; + if (t.id === taskId || String(t.localId) === String(taskId) || (t.odooRecordId && String(t.odooRecordId) === String(taskId))) { + return t; } } return null; @@ -197,7 +228,9 @@ Item { var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; - reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + reloadData(sDate, eDate, accId, uid); } Connections { @@ -213,14 +246,26 @@ Item { } Connections { - target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } } onAccountDataRefreshRequested: function (accountId) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + onGlobalDashboardUserChanged: function (userId, userName) { + root.selectedUserId = userId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId, userId); } } } diff --git a/qml/features/dashboard/charts/TaskTimeChart.qml b/qml/features/dashboard/charts/TaskTimeChart.qml index d13f0ee7..d1955f83 100644 --- a/qml/features/dashboard/charts/TaskTimeChart.qml +++ b/qml/features/dashboard/charts/TaskTimeChart.qml @@ -47,6 +47,18 @@ Item { return "#E95420"; } + readonly property real selectedTaskSpentHours: { + if (!selectedTask) return 0; + if (selectedTaskLogs && selectedTaskLogs.length > 0) { + var sum = 0; + for (var i = 0; i < selectedTaskLogs.length; i++) { + sum += Number(selectedTaskLogs[i].hours || 0); + } + return sum; + } + return Number(selectedTask.totalHours || 0); + } + function openProject(projectData) { if (!projectData) return; selectedProject = projectData; @@ -62,7 +74,7 @@ Item { if (!selectedProject || !taskData) return; selectedTask = taskData; selectedTask.projectTotalHours = Number(selectedProject.totalHours || 0); - selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, taskData.id) || []) : []; + selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, taskData.id, taskData) || []) : []; selectedTask.logs = selectedTaskLogs; currentView = "task"; taskOpened(selectedProject.id, taskData.id); @@ -79,6 +91,80 @@ Item { backNavigated(); } + function refreshCurrentView() { + if (currentView === "project" && selectedProject) { + var updatedProject = null; + if (projectsModel) { + for (var i = 0; i < projectsModel.length; i++) { + if (projectsModel[i].id === selectedProject.id) { + updatedProject = projectsModel[i]; + break; + } + } + } + if (updatedProject) { + selectedProject = updatedProject; + selectedProjectTasks = projectTasksProvider ? ChartUtils.prepareTasks(projectTasksProvider(selectedProject.id) || []) : []; + } else { + goBack(); + } + } else if (currentView === "task" && selectedProject && selectedTask) { + var updatedProj = null; + if (projectsModel) { + for (var j = 0; j < projectsModel.length; j++) { + if (projectsModel[j].id === selectedProject.id) { + updatedProj = projectsModel[j]; + break; + } + } + } + if (updatedProj) { + selectedProject = updatedProj; + selectedProjectTasks = projectTasksProvider ? ChartUtils.prepareTasks(projectTasksProvider(selectedProject.id) || []) : []; + + var updatedTask = null; + for (var k = 0; k < selectedProjectTasks.length; k++) { + if (selectedProjectTasks[k].id === selectedTask.id || + String(selectedProjectTasks[k].localId) === String(selectedTask.localId || selectedTask.id) || + (selectedTask.odooRecordId && String(selectedProjectTasks[k].odooRecordId) === String(selectedTask.odooRecordId))) { + updatedTask = selectedProjectTasks[k]; + break; + } + } + + if (updatedTask) { + selectedTask = updatedTask; + } else { + var resetTask = Object.assign({}, selectedTask); + resetTask.totalHours = 0; + selectedTask = resetTask; + } + selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, selectedTask.id, selectedTask) || []) : []; + if (selectedTask) { + var updatedSelectedTask = Object.assign({}, selectedTask); + updatedSelectedTask.projectTotalHours = Number(selectedProject.totalHours || 0); + updatedSelectedTask.logs = selectedTaskLogs; + if (selectedTaskLogs && selectedTaskLogs.length > 0) { + var totalLogHours = 0; + for (var l = 0; l < selectedTaskLogs.length; l++) { + totalLogHours += Number(selectedTaskLogs[l].hours || 0); + } + updatedSelectedTask.totalHours = totalLogHours; + } else if (!updatedTask) { + updatedSelectedTask.totalHours = 0; + } + selectedTask = updatedSelectedTask; + } + } else { + goBack(); + } + } + } + + onProjectsModelChanged: { + refreshCurrentView(); + } + function ensureVisibleInAncestorFlickable(item) { if (!item) return; @@ -553,7 +639,7 @@ Item { } Label { - text: ChartUtils.formatHours(root.selectedTask ? root.selectedTask.totalHours : 0) + text: ChartUtils.formatHours(root.selectedTaskSpentHours) color: root.summaryAccentTextColor font.bold: true font.pixelSize: units.dp(20) @@ -579,7 +665,7 @@ Item { } Label { - text: ChartUtils.percentLabel(root.selectedTask ? root.selectedTask.totalHours : 0, root.selectedProject ? root.selectedProject.totalHours : 0) + text: ChartUtils.percentLabel(root.selectedTaskSpentHours, root.selectedProject ? root.selectedProject.totalHours : 0) color: root.summaryPrimaryTextColor font.bold: true font.pixelSize: units.dp(20) @@ -706,12 +792,37 @@ Item { } } - Label { + Column { Layout.fillWidth: true - text: modelData.note || i18n.dtr("ubtms", "No note") - color: Theme.palette.normal.backgroundText - font.pixelSize: units.dp(13) - wrapMode: Text.WordWrap + spacing: units.gu(0.4) + + RowLayout { + spacing: units.gu(0.6) + visible: !!modelData.user && modelData.user !== "" + + Icon { + name: "contact" + width: units.gu(1.4) + height: units.gu(1.4) + color: root.activeAccent + } + + Label { + text: modelData.user || "" + color: Theme.palette.normal.baseText + font.pixelSize: units.dp(12) + font.bold: true + elide: Text.ElideRight + } + } + + Label { + width: parent.width + text: modelData.note || i18n.dtr("ubtms", "No note") + color: Theme.palette.normal.backgroundText + font.pixelSize: units.dp(13) + wrapMode: Text.WordWrap + } } Label { diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index f567f7dc..c40b2539 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -45,6 +45,68 @@ Page { property string loadingMessage: i18n.dtr("ubtms", "Loading dashboard...") property int refreshStage: -1 property int lastRefreshAccountId: -999999 + property var selectedUserId: -1 + property string selectedUserName: i18n.dtr("ubtms", "All Users") + property var availableUsers: [] + property bool isUserFilterVisible: false + property int lastFilterAccountId: -999999 + + function initUserFilterForCurrentAccount(targetAccountId) { + var accId = (targetAccountId !== undefined && targetAccountId !== null) + ? targetAccountId + : ((typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) + ? accountPicker.selectedAccountId + : -1); + lastFilterAccountId = accId; + isUserFilterVisible = Account.hasManagerOrAdminAccess(accId); + if (!isUserFilterVisible) { + assigneeFilterMenu.expanded = false; + } + availableUsers = Account.getDashboardFilterUsers(accId); + + var menuAssignees = []; + for (var i = 0; i < availableUsers.length; i++) { + var u = availableUsers[i]; + var acctId = (u.accountId !== undefined && u.accountId !== null) ? u.accountId : ((u.account_id !== undefined) ? u.account_id : accId); + menuAssignees.push({ + id: u.id, + odoo_record_id: u.id, + name: u.name, + email: u.email || "", + account_name: u.account_name || (u.account_names ? u.account_names.join(", ") : ""), + account_id: acctId, + allUserIds: u.allUserIds || [u.id], + allAccountIds: u.allAccountIds || [acctId] + }); + } + assigneeFilterMenu.showAccountName = (accId === -1); + assigneeFilterMenu.assigneeModel = menuAssignees; + + var defaultUser = Account.getDashboardSingleUserDefault(accId, availableUsers); + if (defaultUser) { + selectedUserId = defaultUser.id; + selectedUserName = defaultUser.name; + var defSelections = []; + if (defaultUser.allUserIds && defaultUser.allAccountIds) { + for (var d = 0; d < defaultUser.allUserIds.length; d++) { + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.allUserIds[d], defaultUser.allAccountIds[d] || accId)); + } + } else { + var defAcctId = (defaultUser.accountId !== undefined) ? defaultUser.accountId : accId; + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.id, defAcctId)); + } + assigneeFilterMenu.selectedAssigneeIds = defSelections; + } else { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + } // Timer for deferred loading - gives UI time to render loading indicator Timer { @@ -59,6 +121,13 @@ Page { // Update navigation tracking when Dashboard becomes visible Global.setLastVisitedPage("Dashboard"); + var currentAccId = (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) + ? accountPicker.selectedAccountId + : -1; + if (lastFilterAccountId !== currentAccId || availableUsers.length === 0) { + initUserFilterForCurrentAccount(currentAccId); + } + refreshData(); } } @@ -85,6 +154,7 @@ Page { contents: FilterableHeaderContents { id: headerContents title: i18n.dtr("ubtms", "Dashboard") + userLabel: selectedUserName onDateRangeChanged: refreshData() Component.onCompleted: { @@ -107,7 +177,7 @@ Page { } ] - trailingActionBar.numberOfSlots: 5 + trailingActionBar.numberOfSlots: 6 trailingActionBar.actions: [ Action { id: infoAction @@ -142,6 +212,15 @@ Page { openNewTimesheetPage(false); } }, + Action { + id: userFilterAction + iconName: (Array.isArray(selectedUserId) && selectedUserId.length > 1) ? "contact-group" : "contact" + text: selectedUserName ? selectedUserName : i18n.dtr("ubtms", "All Users") + visible: !headerContents.showDateFilter && isUserFilterVisible + onTriggered: { + assigneeFilterMenu.expanded = !assigneeFilterMenu.expanded; + } + }, Action { id: filterAction iconName: "filters" @@ -183,15 +262,14 @@ Page { } } - function refreshData() { + function refreshData(force) { Logger.debug("Dashboard", "Refreshing Dashboard data...") - var targetAccountId = accountPicker.selectedAccountId; - if (isLoading && refreshStage >= 0 && lastRefreshAccountId === targetAccountId) { + var targetAccountId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + if (!force && isLoading && lastRefreshAccountId === targetAccountId) { return; } lastRefreshAccountId = targetAccountId; - refreshStage = 0; loadingTimer.stop(); loadingMessage = targetAccountId === -1 ? i18n.dtr("ubtms", "Preparing all-account dashboard...") @@ -212,46 +290,38 @@ Page { function _doRefreshData() { try { - var sDate = typeof headerContents.dateFilter !== "undefined" ? headerContents.dateFilter.startDate : ""; - var eDate = typeof headerContents.dateFilter !== "undefined" ? headerContents.dateFilter.endDate : ""; - - switch (refreshStage) { - case 0: - Logger.debug("Dashboard", "Dashboard refresh stage 0: priority matrix") - if (typeof ehoverMatrix !== "undefined" && ehoverMatrix.refreshQuadrants) { - ehoverMatrix.refreshQuadrants(sDate, eDate); - } - loadingMessage = i18n.dtr("ubtms", "Loading project chart..."); - refreshStage = 1; - loadingTimer.interval = 0; - loadingTimer.start(); - return; - case 1: - Logger.debug("Dashboard", "Dashboard refresh stage 1: project chart") - if (typeof projectchart !== "undefined") { - projectchart.refreshForAccount(accountPicker.selectedAccountId, sDate, eDate); - } - loadingMessage = i18n.dtr("ubtms", "Loading additional charts..."); - refreshStage = 2; - loadingTimer.start(); - return; - case 2: - Logger.debug("Dashboard", "Dashboard refresh stage 2: additional charts") - var activeAccId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; - if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { - mobileProjectChartLoader.item.selectedAccountId = activeAccId; - mobileProjectChartLoader.item.reloadData(sDate, eDate, activeAccId); - } - if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { - mobileTaskChartLoader.item.selectedAccountId = activeAccId; - mobileTaskChartLoader.item.reloadData(sDate, eDate, activeAccId); - } - break; - default: - break; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var activeAccId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); + + Logger.debug("Dashboard", "Executing Dashboard refresh for user: " + JSON.stringify(uid) + ", account: " + activeAccId); + + // 1. Priority Matrix (Eisenhower) + if (typeof ehoverMatrix !== "undefined" && ehoverMatrix.refreshQuadrants) { + ehoverMatrix.refreshQuadrants(sDate, eDate, uid, activeAccId); + } + + // 2. Overview Pie Chart + if (typeof projectchart !== "undefined" && typeof projectchart.refreshForAccount === "function") { + projectchart.refreshForAccount(activeAccId, sDate, eDate, uid); + } + + // 3. Mobile tab loaders (Projects & Tasks charts) + if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { + mobileProjectChartLoader.item.selectedAccountId = activeAccId; + mobileProjectChartLoader.item.selectedUserId = uid; + mobileProjectChartLoader.item.reloadData(sDate, eDate, activeAccId, uid); + } + + if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { + mobileTaskChartLoader.item.selectedAccountId = activeAccId; + mobileTaskChartLoader.item.selectedUserId = uid; + mobileTaskChartLoader.item.reloadData(sDate, eDate, activeAccId, uid); } } catch(e) { - Logger.error("Dashboard", "_doRefreshData ERROR: ", e) + Logger.error("Dashboard", "_doRefreshData ERROR: ", e); } finishRefreshData(); } @@ -336,10 +406,6 @@ Page { height: width autoRefreshOnAccountChange: false anchors.centerIn: parent - quadrant1Hours: "120.2" - quadrant2Hours: "65.5" - quadrant3Hours: "55.0" - quadrant4Hours: "178.1" onQuadrantClicked: {} } } @@ -425,6 +491,30 @@ Page { onCurrentIndexChanged: { if (mobileChartTabBar.currentIndex !== currentIndex) mobileChartTabBar.currentIndex = currentIndex; + + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); + + if (currentIndex === 0) { + if (typeof projectchart !== "undefined" && typeof projectchart.refreshForAccount === "function") { + projectchart.refreshForAccount(accId, sDate, eDate, uid); + } + } else if (currentIndex === 1) { + if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { + mobileProjectChartLoader.item.selectedAccountId = accId; + mobileProjectChartLoader.item.selectedUserId = uid; + mobileProjectChartLoader.item.reloadData(sDate, eDate, accId, uid); + } + } else if (currentIndex === 2) { + if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { + mobileTaskChartLoader.item.selectedAccountId = accId; + mobileTaskChartLoader.item.selectedUserId = uid; + mobileTaskChartLoader.item.reloadData(sDate, eDate, accId, uid); + } + } } Item { @@ -447,7 +537,13 @@ Page { if (item) { item.autoRefreshOnAccountChange = false; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); item.selectedAccountId = accId; + item.selectedUserId = uid; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -463,7 +559,13 @@ Page { if (item) { item.autoRefreshOnAccountChange = false; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); item.selectedAccountId = accId; + item.selectedUserId = uid; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -523,22 +625,138 @@ Page { if (accountPicker.selectedAccountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountPicker.selectedAccountName + "]"; } + initUserFilterForCurrentAccount(accountPicker.selectedAccountId); refreshData(); } onAccepted: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; + initUserFilterForCurrentAccount(accountId); refreshData(); } } Connections { - target: typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; - refreshData(); + initUserFilterForCurrentAccount(accountId); + refreshData(true); } onAccountDataRefreshRequested: function (accountId) { - refreshData(); + initUserFilterForCurrentAccount(accountId); + refreshData(true); + } + onGlobalDashboardUserChanged: function (userId, userName) { + selectedUserId = userId; + selectedUserName = userName; + if (userId === -1 || userId === "-1") { + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } else if (Array.isArray(userId)) { + assigneeFilterMenu.selectedAssigneeIds = userId.slice(); + } else { + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(userId, accId)]; + } + refreshData(true); + } + } + + Connections { + target: typeof mainView !== "undefined" ? mainView : null + onGlobalDateRangeChanged: function (presetId, startDate, endDate, presetLabel) { + refreshData(true); + } + } + + function handleAssigneeFilterApplied(assigneeIds) { + var rawIds = assigneeIds || []; + var isAllUsers = false; + for (var k = 0; k < rawIds.length; k++) { + var itm = rawIds[k]; + var uidVal = (typeof itm === "object") ? itm.user_id : itm; + if (uidVal === -1 || uidVal === "-1") { + isAllUsers = true; + break; + } + } + + if (isAllUsers) { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + Global.setDashboardUserFilter(-1, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(-1, selectedUserName); + } + refreshData(true); + return; + } + + // Match selected assignees against availableUsers + var selectedUsersList = []; + for (var i = 0; i < availableUsers.length; i++) { + var u = availableUsers[i]; + var isSel = false; + for (var j = 0; j < rawIds.length; j++) { + var s = rawIds[j]; + var sUid = (typeof s === "object") ? s.user_id : s; + if (sUid === u.id || (u.allUserIds && u.allUserIds.indexOf(sUid) !== -1)) { + isSel = true; + break; + } + } + if (isSel) { + selectedUsersList.push(u); + } + } + + if (selectedUsersList.length === 0) { + handleAssigneeFilterCleared(); + return; + } + + if (selectedUsersList.length === 1) { + selectedUserId = selectedUsersList[0].id; + selectedUserName = selectedUsersList[0].name; + } else { + selectedUserId = rawIds.slice(); + selectedUserName = selectedUsersList.length + " " + i18n.dtr("ubtms", "Employees"); + } + + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + refreshData(true); + } + + function handleAssigneeFilterCleared() { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + refreshData(true); + } + + AssigneeFilterMenu { + id: assigneeFilterMenu + anchors.fill: parent + z: 1000 + showAllUsersOption: true + + onFilterApplied: function (assigneeIds) { + handleAssigneeFilterApplied(assigneeIds); + } + + onFilterCleared: function () { + handleAssigneeFilterCleared(); } } @@ -604,6 +822,7 @@ Page { Component.onCompleted: { Logger.debug("Dashboard", "Dashboard status is: " + mainPage.status) + initUserFilterForCurrentAccount(); // Load notifications on startup notificationBell.loadNotifications(); } diff --git a/qml/features/dashboard/pages/Dashboard2.qml b/qml/features/dashboard/pages/Dashboard2.qml index 2e501b8a..3cc6ef3f 100644 --- a/qml/features/dashboard/pages/Dashboard2.qml +++ b/qml/features/dashboard/pages/Dashboard2.qml @@ -102,13 +102,17 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; if (load3.item && typeof load3.item.reloadData === "function") { load3.item.selectedAccountId = accountId; - load3.item.reloadData(sDate, eDate, accountId); + load3.item.selectedUserId = uid; + load3.item.reloadData(sDate, eDate, accountId, uid); } if (load4.item && typeof load4.item.reloadData === "function") { load4.item.selectedAccountId = accountId; - load4.item.reloadData(sDate, eDate, accountId); + load4.item.selectedUserId = uid; + load4.item.reloadData(sDate, eDate, accountId, uid); } } @@ -121,13 +125,16 @@ Page { } Connections { - target: typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { refreshData(true); } onAccountDataRefreshRequested: function (accountId) { refreshData(true); } + onGlobalDashboardUserChanged: function (userId, userName) { + refreshData(true); + } } Connections { @@ -185,7 +192,10 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - item.reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + item.selectedUserId = uid; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -209,7 +219,10 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - item.reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + item.selectedUserId = uid; + item.reloadData(sDate, eDate, accId, uid); } } } diff --git a/qml/features/dashboard/pages/Dashboard3.qml b/qml/features/dashboard/pages/Dashboard3.qml index f443ffcf..6421d8a8 100644 --- a/qml/features/dashboard/pages/Dashboard3.qml +++ b/qml/features/dashboard/pages/Dashboard3.qml @@ -133,7 +133,9 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - var quadrant_data = Model.get_tasks_spent_hours(accountId, sDate, eDate); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + var quadrant_data = Model.get_tasks_spent_hours(accountId, sDate, eDate, uid); var count = 0; var timeval; var timecat = []; @@ -179,6 +181,13 @@ Page { } } + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + reloadData(); + } + } + Rectangle { id: hoverInfo width: Math.min(hoverText.implicitWidth + units.gu(3), parent.width - units.gu(4)) diff --git a/qml/features/dashboard/pages/TaskDetailPage.qml b/qml/features/dashboard/pages/TaskDetailPage.qml index 56e6fdbd..2a09666b 100644 --- a/qml/features/dashboard/pages/TaskDetailPage.qml +++ b/qml/features/dashboard/pages/TaskDetailPage.qml @@ -11,6 +11,18 @@ Page { property var taskData: ({}) property string projectName: "" + readonly property real taskSpentHours: { + if (!taskData) return 0; + if (taskData.logs && taskData.logs.length > 0) { + var sum = 0; + for (var i = 0; i < taskData.logs.length; i++) { + sum += Number(taskData.logs[i].hours || 0); + } + return sum; + } + return Number(taskData.totalHours || 0); + } + header: PageHeader { id: pageHeader title: taskData ? taskData.name : "" @@ -40,12 +52,12 @@ Page { model: [ { label: i18n.dtr("ubtms", "Time spent"), - value: ChartUtils.formatHours(taskData ? taskData.totalHours : 0) + value: ChartUtils.formatHours(root.taskSpentHours) }, { label: i18n.dtr("ubtms", "% of project"), value: taskData && taskData.projectTotalHours ? - ChartUtils.percentLabel(taskData.totalHours, taskData.projectTotalHours) : + ChartUtils.percentLabel(root.taskSpentHours, taskData.projectTotalHours) : i18n.dtr("ubtms", "0.0%") } ] @@ -137,10 +149,11 @@ Page { delegate: UbuntuShape { width: parent.width - height: units.gu(7) + implicitHeight: Math.max(units.gu(7), logDetailRowLayout.implicitHeight + units.gu(2)) color: Theme.palette.normal.base RowLayout { + id: logDetailRowLayout anchors.fill: parent anchors.leftMargin: units.gu(1.5) anchors.rightMargin: units.gu(1.5) @@ -154,12 +167,37 @@ Page { elide: Text.ElideRight } - Label { + Column { Layout.fillWidth: true - text: modelData.note || i18n.dtr("ubtms", "No note") - color: Theme.palette.normal.backgroundText - font.pixelSize: units.dp(12) - elide: Text.ElideRight + spacing: units.gu(0.3) + + RowLayout { + spacing: units.gu(0.5) + visible: !!modelData.user && modelData.user !== "" + + Icon { + name: "contact" + width: units.gu(1.3) + height: units.gu(1.3) + color: Theme.palette.normal.backgroundText + } + + Label { + text: modelData.user || "" + color: Theme.palette.normal.baseText + font.pixelSize: units.dp(11) + font.bold: true + elide: Text.ElideRight + } + } + + Label { + width: parent.width + text: modelData.note || i18n.dtr("ubtms", "No note") + color: Theme.palette.normal.backgroundText + font.pixelSize: units.dp(12) + wrapMode: Text.WordWrap + } } Label { diff --git a/qml/images/cross.svg b/qml/images/cross.svg deleted file mode 100755 index 3c338838..00000000 --- a/qml/images/cross.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/qml/images/cross_wait.svg b/qml/images/cross_wait.svg deleted file mode 100755 index bab06190..00000000 --- a/qml/images/cross_wait.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/qml/images/delete.png b/qml/images/delete.png deleted file mode 100755 index 0a466f80..00000000 Binary files a/qml/images/delete.png and /dev/null differ diff --git a/qml/images/followup.png b/qml/images/followup.png deleted file mode 100644 index 3ae74893..00000000 Binary files a/qml/images/followup.png and /dev/null differ diff --git a/qml/images/hide.png b/qml/images/hide.png deleted file mode 100755 index 62dc80a9..00000000 Binary files a/qml/images/hide.png and /dev/null differ diff --git a/qml/images/home.svg b/qml/images/home.svg deleted file mode 100755 index af1854f7..00000000 --- a/qml/images/home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/pause.svg b/qml/images/pause.svg deleted file mode 100644 index 1df8c309..00000000 --- a/qml/images/pause.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/qml/images/play.svg b/qml/images/play.svg deleted file mode 100644 index 2a5d692e..00000000 --- a/qml/images/play.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/qml/images/refreshBlack.svg b/qml/images/refreshBlack.svg deleted file mode 100755 index 4fde5498..00000000 --- a/qml/images/refreshBlack.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/reload.png b/qml/images/reload.png deleted file mode 100755 index dbce977c..00000000 Binary files a/qml/images/reload.png and /dev/null differ diff --git a/qml/images/right.svg b/qml/images/right.svg deleted file mode 100755 index e29c29b0..00000000 --- a/qml/images/right.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - -Created by potrace 1.15, written by Peter Selinger 2001-2017 - - - - - diff --git a/qml/images/save.png b/qml/images/save.png deleted file mode 100755 index 4391edf4..00000000 Binary files a/qml/images/save.png and /dev/null differ diff --git a/qml/images/save.svg b/qml/images/save.svg deleted file mode 100755 index d5a1be3f..00000000 --- a/qml/images/save.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/setting.svg b/qml/images/setting.svg deleted file mode 100755 index 3a36c4bb..00000000 --- a/qml/images/setting.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/start.png b/qml/images/start.png deleted file mode 100755 index 97275b71..00000000 Binary files a/qml/images/start.png and /dev/null differ diff --git a/qml/images/start.svg b/qml/images/start.svg deleted file mode 100755 index 7c9cb56a..00000000 --- a/qml/images/start.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/qml/images/stop.svg b/qml/images/stop.svg deleted file mode 100644 index 5bd79d3f..00000000 --- a/qml/images/stop.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/qml/images/timeManagemetLogo.png b/qml/images/timeManagemetLogo.png deleted file mode 100755 index 215b89d1..00000000 Binary files a/qml/images/timeManagemetLogo.png and /dev/null differ diff --git a/qml/images/timemanagementapp_logo.jpg b/qml/images/timemanagementapp_logo.jpg deleted file mode 100755 index 41e62768..00000000 Binary files a/qml/images/timemanagementapp_logo.jpg and /dev/null differ diff --git a/qml/images/timemanagementapp_logo_only_white.png b/qml/images/timemanagementapp_logo_only_white.png deleted file mode 100755 index 8f448d67..00000000 Binary files a/qml/images/timemanagementapp_logo_only_white.png and /dev/null differ diff --git a/qml/images/timesheets_large_logo.png b/qml/images/timesheets_large_logo.png deleted file mode 100755 index 012aa831..00000000 Binary files a/qml/images/timesheets_large_logo.png and /dev/null differ diff --git a/qml/images/timesheets_small_logo.png b/qml/images/timesheets_small_logo.png deleted file mode 100755 index 831fe930..00000000 Binary files a/qml/images/timesheets_small_logo.png and /dev/null differ diff --git a/src/add_demo_connection.py b/src/add_demo_connection.py deleted file mode 100755 index e85d0cb2..00000000 --- a/src/add_demo_connection.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import sqlite3 -import os -from datetime import datetime - - -def add_odoo_account( - db_path="app_settings.db", - name="Onestein Demo", - url="https://demo16.onestein.eu/", - database="project_demo", - username="admin", - api_key=None, - connectwith_id=None, -): - if not os.path.exists(db_path): - print(f"Database {db_path} does not exist. Please initialize it first.") - return - - with sqlite3.connect(db_path) as conn: - cursor = conn.cursor() - - # Insert account into users table - cursor.execute( - """ - INSERT INTO users (name, link, last_modified, database, connectwith_id, api_key, username) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - name, - url, - datetime.now().isoformat(), - database, - connectwith_id, - api_key, - username, - ), - ) - - conn.commit() - conn.close() - print("✅ Odoo account added successfully.") - - -if __name__ == "__main__": - add_odoo_account() diff --git a/src/autotester.py b/src/autotester.py deleted file mode 100644 index 699e3ca1..00000000 --- a/src/autotester.py +++ /dev/null @@ -1,292 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import shutil -import time -from pathlib import Path -from abc import ABC, abstractmethod -from sync_from_odoo import sync_all_from_odoo -from sync_to_odoo import sync_all_to_odoo -from odoo_client import OdooClient -from config import get_all_accounts - -import sqlite3 -from datetime import datetime - -def get_timestamp(): - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -def execute_sql(db_path, sql, values=(), fetch=False): - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - print(f"[SQL] Executing:\n {sql}\n [VALUES] {values}") - cursor.execute(sql, values) - - result = cursor.fetchall() if fetch else None - print(f"***************[SQL] Rows affected: {cursor.rowcount}*******************") - - conn.commit() - conn.close() - - return result - -# ... [keep all your import and helper function sections unchanged] - -class TestCaseBase(ABC): - def __init__(self, temp_db_path, account_index, account_id): - self.temp_db_path = temp_db_path - self.account_index = account_index - self.account_id = account_id - self.data = {} - self.client = self.get_client() - self.result = {"status": "Not Run", "message": ""} - - def mark_success(self, message=""): - self.result["status"] = "Passed" - self.result["message"] = message - - def mark_failure(self, message=""): - self.result["status"] = "Failed" - self.result["message"] = message - - def get_client(self): - accounts = get_all_accounts(self.temp_db_path) - selected = accounts[self.account_index] - return OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"] - ) - - def verify_project_exists(self, name): - ids = self.client.search("project.project", [["name", "=", name]]) - return bool(ids) - - def verify_task_exists(self, task_name): - ids = self.client.search("project.task", [["name", "=", task_name]]) - return bool(ids) - - def sync_push(self): - sync_all_to_odoo(self.client, self.account_id, self.temp_db_path) - - def sync_pull(self): - sync_all_from_odoo(self.client, self.account_id, self.temp_db_path) - - @abstractmethod - def run(self): - pass - - @abstractmethod - def check(self): - pass - - -# --- Test Cases (corrected with closing parens and proper field access) - -class CreateProjectTest(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"name": "ProjectA"} - - def run(self): - ts = get_timestamp() - execute_sql( - self.temp_db_path, - "INSERT INTO project_project_app (name, account_id, last_modified, status, last_update_status) VALUES (?, ?, ?, ?, ?)", - (self.data["name"], self.account_id, ts, "updated", "to_define") - ) - print(f"[TEST] Created project '{self.data['name']}'") - self.sync_push() - - def check(self): - if self.verify_project_exists(self.data["name"]): - self.mark_success(f"[PASS] Project '{self.data['name']}' exists in Odoo.") - else: - self.mark_failure(f"[FAIL] Project '{self.data['name']}' not found in Odoo!") - - -class UpdateProjectTest(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"old_name": "ProjectA", "new_name": "ProjectB"} - - def run(self): - ts = get_timestamp() - execute_sql( - self.temp_db_path, - "UPDATE project_project_app SET name = ?, last_modified = ?, status = 'updated' WHERE name = ?", - (self.data["new_name"], ts, self.data["old_name"]) - ) - print(f"[TEST] Renamed project '{self.data['old_name']}' → '{self.data['new_name']}'") - self.sync_push() - - def check(self): - if self.verify_project_exists(self.data["new_name"]): - self.mark_success(f"[PASS] Project '{self.data['new_name']}' exists in Odoo.") - else: - self.mark_failure(f"[FAIL] Project '{self.data['new_name']}' not found in Odoo!") - - -class DeleteProjectTest(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"name": "ProjectB"} - - def run(self): - ts = get_timestamp() - execute_sql( - self.temp_db_path, - "UPDATE project_project_app SET status = 'deleted', last_modified = ? WHERE name = ?", - (ts, self.data["name"]) - ) - print(f"[TEST] Marked project '{self.data['name']}' for deletion") - self.sync_push() - - def check(self): - if not self.verify_project_exists(self.data["name"]): - self.mark_success(f"[PASS] Project '{self.data['name']}' is deleted from Odoo.") - else: - self.mark_failure(f"[FAIL] Project '{self.data['name']}' still exists in Odoo!") - - -class CreateTask(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"name": "ProjectA", "task_name": "TaskForProject"} - - def run(self): - ts = get_timestamp() - """row = execute_sql( - self.temp_db_path, - "SELECT odoo_record_id FROM project_project_app WHERE name = ?", - (self.data["name"],), - fetch=True - ) - if not row or not row[0][0]: - print(f"[ERROR] Odoo project ID not found for '{self.data['name']}'") - return - - odoo_project_id = int(row[0][0]) - """ - execute_sql( - self.temp_db_path, - "INSERT INTO project_task_app (name, project_id, account_id, last_modified, status) VALUES (?, ?, ?, ?, ?)", - (self.data["task_name"], 0, self.account_id, ts, "updated") - ) - print(f"[TEST] Created task '{self.data['task_name']}' under project '{self.data['name']}'") - self.sync_push() - - def check(self): - if self.verify_task_exists(self.data["task_name"]): - self.mark_success(f"[PASS] Task '{self.data['task_name']}' exists in Odoo.") - else: - self.mark_failure(f"[FAIL] Task '{self.data['task_name']}' not found in Odoo!") - - -class DeleteTask(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"task_name": "TaskForProject"} - - def run(self): - ts = get_timestamp() - row = execute_sql( - self.temp_db_path, - "SELECT id FROM project_task_app WHERE name = ?", - (self.data["task_name"],), - fetch=True - ) - if not row: - print(f"[ERROR] Task '{self.data['task_name']}' not found.") - return - - execute_sql( - self.temp_db_path, - "UPDATE project_task_app SET status = 'deleted', last_modified = ? WHERE name = ?", - (ts, self.data["task_name"]) - ) - print(f"[TEST] Marked task '{self.data['task_name']}' for deletion") - self.sync_push() - - def check(self): - if not self.verify_task_exists(self.data["task_name"]): - self.mark_success(f"[PASS] Task '{self.data['task_name']}' is deleted from Odoo.") - else: - self.mark_failure(f"[FAIL] Task '{self.data['task_name']}' still exists in Odoo!") - - -# --- Runner with summary - -class OdooTestRunner: - def __init__(self): - self.original_db = self.get_original_db_path() - self.temp_db_path = "test_data/temp_app.db" - self.account_index = 0 - self.account_id = 1 - - def get_original_db_path(self, db_filename="8912ff6b21d680ad2a2832070dca24d0.sqlite"): - base_path = Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "Databases" - full_path = base_path / db_filename - if not full_path.exists(): - raise FileNotFoundError(f"Database not found: {full_path}") - return str(full_path) - - def setup_temp_db(self): - shutil.copyfile(self.original_db, self.temp_db_path) - print(f"[SETUP] Copied original DB to temp: {self.temp_db_path}") - - def run_all_tests(self): - self.setup_temp_db() - """ - test_sequence = [ - CreateProjectTest, - CreateTask, - DeleteTask, - UpdateProjectTest, - DeleteProjectTest, - ] - """ - test_sequence = [ - CreateTask, - DeleteTask, - ] - test_instances = [] - for test_cls in test_sequence: - test = test_cls(self.temp_db_path, self.account_index, self.account_id) - try: - test.run() - test.check() - except Exception as e: - test.mark_failure(str(e)) - test_instances.append((test_cls.__name__, test.result)) - - print("\n=== TEST SUMMARY ===") - for name, result in test_instances: - print(f"{name}: {result['status']} - {result['message']}") - - -if __name__ == "__main__": - runner = OdooTestRunner() - runner.run_all_tests() diff --git a/src/backend.py b/src/backend.py deleted file mode 100755 index 9fda42f9..00000000 --- a/src/backend.py +++ /dev/null @@ -1,1678 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - - -from config import get_all_accounts, initialize_app_settings_db, update_last_synced_at, get_setting -from odoo_client import OdooClient -from sync_from_odoo import sync_all_from_odoo,sync_ondemand_tables_from_odoo -from sync_to_odoo import sync_all_to_odoo -from logger import setup_logger -from bus import send - -log = setup_logger() -import os -import sys -from pathlib import Path -import ctypes - -# Add root directory and voice_to_text/lib to sys.path -root_dir = Path(__file__).parent.parent.resolve() -if str(root_dir) not in sys.path: - sys.path.insert(0, str(root_dir)) - -voice_lib = root_dir / "voice_to_text" / "lib" -if voice_lib.exists() and str(voice_lib) not in sys.path: - sys.path.insert(0, str(voice_lib)) -import platform -import urllib3 -import json -import xmlrpc.client -from common import check_table_exists, write_sync_report_to_db -import threading -import base64 -import mimetypes - -sync_lock = threading.Lock() -sync_in_progress = False # Global flag - -urllib3.disable_warnings() - -http = urllib3.PoolManager(cert_reqs="CERT_NONE") - -# Voice model download state -download_status = { - "in_progress": False, - "progress": 0, - "message": "", - "error": "" -} - - -from urllib.parse import urlparse, unquote -import socket - -def check_server_reachability(url, timeout=3): - """ - Check if the Odoo server is reachable using a lightweight HTTP request. - This uses the same HTTP pool manager as the rest of the application - to ensure compatibility with Ubuntu Touch AppArmor sandbox rules. - """ - try: - normalized = (url or "").strip().rstrip("/") - if not normalized: - return False - - if "://" not in normalized: - normalized = f"https://{normalized}" - - # Try a HEAD request first (fast and lightweight) - response = http.request('HEAD', normalized, timeout=timeout, redirect=True) - # Any response (even error status codes like 404 or 403) means the server is reachable - return True - except Exception as e: - log.warning(f"[REACHABILITY] HTTP HEAD failed for {url}: {e}") - # Fallback to GET in case HEAD is blocked by the server - try: - response = http.request('GET', normalized, timeout=timeout, redirect=True) - return True - except Exception as e2: - log.warning(f"[REACHABILITY] HTTP GET fallback also failed: {e2}") - return False - - -def is_file_present(file_path): - """ - Check if a file exists at the specified path. - - Args: - file_path (str): Path to the file to check - - Returns: - bool: True if file exists and is a file, False otherwise - - Note: - Logs information about file existence status. - """ - file = Path(file_path) - if file.exists() and file.is_file(): - log.info(f"[INFO] File exists: {file_path}") - return True - else: - log.error(f"[ERROR] File NOT found: {file_path}") - return False - - -def resolve_qml_db_path(app_id="ubtms"): - """ - Resolve and find the QML application database path across different environments. - - Args: - app_id (str): Application identifier, defaults to "ubtms" - - Returns: - str or None: Path to the SQLite database file if found, None otherwise - - Note: - Searches for SQLite files in standard user directories and clickable sandbox. - Validates the database by checking for required tables like 'project_project_app'. - Returns the most recently modified valid database file. - """ - db_paths = [] - current_dir = Path(__file__).parent.resolve() - - # Real user home, e.g., /home/gokul - user_home = Path.home() - db_paths.append(user_home / ".local" / "share" / app_id / "Databases") - - # Clickable sandbox, e.g., /home/gokul/.clickable/home/ - clickable_home = user_home / ".clickable" / "home" - db_paths.append(clickable_home / ".local" / "share" / app_id / "Databases") - - for db_dir in db_paths: - log.debug(f"[DEBUG] Checking DB path: {db_dir}") - if not db_dir.exists(): - continue - - sqlite_files = list(db_dir.glob("*.sqlite")) - if sqlite_files: - latest = max(sqlite_files, key=lambda f: f.stat().st_mtime) - log.debug(f"[INFO] Found QML DB: {latest}") - if is_file_present(latest): - log.debug(f"file is present {latest}") - if check_table_exists(latest, "project_project_app"): - log.debug( - f"project_project_app present,confirms that {latest} app db" - ) - return str(latest) - else: - log.critical(f"SQlite file found , but do not see a table") - - log.debug("[ERROR] No QML DB found.") - return None - - -def fetch_databases(url): - """ - Fetch available databases from an Odoo server and determine UI visibility options. - - Args: - url (str): Odoo server URL - - Returns: - list: List of available database names - - Note: - Also sets visibility dictionary for UI components based on database count: - - text_field: True if no databases found - - single_db: Database name if only one database - - menu_items: List of databases if multiple found - """ - database_list = get_db_list(url) - visibility_dict = { - "menu_items": False, - "text_field": False, - "single_db": False, - } - - if not database_list: - visibility_dict["text_field"] = True - elif len(database_list) == 1: - visibility_dict["single_db"] = database_list[0] - else: - visibility_dict["menu_items"] = database_list - - return database_list - - -def login_odoo(selected_url, username, password, selected_db): - """ - Authenticate user credentials against an Odoo server. - - Args: - selected_url (str): Odoo server URL - username (str): Username for authentication - password (str): Password for authentication - selected_db (str): Database name to authenticate against - - Returns: - dict: Authentication result containing: - - status: "pass" if successful, "fail" if failed - - name_of_user: Full name of authenticated user - - database: Database name used - - uid: User ID from Odoo - - Note: - Uses Odoo's XML-RPC API for authentication and fetches user details on success. - """ - common = xmlrpc.client.ServerProxy("{}/xmlrpc/2/common".format(selected_url)) - generated_uid = common.authenticate(selected_db, username, password, {}) - if generated_uid: - models = xmlrpc.client.ServerProxy( - "{}/xmlrpc/2/object".format(selected_url), - ) - user_name = models.execute_kw( - selected_db, - generated_uid, - password, - "res.users", - "read", - [generated_uid], - {"fields": ["name"]}, - ) - return { - "status": "pass", - "name_of_user": user_name[0]["name"], - "database": selected_db, - "uid": generated_uid, - } - return {"result": "fail"} - - -def get_db_list(url): - """ - Retrieve list of available databases from an Odoo server. - - Args: - url (str): Odoo server URL - - Returns: - list: List of database names available on the server. Returns empty list on error. - - Note: - Makes HTTP POST request to /web/database/list endpoint. - Handles connection errors gracefully. - """ - try: - response = http.request( - "POST", - url + "/web/database/list", - body="{}", - headers={"Content-type": "application/json"}, - ) - if response.status == 200: - data = json.loads(response.data) - return data["result"] - else: - return [] - except Exception as e: - log.error(f"[Critical] No DB found {e}") - return [] - return [] - -import os, base64, mimetypes -from pathlib import Path - -def _app_data_dir(): - # adjust to your app-id if you want a subdir - return Path.home() / ".local/share" - -def _safe_ext_for(mime): - ext = mimetypes.guess_extension(mime or "") - return ext or "" - -# --- ADD BELOW EXISTING HELPERS --- - -def _export_path_for(suggested_name, mime): - """ - Build the canonical output path we use for exported attachments. - Keeps name normalization and mime-based extension logic in one place. - """ - base = _app_data_dir() / "ubtms" / "tmp" - base.mkdir(parents=True, exist_ok=True) - - name = (suggested_name or "attachment").strip().replace("/", "_") - root, ext = os.path.splitext(name) - if not ext: - ext = _safe_ext_for(mime) - return base / f"{root}{ext}" - -def get_existing_attachment_path(suggested_name, mime): - """ - Return absolute file path (str) if an attachment is already present on disk, - else None. - """ - out = _export_path_for(suggested_name, mime) - return str(out) if out.exists() and out.is_file() else None - -def is_already_downloaded(suggested_name, mime): - try: - out = _export_path_for(suggested_name, mime) - return is_file_present(out) - except Exception as e: - log.exception("is_already_downloaded error: %s", e) - return False - -def ensure_export_file_from_base64(suggested_name, b64_data, mime): - try: - out = _export_path_for(suggested_name, mime) - with open(out, "wb") as f: - f.write(base64.b64decode(b64_data)) - return str(out) - except Exception as e: - print("ensure_export_file_from_base64 error:", e) - return None - - -def attachment_ondemand_download(settings_db, account_id, remote_record_id): - accounts = get_all_accounts(settings_db) - selected = None - for acc in accounts: - if acc.get("id") == account_id: - selected = acc - break - - if not selected: - return { - "success": False, - "error": "Account not found", - } - - try: - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - result = client.ondemanddownload( - remote_record_id, - selected["username"], - selected["api_key"], - False, - ) - if isinstance(result, dict): - result["success"] = True - return result - except Exception as e: - log.exception( - "[ATTACHMENT] Failed to download attachment %s for account %s", - remote_record_id, - account_id, - ) - return { - "success": False, - "error": str(e), - } - -def attachment_upload(settings_db, account_id, filepath, res_type, res_id): - try: - filepath = unquote(filepath) - send("ondemand_upload_message", "Initiating upload...") - log.debug(f"[SYNC] Starting attachment_upload to {account_id} : {filepath} , {res_type} ,{res_id}") - accounts = get_all_accounts(settings_db) - selected = None - for acc in accounts: - if acc.get("id") == account_id: - selected = acc - break - - is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) - - if not selected and not is_local_account: - send("ondemand_upload_message", "Error: Account not found") - send("ondemand_upload_completed", False) - return None - - send("ondemand_upload_message", "Checking file...") - if not os.path.exists(filepath): - send("ondemand_upload_message", "Error: File does not exist") - send("ondemand_upload_completed", False) - return None - - # 25 MB limit to prevent memory exhaustion and large payload failures - file_size = os.path.getsize(filepath) - if file_size > 25 * 1024 * 1024: - send("ondemand_upload_message", "Error: File exceeds 25MB limit") - send("ondemand_upload_completed", False) - return None - - filename = os.path.basename(filepath) - EXT_TO_MIME = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.pdf': 'application/pdf', - '.txt': 'text/plain', - '.csv': 'text/csv', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.zip': 'application/zip' - } - send("ondemand_upload_message", "Reading file...") - ext = os.path.splitext(filename)[1].lower() - mimetype = EXT_TO_MIME.get(ext, 'application/octet-stream') - - # Handle Local Account attachment save - if is_local_account: - send("ondemand_upload_message", "Saving attachment locally...") - dest_path = _export_path_for(filename, mimetype) - import shutil - if Path(filepath).resolve() != Path(dest_path).resolve(): - shutil.copy2(filepath, dest_path) - - import sqlite3 - db_path = settings_db or resolve_qml_db_path() - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute("SELECT COALESCE(MAX(odoo_record_id), 0) + 1 FROM ir_attachment_app WHERE account_id = 0") - row = cursor.fetchone() - next_local_record_id = row[0] if (row and row[0] is not None and row[0] > 0) else 1 - - cursor.execute(""" - INSERT INTO ir_attachment_app ( - account_id, name, res_model, res_id, file_path, local_url, url, - file_size, mimetype, odoo_record_id, last_modified, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 'local') - """, ( - 0, - filename, - res_type, - res_id, - str(dest_path), - f"file://{dest_path}", - f"file://{dest_path}", - file_size, - mimetype, - next_local_record_id - )) - - cursor.execute(""" - INSERT OR REPLACE INTO attachment_download_app (account_id, record_id, file_name, downloaded) - VALUES (0, ?, ?, 1) - """, (next_local_record_id, filename)) - - conn.commit() - conn.close() - - send("ondemand_upload_message", "Attachment saved successfully") - send("ondemand_upload_completed", True) - return next_local_record_id - - # Remote Odoo Account upload - send("ondemand_upload_message", "Checking server connection...") - if not check_server_reachability(selected["link"]): - send("ondemand_upload_message", "Error: No internet connection or server unreachable") - send("ondemand_upload_completed", False) - return None - - file_bytes = None - with open(filepath, 'rb') as f: - file_bytes = f.read() - - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - - vals = { - 'name': filename, - 'type': 'binary', - 'res_model': res_type, - 'res_id': res_id, - 'datas': base64.b64encode(file_bytes).decode('utf-8'), - 'mimetype': mimetype - } - send("ondemand_upload_message", "Uploading file to server...") - attachment_id = client.call('ir.attachment', 'create', [vals]) - if not attachment_id or attachment_id <= 0: - send("ondemand_upload_message", "Error: Server failed to save attachment") - send("ondemand_upload_completed", False) - return None - - send("ondemand_upload_message", "Syncing to local device...") - sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) - send("ondemand_upload_completed", True) - return attachment_id - - except Exception as e: - error_msg = str(e) - log.exception(f"[ATTACHMENT] Failed to upload attachment: {error_msg}") - - # User-friendly error message - friendly_error = "Upload failed" - if "connection" in error_msg.lower() or "refused" in error_msg.lower(): - friendly_error = "Connection failed: Check network/server" - elif "timeout" in error_msg.lower(): - friendly_error = "Request timed out" - elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): - friendly_error = "Authentication failed: Check credentials" - elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): - friendly_error = f"Server error: {error_msg[:60]}" - else: - friendly_error = f"Error: {error_msg[:60]}" - - send("ondemand_upload_message", friendly_error) - send("ondemand_upload_completed", False) - return None - -def attachment_delete(settings_db, account_id, remote_record_id): - log.debug(f"[SYNC] Starting attachment_delete for account {account_id}, attachment {remote_record_id}") - accounts = get_all_accounts(settings_db) - selected = None - for acc in accounts: - if acc.get("id") == account_id: - selected = acc - break - - is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) - - if is_local_account: - try: - import sqlite3 - db_path = settings_db or resolve_qml_db_path() - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute( - "DELETE FROM ir_attachment_app WHERE account_id = 0 AND (odoo_record_id = ? OR id = ?)", - (remote_record_id, remote_record_id) - ) - cursor.execute( - "DELETE FROM attachment_download_app WHERE account_id = 0 AND record_id = ?", - (remote_record_id,) - ) - conn.commit() - conn.close() - - cleanup_orphan_attachment_files(settings_db) - return {"success": True} - except Exception as e: - log.exception(f"[ATTACHMENT] Failed to delete local attachment {remote_record_id}: {e}") - return {"success": False, "error": str(e)} - - if not selected: - return {"success": False, "error": "Account not found"} - - try: - # Check server reachability before proceeding - if not check_server_reachability(selected["link"]): - return {"success": False, "error": "No internet connection or server unreachable"} - - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - res = client.call('ir.attachment', 'unlink', [[remote_record_id]]) - if not res: - log.warning(f"Unlink returned false or empty response for attachment {remote_record_id}") - return {"success": False, "error": "Server failed to delete attachment"} - - sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) - return {"success": True} - except Exception as e: - error_msg = str(e) - log.exception(f"[ATTACHMENT] Failed to delete attachment {remote_record_id} for account {account_id}") - - friendly_error = "Delete failed" - if "connection" in error_msg.lower() or "refused" in error_msg.lower(): - friendly_error = "Connection failed: Check network/server" - elif "timeout" in error_msg.lower(): - friendly_error = "Request timed out" - elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): - friendly_error = "Authentication failed: Check credentials" - elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): - friendly_error = f"Server error: {error_msg[:60]}" - else: - friendly_error = f"Error: {error_msg[:60]}" - - return {"success": False, "error": friendly_error} - -def cleanup_orphan_attachment_files(settings_db=None): - """ - Removes cached temporary/exported attachment files from disk that are no longer referenced - by any active attachment record in the database. - """ - try: - tmp_dir = _app_data_dir() / "ubtms" / "tmp" - if not tmp_dir.exists(): - return {"success": True, "deleted_count": 0} - - db_path = settings_db or resolve_qml_db_path() - if not db_path or not Path(db_path).exists(): - return {"success": False, "error": "Database not found"} - - import sqlite3 - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - active_filenames = set() - try: - cursor.execute("SELECT file_name FROM attachment_download_app WHERE downloaded = 1") - for row in cursor.fetchall(): - if row[0]: - active_filenames.add(str(row[0]).strip().replace("/", "_")) - except Exception: - pass - - try: - cursor.execute("SELECT name FROM ir_attachment_app") - for row in cursor.fetchall(): - if row[0]: - active_filenames.add(str(row[0]).strip().replace("/", "_")) - except Exception: - pass - - conn.close() - - deleted_count = 0 - for file in tmp_dir.iterdir(): - if file.is_file(): - if file.name not in active_filenames and file.stem not in active_filenames: - try: - file.unlink() - deleted_count += 1 - log.info(f"[ATTACHMENT] Cleaned up orphan file on disk: {file.name}") - except Exception as err: - log.warning(f"[ATTACHMENT] Could not delete {file.name}: {err}") - - return {"success": True, "deleted_count": deleted_count} - except Exception as e: - log.exception(f"[ATTACHMENT] Error cleaning orphan attachment files: {e}") - return {"success": False, "error": str(e)} - -def sync(settings_db, account_id): - """ - Perform synchronous bidirectional sync between local database and Odoo. - - Args: - settings_db (str): Path to the settings database file - account_id (int): Account ID to sync - - Returns: - bool: True if sync completed successfully - - Note: - Performs complete sync including: - 1. Sync from Odoo to local database - 2. Sync from local database to Odoo - Updates sync report in database with progress and results. - """ - send("progress",0) - write_sync_report_to_db( - settings_db, account_id, "In Progress", "Sync job triggered" - ) - # initialize_app_settings_db(settings_db) done by js - accounts = get_all_accounts(settings_db) - selected = accounts[account_id] - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - send("progress",20) - log.debug("Syncing from oddo from server" + selected["link"]) - sync_all_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) - - log.debug("Syncing to odoo") - send("progress",50) - sync_all_to_odoo(client, selected["id"], settings_db) - - write_sync_report_to_db( - settings_db, account_id, "Successful", "Sync completed successfully" - ) - send("progress",100) - return True - - -def sync_background(settings_db, account_id): - """ - Perform asynchronous bidirectional sync in a background thread. - - Args: - settings_db (str): Path to the settings database file - account_id (int): Account ID to sync - - Returns: - bool: True if sync thread started successfully, False if sync already in progress - - Note: - Uses global sync_lock to prevent concurrent sync operations. - Runs sync in separate thread to avoid blocking UI. - Updates sync report with progress and handles errors gracefully. - """ - global sync_in_progress - - with sync_lock: - if sync_in_progress: - log.debug("[SYNC] Already in progress. Ignoring new request.") - return False - sync_in_progress = True - - def do_sync(): - global sync_in_progress - try: - send("sync_progress",0) - log.debug(f"[SYNC] Starting background sync to {settings_db}...") - write_sync_report_to_db( - settings_db, account_id, "In Progress", "Sync job triggered" - ) - # initialize_app_settings_db(settings_db) , done by js - accounts = get_all_accounts(settings_db) - selected = next((acc for acc in accounts if acc["id"] == account_id), None) - send("sync_progress",20) - - if not selected: - write_sync_report_to_db(settings_db, account_id, "Failed", "Account not found") - return - - # Proceed with syncing using `account` - log.debug(f"[SYNC] Found account: {selected['name']} (ID: {selected['id']})") - - send("sync_progress",25) - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - send("sync_progress",30) - log.debug("Syncing from oddo : ID Is " + selected["link"]) - sync_all_from_odoo(client, account_id, settings_db, account_name=selected.get("name", "")) - send("sync_progress",50) - log.debug("Syncing to odoo") - sync_all_to_odoo(client, account_id, settings_db) - send("sync_progress",90) - - log.debug("[SYNC] Background sync completed.") - write_sync_report_to_db( - settings_db, - account_id, - "Successful", - "Sync completed successfully", - ) - # Record successful sync timestamp for per-account interval tracking - update_last_synced_at(settings_db, account_id) - send("sync_progress",100) - send("sync_completed",True) - except Exception as e: - log.exception(f"[SYNC] Error during background sync: {e}") - write_sync_report_to_db(settings_db, account_id, "Failed", str(e)) - send("sync_completed",False) - finally: - with sync_lock: - sync_in_progress = False - - thread = threading.Thread(target=do_sync) - thread.start() - return True # Return immediately so QML doesn’t wait - - -def start_sync_in_background(settings_db, account_id): - """ - Start a background synchronization process. - - Args: - settings_db (str): Path to the settings database file - account_id (int): Account ID to sync - - Returns: - bool: Result from sync_background function - - Note: - Wrapper function for sync_background to provide a cleaner interface. - """ - return sync_background(settings_db, account_id) - -# Voice to Text STarts Here -_voice_stop_event = threading.Event() - -def stop_voice_recognition(): - """Signals the voice recognition thread to stop recording.""" - log.info("[VOICE] stop_voice_recognition called") - _voice_stop_event.set() - return True - -def get_voice_models_dir(): - """ - Returns the writable directory for voice models. - - On Ubuntu Touch this is normally: - ~/.local/share/ubtms/voice_models - """ - data_home = os.environ.get("XDG_DATA_HOME") - - if data_home: - base_dir = Path(data_home) / "ubtms" - else: - base_dir = Path.home() / ".local" / "share" / "ubtms" - - models_dir = base_dir / "voice_models" - models_dir.mkdir(parents=True, exist_ok=True) - - return models_dir - -def is_valid_vosk_model(model_path): - """ - Check whether a directory contains a supported Vosk model layout. - - Supported layouts: - 1. Mobile/legacy layout: - model/am/ - model/graph/ - - 2. Flat Kaldi/Vosk layout: - model/final.mdl - model/HCLG.fst - or - model/HCLr.fst - """ - try: - path = Path(model_path) - - if not path.is_dir(): - return False - - # Layout used by some Vosk mobile models - has_am_graph = ( - (path / "am").is_dir() and - (path / "graph").is_dir() - ) - - # Flat Kaldi/Vosk model layout - has_flat_model = ( - (path / "final.mdl").is_file() - and ( - (path / "HCLG.fst").is_file() - or (path / "HCLr.fst").is_file() - ) - ) - - return has_am_graph or has_flat_model - - except Exception as e: - log.warning(f"[VOICE] Error validating model {model_path}: {e}") - return False - -def list_installed_models(): - """ - Scans for installed Vosk models in both the app directory - and writable data directory. - Returns a list of dictionaries with model names, paths, - sizes, and sources. - """ - - # App directory models (read-only) - app_models_dir = root_dir / "voice_to_text" - - # User/downloaded models directory - user_models_dir = get_voice_models_dir() - - search_paths = [ - (app_models_dir, "App"), - (user_models_dir, "User"), - (Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "voice_models", "Clickable") - ] - - models = [] - seen_names = set() - - available_models = list_available_models() - - known_names = { - m["id"]: m["name"] - for m in available_models - if "id" in m - } - - # Bundled model - known_names["model"] = "Indian English" - - for root_dir_to_scan, source_label in search_paths: - if not root_dir_to_scan.exists(): - continue - - for item in root_dir_to_scan.iterdir(): - if item.is_dir() and item.name not in seen_names: - - # Supported Vosk model layouts: - # - # 1. Mobile layout: - # model/am/ - # model/graph/ - # - # 2. Standard Vosk/Kaldi layout: - # model/final.mdl - # model/HCLG.fst - # - # or: - # model/final.mdl - # model/HCLr.fst - - if not is_valid_vosk_model(item): - continue - - if item.name in known_names: - model_name = known_names[item.name] - - else: - # Fallback to folder name - model_name = item.name - - # Try README for a nicer display name - readme_path = item / "README" - - if readme_path.exists(): - try: - with open( - readme_path, - "r", - encoding="utf-8" - ) as f: - first_line = f.readline().strip() - - if first_line: - clean_name = first_line - - noise_phrases = [ - "for mobile Vosk applications", - "for Android and iOS", - "Vosk mobile model", - "Vosk model", - "Vosk", - "model" - ] - - for phrase in noise_phrases: - clean_name = clean_name.replace( - phrase, - "" - ).strip() - - if clean_name: - model_name = clean_name - - except Exception as e: - log.error( - f"[VOICE] Error reading README " - f"for {item.name}: {e}" - ) - - # Bundled models get "(Default)" - display_name = model_name - - if source_label == "App": - display_name = f"{model_name} (Default)" - - # Calculate directory size - total_size = 0 - - try: - for f in item.rglob("*"): - if f.is_file(): - total_size += f.stat().st_size - - size_mb = total_size / (1024 * 1024) - model_size = f"{size_mb:.1f} MB" - - except Exception: - model_size = "Unknown" - - # Store model information - try: - if source_label == "App": - rel_path = item.relative_to(root_dir) - else: - rel_path = item - - models.append({ - "m_name": display_name, - "m_path": str(rel_path), - "m_size": model_size, - "m_source": source_label - }) - - except ValueError: - models.append({ - "m_name": display_name, - "m_path": str(item), - "m_size": model_size, - "m_source": source_label - }) - - seen_names.add(item.name) - - models.sort(key=lambda x: x["m_name"].lower()) - - log.info( - f"[VOICE] Found {len(models)} installed models" - ) - - return models - -def get_installed_voice_models(): - """Returns a list of installed voice model IDs.""" - models_dir = get_voice_models_dir() - if not models_dir.exists(): - return [] - # Any directory in models_dir is considered an installed model - installed = [d.name for d in models_dir.iterdir() if d.is_dir()] - log.info(f"[VOICE] Found {len(installed)} installed models") - return installed - -def get_device_total_ram_mb(): - """Returns total device RAM in MB to check model compatibility.""" - try: - with open('/proc/meminfo', 'r') as f: - for line in f: - if line.startswith('MemTotal:'): - parts = line.split() - return int(parts[1]) // 1024 - except Exception: - pass - # Fallback using sysconf if /proc/meminfo is not available - try: - import os - pagesize = os.sysconf('SC_PAGE_SIZE') - pages = os.sysconf('SC_PHYS_PAGES') - return (pagesize * pages) // (1024 * 1024) - except Exception: - pass - return 2048 # Fallback to 2048MB - - -def get_paused_voice_models(): - """Returns a list of model IDs that have a partial download file.""" - models_dir = get_voice_models_dir() - if not models_dir.exists(): - return [] - paused = [] - for f in models_dir.glob("*.zip.tmp"): - paused.append(f.name.replace(".zip.tmp", "")) - return paused - - -def list_available_models(): - """ - Returns a list of models available for download. - """ - models = [ - {"id": "vosk-model-small-en-us-0.15", "name": "English (US, Small)", "size": "40M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"}, - {"id": "vosk-model-en-us-0.22", "name": "English (US)", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip"}, - {"id": "vosk-model-en-us-0.22-lgraph", "name": "English (US, LGraph)", "size": "128M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22-lgraph.zip"}, - {"id": "vosk-model-en-us-0.42-gigaspeech", "name": "English (US, Gigaspeech)", "size": "2.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip"}, - {"id": "vosk-model-en-us-daanzu-20200905", "name": "English (US, Daanzu)", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905.zip"}, - {"id": "vosk-model-en-us-daanzu-20200905-lgraph", "name": "English (US, Daanzu LGraph)", "size": "129M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905-lgraph.zip"}, - {"id": "vosk-model-en-us-librispeech-0.2", "name": "English (US, Librispeech)", "size": "845M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-librispeech-0.2.zip"}, - {"id": "vosk-model-small-en-us-zamia-0.5", "name": "English (US, Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-zamia-0.5.zip"}, - {"id": "vosk-model-en-us-aspire-0.2", "name": "English (US, Aspire)", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-aspire-0.2.zip"}, - {"id": "vosk-model-en-us-0.21", "name": "English (US, 0.21)", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.21.zip"}, - {"id": "vosk-model-en-in-0.5", "name": "English (Indian)", "size": "1G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-in-0.5.zip"}, - {"id": "vosk-model-small-en-in-0.4", "name": "English (Indian, Small)", "size": "36M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-in-0.4.zip"}, - {"id": "vosk-model-small-cn-0.22", "name": "Chinese (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"}, - {"id": "vosk-model-cn-0.22", "name": "Chinese", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip"}, - {"id": "vosk-model-cn-kaldi-multicn-0.15", "name": "Chinese (Multi-cn)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-kaldi-multicn-0.15.zip"}, - {"id": "vosk-model-ru-0.42", "name": "Russian", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.42.zip"}, - {"id": "vosk-model-small-ru-0.22", "name": "Russian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip"}, - {"id": "vosk-model-ru-0.22", "name": "Russian (0.22)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.22.zip"}, - {"id": "vosk-model-ru-0.10", "name": "Russian (0.10)", "size": "2.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.10.zip"}, - {"id": "vosk-model-small-fr-0.22", "name": "French (Small)", "size": "41M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip"}, - {"id": "vosk-model-fr-0.22", "name": "French", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.22.zip"}, - {"id": "vosk-model-small-fr-pguyot-0.3", "name": "French (pguyot Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-pguyot-0.3.zip"}, - {"id": "vosk-model-fr-0.6-linto-2.2.0", "name": "French (Linto)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.6-linto-2.2.0.zip"}, - {"id": "vosk-model-de-0.21", "name": "German", "size": "1.9G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip"}, - {"id": "vosk-model-de-tuda-0.6-900k", "name": "German (Tuda)", "size": "4.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-tuda-0.6-900k.zip"}, - {"id": "vosk-model-small-de-zamia-0.3", "name": "German (Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-zamia-0.3.zip"}, - {"id": "vosk-model-small-de-0.15", "name": "German (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip"}, - {"id": "vosk-model-small-es-0.42", "name": "Spanish (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip"}, - {"id": "vosk-model-es-0.42", "name": "Spanish", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-es-0.42.zip"}, - {"id": "vosk-model-small-pt-0.3", "name": "Portuguese (Small)", "size": "31M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip"}, - {"id": "vosk-model-pt-fb-v0.1.1-20220516_2113", "name": "Portuguese", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-pt-fb-v0.1.1-20220516_2113.zip"}, - {"id": "vosk-model-el-gr-0.7", "name": "Greek", "size": "1.1G", "url": "https://alphacephei.com/vosk/models/vosk-model-el-gr-0.7.zip"}, - {"id": "vosk-model-small-tr-0.3", "name": "Turkish (Small)", "size": "35M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip"}, - {"id": "vosk-model-small-vn-0.4", "name": "Vietnamese (Small)", "size": "32M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip"}, - {"id": "vosk-model-vn-0.4", "name": "Vietnamese", "size": "78M", "url": "https://alphacephei.com/vosk/models/vosk-model-vn-0.4.zip"}, - {"id": "vosk-model-small-it-0.22", "name": "Italian (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip"}, - {"id": "vosk-model-it-0.22", "name": "Italian", "size": "1.2G", "url": "https://alphacephei.com/vosk/models/vosk-model-it-0.22.zip"}, - {"id": "vosk-model-small-nl-0.22", "name": "Dutch (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip"}, - {"id": "vosk-model-nl-spraakherkenning-0.6", "name": "Dutch", "size": "860M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6.zip"}, - {"id": "vosk-model-nl-spraakherkenning-0.6-lgraph", "name": "Dutch (LGraph)", "size": "100M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6-lgraph.zip"}, - {"id": "vosk-model-small-ca-0.4", "name": "Catalan (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ca-0.4.zip"}, - {"id": "vosk-model-ar-mgb2-0.4", "name": "Arabic (mgb2)", "size": "318M", "url": "https://alphacephei.com/vosk/models/vosk-model-ar-mgb2-0.4.zip"}, - {"id": "vosk-model-ar-0.22-linto-1.1.0", "name": "Arabic (Linto)", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-ar-0.22-linto-1.1.0.zip"}, - {"id": "vosk-model-small-ar-tn-0.1-linto", "name": "Arabic Tunisian (Small)", "size": "158M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ar-tn-0.1-linto.zip"}, - {"id": "vosk-model-ar-tn-0.1-linto", "name": "Arabic Tunisian", "size": "517M", "url": "https://alphacephei.com/vosk/models/vosk-model-ar-tn-0.1-linto.zip"}, - {"id": "vosk-model-fa-0.42", "name": "Farsi", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-fa-0.42.zip"}, - {"id": "vosk-model-small-fa-0.42", "name": "Farsi (Small)", "size": "53M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.42.zip"}, - {"id": "vosk-model-fa-0.5", "name": "Farsi (0.5)", "size": "1G", "url": "https://alphacephei.com/vosk/models/vosk-model-fa-0.5.zip"}, - {"id": "vosk-model-small-fa-0.5", "name": "Farsi (Small 0.5)", "size": "60M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.5.zip"}, - {"id": "vosk-model-tl-ph-generic-0.6", "name": "Filipino", "size": "320M", "url": "https://alphacephei.com/vosk/models/vosk-model-tl-ph-generic-0.6.zip"}, - {"id": "vosk-model-small-uk-v3-nano", "name": "Ukrainian (Nano)", "size": "73M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-nano.zip"}, - {"id": "vosk-model-small-uk-v3-small", "name": "Ukrainian (Small)", "size": "133M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip"}, - {"id": "vosk-model-uk-v3", "name": "Ukrainian", "size": "343M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3.zip"}, - {"id": "vosk-model-uk-v3-lgraph", "name": "Ukrainian (LGraph)", "size": "325M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3-lgraph.zip"}, - {"id": "vosk-model-small-kz-0.42", "name": "Kazakh (Small)", "size": "58M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-kz-0.42.zip"}, - {"id": "vosk-model-kz-0.42", "name": "Kazakh", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-kz-0.42.zip"}, - {"id": "vosk-model-small-sv-rhasspy-0.15", "name": "Swedish (Small)", "size": "289M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-sv-rhasspy-0.15.zip"}, - {"id": "vosk-model-small-ja-0.22", "name": "Japanese (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip"}, - {"id": "vosk-model-ja-0.22", "name": "Japanese", "size": "1Gb", "url": "https://alphacephei.com/vosk/models/vosk-model-ja-0.22.zip"}, - {"id": "vosk-model-small-eo-0.42", "name": "Esperanto (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-eo-0.42.zip"}, - {"id": "vosk-model-small-hi-0.22", "name": "Hindi (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip"}, - {"id": "vosk-model-hi-0.22", "name": "Hindi", "size": "1.5Gb", "url": "https://alphacephei.com/vosk/models/vosk-model-hi-0.22.zip"}, - {"id": "vosk-model-small-cs-0.4-rhasspy", "name": "Czech (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip"}, - {"id": "vosk-model-small-pl-0.22", "name": "Polish (Small)", "size": "50M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip"}, - {"id": "vosk-model-small-uz-0.22", "name": "Uzbek (Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip"}, - {"id": "vosk-model-small-ko-0.22", "name": "Korean (Small)", "size": "82M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip"}, - {"id": "vosk-model-br-0.8", "name": "Breton", "size": "70M", "url": "https://alphacephei.com/vosk/models/vosk-model-br-0.8.zip"}, - {"id": "vosk-model-gu-0.42", "name": "Gujarati", "size": "700M", "url": "https://alphacephei.com/vosk/models/vosk-model-gu-0.42.zip"}, - {"id": "vosk-model-small-gu-0.42", "name": "Gujarati (Small)", "size": "100M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-gu-0.42.zip"}, - {"id": "vosk-model-tg-0.22", "name": "Tajik", "size": "327M", "url": "https://alphacephei.com/vosk/models/vosk-model-tg-0.22.zip"}, - {"id": "vosk-model-small-tg-0.22", "name": "Tajik (Small)", "size": "50M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tg-0.22.zip"}, - {"id": "vosk-model-small-te-0.42", "name": "Telugu (Small)", "size": "58M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-te-0.42.zip"}, - {"id": "vosk-model-small-ky-0.42", "name": "Kyrgyz (Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ky-0.42.zip"}, - {"id": "vosk-model-ky-0.42", "name": "Kyrgyz", "size": "1.1G", "url": "https://alphacephei.com/vosk/models/vosk-model-ky-0.42.zip"}, - {"id": "vosk-model-small-ka-0.42", "name": "Georgian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ka-0.42.zip"}, - {"id": "vosk-model-ka-0.42", "name": "Georgian", "size": "700M", "url": "https://alphacephei.com/vosk/models/vosk-model-ka-0.42.zip"} - ] - models.sort(key=lambda x: x["name"].lower()) - return models - - -def get_model_download_status(): - """Returns the current download status for UI polling.""" - return download_status - - -_voice_download_cancel_event = threading.Event() -_voice_download_action = None - -def cancel_voice_model_download(): - """Cancels an ongoing voice model download and deletes partial data.""" - global download_status, _voice_download_action - _voice_download_action = "cancel" - _voice_download_cancel_event.set() - download_status["in_progress"] = False - download_status["is_paused"] = False - download_status["message"] = "Download cancelled" - - # Clean up any partial download files immediately to prevent them from showing up on restart - models_dir = get_voice_models_dir() - if models_dir.exists(): - for f in models_dir.glob("*.zip.tmp"): - try: - f.unlink() - except Exception: - pass - - return {"status": "cancelled"} - -def pause_voice_model_download(): - """Pauses an ongoing voice model download, keeping partial data.""" - global download_status, _voice_download_action - _voice_download_action = "pause" - _voice_download_cancel_event.set() - download_status["in_progress"] = False - download_status["is_paused"] = True - download_status["message"] = "Download paused" - return {"status": "paused"} - -def download_voice_model(model_id, url): - """ - Initiates a background thread to download and extract a voice model. - """ - global download_status - _voice_download_cancel_event.clear() - if download_status["in_progress"]: - return {"status": "error", "message": "Download already in progress"} - - download_status = { - "in_progress": True, - "progress": download_status.get("progress", 0), - "message": "Starting download...", - "error": "", - "model_id": model_id, - "is_paused": False - } - - def do_download(): - import zipfile - import shutil - temp_zip = None - temp_extract_dir = None - try: - models_dir = get_voice_models_dir() - target_path = models_dir / model_id - - if target_path.exists(): - download_status["in_progress"] = False - download_status["message"] = "Model already installed" - download_status["progress"] = 100 - return - - temp_zip = models_dir / f"{model_id}.zip.tmp" - existing_size = 0 - if temp_zip.exists(): - existing_size = temp_zip.stat().st_size - - headers = {} - if existing_size > 0: - headers['Range'] = f'bytes={existing_size}-' - log.info(f"[VOICE] Resuming {model_id} from {existing_size} bytes") - download_status["message"] = "Resuming..." - else: - log.info(f"[VOICE] Downloading model from {url}") - download_status["message"] = "Downloading..." - - # Using urllib3 for download with a timeout to handle sudden internet disconnection - response = http.request('GET', url, headers=headers, preload_content=False, timeout=urllib3.Timeout(connect=5.0, read=10.0)) - if response.status not in (200, 206, 416): - raise Exception(f"Server returned status {response.status}") - - if response.status == 200 and existing_size > 0: - # Server ignored Range header, restart from scratch - log.info(f"[VOICE] Server ignored Range, restarting download") - existing_size = 0 - temp_zip.unlink() - - content_length = response.getheader('Content-Length') - total_size = (int(content_length) + existing_size) if content_length else None - - if total_size and existing_size == 0: - # Check available disk space only on new downloads - try: - _, _, free = shutil.disk_usage(str(models_dir)) - free_mb = free / (1024 * 1024) - required_mb = (total_size * 2.5) / (1024 * 1024) - if free < total_size * 2.5: - raise Exception(f"Insufficient storage. Have {free_mb:.1f} MB, need at least {required_mb:.1f} MB available.") - except Exception as disk_err: - if isinstance(disk_err, Exception) and "Insufficient storage" in str(disk_err): - raise disk_err - log.warning(f"[VOICE] Could not check disk space: {disk_err}") - - downloaded = existing_size - mode = 'ab' if existing_size > 0 else 'wb' - with open(temp_zip, mode) as f: - for chunk in response.stream(1024 * 64): - if _voice_download_cancel_event.is_set(): - action = _voice_download_action - if action == "pause": - raise Exception("PAUSED_BY_USER") - else: - raise Exception("CANCELLED_BY_USER") - f.write(chunk) - downloaded += len(chunk) - if total_size: - progress = int((downloaded / total_size) * 80) # 80% for download - download_status["progress"] = progress - send("download_progress", progress) - - download_status["message"] = "Extracting..." - send("download_message", "Extracting...") - download_status["progress"] = 85 - send("download_progress", 85) - - # Extract to a temporary directory to ensure atomic installation - temp_extract_dir = models_dir / f"{model_id}.extract.tmp" - if temp_extract_dir.exists(): - shutil.rmtree(temp_extract_dir) - temp_extract_dir.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(temp_zip) as z: - z.extractall(temp_extract_dir) - - # Vosk models usually have a single folder inside the zip - # We want to find the directory containing the actual model files ('am' and 'graph') - model_folder = None - for root, dirs, files in os.walk(temp_extract_dir): - if 'am' in dirs and 'graph' in dirs: - model_folder = Path(root) - break - - if not model_folder: - # Fallback: look for any subdirectory - subdirs = [d for d in temp_extract_dir.iterdir() if d.is_dir()] - if len(subdirs) == 1: - model_folder = subdirs[0] - else: - model_folder = temp_extract_dir - - # Move to final location (atomic move on same filesystem) - if target_path.exists(): - shutil.rmtree(target_path) - shutil.move(str(model_folder), str(target_path)) - - download_status["progress"] = 100 - download_status["message"] = "Installation complete" - download_status["in_progress"] = False - send("download_progress", 100) - send("download_completed", True) - log.info(f"[VOICE] Successfully installed model {model_id}") - - except Exception as e: - err_str = str(e) - log.exception(f"[VOICE] Download interrupted: {err_str}") - download_status["in_progress"] = False - - if err_str == "PAUSED_BY_USER": - download_status["is_paused"] = True - download_status["message"] = "Paused" - # Do not unlink temp_zip - elif err_str == "CANCELLED_BY_USER": - download_status["is_paused"] = False - download_status["message"] = "Cancelled" - if temp_zip and temp_zip.exists(): - try: - temp_zip.unlink() - except: - pass - else: - # Network error or other failure - download_status["error"] = err_str - download_status["message"] = "Failed" - download_status["is_paused"] = True # Keep partial file for resume - send("download_error", err_str) - - finally: - if temp_extract_dir and temp_extract_dir.exists(): - try: - shutil.rmtree(temp_extract_dir) - except Exception: - pass - - threading.Thread(target=do_download, daemon=True).start() - return {"status": "started"} - - -def delete_voice_model(model_path): - """ - Deletes a voice model from the user data directory. - Cannot delete bundled models in the app directory. - """ - try: - path = Path(model_path) - - user_models_dir = get_voice_models_dir() - clickable_models_dir = ( - Path.home() / ".clickable" / "home" / - ".local" / "share" / "ubtms" / "voice_models" - ) - - # If relative, it might be a bundled model or a legacy relative path - if not path.is_absolute(): - # Check user and Clickable model directories - potential_path = user_models_dir / model_path - if potential_path.exists(): - path = potential_path - else: - potential_path = clickable_models_dir / model_path - if potential_path.exists(): - path = potential_path - else: - # Check if it's relative to root (bundled models) - potential_path = root_dir / model_path - if potential_path.exists(): - path = potential_path - - if not path.exists(): - return {"status": "error", "message": "Model path not found"} - - # Security check: resolve paths before validation - resolved_path = path.resolve() - resolved_user_dir = user_models_dir.resolve() - resolved_clickable_dir = clickable_models_dir.resolve() - - if ( - resolved_user_dir in resolved_path.parents - or resolved_clickable_dir in resolved_path.parents - ): - import shutil - if resolved_path.is_dir(): - shutil.rmtree(resolved_path) - else: - resolved_path.unlink() - log.info(f"[VOICE] Deleted user model: {model_path}") - return {"status": "success"} - - # Check if it's in the app dir - app_models_dir = (root_dir / "voice_to_text").resolve() - if app_models_dir in resolved_path.parents or resolved_path == app_models_dir: - log.warning(f"[VOICE] Attempted to delete bundled model: {model_path}") - return {"status": "error", "message": "Cannot delete bundled system models"} - - return {"status": "error", "message": "Permission denied"} - except Exception as e: - log.exception(f"[VOICE] Failed to delete model {model_path}: {e}") - return {"status": "error", "message": str(e)} - - -def resolve_settings_db_path(db_name="myDatabase", app_id="ubtms"): - """Finds a specific QML database by its name (hash).""" - import hashlib - db_hash = hashlib.md5(db_name.encode()).hexdigest() - target_filename = f"{db_hash}.sqlite" - - user_home = Path.home() - db_paths = [ - user_home / ".local" / "share" / app_id / "Databases", - user_home / ".clickable" / "home" / ".local" / "share" / app_id / "Databases" - ] - - for db_dir in db_paths: - target = db_dir / target_filename - if target.exists(): - return str(target) - return None - - -def _subprocess_recognize(model_path, pipe, timeout): - """Runs voice recognition in a separate process to avoid blocking the GIL.""" - import signal - def sigterm_handler(signum, frame): - raise SystemExit(0) - signal.signal(signal.SIGTERM, sigterm_handler) - - try: - from voice_to_text.voice2text import recognize_from_mic - - class PipeStopEvent: - def is_set(self): - if pipe.poll(): - msg = pipe.recv() - if msg == "stop": - return True - return False - - def _partial(txt): - pipe.send(("partial", txt)) - - def _status(txt): - pipe.send(("status", txt)) - - text, err = recognize_from_mic(verbose=False, stop_event=PipeStopEvent(), timeout=timeout, partial_callback=_partial, status_callback=_status, model_path=model_path) - pipe.send(("final", text, err)) - except Exception as e: - import traceback - pipe.send(("final", None, f"Subprocess Error: {e}")) - - -def run_voice_recognition(): - """ - Runs voice recognition in a background thread to avoid blocking the UI. - Uses the offline Vosk engine. - """ - def do_recognition(): - import multiprocessing - try: - log.info("[VOICE] Starting offline voice recognition thread") - - # Fetch the active model path from settings - # First try the specific settings DB - db_path = resolve_settings_db_path("myDatabase") - if not db_path: - # Fallback to main app DB - db_path = resolve_qml_db_path() - - low_mem_mode = True # Default to True on mobile - if db_path: - try: - low_mem_mode = get_setting(db_path, "voice_low_memory_mode", "true") == "true" - except Exception as e: - log.warning(f"[VOICE] Could not read low memory setting: {e}") - - active_model_rel_path = "" - if db_path: - active_model_rel_path = get_setting(db_path, "active_voice_model", "") - - # Resolve relative path if necessary - root_dir = Path(__file__).parent.parent.resolve() - - model_path = None - if active_model_rel_path: - if not os.path.isabs(active_model_rel_path): - model_path = root_dir / active_model_rel_path - else: - model_path = Path(active_model_rel_path) - - if not model_path or not model_path.exists(): - installed_models = list_installed_models() - if installed_models: - active_model_rel_path = installed_models[0]["m_path"] - if not os.path.isabs(active_model_rel_path): - model_path = root_dir / active_model_rel_path - else: - model_path = Path(active_model_rel_path) - - if db_path: - try: - from config import set_setting - set_setting(db_path, "active_voice_model", active_model_rel_path) - except Exception as e: - log.warning(f"[VOICE] Could not auto-save active model to db: {e}") - else: - log.error("[VOICE] No language model downloaded") - send("voice_recognition_error", "No language model downloaded. Please download one in Voice Model Settings.") - return - - # Check model graph size to prevent Out of Memory crash - if model_path and model_path.exists(): - # If low memory mode is enabled, temporarily disable rescoring models - # to save ~1.5 - 2 GB of RAM. - rescore_path = model_path / "rescore" - rescore_disabled_path = model_path / "rescore.disabled" - rnnlm_path = model_path / "rnnlm" - rnnlm_disabled_path = model_path / "rnnlm.disabled" - - if low_mem_mode: - if rescore_path.exists() and rescore_path.is_dir(): - try: - rescore_path.rename(rescore_disabled_path) - log.info("[VOICE] Disabled rescore folder for low memory mode") - except Exception as e: - log.warning(f"[VOICE] Could not rename rescore folder: {e}") - if rnnlm_path.exists() and rnnlm_path.is_dir(): - try: - rnnlm_path.rename(rnnlm_disabled_path) - log.info("[VOICE] Disabled rnnlm folder for low memory mode") - except Exception as e: - log.warning(f"[VOICE] Could not rename rnnlm folder: {e}") - else: - if rescore_disabled_path.exists() and rescore_disabled_path.is_dir(): - try: - rescore_disabled_path.rename(rescore_path) - log.info("[VOICE] Re-enabled rescore folder") - except Exception as e: - log.warning(f"[VOICE] Could not restore rescore folder: {e}") - if rnnlm_disabled_path.exists() and rnnlm_disabled_path.is_dir(): - try: - rnnlm_disabled_path.rename(rnnlm_path) - log.info("[VOICE] Re-enabled rnnlm folder") - except Exception as e: - log.warning(f"[VOICE] Could not restore rnnlm folder: {e}") - - try: - # Check the size of the search graph file HCLG.fst, which is the main driver of RAM usage. - # If HCLG.fst is > 1.3 GB, loading it requires 4+ GB of RAM and will crash the app cgroup on mobile. - hclg_path = model_path / "graph" / "HCLG.fst" - if hclg_path.exists(): - hclg_size = hclg_path.stat().st_size - # With low memory mode (rescoring disabled), we can support up to 1.8 GB HCLG.fst. - # Without it, we limit it to 1.0 GB. - limit = 1.8 * 1024 * 1024 * 1024 if low_mem_mode else 1.0 * 1024 * 1024 * 1024 - if hclg_size > limit: - size_mb = hclg_size / (1024 * 1024) - if not low_mem_mode: - err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large to run with rescoring enabled. Please enable 'Low Memory Mode' in voice settings to run it." - else: - err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large even for Low Memory Mode. Please switch to the LGraph or Small version in settings." - log.error(f"[VOICE] {err_msg}") - send("voice_recognition_error", err_msg) - return - except Exception as e: - log.warning(f"[VOICE] Could not check model graph size: {e}") - - # Paths to search for bundled libraries - base_voice_path = Path(__file__).parent.parent / "voice_to_text" - lib_path = base_voice_path / "lib" - - # Pre-load libatomic if it exists in our bundle (fixes arm64 dependency issue) - atomic_lib = lib_path / "libatomic.so.1" - if atomic_lib.exists(): - try: - ctypes.CDLL(str(atomic_lib)) - log.info(f"[VOICE] Pre-loaded {atomic_lib}") - except Exception as e: - log.warning(f"[VOICE] Could not pre-load libatomic: {e}") - - # Add bundled libs to environment for nested dependencies - env_path = str(lib_path) - if "LD_LIBRARY_PATH" in os.environ: - os.environ["LD_LIBRARY_PATH"] = f"{env_path}:{os.environ['LD_LIBRARY_PATH']}" - else: - os.environ["LD_LIBRARY_PATH"] = env_path - - from voice_to_text.voice2text import list_microphones - - # Reset the stop event - _voice_stop_event.clear() - - # Log available mics for debug - log.info(f"[VOICE] mics: {list_microphones()}") - - # Run recognition in a separate process to avoid GIL blocking and UI freezing - parent_conn, child_conn = multiprocessing.Pipe() - ctx = multiprocessing.get_context("spawn") - p = ctx.Process(target=_subprocess_recognize, args=(str(model_path), child_conn, 30)) - p.start() - - import time - received_final = False - stop_sent_time = None - while p.is_alive(): - if _voice_stop_event.is_set(): - parent_conn.send("stop") - _voice_stop_event.clear() - if stop_sent_time is None: - stop_sent_time = time.time() - - # Watchdog: terminate if process ignores stop signal for > 5 seconds - if stop_sent_time is not None and (time.time() - stop_sent_time > 5.0): - log.warning("[VOICE] Watchdog triggered: Process failed to stop cleanly. Terminating.") - p.terminate() - break - - if parent_conn.poll(0.5): - msg = parent_conn.recv() - if msg[0] == "partial": - send("voice_recognition_partial", msg[1]) - elif msg[0] == "status": - send("voice_recognition_status", msg[1]) - elif msg[0] == "final": - received_final = True - text, error = msg[1], msg[2] - if text: - log.info(f"[VOICE] Recognized text: {text}") - send("voice_recognition_result", text) - else: - log.warning(f"[VOICE] Recognition failed: {error or 'No speech detected'}") - send("voice_recognition_error", error or "No speech detected") - break - - # Ensure process cleanup - p.join(timeout=1) - if p.is_alive(): - p.terminate() - p.join() - - # Handle unexpected crash (like OOM kill) - if not received_final: - exit_code = p.exitcode - if exit_code is not None and exit_code != 0: - err_msg = f"Voice recognition crashed unexpectedly (possibly Out of Memory). Exit code: {exit_code}" - log.error(f"[VOICE] {err_msg}") - send("voice_recognition_error", err_msg) - - except Exception as e: - log.exception(f"[VOICE] Error during voice recognition: {e}") - send("voice_recognition_error", f"System Error: {str(e)}") - - thread = threading.Thread(target=do_recognition) - thread.daemon = True - thread.start() - return True diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 00000000..5c5b2b31 --- /dev/null +++ b/src/backend/__init__.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +from pathlib import Path + +# Add project root and voice_to_text/lib to sys.path +root_dir = Path(__file__).resolve().parents[2] +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) + +voice_lib = root_dir / "voice_to_text" / "lib" +if voice_lib.exists() and str(voice_lib) not in sys.path: + sys.path.insert(0, str(voice_lib)) + +from logger import setup_logger +log = setup_logger() + +from .attachments import ( + attachment_delete, + attachment_ondemand_download, + attachment_upload, + cleanup_orphan_attachment_files, +) +from .auth import ( + check_server_reachability, + fetch_databases, + get_db_list, + http, + login_odoo, +) +from .storage import ( + _app_data_dir, + _export_path_for, + _safe_ext_for, + ensure_export_file_from_base64, + get_existing_attachment_path, + is_already_downloaded, + is_file_present, + resolve_qml_db_path, + resolve_settings_db_path, +) +from .sync import ( + start_sync_in_background, + sync, + sync_background, + sync_in_progress, + sync_lock, +) +from .voice import ( + cancel_voice_model_download, + delete_voice_model, + download_status, + download_voice_model, + get_device_total_ram_mb, + get_installed_voice_models, + get_model_download_status, + get_paused_voice_models, + get_voice_models_dir, + is_valid_vosk_model, + list_available_models, + list_installed_models, + pause_voice_model_download, + run_voice_recognition, + stop_voice_recognition, +) + +__all__ = [ + # Logger and HTTP + "log", + "http", + # Auth + "check_server_reachability", + "fetch_databases", + "login_odoo", + "get_db_list", + # Storage + "is_file_present", + "resolve_qml_db_path", + "resolve_settings_db_path", + "_app_data_dir", + "_safe_ext_for", + "_export_path_for", + "get_existing_attachment_path", + "is_already_downloaded", + "ensure_export_file_from_base64", + # Attachments + "attachment_ondemand_download", + "attachment_upload", + "attachment_delete", + "cleanup_orphan_attachment_files", + # Sync + "sync", + "sync_background", + "start_sync_in_background", + "sync_lock", + "sync_in_progress", + # Voice + "stop_voice_recognition", + "get_voice_models_dir", + "is_valid_vosk_model", + "list_installed_models", + "get_installed_voice_models", + "get_device_total_ram_mb", + "get_paused_voice_models", + "list_available_models", + "get_model_download_status", + "cancel_voice_model_download", + "pause_voice_model_download", + "download_voice_model", + "delete_voice_model", + "run_voice_recognition", + "download_status", +] diff --git a/src/backend/attachments.py b/src/backend/attachments.py new file mode 100644 index 00000000..e2632203 --- /dev/null +++ b/src/backend/attachments.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import base64 +import os +import shutil +import sqlite3 +from pathlib import Path +from urllib.parse import unquote + +from bus import send +from config import get_all_accounts +from logger import setup_logger +from odoo_client import OdooClient +from sync_from_odoo import sync_ondemand_tables_from_odoo + +from .auth import check_server_reachability +from .storage import _app_data_dir, _export_path_for, resolve_qml_db_path + +log = setup_logger() + + +def attachment_ondemand_download(settings_db, account_id, remote_record_id): + accounts = get_all_accounts(settings_db) + selected = None + for acc in accounts: + if acc.get("id") == account_id: + selected = acc + break + + if not selected: + return { + "success": False, + "error": "Account not found", + } + + try: + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + result = client.ondemanddownload( + remote_record_id, + selected["username"], + selected["api_key"], + False, + ) + if isinstance(result, dict): + result["success"] = True + return result + except Exception as e: + log.exception( + "[ATTACHMENT] Failed to download attachment %s for account %s", + remote_record_id, + account_id, + ) + return { + "success": False, + "error": str(e), + } + + +def attachment_upload(settings_db, account_id, filepath, res_type, res_id): + try: + filepath = unquote(filepath) + send("ondemand_upload_message", "Initiating upload...") + log.debug(f"[SYNC] Starting attachment_upload to {account_id} : {filepath} , {res_type} ,{res_id}") + accounts = get_all_accounts(settings_db) + selected = None + for acc in accounts: + if acc.get("id") == account_id: + selected = acc + break + + is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) + + if not selected and not is_local_account: + send("ondemand_upload_message", "Error: Account not found") + send("ondemand_upload_completed", False) + return None + + send("ondemand_upload_message", "Checking file...") + if not os.path.exists(filepath): + send("ondemand_upload_message", "Error: File does not exist") + send("ondemand_upload_completed", False) + return None + + # 25 MB limit to prevent memory exhaustion and large payload failures + file_size = os.path.getsize(filepath) + if file_size > 25 * 1024 * 1024: + send("ondemand_upload_message", "Error: File exceeds 25MB limit") + send("ondemand_upload_completed", False) + return None + + filename = os.path.basename(filepath) + EXT_TO_MIME = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".zip": "application/zip", + } + send("ondemand_upload_message", "Reading file...") + ext = os.path.splitext(filename)[1].lower() + mimetype = EXT_TO_MIME.get(ext, "application/octet-stream") + + # Handle Local Account attachment save + if is_local_account: + send("ondemand_upload_message", "Saving attachment locally...") + dest_path = _export_path_for(filename, mimetype) + if Path(filepath).resolve() != Path(dest_path).resolve(): + shutil.copy2(filepath, dest_path) + + db_path = settings_db or resolve_qml_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("SELECT COALESCE(MAX(odoo_record_id), 0) + 1 FROM ir_attachment_app WHERE account_id = 0") + row = cursor.fetchone() + next_local_record_id = row[0] if (row and row[0] is not None and row[0] > 0) else 1 + + cursor.execute(""" + INSERT INTO ir_attachment_app ( + account_id, name, res_model, res_id, file_path, local_url, url, + file_size, mimetype, odoo_record_id, last_modified, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 'local') + """, ( + 0, + filename, + res_type, + res_id, + str(dest_path), + f"file://{dest_path}", + f"file://{dest_path}", + file_size, + mimetype, + next_local_record_id, + )) + + cursor.execute(""" + INSERT OR REPLACE INTO attachment_download_app (account_id, record_id, file_name, downloaded) + VALUES (0, ?, ?, 1) + """, (next_local_record_id, filename)) + + conn.commit() + conn.close() + + send("ondemand_upload_message", "Attachment saved successfully") + send("ondemand_upload_completed", True) + return next_local_record_id + + # Remote Odoo Account upload + send("ondemand_upload_message", "Checking server connection...") + if not check_server_reachability(selected["link"]): + send("ondemand_upload_message", "Error: No internet connection or server unreachable") + send("ondemand_upload_completed", False) + return None + + with open(filepath, "rb") as f: + file_bytes = f.read() + + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + + vals = { + "name": filename, + "type": "binary", + "res_model": res_type, + "res_id": res_id, + "datas": base64.b64encode(file_bytes).decode("utf-8"), + "mimetype": mimetype, + } + send("ondemand_upload_message", "Uploading file to server...") + attachment_id = client.call("ir.attachment", "create", [vals]) + if not attachment_id or attachment_id <= 0: + send("ondemand_upload_message", "Error: Server failed to save attachment") + send("ondemand_upload_completed", False) + return None + + send("ondemand_upload_message", "Syncing to local device...") + sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) + send("ondemand_upload_completed", True) + return attachment_id + + except Exception as e: + error_msg = str(e) + log.exception(f"[ATTACHMENT] Failed to upload attachment: {error_msg}") + + friendly_error = "Upload failed" + if "connection" in error_msg.lower() or "refused" in error_msg.lower(): + friendly_error = "Connection failed: Check network/server" + elif "timeout" in error_msg.lower(): + friendly_error = "Request timed out" + elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): + friendly_error = "Authentication failed: Check credentials" + elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): + friendly_error = f"Server error: {error_msg[:60]}" + else: + friendly_error = f"Error: {error_msg[:60]}" + + send("ondemand_upload_message", friendly_error) + send("ondemand_upload_completed", False) + return None + + +def attachment_delete(settings_db, account_id, remote_record_id): + log.debug(f"[SYNC] Starting attachment_delete for account {account_id}, attachment {remote_record_id}") + accounts = get_all_accounts(settings_db) + selected = None + for acc in accounts: + if acc.get("id") == account_id: + selected = acc + break + + is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) + + if is_local_account: + try: + db_path = settings_db or resolve_qml_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + "DELETE FROM ir_attachment_app WHERE account_id = 0 AND (odoo_record_id = ? OR id = ?)", + (remote_record_id, remote_record_id), + ) + cursor.execute( + "DELETE FROM attachment_download_app WHERE account_id = 0 AND record_id = ?", + (remote_record_id,), + ) + conn.commit() + conn.close() + + cleanup_orphan_attachment_files(settings_db) + return {"success": True} + except Exception as e: + log.exception(f"[ATTACHMENT] Failed to delete local attachment {remote_record_id}: {e}") + return {"success": False, "error": str(e)} + + if not selected: + return {"success": False, "error": "Account not found"} + + try: + if not check_server_reachability(selected["link"]): + return {"success": False, "error": "No internet connection or server unreachable"} + + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + res = client.call("ir.attachment", "unlink", [[remote_record_id]]) + if not res: + log.warning(f"Unlink returned false or empty response for attachment {remote_record_id}") + return {"success": False, "error": "Server failed to delete attachment"} + + sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) + return {"success": True} + except Exception as e: + error_msg = str(e) + log.exception(f"[ATTACHMENT] Failed to delete attachment {remote_record_id} for account {account_id}") + + friendly_error = "Delete failed" + if "connection" in error_msg.lower() or "refused" in error_msg.lower(): + friendly_error = "Connection failed: Check network/server" + elif "timeout" in error_msg.lower(): + friendly_error = "Request timed out" + elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): + friendly_error = "Authentication failed: Check credentials" + elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): + friendly_error = f"Server error: {error_msg[:60]}" + else: + friendly_error = f"Error: {error_msg[:60]}" + + return {"success": False, "error": friendly_error} + + +def cleanup_orphan_attachment_files(settings_db=None): + """ + Removes cached temporary/exported attachment files from disk that are no longer referenced + by any active attachment record in the database. + """ + try: + tmp_dir = _app_data_dir() / "ubtms" / "tmp" + if not tmp_dir.exists(): + return {"success": True, "deleted_count": 0} + + db_path = settings_db or resolve_qml_db_path() + if not db_path or not Path(db_path).exists(): + return {"success": False, "error": "Database not found"} + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + active_filenames = set() + try: + cursor.execute("SELECT file_name FROM attachment_download_app WHERE downloaded = 1") + for row in cursor.fetchall(): + if row[0]: + active_filenames.add(str(row[0]).strip().replace("/", "_")) + except Exception: + pass + + try: + cursor.execute("SELECT name FROM ir_attachment_app") + for row in cursor.fetchall(): + if row[0]: + active_filenames.add(str(row[0]).strip().replace("/", "_")) + except Exception: + pass + + conn.close() + + deleted_count = 0 + for file in tmp_dir.iterdir(): + if file.is_file(): + if file.name not in active_filenames and file.stem not in active_filenames: + try: + file.unlink() + deleted_count += 1 + log.info(f"[ATTACHMENT] Cleaned up orphan file on disk: {file.name}") + except Exception as err: + log.warning(f"[ATTACHMENT] Could not delete {file.name}: {err}") + + return {"success": True, "deleted_count": deleted_count} + except Exception as e: + log.exception(f"[ATTACHMENT] Error cleaning orphan attachment files: {e}") + return {"success": False, "error": str(e)} diff --git a/src/backend/auth.py b/src/backend/auth.py new file mode 100644 index 00000000..6be8ae53 --- /dev/null +++ b/src/backend/auth.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import json +import xmlrpc.client +import urllib3 +from logger import setup_logger + +log = setup_logger() + +urllib3.disable_warnings() +http = urllib3.PoolManager(cert_reqs="CERT_NONE") + + +def check_server_reachability(url, timeout=3): + """ + Check if the Odoo server is reachable using a lightweight HTTP request. + Uses urllib3 PoolManager to ensure compatibility with Ubuntu Touch AppArmor sandbox rules. + """ + try: + normalized = (url or "").strip().rstrip("/") + if not normalized: + return False + + if "://" not in normalized: + normalized = f"https://{normalized}" + + response = http.request("HEAD", normalized, timeout=timeout, redirect=True) + return True + except Exception as e: + log.warning(f"[REACHABILITY] HTTP HEAD failed for {url}: {e}") + try: + response = http.request("GET", normalized, timeout=timeout, redirect=True) + return True + except Exception as e2: + log.warning(f"[REACHABILITY] HTTP GET fallback also failed: {e2}") + return False + + +def get_db_list(url): + """ + Retrieve list of available databases from an Odoo server. + """ + try: + response = http.request( + "POST", + url.rstrip("/") + "/web/database/list", + body="{}", + headers={"Content-type": "application/json"}, + ) + if response.status == 200: + data = json.loads(response.data) + return data.get("result", []) + return [] + except Exception as e: + log.error(f"[Critical] No DB found {e}") + return [] + + +def fetch_databases(url): + """ + Fetch available databases from an Odoo server and determine UI visibility options. + """ + database_list = get_db_list(url) + visibility_dict = { + "menu_items": False, + "text_field": False, + "single_db": False, + } + + if not database_list: + visibility_dict["text_field"] = True + elif len(database_list) == 1: + visibility_dict["single_db"] = database_list[0] + else: + visibility_dict["menu_items"] = database_list + + return database_list + + +def login_odoo(selected_url, username, password, selected_db): + """ + Authenticate user credentials against an Odoo server. + """ + common = xmlrpc.client.ServerProxy(f"{selected_url}/xmlrpc/2/common") + generated_uid = common.authenticate(selected_db, username, password, {}) + if generated_uid: + models = xmlrpc.client.ServerProxy(f"{selected_url}/xmlrpc/2/object") + user_name = models.execute_kw( + selected_db, + generated_uid, + password, + "res.users", + "read", + [generated_uid], + {"fields": ["name"]}, + ) + return { + "status": "pass", + "name_of_user": user_name[0]["name"], + "database": selected_db, + "uid": generated_uid, + } + return {"result": "fail"} diff --git a/src/backend/storage.py b/src/backend/storage.py new file mode 100644 index 00000000..612bb349 --- /dev/null +++ b/src/backend/storage.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import base64 +import hashlib +import mimetypes +import os +from pathlib import Path + +from common import check_table_exists +from logger import setup_logger + +log = setup_logger() + + +def is_file_present(file_path): + """ + Check if a file exists at the specified path. + """ + file = Path(file_path) + if file.exists() and file.is_file(): + log.info(f"[INFO] File exists: {file_path}") + return True + else: + log.error(f"[ERROR] File NOT found: {file_path}") + return False + + +def resolve_qml_db_path(app_id="ubtms"): + """ + Resolve and find the QML application database path across different environments. + """ + db_paths = [] + user_home = Path.home() + db_paths.append(user_home / ".local" / "share" / app_id / "Databases") + + # Clickable sandbox, e.g., /home/user/.clickable/home/ + clickable_home = user_home / ".clickable" / "home" + db_paths.append(clickable_home / ".local" / "share" / app_id / "Databases") + + for db_dir in db_paths: + log.debug(f"[DEBUG] Checking DB path: {db_dir}") + if not db_dir.exists(): + continue + + sqlite_files = list(db_dir.glob("*.sqlite")) + if sqlite_files: + latest = max(sqlite_files, key=lambda f: f.stat().st_mtime) + log.debug(f"[INFO] Found QML DB: {latest}") + if is_file_present(latest): + log.debug(f"file is present {latest}") + if check_table_exists(latest, "project_project_app"): + log.debug( + f"project_project_app present,confirms that {latest} app db" + ) + return str(latest) + else: + log.critical("SQlite file found , but do not see a table") + + log.debug("[ERROR] No QML DB found.") + return None + + +def resolve_settings_db_path(db_name="myDatabase", app_id="ubtms"): + """ + Finds a specific QML database by its name (hash). + """ + db_hash = hashlib.md5(db_name.encode()).hexdigest() + target_filename = f"{db_hash}.sqlite" + + user_home = Path.home() + db_paths = [ + user_home / ".local" / "share" / app_id / "Databases", + user_home / ".clickable" / "home" / ".local" / "share" / app_id / "Databases", + ] + + for db_dir in db_paths: + target = db_dir / target_filename + if target.exists(): + return str(target) + return None + + +def _app_data_dir(): + return Path.home() / ".local/share" + + +def _safe_ext_for(mime): + ext = mimetypes.guess_extension(mime or "") + return ext or "" + + +def _export_path_for(suggested_name, mime): + """ + Build the canonical output path used for exported attachments. + Keeps name normalization and mime-based extension logic in one place. + """ + base = _app_data_dir() / "ubtms" / "tmp" + base.mkdir(parents=True, exist_ok=True) + + name = (suggested_name or "attachment").strip().replace("/", "_") + root, ext = os.path.splitext(name) + if not ext: + ext = _safe_ext_for(mime) + return base / f"{root}{ext}" + + +def get_existing_attachment_path(suggested_name, mime): + """ + Return absolute file path (str) if an attachment is already present on disk, + else None. + """ + out = _export_path_for(suggested_name, mime) + return str(out) if out.exists() and out.is_file() else None + + +def is_already_downloaded(suggested_name, mime): + try: + out = _export_path_for(suggested_name, mime) + return is_file_present(out) + except Exception as e: + log.exception("is_already_downloaded error: %s", e) + return False + + +def ensure_export_file_from_base64(suggested_name, b64_data, mime): + try: + out = _export_path_for(suggested_name, mime) + with open(out, "wb") as f: + f.write(base64.b64decode(b64_data)) + return str(out) + except Exception as e: + log.error("ensure_export_file_from_base64 error: %s", e) + return None diff --git a/src/backend/sync.py b/src/backend/sync.py new file mode 100644 index 00000000..c3965261 --- /dev/null +++ b/src/backend/sync.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import threading + +from bus import send +from common import write_sync_report_to_db +from config import get_all_accounts, update_last_synced_at +from logger import setup_logger +from odoo_client import OdooClient +from sync_from_odoo import sync_all_from_odoo +from sync_to_odoo import sync_all_to_odoo + +log = setup_logger() + +sync_lock = threading.Lock() +sync_in_progress = False + + +def sync(settings_db, account_id): + """ + Perform synchronous bidirectional sync between local database and Odoo. + """ + send("progress", 0) + write_sync_report_to_db( + settings_db, account_id, "In Progress", "Sync job triggered" + ) + accounts = get_all_accounts(settings_db) + selected = accounts[account_id] + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + send("progress", 20) + log.debug("Syncing from odoo from server: " + selected["link"]) + sync_all_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) + + log.debug("Syncing to odoo") + send("progress", 50) + sync_all_to_odoo(client, selected["id"], settings_db) + + write_sync_report_to_db( + settings_db, account_id, "Successful", "Sync completed successfully" + ) + send("progress", 100) + return True + + +def sync_background(settings_db, account_id): + """ + Perform asynchronous bidirectional sync in a background thread. + """ + global sync_in_progress + + with sync_lock: + if sync_in_progress: + log.debug("[SYNC] Already in progress. Ignoring new request.") + return False + sync_in_progress = True + + def do_sync(): + global sync_in_progress + try: + send("sync_progress", 0) + log.debug(f"[SYNC] Starting background sync to {settings_db}...") + write_sync_report_to_db( + settings_db, account_id, "In Progress", "Sync job triggered" + ) + accounts = get_all_accounts(settings_db) + selected = next((acc for acc in accounts if acc["id"] == account_id), None) + send("sync_progress", 20) + + if not selected: + write_sync_report_to_db(settings_db, account_id, "Failed", "Account not found") + return + + log.debug(f"[SYNC] Found account: {selected['name']} (ID: {selected['id']})") + + send("sync_progress", 25) + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + send("sync_progress", 30) + log.debug("Syncing from odoo: ID Is " + selected["link"]) + sync_all_from_odoo(client, account_id, settings_db, account_name=selected.get("name", "")) + send("sync_progress", 50) + log.debug("Syncing to odoo") + sync_all_to_odoo(client, account_id, settings_db) + send("sync_progress", 90) + + log.debug("[SYNC] Background sync completed.") + write_sync_report_to_db( + settings_db, + account_id, + "Successful", + "Sync completed successfully", + ) + update_last_synced_at(settings_db, account_id) + send("sync_progress", 100) + send("sync_completed", True) + except Exception as e: + log.exception(f"[SYNC] Error during background sync: {e}") + write_sync_report_to_db(settings_db, account_id, "Failed", str(e)) + send("sync_completed", False) + finally: + with sync_lock: + sync_in_progress = False + + thread = threading.Thread(target=do_sync) + thread.start() + return True + + +def start_sync_in_background(settings_db, account_id): + """ + Start a background synchronization process. + """ + return sync_background(settings_db, account_id) diff --git a/src/backend/voice.py b/src/backend/voice.py new file mode 100644 index 00000000..b4bbacf5 --- /dev/null +++ b/src/backend/voice.py @@ -0,0 +1,747 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import ctypes +import multiprocessing +import os +import shutil +import signal +import sys +import threading +import time +import zipfile +from pathlib import Path + +import urllib3 +from bus import send +from config import get_setting, set_setting +from logger import setup_logger + +from .storage import resolve_qml_db_path, resolve_settings_db_path + +log = setup_logger() + +root_dir = Path(__file__).resolve().parents[2] +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) + +voice_lib = root_dir / "voice_to_text" / "lib" +if voice_lib.exists() and str(voice_lib) not in sys.path: + sys.path.insert(0, str(voice_lib)) + +urllib3.disable_warnings() +http = urllib3.PoolManager(cert_reqs="CERT_NONE") + +download_status = { + "in_progress": False, + "progress": 0, + "message": "", + "error": "", +} + +_voice_stop_event = threading.Event() +_voice_download_cancel_event = threading.Event() +_voice_download_action = None + + +def stop_voice_recognition(): + """Signals the voice recognition thread to stop recording.""" + log.info("[VOICE] stop_voice_recognition called") + _voice_stop_event.set() + return True + + +def get_voice_models_dir(): + """ + Returns the writable directory for voice models. + On Ubuntu Touch: ~/.local/share/ubtms/voice_models + """ + data_home = os.environ.get("XDG_DATA_HOME") + if data_home: + base_dir = Path(data_home) / "ubtms" + else: + base_dir = Path.home() / ".local" / "share" / "ubtms" + + models_dir = base_dir / "voice_models" + models_dir.mkdir(parents=True, exist_ok=True) + return models_dir + + +def is_valid_vosk_model(model_path): + """ + Check whether a directory contains a supported Vosk model layout. + """ + try: + path = Path(model_path) + if not path.is_dir(): + return False + + has_am_graph = (path / "am").is_dir() and (path / "graph").is_dir() + has_flat_model = (path / "final.mdl").is_file() and ( + (path / "HCLG.fst").is_file() or (path / "HCLr.fst").is_file() + ) + return has_am_graph or has_flat_model + except Exception as e: + log.warning(f"[VOICE] Error validating model {model_path}: {e}") + return False + + +def list_available_models(): + """ + Returns a list of models available for download. + """ + models = [ + {"id": "vosk-model-small-en-us-0.15", "name": "English (US, Small)", "size": "40M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"}, + {"id": "vosk-model-en-us-0.22", "name": "English (US)", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip"}, + {"id": "vosk-model-en-us-0.22-lgraph", "name": "English (US, LGraph)", "size": "128M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22-lgraph.zip"}, + {"id": "vosk-model-en-us-0.42-gigaspeech", "name": "English (US, Gigaspeech)", "size": "2.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip"}, + {"id": "vosk-model-en-us-daanzu-20200905", "name": "English (US, Daanzu)", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905.zip"}, + {"id": "vosk-model-en-us-daanzu-20200905-lgraph", "name": "English (US, Daanzu LGraph)", "size": "129M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905-lgraph.zip"}, + {"id": "vosk-model-en-us-librispeech-0.2", "name": "English (US, Librispeech)", "size": "845M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-librispeech-0.2.zip"}, + {"id": "vosk-model-small-en-us-zamia-0.5", "name": "English (US, Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-zamia-0.5.zip"}, + {"id": "vosk-model-en-us-aspire-0.2", "name": "English (US, Aspire)", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-aspire-0.2.zip"}, + {"id": "vosk-model-en-us-0.21", "name": "English (US, 0.21)", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.21.zip"}, + {"id": "vosk-model-en-in-0.5", "name": "English (Indian)", "size": "1G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-in-0.5.zip"}, + {"id": "vosk-model-small-en-in-0.4", "name": "English (Indian, Small)", "size": "36M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-in-0.4.zip"}, + {"id": "vosk-model-small-cn-0.22", "name": "Chinese (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"}, + {"id": "vosk-model-cn-0.22", "name": "Chinese", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip"}, + {"id": "vosk-model-cn-kaldi-multicn-0.15", "name": "Chinese (Multi-cn)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-kaldi-multicn-0.15.zip"}, + {"id": "vosk-model-ru-0.42", "name": "Russian", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.42.zip"}, + {"id": "vosk-model-small-ru-0.22", "name": "Russian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip"}, + {"id": "vosk-model-ru-0.22", "name": "Russian (0.22)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.22.zip"}, + {"id": "vosk-model-ru-0.10", "name": "Russian (0.10)", "size": "2.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.10.zip"}, + {"id": "vosk-model-small-fr-0.22", "name": "French (Small)", "size": "41M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip"}, + {"id": "vosk-model-fr-0.22", "name": "French", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.22.zip"}, + {"id": "vosk-model-small-fr-pguyot-0.3", "name": "French (pguyot Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-pguyot-0.3.zip"}, + {"id": "vosk-model-fr-0.6-linto-2.2.0", "name": "French (Linto)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.6-linto-2.2.0.zip"}, + {"id": "vosk-model-de-0.21", "name": "German", "size": "1.9G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip"}, + {"id": "vosk-model-de-tuda-0.6-900k", "name": "German (Tuda)", "size": "4.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-tuda-0.6-900k.zip"}, + {"id": "vosk-model-small-de-0.15", "name": "German (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip"}, + {"id": "vosk-model-small-de-zamia-0.3", "name": "German (Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-zamia-0.3.zip"}, + {"id": "vosk-model-es-0.42", "name": "Spanish", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-es-0.42.zip"}, + {"id": "vosk-model-small-es-0.42", "name": "Spanish (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip"}, + {"id": "vosk-model-pt-fb-v0.1.1-20220516_2113", "name": "Portuguese", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-pt-fb-v0.1.1-20220516_2113.zip"}, + {"id": "vosk-model-small-pt-0.3", "name": "Portuguese (Small)", "size": "31M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip"}, + {"id": "vosk-model-small-tr-0.3", "name": "Turkish (Small)", "size": "35M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip"}, + {"id": "vosk-model-small-vn-0.4", "name": "Vietnamese (Small)", "size": "32M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip"}, + {"id": "vosk-model-vn-0.4", "name": "Vietnamese", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-vn-0.4.zip"}, + {"id": "vosk-model-it-0.22", "name": "Italian", "size": "1.2G", "url": "https://alphacephei.com/vosk/models/vosk-model-it-0.22.zip"}, + {"id": "vosk-model-small-it-0.22", "name": "Italian (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip"}, + {"id": "vosk-model-small-nl-0.22", "name": "Dutch (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip"}, + {"id": "vosk-model-nl-spraakherkenning-0.6", "name": "Dutch", "size": "860M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6.zip"}, + {"id": "vosk-model-nl-spraakherkenning-0.6-lgraph", "name": "Dutch (LGraph)", "size": "100M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6-lgraph.zip"}, + {"id": "vosk-model-small-ca-0.4", "name": "Catalan (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ca-0.4.zip"}, + {"id": "vosk-model-small-fa-0.4", "name": "Persian (Small)", "size": "47M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.4.zip"}, + {"id": "vosk-model-small-fa-0.5", "name": "Persian (Small, 0.5)", "size": "60M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.5.zip"}, + {"id": "vosk-model-fa-0.5", "name": "Persian", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-fa-0.5.zip"}, + {"id": "vosk-model-small-uk-v3-nano", "name": "Ukrainian (Nano)", "size": "73M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-nano.zip"}, + {"id": "vosk-model-small-uk-v3-small", "name": "Ukrainian (Small)", "size": "133M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip"}, + {"id": "vosk-model-uk-v3-lgraph", "name": "Ukrainian (LGraph)", "size": "335M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3-lgraph.zip"}, + {"id": "vosk-model-uk-v3", "name": "Ukrainian", "size": "343M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3.zip"}, + {"id": "vosk-model-small-kz-0.15", "name": "Kazakh (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-kz-0.15.zip"}, + {"id": "vosk-model-kz-0.15", "name": "Kazakh", "size": "374M", "url": "https://alphacephei.com/vosk/models/vosk-model-kz-0.15.zip"}, + {"id": "vosk-model-small-sv-rhasspy-0.15", "name": "Swedish (Small)", "size": "260M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-sv-rhasspy-0.15.zip"}, + {"id": "vosk-model-small-ja-0.22", "name": "Japanese (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip"}, + {"id": "vosk-model-ja-0.22", "name": "Japanese", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-ja-0.22.zip"}, + {"id": "vosk-model-small-eo-0.42", "name": "Esperanto (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-eo-0.42.zip"}, + {"id": "vosk-model-el-gr-0.7", "name": "Greek", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-el-gr-0.7.zip"}, + {"id": "vosk-model-small-hi-0.22", "name": "Hindi (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip"}, + {"id": "vosk-model-hi-0.22", "name": "Hindi", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-hi-0.22.zip"}, + {"id": "vosk-model-small-cs-0.4-rhasspy", "name": "Czech (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip"}, + {"id": "vosk-model-small-pl-0.22", "name": "Polish (Small)", "size": "51M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip"}, + {"id": "vosk-model-small-uz-0.22", "name": "Uzbek (Small)", "size": "51M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip"}, + {"id": "vosk-model-small-ko-0.22", "name": "Korean (Small)", "size": "70M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip"}, + {"id": "vosk-model-small-tg-0.22", "name": "Tajik (Small)", "size": "37M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tg-0.22.zip"}, + {"id": "vosk-model-tg-0.22", "name": "Tajik", "size": "342M", "url": "https://alphacephei.com/vosk/models/vosk-model-tg-0.22.zip"}, + {"id": "vosk-model-small-gu-0.42", "name": "Gujarati (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-gu-0.42.zip"}, + {"id": "vosk-model-small-te-0.42", "name": "Telugu (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-te-0.42.zip"}, + {"id": "vosk-model-small-ka-0.42", "name": "Georgian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ka-0.42.zip"}, + {"id": "vosk-model-ka-0.42", "name": "Georgian", "size": "700M", "url": "https://alphacephei.com/vosk/models/vosk-model-ka-0.42.zip"}, + ] + models.sort(key=lambda x: x["name"].lower()) + return models + + +def list_installed_models(): + app_models_dir = root_dir / "voice_to_text" + user_models_dir = get_voice_models_dir() + + search_paths = [ + (app_models_dir, "App"), + (user_models_dir, "User"), + (Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "voice_models", "Clickable"), + ] + + models = [] + seen_names = set() + available_models = list_available_models() + known_names = {m["id"]: m["name"] for m in available_models if "id" in m} + known_names["model"] = "Indian English" + + for root_dir_to_scan, source_label in search_paths: + if not root_dir_to_scan.exists(): + continue + + for item in root_dir_to_scan.iterdir(): + if item.is_dir() and item.name not in seen_names: + if not is_valid_vosk_model(item): + continue + + if item.name in known_names: + model_name = known_names[item.name] + else: + model_name = item.name + readme_path = item / "README" + if readme_path.exists(): + try: + with open(readme_path, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line: + clean_name = first_line + for phrase in [ + "for mobile Vosk applications", + "for Android and iOS", + "Vosk mobile model", + "Vosk model", + "Vosk", + "model", + ]: + clean_name = clean_name.replace(phrase, "").strip() + if clean_name: + model_name = clean_name + except Exception as e: + log.error(f"[VOICE] Error reading README for {item.name}: {e}") + + display_name = f"{model_name} (Default)" if source_label == "App" else model_name + + total_size = 0 + try: + for f in item.rglob("*"): + if f.is_file(): + total_size += f.stat().st_size + model_size = f"{total_size / (1024 * 1024):.1f} MB" + except Exception: + model_size = "Unknown" + + try: + rel_path = item.relative_to(root_dir) if source_label == "App" else item + models.append({ + "m_name": display_name, + "m_path": str(rel_path), + "m_size": model_size, + "m_source": source_label, + }) + except ValueError: + models.append({ + "m_name": display_name, + "m_path": str(item), + "m_size": model_size, + "m_source": source_label, + }) + + seen_names.add(item.name) + + models.sort(key=lambda x: x["m_name"].lower()) + log.info(f"[VOICE] Found {len(models)} installed models") + return models + + +def get_installed_voice_models(): + models_dir = get_voice_models_dir() + if not models_dir.exists(): + return [] + installed = [d.name for d in models_dir.iterdir() if d.is_dir()] + log.info(f"[VOICE] Found {len(installed)} installed models") + return installed + + +def get_device_total_ram_mb(): + try: + with open("/proc/meminfo", "r") as f: + for line in f: + if line.startswith("MemTotal:"): + return int(line.split()[1]) // 1024 + except Exception: + pass + + try: + pagesize = os.sysconf("SC_PAGE_SIZE") + pages = os.sysconf("SC_PHYS_PAGES") + return (pagesize * pages) // (1024 * 1024) + except Exception: + pass + return 2048 + + +def get_paused_voice_models(): + models_dir = get_voice_models_dir() + if not models_dir.exists(): + return [] + return [f.name.replace(".zip.tmp", "") for f in models_dir.glob("*.zip.tmp")] + + +def get_model_download_status(): + return download_status + + +def cancel_voice_model_download(): + global download_status, _voice_download_action + _voice_download_action = "cancel" + _voice_download_cancel_event.set() + download_status["in_progress"] = False + download_status["is_paused"] = False + download_status["message"] = "Download cancelled" + + models_dir = get_voice_models_dir() + if models_dir.exists(): + for f in models_dir.glob("*.zip.tmp"): + try: + f.unlink() + except Exception: + pass + + return {"status": "cancelled"} + + +def pause_voice_model_download(): + global download_status, _voice_download_action + _voice_download_action = "pause" + _voice_download_cancel_event.set() + download_status["in_progress"] = False + download_status["is_paused"] = True + download_status["message"] = "Download paused" + return {"status": "paused"} + + +def download_voice_model(model_id, url): + global download_status + _voice_download_cancel_event.clear() + if download_status["in_progress"]: + return {"status": "error", "message": "Download already in progress"} + + download_status = { + "in_progress": True, + "progress": download_status.get("progress", 0), + "message": "Starting download...", + "error": "", + "model_id": model_id, + "is_paused": False, + } + + def do_download(): + temp_zip = None + temp_extract_dir = None + try: + models_dir = get_voice_models_dir() + target_path = models_dir / model_id + + if target_path.exists(): + download_status["in_progress"] = False + download_status["message"] = "Model already installed" + download_status["progress"] = 100 + return + + temp_zip = models_dir / f"{model_id}.zip.tmp" + existing_size = temp_zip.stat().st_size if temp_zip.exists() else 0 + + headers = {} + if existing_size > 0: + headers["Range"] = f"bytes={existing_size}-" + log.info(f"[VOICE] Resuming {model_id} from {existing_size} bytes") + download_status["message"] = "Resuming..." + else: + log.info(f"[VOICE] Downloading model from {url}") + download_status["message"] = "Downloading..." + + response = http.request( + "GET", + url, + headers=headers, + preload_content=False, + timeout=urllib3.Timeout(connect=5.0, read=10.0), + ) + if response.status not in (200, 206, 416): + raise Exception(f"Server returned status {response.status}") + + if response.status == 200 and existing_size > 0: + log.info("[VOICE] Server ignored Range, restarting download") + existing_size = 0 + temp_zip.unlink() + + content_length = response.getheader("Content-Length") + total_size = (int(content_length) + existing_size) if content_length else None + + if total_size and existing_size == 0: + try: + _, _, free = shutil.disk_usage(str(models_dir)) + free_mb = free / (1024 * 1024) + required_mb = (total_size * 2.5) / (1024 * 1024) + if free < total_size * 2.5: + raise Exception(f"Insufficient storage. Have {free_mb:.1f} MB, need at least {required_mb:.1f} MB available.") + except Exception as disk_err: + if isinstance(disk_err, Exception) and "Insufficient storage" in str(disk_err): + raise disk_err + log.warning(f"[VOICE] Could not check disk space: {disk_err}") + + downloaded = existing_size + mode = "ab" if existing_size > 0 else "wb" + with open(temp_zip, mode) as f: + for chunk in response.stream(1024 * 64): + if _voice_download_cancel_event.is_set(): + action = _voice_download_action + if action == "pause": + raise Exception("PAUSED_BY_USER") + else: + raise Exception("CANCELLED_BY_USER") + f.write(chunk) + downloaded += len(chunk) + if total_size: + progress = int((downloaded / total_size) * 80) + download_status["progress"] = progress + send("download_progress", progress) + + download_status["message"] = "Extracting..." + send("download_message", "Extracting...") + download_status["progress"] = 85 + send("download_progress", 85) + + temp_extract_dir = models_dir / f"{model_id}.extract.tmp" + if temp_extract_dir.exists(): + shutil.rmtree(temp_extract_dir) + temp_extract_dir.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(temp_zip) as z: + z.extractall(temp_extract_dir) + + model_folder = None + for root_walk, dirs, files in os.walk(temp_extract_dir): + if "am" in dirs and "graph" in dirs: + model_folder = Path(root_walk) + break + + if not model_folder: + subdirs = [d for d in temp_extract_dir.iterdir() if d.is_dir()] + model_folder = subdirs[0] if len(subdirs) == 1 else temp_extract_dir + + if target_path.exists(): + shutil.rmtree(target_path) + shutil.move(str(model_folder), str(target_path)) + + download_status["progress"] = 100 + download_status["message"] = "Installation complete" + download_status["in_progress"] = False + send("download_progress", 100) + send("download_completed", True) + log.info(f"[VOICE] Successfully installed model {model_id}") + + except Exception as e: + err_str = str(e) + log.exception(f"[VOICE] Download interrupted: {err_str}") + download_status["in_progress"] = False + + if err_str == "PAUSED_BY_USER": + download_status["is_paused"] = True + download_status["message"] = "Paused" + elif err_str == "CANCELLED_BY_USER": + download_status["is_paused"] = False + download_status["message"] = "Cancelled" + if temp_zip and temp_zip.exists(): + try: + temp_zip.unlink() + except Exception: + pass + else: + download_status["error"] = err_str + download_status["message"] = "Failed" + download_status["is_paused"] = True + send("download_error", err_str) + + finally: + if temp_extract_dir and temp_extract_dir.exists(): + try: + shutil.rmtree(temp_extract_dir) + except Exception: + pass + + threading.Thread(target=do_download, daemon=True).start() + return {"status": "started"} + + +def delete_voice_model(model_path): + try: + path = Path(model_path) + user_models_dir = get_voice_models_dir() + clickable_models_dir = ( + Path.home() / ".clickable" / "home" / + ".local" / "share" / "ubtms" / "voice_models" + ) + + if not path.is_absolute(): + potential_path = user_models_dir / model_path + if potential_path.exists(): + path = potential_path + else: + potential_path = clickable_models_dir / model_path + if potential_path.exists(): + path = potential_path + else: + potential_path = root_dir / model_path + if potential_path.exists(): + path = potential_path + + if not path.exists(): + return {"status": "error", "message": "Model path not found"} + + resolved_path = path.resolve() + resolved_user_dir = user_models_dir.resolve() + resolved_clickable_dir = clickable_models_dir.resolve() + + if ( + resolved_user_dir in resolved_path.parents + or resolved_clickable_dir in resolved_path.parents + ): + if resolved_path.is_dir(): + shutil.rmtree(resolved_path) + else: + resolved_path.unlink() + log.info(f"[VOICE] Deleted user model: {model_path}") + return {"status": "success"} + + app_models_dir = (root_dir / "voice_to_text").resolve() + if app_models_dir in resolved_path.parents or resolved_path == app_models_dir: + log.warning(f"[VOICE] Attempted to delete bundled model: {model_path}") + return {"status": "error", "message": "Cannot delete bundled system models"} + + return {"status": "error", "message": "Permission denied"} + except Exception as e: + log.exception(f"[VOICE] Failed to delete model {model_path}: {e}") + return {"status": "error", "message": str(e)} + + +def _subprocess_recognize(model_path, pipe, timeout): + def sigterm_handler(signum, frame): + raise SystemExit(0) + signal.signal(signal.SIGTERM, sigterm_handler) + + try: + from voice_to_text.voice2text import recognize_from_mic + + class PipeStopEvent: + def is_set(self): + if pipe.poll(): + msg = pipe.recv() + if msg == "stop": + return True + return False + + def _partial(txt): + pipe.send(("partial", txt)) + + def _status(txt): + pipe.send(("status", txt)) + + text, err = recognize_from_mic( + verbose=False, + stop_event=PipeStopEvent(), + timeout=timeout, + partial_callback=_partial, + status_callback=_status, + model_path=model_path, + ) + pipe.send(("final", text, err)) + except Exception as e: + pipe.send(("final", None, f"Subprocess Error: {e}")) + + +def run_voice_recognition(): + def do_recognition(): + try: + log.info("[VOICE] Starting offline voice recognition thread") + + db_path = resolve_settings_db_path("myDatabase") or resolve_qml_db_path() + + low_mem_mode = True + if db_path: + try: + low_mem_mode = get_setting(db_path, "voice_low_memory_mode", "true") == "true" + except Exception as e: + log.warning(f"[VOICE] Could not read low memory setting: {e}") + + active_model_rel_path = "" + if db_path: + active_model_rel_path = get_setting(db_path, "active_voice_model", "") + + model_path = None + if active_model_rel_path: + if not os.path.isabs(active_model_rel_path): + model_path = root_dir / active_model_rel_path + else: + model_path = Path(active_model_rel_path) + + if not model_path or not model_path.exists(): + installed_models = list_installed_models() + if installed_models: + active_model_rel_path = installed_models[0]["m_path"] + if not os.path.isabs(active_model_rel_path): + model_path = root_dir / active_model_rel_path + else: + model_path = Path(active_model_rel_path) + + if db_path: + try: + set_setting(db_path, "active_voice_model", active_model_rel_path) + except Exception as e: + log.warning(f"[VOICE] Could not auto-save active model to db: {e}") + else: + log.error("[VOICE] No language model downloaded") + send("voice_recognition_error", "No language model downloaded. Please download one in Voice Model Settings.") + return + + if model_path and model_path.exists(): + rescore_path = model_path / "rescore" + rescore_disabled_path = model_path / "rescore.disabled" + rnnlm_path = model_path / "rnnlm" + rnnlm_disabled_path = model_path / "rnnlm.disabled" + + if low_mem_mode: + if rescore_path.exists() and rescore_path.is_dir(): + try: + rescore_path.rename(rescore_disabled_path) + log.info("[VOICE] Disabled rescore folder for low memory mode") + except Exception as e: + log.warning(f"[VOICE] Could not rename rescore folder: {e}") + if rnnlm_path.exists() and rnnlm_path.is_dir(): + try: + rnnlm_path.rename(rnnlm_disabled_path) + log.info("[VOICE] Disabled rnnlm folder for low memory mode") + except Exception as e: + log.warning(f"[VOICE] Could not rename rnnlm folder: {e}") + else: + if rescore_disabled_path.exists() and rescore_disabled_path.is_dir(): + try: + rescore_disabled_path.rename(rescore_path) + log.info("[VOICE] Re-enabled rescore folder") + except Exception as e: + log.warning(f"[VOICE] Could not restore rescore folder: {e}") + if rnnlm_disabled_path.exists() and rnnlm_disabled_path.is_dir(): + try: + rnnlm_disabled_path.rename(rnnlm_path) + log.info("[VOICE] Re-enabled rnnlm folder") + except Exception as e: + log.warning(f"[VOICE] Could not restore rnnlm folder: {e}") + + try: + hclg_path = model_path / "graph" / "HCLG.fst" + if hclg_path.exists(): + hclg_size = hclg_path.stat().st_size + limit = 1.8 * 1024 * 1024 * 1024 if low_mem_mode else 1.0 * 1024 * 1024 * 1024 + if hclg_size > limit: + size_mb = hclg_size / (1024 * 1024) + if not low_mem_mode: + err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large to run with rescoring enabled. Please enable 'Low Memory Mode' in voice settings to run it." + else: + err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large even for Low Memory Mode. Please switch to the LGraph or Small version in settings." + log.error(f"[VOICE] {err_msg}") + send("voice_recognition_error", err_msg) + return + except Exception as e: + log.warning(f"[VOICE] Could not check model graph size: {e}") + + base_voice_path = Path(__file__).resolve().parents[2] / "voice_to_text" + lib_path = base_voice_path / "lib" + + atomic_lib = lib_path / "libatomic.so.1" + if atomic_lib.exists(): + try: + ctypes.CDLL(str(atomic_lib)) + log.info(f"[VOICE] Pre-loaded {atomic_lib}") + except Exception as e: + log.warning(f"[VOICE] Could not pre-load libatomic: {e}") + + env_path = str(lib_path) + if "LD_LIBRARY_PATH" in os.environ: + os.environ["LD_LIBRARY_PATH"] = f"{env_path}:{os.environ['LD_LIBRARY_PATH']}" + else: + os.environ["LD_LIBRARY_PATH"] = env_path + + from voice_to_text.voice2text import list_microphones + + _voice_stop_event.clear() + log.info(f"[VOICE] mics: {list_microphones()}") + + parent_conn, child_conn = multiprocessing.Pipe() + ctx = multiprocessing.get_context("spawn") + p = ctx.Process(target=_subprocess_recognize, args=(str(model_path), child_conn, 30)) + p.start() + + received_final = False + stop_sent_time = None + while p.is_alive(): + if _voice_stop_event.is_set(): + parent_conn.send("stop") + _voice_stop_event.clear() + if stop_sent_time is None: + stop_sent_time = time.time() + + if stop_sent_time is not None and (time.time() - stop_sent_time > 5.0): + log.warning("[VOICE] Watchdog triggered: Process failed to stop cleanly. Terminating.") + p.terminate() + break + + if parent_conn.poll(0.5): + msg = parent_conn.recv() + if msg[0] == "partial": + send("voice_recognition_partial", msg[1]) + elif msg[0] == "status": + send("voice_recognition_status", msg[1]) + elif msg[0] == "final": + received_final = True + text, error = msg[1], msg[2] + if text: + log.info(f"[VOICE] Recognized text: {text}") + send("voice_recognition_result", text) + else: + log.warning(f"[VOICE] Recognition failed: {error or 'No speech detected'}") + send("voice_recognition_error", error or "No speech detected") + break + + p.join(timeout=1) + if p.is_alive(): + p.terminate() + p.join() + + if not received_final: + exit_code = p.exitcode + if exit_code is not None and exit_code != 0: + err_msg = f"Voice recognition crashed unexpectedly (possibly Out of Memory). Exit code: {exit_code}" + log.error(f"[VOICE] {err_msg}") + send("voice_recognition_error", err_msg) + + except Exception as e: + log.exception(f"[VOICE] Error during voice recognition: {e}") + send("voice_recognition_error", f"System Error: {str(e)}") + + thread = threading.Thread(target=do_recognition) + thread.daemon = True + thread.start() + return True diff --git a/src/cli.py b/src/cli.py deleted file mode 100755 index c85178b9..00000000 --- a/src/cli.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import os -import glob -from backend import sync - - -def find_settings_db(app_id): - # Construct the base path - home_dir = os.path.expanduser("~") - db_path_pattern = os.path.join( - home_dir, - ".clickable", - "home", - ".local", - "share", - app_id, - "Databases", - "*.sqlite", - ) - - # Find matching database files - db_files = glob.glob(db_path_pattern) - - if not db_files: - raise FileNotFoundError(f"No SQLite database found for app '{app_id}'") - - # If multiple, return the first or select based on logic - return db_files[0] - - -# Usage -app_id = "ubtms" -settings_db = find_settings_db(app_id) -choice = 1 -sync(settings_db, choice) diff --git a/src/daemon.py b/src/daemon.py index ff7a190f..a87f6047 100644 --- a/src/daemon.py +++ b/src/daemon.py @@ -150,7 +150,7 @@ def get_app_version(): if Path(MANIFEST_PATH).exists(): with open(MANIFEST_PATH, 'r') as f: manifest = json.load(f) - return manifest.get('version', '1.3.3') + return manifest.get('version', '1.3.4') # Fallback to development path dev_manifest = Path(__file__).parent.parent / "manifest.json.in" if dev_manifest.exists(): @@ -163,7 +163,7 @@ def get_app_version(): return match.group(1) except Exception as e: log.error(f"[DAEMON] Failed to read app version: {e}") - return "1.3.3" # Fallback version + return "1.3.4" # Fallback version APP_VERSION = get_app_version() diff --git a/src/daemon_bootstrap.py b/src/daemon_bootstrap.py index ed2e8839..7c9cfa8c 100755 --- a/src/daemon_bootstrap.py +++ b/src/daemon_bootstrap.py @@ -28,7 +28,7 @@ def log(message): def get_service_target_path(): """Get stable click path for systemd service across app updates.""" - current_symlink = Path("/opt/click.ubuntu.com/ubtms/current") + current_symlink = CLICK_PATH.parent / "current" if current_symlink.exists(): return current_symlink return CLICK_PATH diff --git a/src/tool_field_sync.py b/src/tool_field_sync.py deleted file mode 100755 index a2e6fb84..00000000 --- a/src/tool_field_sync.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import json -import xmlrpc.client -from config import get_all_accounts - - -def fetch_fields(model_name, models, db, uid, password): - """Fetch field metadata for a given model.""" - return models.execute_kw( - db, - uid, - password, - model_name, - "fields_get", - [], - {"attributes": ["string", "type"]}, - ) - - -def main(): - # Select account - settings_db = "/home/gokul/.clickable/home/.local/share/ubtms/Databases/8912ff6b21d680ad2a2832070dca24d0.sqlite" - accounts = get_all_accounts(settings_db) - print("Available accounts:") - for i, acct in enumerate(accounts): - print(f"{i + 1}. {acct['name']} ({acct['link']})") - - selected_index = int(input("Select account: ")) - 1 - selected = accounts[selected_index] - - # Authenticate - common = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/common") - uid = common.authenticate( - selected["database"], selected["username"], selected["api_key"], {} - ) - - if not uid: - print("❌ Authentication failed.") - return - - models = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/object") - - # Fetch fields - fields_data = {} - for model in ["project.project", "project.task","account.analytic.line","res.users","mail.activity","ir.model","ir.attachment","project.update","project.task.type","project.project.stage"]: - print(f"Fetching fields for {model}...") - fields_data[model] = fetch_fields( - model, models, selected["database"], uid, selected["api_key"] - ) - - # Save to JSON - with open("odoo_config/all_odoo_fields.json", "w") as f: - json.dump(fields_data, f, indent=4) - - print("Field metadata saved to odoo_fields.json") - - -if __name__ == "__main__": - main() diff --git a/src/tool_get_remote_record.py b/src/tool_get_remote_record.py deleted file mode 100755 index 01b9187c..00000000 --- a/src/tool_get_remote_record.py +++ /dev/null @@ -1,89 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License (see top for full terms) - -import json -import xmlrpc.client -from config import get_all_accounts - - -def fetch_fields(model_name, models, db, uid, password): - """Fetch field metadata for a given model.""" - return models.execute_kw( - db, - uid, - password, - model_name, - "fields_get", - [], - {"attributes": ["string", "type"]}, - ) - - -def fetch_record_by_id(model_name, record_id, models, db, uid, password): - """Fetch all fields of a record with given ID.""" - return models.execute_kw( - db, - uid, - password, - model_name, - 'read', - [[record_id]], - {'fields': []} - )[0] - - -def main(): - # Select account - settings_db = "/home/gokul/.clickable/home/.local/share/ubtms/Databases/8912ff6b21d680ad2a2832070dca24d0.sqlite" - accounts = get_all_accounts(settings_db) - print("Available accounts:") - for i, acct in enumerate(accounts): - print(f"{i + 1}. {acct['name']} ({acct['link']})") - - selected_index = int(input("Select account: ")) - 1 - selected = accounts[selected_index] - - # Authenticate - common = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/common") - uid = common.authenticate( - selected["database"], selected["username"], selected["api_key"], {} - ) - - if not uid: - print("❌ Authentication failed.") - return - - models = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/object") - - # Model menu - model_options = [ - "project.project", - "project.task", - "account.analytic.line", - "res.users", - "mail.activity", - "mail.activity.type" - ] - - print("\nAvailable models:") - for i, model in enumerate(model_options): - print(f"{i + 1}. {model}") - - model_index = int(input("Select a model: ")) - 1 - model_name = model_options[model_index] - - # Record ID - record_id = int(input(f"Enter Odoo record ID for model '{model_name}': ")) - - # Fetch and display record - try: - record = fetch_record_by_id(model_name, record_id, models, selected["database"], uid, selected["api_key"]) - print("\n Record Details:") - for k, v in record.items(): - print(f"{k}: {v}") - except Exception as e: - print(f"❌ Error: {e}") - - -if __name__ == "__main__": - main() diff --git a/tests/python/README.md b/tests/python/README.md new file mode 100644 index 00000000..6b1a4021 --- /dev/null +++ b/tests/python/README.md @@ -0,0 +1,50 @@ +# Python Backend Unit Testing Guide + +This directory contains automated unit tests for the Python backend modules (`src/backend/`). + +## Running Tests + +To run the entire Python test suite: + +```bash +pytest tests/python +``` + +To run with verbose output: + +```bash +pytest tests/python -v +``` + +To run a specific test file: + +```bash +pytest tests/python/test_storage.py -v +pytest tests/python/test_auth.py -v +pytest tests/python/test_voice.py -v +pytest tests/python/test_attachments.py -v +pytest tests/python/test_sync.py -v +pytest tests/python/test_backend_facade.py -v +``` + +## Test Directory Structure + +``` +tests/ +└── python/ + ├── README.md # This guide + ├── conftest.py # pytest configuration and sys.path setup + ├── test_attachments.py # Unit tests for attachment downloads and operations + ├── test_auth.py # Unit tests for reachability, db discovery, and auth + ├── test_backend_facade.py # Unit tests for QML API backward compatibility + ├── test_storage.py # Unit tests for storage and attachment path utilities + ├── test_sync.py # Unit tests for synchronization concurrency and locks + └── test_voice.py # Unit tests for voice models layout and signals +``` + +## Guidelines for New Backend Tests + +1. **Isolation**: Always use pytest fixtures (`tmp_path`, `monkeypatch`) to avoid side effects on the user's filesystem or real database files. +2. **Fast Execution**: Backend unit tests must run purely in memory or using isolated temporary directories without spawning network requests or live background daemons. +3. **AAA Pattern**: Follow the Arrange-Act-Assert structure in every test method. +4. **Naming**: Test files should be named `test_.py`, classes `Test`, and methods `test_`. diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 00000000..9c688480 --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_DIR = REPO_ROOT / "src" + +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) diff --git a/tests/python/test_attachments.py b/tests/python/test_attachments.py new file mode 100644 index 00000000..36097d45 --- /dev/null +++ b/tests/python/test_attachments.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from unittest.mock import MagicMock + +import pytest +from backend import attachments + + +class TestAttachmentOndemandDownload: + def test_account_not_found(self, monkeypatch): + monkeypatch.setattr(attachments, "get_all_accounts", lambda db: []) + + result = attachments.attachment_ondemand_download("fake_db.sqlite", 999, 123) + + assert result == {"success": False, "error": "Account not found"} + + def test_successful_download(self, monkeypatch): + fake_account = { + "id": 1, + "link": "https://odoo.example.com", + "database": "demo", + "username": "user", + "api_key": "key", + } + monkeypatch.setattr(attachments, "get_all_accounts", lambda db: [fake_account]) + + mock_client = MagicMock() + mock_client.ondemanddownload.return_value = {"file_path": "/tmp/test.pdf"} + monkeypatch.setattr(attachments, "OdooClient", lambda *args, **kwargs: mock_client) + + result = attachments.attachment_ondemand_download("fake_db.sqlite", 1, 123) + + assert result["success"] is True + assert result["file_path"] == "/tmp/test.pdf" + + def test_handles_client_exception(self, monkeypatch): + fake_account = { + "id": 1, + "link": "https://odoo.example.com", + "database": "demo", + "username": "user", + "api_key": "key", + } + monkeypatch.setattr(attachments, "get_all_accounts", lambda db: [fake_account]) + + def raise_download_error(*args, **kwargs): + raise ConnectionError("Server refused connection") + + monkeypatch.setattr(attachments, "OdooClient", raise_download_error) + + result = attachments.attachment_ondemand_download("fake_db.sqlite", 1, 123) + + assert result["success"] is False + assert "Server refused connection" in result["error"] diff --git a/tests/python/test_auth.py b/tests/python/test_auth.py new file mode 100644 index 00000000..b184dd1d --- /dev/null +++ b/tests/python/test_auth.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +from unittest.mock import MagicMock + +import pytest +from backend import auth + + +class TestCheckServerReachability: + def test_empty_or_none_url_returns_false(self): + assert auth.check_server_reachability("") is False + assert auth.check_server_reachability(None) is False + assert auth.check_server_reachability(" ") is False + + def test_head_success_returns_true(self, monkeypatch): + mock_response = MagicMock(status=200) + monkeypatch.setattr(auth.http, "request", MagicMock(return_value=mock_response)) + + result = auth.check_server_reachability("example.com") + assert result is True + + def test_head_fails_get_succeeds_returns_true(self, monkeypatch): + def mock_request(method, url, timeout, redirect): + if method == "HEAD": + raise RuntimeError("HEAD not supported") + return MagicMock(status=200) + + monkeypatch.setattr(auth.http, "request", mock_request) + + result = auth.check_server_reachability("https://example.com") + assert result is True + + def test_both_head_and_get_fail_returns_false(self, monkeypatch): + def mock_request(method, url, timeout, redirect): + raise ConnectionError("Host unreachable") + + monkeypatch.setattr(auth.http, "request", mock_request) + + result = auth.check_server_reachability("https://unreachable.local") + assert result is False + + +class TestGetDbList: + def test_successful_response(self, monkeypatch): + mock_response = MagicMock( + status=200, + data=b'{"result": ["production", "staging"]}', + ) + monkeypatch.setattr(auth.http, "request", MagicMock(return_value=mock_response)) + + databases = auth.get_db_list("https://demo.odoo.com") + assert databases == ["production", "staging"] + + def test_non_200_returns_empty_list(self, monkeypatch): + mock_response = MagicMock(status=500, data=b"Internal Error") + monkeypatch.setattr(auth.http, "request", MagicMock(return_value=mock_response)) + + databases = auth.get_db_list("https://demo.odoo.com") + assert databases == [] + + def test_network_exception_returns_empty_list(self, monkeypatch): + def mock_request(*args, **kwargs): + raise ConnectionError("Network down") + + monkeypatch.setattr(auth.http, "request", mock_request) + + databases = auth.get_db_list("https://demo.odoo.com") + assert databases == [] + + +class TestFetchDatabases: + def test_forwards_databases(self, monkeypatch): + monkeypatch.setattr(auth, "get_db_list", lambda url: ["db1", "db2"]) + + result = auth.fetch_databases("https://demo.odoo.com") + assert result == ["db1", "db2"] + + def test_returns_empty_when_no_dbs(self, monkeypatch): + monkeypatch.setattr(auth, "get_db_list", lambda url: []) + + result = auth.fetch_databases("https://demo.odoo.com") + assert result == [] + + +class TestLoginOdoo: + def test_successful_login(self, monkeypatch): + mock_common = MagicMock() + mock_common.authenticate.return_value = 42 + + mock_models = MagicMock() + mock_models.execute_kw.return_value = [{"name": "Jane Doe"}] + + def mock_server_proxy(url): + if "common" in url: + return mock_common + return mock_models + + monkeypatch.setattr(auth.xmlrpc.client, "ServerProxy", mock_server_proxy) + + response = auth.login_odoo("https://demo.odoo.com", "jane", "secret", "demo_db") + assert response == { + "status": "pass", + "name_of_user": "Jane Doe", + "database": "demo_db", + "uid": 42, + } + + def test_failed_authentication(self, monkeypatch): + mock_common = MagicMock() + mock_common.authenticate.return_value = False + + monkeypatch.setattr(auth.xmlrpc.client, "ServerProxy", lambda url: mock_common) + + response = auth.login_odoo("https://demo.odoo.com", "wrong", "wrong", "demo_db") + assert response == {"result": "fail"} diff --git a/tests/python/test_backend_facade.py b/tests/python/test_backend_facade.py new file mode 100644 index 00000000..4ce4a844 --- /dev/null +++ b/tests/python/test_backend_facade.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +import inspect +import pytest +import backend + + +def test_qml_exposed_functions_present(): + required_qml_calls = [ + "resolve_qml_db_path", + "attachment_upload", + "attachment_delete", + "get_existing_attachment_path", + "attachment_ondemand_download", + "ensure_export_file_from_base64", + "list_installed_models", + "list_available_models", + "download_voice_model", + "cancel_voice_model_download", + "pause_voice_model_download", + "get_device_total_ram_mb", + "get_paused_voice_models", + "get_model_download_status", + "delete_voice_model", + "cleanup_orphan_attachment_files", + "start_sync_in_background", + "login_odoo", + "sync", + "sync_background", + "check_server_reachability", + "fetch_databases", + "get_db_list", + "is_file_present", + "resolve_settings_db_path", + "run_voice_recognition", + "stop_voice_recognition", + ] + + for func_name in required_qml_calls: + assert hasattr(backend, func_name), f"backend is missing required function: {func_name}" + assert callable(getattr(backend, func_name)), f"backend.{func_name} is not callable" + + +def test_all_exports_exist(): + for name in backend.__all__: + assert hasattr(backend, name), f"backend.__all__ exports '{name}' but attribute is missing" + + +def test_submodules_importable(): + from backend import storage, auth, attachments, sync, voice + + assert storage is not None + assert auth is not None + assert attachments is not None + assert sync is not None + assert voice is not None + + +def test_facade_routing(): + # Calling through backend facade routes to the underlying submodule implementation + assert backend.is_file_present is backend.storage.is_file_present + assert backend.check_server_reachability is backend.auth.check_server_reachability + assert backend.attachment_upload is backend.attachments.attachment_upload + assert backend.sync is backend.sync_mod.sync if hasattr(backend, "sync_mod") else callable(backend.sync) + assert backend.stop_voice_recognition is backend.voice.stop_voice_recognition diff --git a/tests/python/test_storage.py b/tests/python/test_storage.py new file mode 100644 index 00000000..f741e13d --- /dev/null +++ b/tests/python/test_storage.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +import base64 +import hashlib +from pathlib import Path + +import pytest +from backend import storage + + +class TestSafeExtFor: + def test_known_mime_png(self): + assert storage._safe_ext_for("image/png") == ".png" + + def test_known_mime_pdf(self): + assert storage._safe_ext_for("application/pdf") == ".pdf" + + def test_known_mime_json(self): + assert storage._safe_ext_for("application/json") == ".json" + + def test_unknown_mime(self): + assert storage._safe_ext_for("application/x-custom-unknown") == "" + + def test_empty_mime(self): + assert storage._safe_ext_for("") == "" + + def test_none_mime(self): + assert storage._safe_ext_for(None) == "" + + +class TestExportPathFor: + def test_with_explicit_extension(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for("document.pdf", "application/pdf") + + assert out.name == "document.pdf" + assert out.parent == tmp_path / "ubtms" / "tmp" + assert out.parent.is_dir() + + def test_without_extension_inferred_from_mime(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for("avatar", "image/png") + + assert out.name == "avatar.png" + + def test_empty_or_none_name_defaults_to_attachment(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for(None, "application/pdf") + + assert out.name == "attachment.pdf" + + def test_sanitizes_slashes_and_whitespace(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for(" nested/path/to/notes.txt ", "text/plain") + + assert out.name == "nested_path_to_notes.txt" + + +class TestIsFilePresent: + def test_file_exists(self, tmp_path): + sample = tmp_path / "sample.txt" + sample.write_text("content") + + assert storage.is_file_present(sample) is True + + def test_file_missing(self, tmp_path): + sample = tmp_path / "missing.txt" + + assert storage.is_file_present(sample) is False + + def test_directory_returns_false(self, tmp_path): + sample_dir = tmp_path / "sub_dir" + sample_dir.mkdir() + + assert storage.is_file_present(sample_dir) is False + + +class TestExistingAttachmentPath: + def test_returns_path_when_present(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + export_file = tmp_path / "ubtms" / "tmp" / "invoice.pdf" + export_file.parent.mkdir(parents=True, exist_ok=True) + export_file.write_text("pdf content") + + result = storage.get_existing_attachment_path("invoice.pdf", "application/pdf") + + assert result == str(export_file) + + def test_returns_none_when_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + + result = storage.get_existing_attachment_path("absent.pdf", "application/pdf") + + assert result is None + + +class TestIsAlreadyDownloaded: + def test_true_when_file_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + export_file = tmp_path / "ubtms" / "tmp" / "image.png" + export_file.parent.mkdir(parents=True, exist_ok=True) + export_file.write_bytes(b"image bytes") + + assert storage.is_already_downloaded("image.png", "image/png") is True + + def test_false_when_file_does_not_exist(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + + assert storage.is_already_downloaded("image.png", "image/png") is False + + def test_handles_exception_gracefully(self, monkeypatch): + def raise_error(*args, **kwargs): + raise RuntimeError("Unexpected failure") + + monkeypatch.setattr(storage, "_export_path_for", raise_error) + + assert storage.is_already_downloaded("file.txt", "text/plain") is False + + +class TestEnsureExportFileFromBase64: + def test_successful_decode_and_write(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + raw_payload = b"ubtms test attachment payload" + b64_payload = base64.b64encode(raw_payload).decode("utf-8") + + result_path = storage.ensure_export_file_from_base64("data.bin", b64_payload, "application/octet-stream") + + assert result_path is not None + saved_file = Path(result_path) + assert saved_file.exists() + assert saved_file.read_bytes() == raw_payload + + def test_invalid_base64_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + + result = storage.ensure_export_file_from_base64("bad.bin", "!!!not_valid_b64!!!", "text/plain") + + assert result is None + + +class TestResolveSettingsDbPath: + def test_resolves_when_file_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_name = "testSettingsDb" + db_hash = hashlib.md5(db_name.encode()).hexdigest() + db_dir = tmp_path / ".local" / "share" / "ubtms" / "Databases" + db_dir.mkdir(parents=True, exist_ok=True) + target_db = db_dir / f"{db_hash}.sqlite" + target_db.write_text("sqlite stub") + + resolved = storage.resolve_settings_db_path(db_name=db_name, app_id="ubtms") + + assert resolved == str(target_db) + + def test_returns_none_when_not_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + resolved = storage.resolve_settings_db_path(db_name="absentDb", app_id="ubtms") + + assert resolved is None + + +class TestResolveQmlDbPath: + def test_returns_latest_valid_db(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_dir = tmp_path / ".local" / "share" / "ubtms" / "Databases" + db_dir.mkdir(parents=True, exist_ok=True) + + db1 = db_dir / "db1.sqlite" + db1.write_text("older db") + db2 = db_dir / "db2.sqlite" + db2.write_text("newer db") + + # Ensure db2 has later mtime + db1_stat = db1.stat() + import os + os.utime(db2, (db1_stat.st_atime + 10, db1_stat.st_mtime + 10)) + + monkeypatch.setattr(storage, "check_table_exists", lambda path, table: table == "project_project_app") + + resolved = storage.resolve_qml_db_path(app_id="ubtms") + + assert resolved == str(db2) + + def test_returns_none_when_table_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_dir = tmp_path / ".local" / "share" / "ubtms" / "Databases" + db_dir.mkdir(parents=True, exist_ok=True) + db = db_dir / "empty.sqlite" + db.write_text("empty") + + monkeypatch.setattr(storage, "check_table_exists", lambda path, table: False) + + resolved = storage.resolve_qml_db_path(app_id="ubtms") + + assert resolved is None + + def test_returns_none_when_no_directory_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + resolved = storage.resolve_qml_db_path(app_id="ubtms") + + assert resolved is None diff --git a/tests/python/test_sync.py b/tests/python/test_sync.py new file mode 100644 index 00000000..7a53bfc4 --- /dev/null +++ b/tests/python/test_sync.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +import sys +import pytest +import backend + +sync_module = sys.modules["backend.sync"] + + +class TestSyncLockAndConcurrency: + def test_sync_background_skips_when_already_in_progress(self, monkeypatch): + monkeypatch.setattr(sync_module, "sync_in_progress", True) + + result = sync_module.sync_background("test.sqlite", 1) + + assert result is False + + def test_start_sync_in_background_alias(self, monkeypatch): + called = [] + + def mock_sync_bg(db, acc_id): + called.append((db, acc_id)) + return True + + monkeypatch.setattr(sync_module, "sync_background", mock_sync_bg) + + result = sync_module.start_sync_in_background("test.sqlite", 42) + + assert result is True + assert called == [("test.sqlite", 42)] diff --git a/tests/python/test_voice.py b/tests/python/test_voice.py new file mode 100644 index 00000000..c8f54a5e --- /dev/null +++ b/tests/python/test_voice.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +import pytest +from backend import voice + + +class TestVoiceModelsDir: + def test_respects_xdg_data_home(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + models_dir = voice.get_voice_models_dir() + + assert models_dir == tmp_path / "ubtms" / "voice_models" + assert models_dir.is_dir() + + def test_falls_back_to_home(self, tmp_path, monkeypatch): + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + models_dir = voice.get_voice_models_dir() + + assert models_dir == tmp_path / ".local" / "share" / "ubtms" / "voice_models" + assert models_dir.is_dir() + + +class TestIsValidVoskModel: + def test_non_directory_returns_false(self, tmp_path): + sample_file = tmp_path / "regular_file.txt" + sample_file.write_text("text") + + assert voice.is_valid_vosk_model(sample_file) is False + + def test_empty_directory_returns_false(self, tmp_path): + empty_dir = tmp_path / "empty_model" + empty_dir.mkdir() + + assert voice.is_valid_vosk_model(empty_dir) is False + + def test_am_and_graph_structure_returns_true(self, tmp_path): + model_dir = tmp_path / "valid_vosk_am_graph" + (model_dir / "am").mkdir(parents=True) + (model_dir / "graph").mkdir(parents=True) + + assert voice.is_valid_vosk_model(model_dir) is True + + def test_flat_final_mdl_and_hclg_returns_true(self, tmp_path): + model_dir = tmp_path / "valid_vosk_flat" + model_dir.mkdir(parents=True) + (model_dir / "final.mdl").write_bytes(b"model data") + (model_dir / "HCLG.fst").write_bytes(b"fst data") + + assert voice.is_valid_vosk_model(model_dir) is True + + +class TestDeviceRam: + def test_returns_positive_integer_on_linux(self): + ram_mb = voice.get_device_total_ram_mb() + assert isinstance(ram_mb, int) + assert ram_mb > 0 + + +class TestModelDownloadStatus: + def test_returns_status_dictionary(self): + status = voice.get_model_download_status() + assert isinstance(status, dict) + assert "in_progress" in status + assert "progress" in status + assert "message" in status + assert "error" in status + + +class TestVoiceSignals: + def test_stop_voice_recognition(self): + result = voice.stop_voice_recognition() + assert result is True diff --git a/tests/qml/Pparent/Notifications/NotificationHelper.qml b/tests/qml/Pparent/Notifications/NotificationHelper.qml new file mode 100644 index 00000000..4b6491b9 --- /dev/null +++ b/tests/qml/Pparent/Notifications/NotificationHelper.qml @@ -0,0 +1,9 @@ +import QtQuick 2.7 + +Item { + property string push_app_id: "" + property int count: 0 + function updateCount(c) { + count = c; + } +} diff --git a/tests/qml/Pparent/Notifications/qmldir b/tests/qml/Pparent/Notifications/qmldir new file mode 100644 index 00000000..8aad02d0 --- /dev/null +++ b/tests/qml/Pparent/Notifications/qmldir @@ -0,0 +1,2 @@ +module Pparent.Notifications +NotificationHelper 1.0 NotificationHelper.qml diff --git a/tests/qml/QtCharts/ChartView.qml b/tests/qml/QtCharts/ChartView.qml new file mode 100644 index 00000000..6b37629a --- /dev/null +++ b/tests/qml/QtCharts/ChartView.qml @@ -0,0 +1,26 @@ +import QtQuick 2.7 + +Item { + id: chartView + + default property alias children: contentArea.data + + property color backgroundColor: "transparent" + property bool antialiasing: true + property string title: "" + + property Margins margins: Margins {} + property Legend legend: Legend {} + + property QtObject plotArea: QtObject { + property real x: 0 + property real y: 0 + property real width: chartView.width + property real height: chartView.height + } + + Item { + id: contentArea + anchors.fill: parent + } +} diff --git a/tests/qml/QtCharts/Legend.qml b/tests/qml/QtCharts/Legend.qml new file mode 100644 index 00000000..40da028b --- /dev/null +++ b/tests/qml/QtCharts/Legend.qml @@ -0,0 +1,8 @@ +import QtQuick 2.7 + +QtObject { + property bool visible: false + property int alignment: 0 + property color labelColor: "black" + property font font +} diff --git a/tests/qml/QtCharts/Margins.qml b/tests/qml/QtCharts/Margins.qml new file mode 100644 index 00000000..ca616166 --- /dev/null +++ b/tests/qml/QtCharts/Margins.qml @@ -0,0 +1,8 @@ +import QtQuick 2.7 + +QtObject { + property int top: 0 + property int bottom: 0 + property int left: 0 + property int right: 0 +} diff --git a/tests/qml/QtCharts/PieSeries.qml b/tests/qml/QtCharts/PieSeries.qml new file mode 100644 index 00000000..a77cb796 --- /dev/null +++ b/tests/qml/QtCharts/PieSeries.qml @@ -0,0 +1,48 @@ +import QtQuick 2.7 + +Item { + id: pieSeries + property real size: 0.6 + property real holeSize: 0.3 + property real horizontalPosition: 0.5 + property real verticalPosition: 0.5 + property var slices: [] + readonly property int count: slices.length + + signal hovered(var slice, bool state) + + function clear() { + slices = []; + } + + function append(label, value) { + var comp = Qt.createComponent("PieSlice.qml"); + var startA = 0; + if (slices.length > 0) { + var last = slices[slices.length - 1]; + startA = last.startAngle + last.angleSpan; + } + + var sliceObj = comp.createObject(pieSeries, { + label: label, + value: value, + startAngle: startA, + angleSpan: 45 + }); + + slices = slices.concat([sliceObj]); + return sliceObj; + } + + function at(index) { + if (index >= 0 && index < slices.length) return slices[index]; + return null; + } + + function find(label) { + for (var i = 0; i < slices.length; i++) { + if (slices[i].label === label) return slices[i]; + } + return null; + } +} diff --git a/tests/qml/QtCharts/PieSlice.qml b/tests/qml/QtCharts/PieSlice.qml new file mode 100644 index 00000000..61fc82d3 --- /dev/null +++ b/tests/qml/QtCharts/PieSlice.qml @@ -0,0 +1,22 @@ +import QtQuick 2.7 + +QtObject { + id: slice + + enum LabelPosition { + LabelOutside, + LabelInsideHorizontal, + LabelInsideTangential, + LabelInsideNormal, + LabelArm + } + + property string label: "" + property real value: 0 + property bool exploded: false + property int labelPosition: 0 + property bool labelVisible: false + property color color: "blue" + property real startAngle: 0 + property real angleSpan: 0 +} diff --git a/tests/qml/QtCharts/qmldir b/tests/qml/QtCharts/qmldir new file mode 100644 index 00000000..3611ebc6 --- /dev/null +++ b/tests/qml/QtCharts/qmldir @@ -0,0 +1,6 @@ +module QtCharts +ChartView 2.0 ChartView.qml +PieSeries 2.0 PieSeries.qml +PieSlice 2.0 PieSlice.qml +Margins 2.0 Margins.qml +Legend 2.0 Legend.qml diff --git a/tests/qml/README.md b/tests/qml/README.md new file mode 100644 index 00000000..207aab70 --- /dev/null +++ b/tests/qml/README.md @@ -0,0 +1,210 @@ +# QML Unit Testing Guide + +This directory contains automated unit tests for QML components using Qt Quick Test (`qmltestrunner` and `QtTest 1.0`). + +## Running Tests + +To run the full QML test suite inside the Clickable container: + +```bash +clickable test +``` + +To run a specific test file or filter by test name: + +```bash +clickable test -- -functions test_openDisplaysMessageAndEmitsSignal +``` + +## Test Directory Structure + +``` +tests/ +├── qml/ +│ ├── README.md # This guide +│ ├── QtCharts/ # Mock QtCharts module for headless/unit test execution +│ │ ├── qmldir # Module definition registering mock ChartView, PieSeries, etc. +│ │ ├── ChartView.qml # Mock ChartView container component +│ │ ├── Legend.qml # Mock Legend component +│ │ ├── Margins.qml # Mock Margins component +│ │ ├── PieSeries.qml # Mock PieSeries component with slice management +│ │ └── PieSlice.qml # Mock PieSlice component with slice properties +│ ├── models/ # JavaScript core and model test suites +│ │ ├── tst_accounts.qml # Test suite for account management, defaults, sync settings, and deletion +│ │ ├── tst_activity.qml # Test suite for activity data, sanitizeId, type icons, date filters, and CRUD +│ │ ├── tst_constants.qml # Test suite for constants and quadrant color configuration +│ │ ├── tst_dashboard_user_filter.qml # Test suite for dashboard user filter state, models, and queries +│ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization +│ │ ├── tst_main.qml # Test suite for Main.js date helpers, name resolvers, and spent hours queries +│ │ ├── tst_notifications.qml # Test suite for notifications creation, read status, and sync notifications +│ │ ├── tst_project.qml # Test suite for project details, offline cache, favorites, and project updates +│ │ ├── tst_task.qml # Test suite for task model filters, assignee parsing, date status, and CRUD +│ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration +│ │ ├── tst_timesheet.qml # Test suite for timesheet creation, duration, readiness, and status transitions +│ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities +│ ├── scripts/ # Feature scripts and helper utility test suites +│ │ ├── tst_chartutils.qml # Test suite for chart data formatting, percentages, and sorting +│ │ ├── tst_chartworker.qml # Test suite for chart WorkerScript asynchronous processing +│ │ ├── tst_htmlsanitizer.qml # Test suite for HTML tag sanitization, color normalization, and validation +│ │ ├── tst_navigationroutes.qml # Test suite for navigation routes and page URL resolution +│ │ ├── tst_squirebridge.qml # Test suite for Squire.js QtWebEngine messaging bridge +│ │ └── tst_taskformutils.qml # Test suite for task form validation and save data preparation +│ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch +│ ├── tst_activitydetailscard.qml # Test suite for ActivityDetailsCard activity details, assignee, due date, and action signals +│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu multi-selection, search, and delegate interaction +│ ├── tst_chartinfopopup.qml # Test suite for ChartInfoPopup guide dialog and chart section descriptions +│ ├── tst_attachmentmanager.qml # Test suite for AttachmentManager attachment list, file deletion, and upload signals +│ ├── tst_backendbridge.qml # Test suite for BackendBridge signal dispatch between QML and Python +│ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color +│ ├── tst_contentpickerdialog.qml # Test suite for ContentPickerDialog single/multi selection +│ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component +│ ├── tst_daterangeheaderfilter.qml # Test suite for DateRangeHeaderFilter filter toggle and active range text +│ ├── tst_daterangeselector.qml # Test suite for DateRangeSelector date bounds and range pick +│ ├── tst_dayselector.qml # Test suite for DaySelector day selection signals +│ ├── tst_ehower.qml # Test suite for EHower 2x2 priority matrix layout and quadrant hours +│ ├── tst_ehowerindicator.qml # Test suite for EHowerIndicator quadrant key normalization and quadrant colors +│ ├── tst_filterableheadercontents.qml # Test suite for FilterableHeaderContents filter state integration +│ ├── tst_formdrafthandler.qml # Test suite for FormDraftHandler draft autosave, change tracking, and unsaved changes +│ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ ├── tst_imagepreviewer.qml # Test suite for ImagePreviewer image display, close actions, and download states +│ ├── tst_infobar.qml # Test suite for InfoBar feedback component +│ ├── tst_inlineoptionselector.qml # Test suite for InlineOptionSelector radio-style option selection +│ ├── tst_loadingindicator.qml # Test suite for LoadingIndicator running state and visibility +│ ├── tst_loadmorefooter.qml # Test suite for LoadMoreFooter loading state and retry trigger +│ ├── tst_modeldownloadtimerwidget.qml # Test suite for ModelDownloadTimerWidget download progress and cancel controls +│ ├── tst_multiassigneeselector.qml # Test suite for MultiAssigneeSelector multi-select IDs array output +│ ├── tst_mytasksclosedindicator.qml # Test suite for MyTasksClosedIndicator closed/completed tasks badge +│ ├── tst_notificationbell.qml # Test suite for NotificationBell unread counter badge and navigation +│ ├── tst_notificationpopup.qml # Test suite for NotificationPopup message, dismiss, and timeout +│ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_personalstageselector.qml # Test suite for PersonalStageSelector user-specific stage selection dialog +│ ├── tst_projectcard.qml # Test suite for ProjectCard dashboard summary card and progress bar +│ ├── tst_projectdetailscard.qml # Test suite for ProjectDetailsCard project info, status badge, progress, and timer controls +│ ├── tst_projectlist.qml # Test suite for ProjectList hierarchy navigation stack, list model population, and selection +│ ├── tst_projectpiechart.qml # Test suite for ProjectPieChart pie slice data loading, sorting, and empty state +│ ├── tst_projectstageselector.qml # Test suite for ProjectStageSelector stage list per project +│ ├── tst_savediscarddialog.qml # Test suite for SaveDiscardDialog save, discard, and cancel signals +│ ├── tst_selectionbutton.qml # Test suite for SelectionButton selection state and click +│ ├── tst_settingsheader.qml # Test suite for SettingsHeader page header and trailing actions +│ ├── tst_settingslistitem.qml # Test suite for SettingsListItem settings list item and click handling +│ ├── tst_settingspagelayout.qml # Test suite for SettingsPageLayout layout wrapper and header anchoring +│ ├── tst_stagefiltermenu.qml # Test suite for StageFilterMenu stage filter menu toggling +│ ├── tst_statusselector.qml # Test suite for StatusSelector status changes and badge color +│ ├── tst_taskchartcanvas.qml # Test suite for TaskChartCanvas horizontal bar chart rendering and task selection +│ ├── tst_taskdetailscard.qml # Test suite for TaskDetailsCard task details card info and action signals +│ ├── tst_tasklist.qml # Test suite for TaskList tasks list rendering, filter, and search logic +│ ├── tst_tasknamefield.qml # Test suite for TaskNameField task name input validation and signal emission +│ ├── tst_taskpriorityselector.qml # Test suite for TaskPrioritySelector star-based priority level selector +│ ├── tst_taskrecordactionsgrid.qml # Test suite for TaskRecordActionsGrid task action buttons and signal emission +│ ├── tst_taskrow.qml # Test suite for TaskRow task list row presentation and hours display +│ ├── tst_taskschedulefields.qml # Test suite for TaskScheduleFields planned hours and date range inputs +│ ├── tst_taskstageselector.qml # Test suite for TaskStageSelector project task stage selection dialog +│ ├── tst_tasktimechart.qml # Test suite for TaskTimeChart drilldown time tracking chart navigation and model updates +│ ├── tst_timepickerpopup.qml # Test suite for TimePickerPopup time selection and confirmation +│ ├── tst_timerecorderwidget.qml # Test suite for TimeRecorderWidget timer start, stop, and pause actions +│ ├── tst_timesheetdescriptionpopup.qml # Test suite for TimeSheetDescriptionPopup description editing dialog +│ ├── tst_timesheetdetailscard.qml # Test suite for TimeSheetDetailsCard entry summary display and workflow signals +│ ├── tst_tsbutton.qml # Test suite for TSButton action button component +│ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component +│ ├── tst_tslabel.qml # Test suite for TSLabel styled text component +│ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator +│ ├── tst_tssearchbar.qml # Test suite for TSSearchBar search input component +│ ├── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component +│ ├── tst_updatesdetailscard.qml # Test suite for UpdatesDetailsCard update summary, timestamp, status, and progress +│ ├── tst_voicetimerwidget.qml # Test suite for VoiceTimerWidget voice recording states and timer controls +│ └── tst_workitemselector.qml # Test suite for WorkItemSelector project and task selection logic +``` + +Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. + +## Mocking Native Modules via `-import tests/qml` + +Components that depend on native or platform modules (such as `QtCharts 2.0`) can be tested headlessly without requiring native C++ Qt plugin binaries or display servers. + +In `clickable.yaml`, test execution is configured with: + +```bash +test: qmltestrunner -input tests/qml -import tests/qml +``` + +The `-import tests/qml` option instructs the QML engine's import resolver to look inside `tests/qml/` for QML modules. When a component or test executes `import QtCharts 2.0`, QML finds `tests/qml/QtCharts/qmldir` and resolves `ChartView`, `PieSeries`, `PieSlice`, `Legend`, and `Margins` to the mock QML implementations in `tests/qml/QtCharts/`. + +This mechanism allows: +- Testing chart-dependent components (e.g., `ProjectPieChart`, `EHower`) in headless CI/CD environments. +- Simulating slice interactions, hover events, and animations in pure QML without GPU requirements. +- Fast execution and complete isolation from native charting backend state. + +## Anatomy of a QML Test Case + +Every test file follows this standard template: + +```qml +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/feedback" + +Item { + width: units.gu(40) + height: units.gu(70) + + // 1. Declare the component under test + InfoBar { + id: testTarget + } + + // 2. Optional: Declare SignalSpies for signal verification + SignalSpy { + id: openedSpy + target: testTarget + signalName: "opened" + } + + // 3. Define the TestCase suite + TestCase { + name: "InfoBarTests" + when: windowShown + + // Run before each test function + function init() { + testTarget.close(); + openedSpy.clear(); + } + + // Test function: Must start with "test_" + function test_initialProperties() { + compare(testTarget.visible, false); + compare(testTarget.text, ""); + } + + function test_openAction() { + testTarget.open("Hello", 2000); + compare(testTarget.visible, true); + compare(openedSpy.count, 1); + } + + // Asynchronous / timer verification + function test_timerAutoClose() { + testTarget.open("Expiring", 100); + tryCompare(testTarget, "visible", false, 500); + } + } +} +``` + +## Key Assertions and Helpers + +| Function | Usage | Description | +|---|---|---| +| `compare(actual, expected)` | `compare(item.visible, true)` | Strict equality check | +| `verify(condition)` | `verify(item.width > 0)` | Boolean condition check | +| `tryCompare(item, property, value, timeout)` | `tryCompare(item, "visible", false, 1000)` | Polls until property matches or timeout expires | +| `wait(ms)` | `wait(200)` | Pauses test execution for specified duration | +| `mouseClick(item, x, y, button)` | `mouseClick(buttonItem)` | Simulates user click on an Item | +| `keyClick(key)` | `keyClick(Qt.Key_Return)` | Simulates keyboard input | + +## Guidelines for New Tests + +1. **Isolation**: Always use `init()` or `cleanup()` to reset component state and clear `SignalSpy` counts between tests. +2. **Dimension Constraints**: Wrap the component inside an `Item` with defined width and height (e.g., using `units.gu(N)`) so layout calculations function properly. +3. **No Network or DB Calls**: Unit tests should focus on presentation components and pure UI state. For backend and sync logic, use mock data fixtures. diff --git a/tests/qml/models/tst_accounts.qml b/tests/qml/models/tst_accounts.qml new file mode 100644 index 00000000..581f43b4 --- /dev/null +++ b/tests/qml/models/tst_accounts.qml @@ -0,0 +1,172 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/accounts.js" as AccountsModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "AccountsModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM users WHERE id >= 90000 OR name LIKE 'TestAccount%' OR name LIKE 'CreatedAccount%'"); + tx.executeSql("DELETE FROM attachment_download_app WHERE account_id = 999 OR record_id >= 90000"); + tx.executeSql("UPDATE users SET is_default = 0"); + // Ensure local account is default + tx.executeSql("UPDATE users SET is_default = 1 WHERE id = 0"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Default Account Management --- + + function test_defaultAccountHandling() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username, is_default) VALUES (?, ?, ?, ?, ?, ?)", + [90001, "TestAccount Alpha", "https://alpha.example.com", "db_alpha", "user_alpha", 0] + ); + tx.executeSql( + "INSERT INTO users (id, name, link, database, username, is_default) VALUES (?, ?, ?, ?, ?, ?)", + [90002, "TestAccount Beta", "https://beta.example.com", "db_beta", "user_beta", 0] + ); + }); + + AccountsModel.setDefaultAccount(90001); + compare(AccountsModel.getDefaultAccountId(), 90001); + + AccountsModel.setDefaultAccount(90002); + compare(AccountsModel.getDefaultAccountId(), 90002); + + AccountsModel.clearDefaultAccount(); + // When none marked default, getDefaultAccountId falls back to first account (usually id 0) + compare(AccountsModel.getDefaultAccountId(), 0); + } + + // --- 2. Account Creation and Duplicate Detection --- + + function test_createAccount_success_and_duplicates() { + var res1 = AccountsModel.createAccount("CreatedAccount One", "https://odoo1.com", "db1", "admin1", 1, "apikey1"); + verify(!res1.duplicateFound); + + // Duplicate name (case-insensitive) + var resDupName = AccountsModel.createAccount("createdaccount one", "https://other.com", "otherdb", "otheruser", 1, "key"); + verify(resDupName.duplicateFound); + compare(resDupName.duplicateType, "name"); + + // Duplicate server connection + var resDupConn = AccountsModel.createAccount("CreatedAccount Two", "https://odoo1.com", "db1", "admin1", 1, "apikey2"); + verify(resDupConn.duplicateFound); + compare(resDupConn.duplicateType, "connection"); + } + + // --- 3. Account Name and Update --- + + function test_getAccountName_and_updateAccount() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username) VALUES (?, ?, ?, ?, ?)", + [90010, "TestAccount Gamma", "https://gamma.com", "db_gamma", "user_gamma"] + ); + }); + + // Local account ID 0 name check + compare(AccountsModel.getAccountName(0), "Local"); + + // User account name check + compare(AccountsModel.getAccountName(90010), "TestAccount Gamma"); + + // Update account + var updateRes = AccountsModel.updateAccount(90010, "TestAccount Gamma Renamed", "https://gamma2.com", "db_gamma2", "user_gamma2", 1, "newkey"); + verify(updateRes.success); + + compare(AccountsModel.getAccountName(90010), "TestAccount Gamma Renamed"); + } + + // --- 4. Delete Account and Related Data --- + + function test_deleteAccountAndRelatedData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username) VALUES (?, ?, ?, ?, ?)", + [90020, "TestAccount Delta", "https://delta.com", "db_delta", "user_delta"] + ); + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", + [90021, 90021, "Delta Project", 90020] + ); + }); + + AccountsModel.deleteAccountAndRelatedData(90020); + + // User record should be deleted + var afterUser = AccountsModel.getAccountName(90020); + compare(afterUser, ""); + + // Related project should also be deleted + db.transaction(function (tx) { + var rs = tx.executeSql("SELECT COUNT(*) AS count FROM project_project_app WHERE account_id = ?", [90020]); + compare(rs.rows.item(0).count, 0); + }); + + // Verify local account 0 cannot be deleted + AccountsModel.deleteAccountAndRelatedData(0); + compare(AccountsModel.getAccountName(0), "Local"); + } + + // --- 5. Sync Settings --- + + function test_accountSyncSettings() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username) VALUES (?, ?, ?, ?, ?)", + [90030, "TestAccount Sync", "https://sync.com", "db_sync", "user_sync"] + ); + }); + + AccountsModel.updateAccountSyncSettings(90030, 30, "download_only", 1); + var settings = AccountsModel.getAccountSyncSettings(90030); + compare(settings.sync_interval_minutes, 30); + compare(settings.sync_direction, "download_only"); + compare(settings.autosync_enabled, 1); + } + + // --- 6. Attachment Download Tracking --- + + function test_attachmentDownloadTracking() { + compare(AccountsModel.isAttachmentDownloaded(999, 90040), false); + + AccountsModel.markAttachmentDownloaded(999, 90040, "specs.pdf"); + compare(AccountsModel.isAttachmentDownloaded(999, 90040), true); + } + } +} diff --git a/tests/qml/models/tst_activity.qml b/tests/qml/models/tst_activity.qml new file mode 100644 index 00000000..ca1d8800 --- /dev/null +++ b/tests/qml/models/tst_activity.qml @@ -0,0 +1,198 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/activity.js" as ActivityModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "ActivityModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM mail_activity_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM mail_activity_type_app WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Pure Helper Functions --- + + function test_sanitizeId() { + compare(ActivityModel.sanitizeId(null), -1); + compare(ActivityModel.sanitizeId(undefined), -1); + compare(ActivityModel.sanitizeId(""), -1); + compare(ActivityModel.sanitizeId(0), 0); + compare(ActivityModel.sanitizeId(42), 42); + compare(ActivityModel.sanitizeId("42"), 42); + compare(ActivityModel.sanitizeId(-5), -5); + compare(ActivityModel.sanitizeId("invalid"), -1); + } + + function test_getActivityIconForType() { + compare(ActivityModel.getActivityIconForType("Send Mail"), "activity_mail.png"); + compare(ActivityModel.getActivityIconForType("Client Call"), "activity_call.png"); + compare(ActivityModel.getActivityIconForType("Project Meeting"), "activity_meeting.png"); + compare(ActivityModel.getActivityIconForType("Todo List"), "activity_others.png"); + compare(ActivityModel.getActivityIconForType(""), "activity_others.png"); + compare(ActivityModel.getActivityIconForType(null), "activity_others.png"); + } + + function test_passesActivityDateFilter() { + var refDate = new Date(2026, 9, 13); // 2026-10-13 + + var actToday = { due_date: "2026-10-13" }; + var actYesterday = { due_date: "2026-10-12" }; + var actNoDate = { due_date: null }; + + // "all" filter + verify(ActivityModel.passesActivityDateFilter(actToday, "all", refDate)); + verify(ActivityModel.passesActivityDateFilter(actNoDate, "all", refDate)); + + // Without date, non-"all" filters return false + verify(!ActivityModel.passesActivityDateFilter(actNoDate, "today", refDate)); + + // "today" includes today and overdue items + verify(ActivityModel.passesActivityDateFilter(actToday, "today", refDate)); + verify(ActivityModel.passesActivityDateFilter(actYesterday, "today", refDate)); + + // "overdue" + verify(ActivityModel.passesActivityDateFilter(actYesterday, "overdue", refDate)); + verify(!ActivityModel.passesActivityDateFilter(actToday, "overdue", refDate)); + } + + function test_passesActivitySearchFilter() { + var activity = { + summary: "Quarterly Planning", + notes: "Discuss Q4 objectives and budget", + activity_type_id: 1, + account_id: 999 + }; + + verify(ActivityModel.passesActivitySearchFilter(activity, "")); + verify(ActivityModel.passesActivitySearchFilter(activity, null)); + verify(ActivityModel.passesActivitySearchFilter(activity, "Quarterly")); + verify(ActivityModel.passesActivitySearchFilter(activity, "planning")); + verify(ActivityModel.passesActivitySearchFilter(activity, "budget")); + verify(!ActivityModel.passesActivitySearchFilter(activity, "UnrelatedTerm")); + } + + // --- 2. Database CRUD and Mutations --- + + function test_saveActivityData_and_getActivityById() { + var newActivity = { + updatedAccount: 999, + updatedActivity: 1, + updatedSummary: "Review Architecture Doc", + updatedUserId: 10, + updatedDate: "2026-10-14", + updatedNote: "Detailed review comments", + resModel: "project.project", + resId: 101, + task_id: 0, + project_id: 101, + link_id: 101, + state: "planned", + status: "normal" + }; + + var saveRes = ActivityModel.saveActivityData(newActivity, 0); + verify(saveRes.success); + var actId = saveRes.recordId; + verify(actId > 0); + + var details = ActivityModel.getActivityById(actId, 999); + verify(details !== null); + compare(details.id, actId); + compare(details.summary, "Review Architecture Doc"); + compare(details.account_id, 999); + compare(details.state, "planned"); + + // Update the activity + newActivity.updatedSummary = "Review Architecture Doc Updated"; + var updateRes = ActivityModel.saveActivityData(newActivity, actId); + verify(updateRes.success); + + var updatedDetails = ActivityModel.getActivityById(actId, 999); + compare(updatedDetails.summary, "Review Architecture Doc Updated"); + } + + function test_markAsDone_and_updateActivityDate() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO mail_activity_app (id, account_id, summary, due_date, state, status) VALUES (?, ?, ?, ?, ?, ?)", + [90010, 999, "Call Vendor", "2026-10-13", "planned", "normal"] + ); + }); + + ActivityModel.updateActivityDate(999, 90010, "2026-10-15"); + var d1 = ActivityModel.getActivityById(90010, 999); + compare(d1.due_date, "2026-10-15"); + compare(d1.status, "updated"); + + ActivityModel.markAsDone(999, 90010); + var d2 = ActivityModel.getActivityById(90010, 999); + compare(d2.state, "done"); + compare(d2.status, "updated"); + } + + function test_deleteActivity() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO mail_activity_app (id, account_id, summary, status) VALUES (?, ?, ?, ?)", + [90020, 999, "Temporary Activity", "normal"] + ); + }); + + var delRes = ActivityModel.deleteActivity(999, 90020); + verify(delRes.success); + + var details = ActivityModel.getActivityById(90020, 999); + // Non-existent activity returns empty object or null + verify(!details || !details.id); + } + + function test_getActivityTypesForAccount() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO mail_activity_type_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90031, 90031, "Email", 999, "normal"] + ); + tx.executeSql( + "INSERT INTO mail_activity_type_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90032, 90032, "Meeting", 999, "normal"] + ); + }); + + var types = ActivityModel.getActivityTypesForAccount(999); + compare(types.length, 2); + } + } +} diff --git a/tests/qml/models/tst_constants.qml b/tests/qml/models/tst_constants.qml new file mode 100644 index 00000000..4634a92f --- /dev/null +++ b/tests/qml/models/tst_constants.qml @@ -0,0 +1,49 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../models/constants.js" as Constants + +Item { + width: 200 + height: 200 + + TestCase { + name: "ConstantsTests" + when: windowShown + + function test_version() { + verify(Constants.version !== undefined); + compare(typeof Constants.version, "string"); + verify(Constants.version.length > 0); + compare(Constants.version, "1.3.4"); + } + + function test_fontSizesStructure() { + verify(Constants.FontSizes !== undefined); + compare(typeof Constants.FontSizes, "object"); + compare(Constants.FontSizes.ListHeading, 1.7); + compare(Constants.FontSizes.ListSubHeading, 1.5); + compare(Constants.FontSizes.ListSubSubHeading, 1.7); + } + + function test_colorsBasePalette() { + verify(Constants.Colors !== undefined); + compare(Constants.Colors.StickyNote, "#F5F5F5"); + compare(Constants.Colors.Border, "#B0BEC5"); + compare(Constants.Colors.Shadow, "#55000000"); + compare(Constants.Colors.Button, "#E95420"); + compare(Constants.Colors.ButtonText, "white"); + compare(Constants.Colors.ButtonHover, "#d3481b"); + compare(Constants.Colors.Orange, "#E95420"); + compare(Constants.Colors.ButtonDisabled, "grey"); + } + + function test_quadrantColors() { + verify(Constants.Colors.Quadrants !== undefined); + compare(Constants.Colors.Quadrants.Q1, "#E53935"); + compare(Constants.Colors.Quadrants.Q2, "#1E88E5"); + compare(Constants.Colors.Quadrants.Q3, "#43A047"); + compare(Constants.Colors.Quadrants.Q4, "#757575"); + compare(Constants.Colors.Quadrants.Default, "transparent"); + } + } +} diff --git a/tests/qml/models/tst_dashboard_user_filter.qml b/tests/qml/models/tst_dashboard_user_filter.qml new file mode 100644 index 00000000..1d65e6ff --- /dev/null +++ b/tests/qml/models/tst_dashboard_user_filter.qml @@ -0,0 +1,843 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DBInit +import "../../../models/global.js" as Global +import "../../../models/accounts.js" as Account +import "../../../models/project.js" as Project +import "../../../models/task.js" as Task +import "../../../models/timesheet.js" as Timesheet +import "../../../models/Main.js" as Main +import "../../../qml/components/visualization" + +Item { + width: 200 + height: 200 + + EHower { + id: testEHower + visible: false + autoRefreshOnAccountChange: false + } + + TestCase { + name: "DashboardUserFilterTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DBInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + Global.setDashboardUserFilter(-1, "All Users"); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM users"); + tx.executeSql("DELETE FROM res_users_app"); + tx.executeSql("DELETE FROM project_project_app"); + tx.executeSql("DELETE FROM project_task_app"); + tx.executeSql("DELETE FROM account_analytic_line_app"); + }); + } + + // --- 1. Global User Filter State --- + function test_globalDashboardUserFilterState() { + Global.setDashboardUserFilter(-1, "All Users"); + var current = Global.getDashboardUserFilter(); + compare(current.userId, -1); + compare(current.userName, "All Users"); + + Global.setDashboardUserFilter(101, "Alice Smith"); + current = Global.getDashboardUserFilter(); + compare(current.userId, 101); + compare(current.userName, "Alice Smith"); + + // Reset with null/undefined values + Global.setDashboardUserFilter(null, ""); + current = Global.getDashboardUserFilter(); + compare(current.userId, -1); + compare(current.userName, "All Users"); + } + + // --- 2. Account.getDashboardFilterUsers --- + function test_getDashboardFilterUsers_empty() { + var users = Account.getDashboardFilterUsers(1); + compare(users.length, 0); + } + + function test_getDashboardFilterUsers_populated() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1 active users + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 10, 'Charlie', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 20, 'Alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 30, 'Bob', 1)"); + // Account 1 inactive user (should be ignored) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 40, 'Inactive User', 0)"); + // Account 2 user (should be ignored) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (2, 50, 'Account2 User', 1)"); + }); + + var users = Account.getDashboardFilterUsers(1); + compare(users.length, 3); + // Should be sorted alphabetically: Alice, Bob, Charlie + compare(users[0].name, "Alice"); + compare(users[0].id, 20); + compare(users[1].name, "Bob"); + compare(users[1].id, 30); + compare(users[2].name, "Charlie"); + compare(users[2].id, 10); + } + + // --- 3. Account.getDashboardSingleUserDefault --- + function test_getDashboardSingleUserDefault_singleUser() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 15, 'Single User', 1)"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 1); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + verify(defaultUser !== null); + compare(defaultUser.id, 15); + compare(defaultUser.name, "Single User"); + } + + function test_getDashboardSingleUserDefault_standardEmployeeSingleTimesheets() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Multiple users exist in directory + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 10, 'Employee Alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 20, 'Manager Bob', 1)"); + + // But only Alice's timesheets are synced locally (standard employee permission) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 8.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 7.5, '2026-10-02', 'synced')"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 1); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + verify(defaultUser !== null); + compare(defaultUser.id, 10); + compare(defaultUser.name, "Employee Alice"); + } + + function test_getDashboardSingleUserDefault_managerMultipleTimesheets() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1 with Bob logged in as manager + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acme Corp', 'https://odoo.acme.com', 'acme_db', 'bob')"); + + // Multiple users exist + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'Alice', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 20, 'Bob', 'bob', 1)"); + + // Both users have timesheets + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 8.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 20, 6.0, '2026-10-01', 'synced')"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 2); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + // Manager viewing team: should default to logged-in user's personal metrics (Bob, 20), not All Users + verify(defaultUser !== null); + compare(defaultUser.id, 20); + compare(defaultUser.name, "Bob"); + } + + function test_getDashboardSingleUserDefault_unresolvedLoggedInUserMultipleUsers() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // No entry in users table for account 1 + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'Alice', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 20, 'Bob', 'bob', 1)"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 2); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + // If logged-in user cannot be resolved and multiple users exist, returns null (fallback to All Users) + compare(defaultUser, null); + } + + function test_getDashboardSingleUserDefault_localAccount() { + var defaultUser = Account.getDashboardSingleUserDefault(0); + verify(defaultUser !== null); + compare(defaultUser.id, 1); + compare(defaultUser.name, "Local User"); + + var users = Account.getDashboardFilterUsers(0); + compare(users.length, 1); + compare(users[0].name, "Local User"); + } + + // --- 4. Filtering in Model Queries --- + function test_projectSpentHoursList_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 1, 200, 'Project Beta')"); + + // Project Alpha: Alice 5 hrs, Bob 3 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 10, 5.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 20, 3.0, '2026-10-01', 'synced')"); + + // Project Beta: Alice 2 hrs, Bob 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 200, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 200, 20, 7.0, '2026-10-01', 'synced')"); + }); + + // All Users (userId = -1) + var allData = Project.getProjectSpentHoursList(true, 1, "", "", -1); + compare(allData.length, 2); + var alphaAll = allData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaAll = allData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaAll.spentHours, 8.0); + compare(betaAll.spentHours, 9.0); + + // Filter by Alice (userId = 10) + var aliceData = Project.getProjectSpentHoursList(true, 1, "", "", 10); + compare(aliceData.length, 2); + var alphaAlice = aliceData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaAlice = aliceData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaAlice.spentHours, 5.0); + compare(betaAlice.spentHours, 2.0); + + // Filter by Bob (userId = 20) + var bobData = Project.getProjectSpentHoursList(true, 1, "", "", 20); + compare(bobData.length, 2); + var alphaBob = bobData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaBob = bobData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaBob.spentHours, 3.0); + compare(betaBob.spentHours, 7.0); + } + + function test_mainSpentHours_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 10, 4.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 20, 6.0, '2026-10-01', 'synced')"); + }); + + // Projects spent hours via Main.get_projects_spent_hours + var allProjects = Main.get_projects_spent_hours(1, "", "", -1); + compare(allProjects.length, 1); + compare(allProjects[0].total, 10.0); + + var aliceProjects = Main.get_projects_spent_hours(1, "", "", 10); + compare(aliceProjects.length, 1); + compare(aliceProjects[0].total, 4.0); + + // Pie chart spent hours via Project.getProjectSpentHoursList + var allPieProjects = Project.getProjectSpentHoursList(true, 1, "", "", -1); + compare(allPieProjects.length, 1); + compare(allPieProjects[0].spentHours, 10.0); + + var alicePieProjects = Project.getProjectSpentHoursList(true, 1, "", "", 10); + compare(alicePieProjects.length, 1); + compare(alicePieProjects[0].spentHours, 4.0); + + var bobPieProjects = Project.getProjectSpentHoursList(true, 1, "", "", 20); + compare(bobPieProjects.length, 1); + compare(bobPieProjects[0].spentHours, 6.0); + + // Tasks spent hours via Main.get_tasks_spent_hours + var allTasks = Main.get_tasks_spent_hours(1, "", "", -1); + compare(allTasks["Task 1"], 10.0); + + var aliceTasks = Main.get_tasks_spent_hours(1, "", "", 10); + compare(aliceTasks["Task 1"], 4.0); + + var bobTasks = Main.get_tasks_spent_hours(1, "", "", 20); + compare(bobTasks["Task 1"], 6.0); + } + + function test_timesheetsForTask_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, name, record_date, status) VALUES (1, 100, 501, 10, 2.5, 'Alice Work', '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, name, record_date, status) VALUES (1, 100, 501, 20, 3.5, 'Bob Work', '2026-10-01', 'synced')"); + }); + + // All timesheets + var allTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", -1); + compare(allTs.length, 2); + + // Alice only + var aliceTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", 10); + compare(aliceTs.length, 1); + compare(aliceTs[0].name, "Alice Work"); + + // Bob only + var bobTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", 20); + compare(bobTs.length, 1); + compare(bobTs[0].name, "Bob Work"); + } + + // --- 5. Eisenhower Priority Matrix User Filter --- + function test_eisenhowerQuadrants_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Alice (10): Q1 = 3 hrs, Q3 = 1 hr + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 1, 10, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 3, 10, 1.0, '2026-10-01', 'synced')"); + // Bob (20): Q1 = 5 hrs, Q2 = 4 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 1, 20, 5.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 2, 20, 4.0, '2026-10-01', 'synced')"); + // Charlie (30): Q2 = 2 hrs, Q4 = 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 2, 30, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 4, 30, 7.0, '2026-10-01', 'synced')"); + }); + + // 1. All users (-1) + var allTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", -1); + compare(allTotals[1], "8"); // 3 + 5 + compare(allTotals[2], "6"); // 4 + 2 + compare(allTotals[3], "1"); // 1 + compare(allTotals[4], "7"); // 7 + + // 2. Single user: Alice (10) + var aliceTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", 10); + compare(aliceTotals[1], "3"); + compare(aliceTotals[2], "0"); + compare(aliceTotals[3], "1"); + compare(aliceTotals[4], "0"); + + // 3. Single user: Bob (20) + var bobTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", 20); + compare(bobTotals[1], "5"); + compare(bobTotals[2], "4"); + compare(bobTotals[3], "0"); + compare(bobTotals[4], "0"); + + // 4. Multi-user selection array: Alice (10) and Bob (20) + var aliceBobTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [10, 20]); + compare(aliceBobTotals[1], "8"); // 3 + 5 + compare(aliceBobTotals[2], "4"); // 4 + compare(aliceBobTotals[3], "1"); // 1 + compare(aliceBobTotals[4], "0"); // 0 + + // 5. Multi-user selection array: Bob (20) and Charlie (30) + var bobCharlieTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [20, 30]); + compare(bobCharlieTotals[1], "5"); // 5 + compare(bobCharlieTotals[2], "6"); // 4 + 2 + compare(bobCharlieTotals[3], "0"); // 0 + compare(bobCharlieTotals[4], "7"); // 7 + + // 6. Selection objects array: [{ user_id: 10, account_id: 1 }, { user_id: 30, account_id: 1 }] + var objTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [ + { user_id: 10, account_id: 1 }, + { user_id: 30, account_id: 1 } + ]); + compare(objTotals[1], "3"); // 3 + compare(objTotals[2], "2"); // 2 + compare(objTotals[3], "1"); // 1 + compare(objTotals[4], "7"); // 7 + + // 7. Verify refreshQuadrants updates component properties + testEHower.refreshQuadrants("", "", [10, 20], 1); + compare(testEHower.quadrant1Hours, "8H"); + compare(testEHower.quadrant2Hours, "4H"); + compare(testEHower.quadrant3Hours, "1H"); + compare(testEHower.quadrant4Hours, "0H"); + } + + // --- 6. Tasks Tab Summary & Project Tasks User Filter --- + function test_dashboardProjectTaskSummary_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 20, 6.0, '2026-10-01', 'synced')"); + }); + + // Summary: all users (-1) + var allSummary = Project.getDashboardProjectTaskSummary(1, "", "", -1); + compare(allSummary.length, 1); + compare(allSummary[0].totalHours, 8.0); + + // Summary: Alice (10) + var aliceSummary = Project.getDashboardProjectTaskSummary(1, "", "", 10); + compare(aliceSummary.length, 1); + compare(aliceSummary[0].totalHours, 2.0); + + // Summary: Bob (20) + var bobSummary = Project.getDashboardProjectTaskSummary(1, "", "", 20); + compare(bobSummary.length, 1); + compare(bobSummary[0].totalHours, 6.0); + + // Tasks for project: Alice (10) + var aliceTasks = Task.getTasksForProject(100, 1, "", "", 10); + compare(aliceTasks.length, 1); + + // Tasks for project: non-existent user (99) + var nonExistentTasks = Task.getTasksForProject(100, 1, "", "", 99); + compare(nonExistentTasks.length, 0); + } + + // --- 7. Combining users by email across accounts --- + function test_combineUsersByEmail_acrossAccounts() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Account 1', 'https://a1.com', 'db1', 'u1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Account 2', 'https://a2.com', 'db2', 'u2', 0)"); + + // Account 1: User 10 named 'Parvathy', email 'p.nair@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 'pnair', 1)"); + // Account 2: User 25 named 'Parvathy Nair', email 'p.nair@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 'pnair2', 1)"); + // Account 2: User 30 named 'Other User', email 'other@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 30, 'Other User', 'other@cit-services.eu', 'other', 1)"); + + // Timesheets so they appear as active dashboard users + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 2.5, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (2, 25, 4.0, '2026-10-02', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (2, 30, 1.5, '2026-10-03', 'synced')"); + }); + + // Single account 1: only Parvathy + var acc1Users = Account.getDashboardFilterUsers(1); + compare(acc1Users.length, 1); + compare(acc1Users[0].id, 10); + compare(acc1Users[0].name, "Parvathy"); + + // Single account 2: Parvathy Nair and Other User + var acc2Users = Account.getDashboardFilterUsers(2); + compare(acc2Users.length, 2); + + // All accounts (-1): Should combine users by email + var allUsers = Account.getDashboardFilterUsers(-1); + compare(allUsers.length, 2); + + // Find combined Parvathy Nair entry + var pEntry = null; + for (var i = 0; i < allUsers.length; i++) { + if (allUsers[i].email === "p.nair@cit-services.eu") { + pEntry = allUsers[i]; + break; + } + } + verify(pEntry !== null); + // Should choose the longer, more complete name 'Parvathy Nair' + compare(pEntry.name, "Parvathy Nair"); + // allUserIds should contain both IDs (10 and 25) + verify(pEntry.allUserIds.indexOf(10) !== -1); + verify(pEntry.allUserIds.indexOf(25) !== -1); + } + + // --- 8. Cross-Account buildUserFilterSQL & Data Aggregation across widgets --- + function test_crossAccountCombinedUserDataAggregation() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Instance 1', 'https://a1.com', 'db1', 'u1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Instance 2', 'https://a2.com', 'db2', 'u2', 0)"); + + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 30, 'Bob Ross', 'bob@example.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project In Instance 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Project In Instance 2')"); + + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task A1', 101)"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (2, 2, 601, 'Task A2', 202)"); + + // Timesheets: + // Instance 1, user 10 (Parvathy): 3.0 hrs in Project 101 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (1, 101, 501, 10, 3.0, '2026-10-01', 'synced', 1)"); + // Instance 2, user 25 (Parvathy Nair): 5.0 hrs in Project 202 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (2, 202, 601, 25, 5.0, '2026-10-02', 'synced', 2)"); + // Instance 2, user 30 (Bob): 4.0 hrs in Project 202 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (2, 202, 601, 30, 4.0, '2026-10-02', 'synced', 1)"); + }); + + // 1. Overview Pie Chart: getProjectSpentHoursList for All Accounts (-1) with user 10 + var pieProjects = Project.getProjectSpentHoursList(true, -1, "", "", 10); + compare(pieProjects.length, 2); + var totalSpent = 0; + for (var p = 0; p < pieProjects.length; p++) { + totalSpent += pieProjects[p].spentHours; + } + compare(totalSpent, 8.0); // 3.0 + 5.0 + + // 2. Projects Bar Chart: Main.get_projects_spent_hours for All Accounts (-1) with user 10 + var barProjects = Main.get_projects_spent_hours(-1, "", "", 10); + compare(barProjects.length, 2); + var barTotal = 0; + for (var b = 0; b < barProjects.length; b++) { + barTotal += barProjects[b].total; + } + compare(barTotal, 8.0); // 3.0 + 5.0 + + // 3. Tasks Tab Project Summary: Project.getDashboardProjectTaskSummary for All Accounts (-1) with user 10 + var summary = Project.getDashboardProjectTaskSummary(-1, "", "", 10); + var summaryHours = 0; + for (var s = 0; s < summary.length; s++) { + summaryHours += summary[s].totalHours; + } + compare(summaryHours, 8.0); // 3.0 + 5.0 + + // 4. Tasks Tab Drilldown: Task.getTasksForProject for Project 202 (Account 2) passing user 10 + // Even though user ID 10 is from Account 1, it should map to user 25 in Account 2 + var tasksForP2 = Task.getTasksForProject(202, 2, "", "", 10); + compare(tasksForP2.length, 1); + compare(tasksForP2[0].spent_hours, 5.0); + + // 5. Timesheet logs for task 601 (Account 2) passing user 10 + var logs = Timesheet.getTimesheetsForTask(601, 2, "all", "", "", 10); + compare(logs.length, 1); + compare(logs[0].spentHours, "5:00"); + } + + // --- 11. Multi-assignee Filter Tests --- + function test_multiUserFilterSQL_emptyOrAllUsers() { + var fEmpty = Account.buildUserFilterSQL([], 1, "a"); + compare(fEmpty.clause, ""); + compare(fEmpty.params.length, 0); + + var fAllArr = Account.buildUserFilterSQL([-1], 1, "a"); + compare(fAllArr.clause, ""); + compare(fAllArr.params.length, 0); + + var fAllObj = Account.buildUserFilterSQL([{ user_id: -1, account_id: -1 }], 1, "a"); + compare(fAllObj.clause, ""); + compare(fAllObj.params.length, 0); + } + + function test_multiUserFilterSQL_singleAccountMultiSelect() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acct 1', 'https://a1.com', 'db1', 'admin1')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Alice', 'alice@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 20, 'Bob', 'bob@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 30, 'Charlie', 'charlie@test.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project 1')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 101)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 20, 3.5, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 30, 5.0, '2026-10-01', 'synced')"); + }); + + // Filtering by Alice and Bob [10, 20] + var filterSQL = Account.buildUserFilterSQL([10, 20], 1, "a"); + compare(filterSQL.clause, "a.user_id IN (?, ?)"); + compare(filterSQL.params.length, 2); + + // Verify Project.getProjectSpentHoursList filters for both Alice and Bob (2.0 + 3.5 = 5.5 hrs) + var pieProjects = Project.getProjectSpentHoursList(true, 1, "", "", [10, 20]); + compare(pieProjects.length, 1); + compare(pieProjects[0].spentHours, 5.5); + + // Verify Main.get_projects_spent_hours + var barProjects = Main.get_projects_spent_hours(1, "", "", [10, 20]); + compare(barProjects.length, 1); + compare(barProjects[0].total, 5.5); + + // Selection objects [{ user_id: 10, account_id: 1 }, { user_id: 20, account_id: 1 }] + var pieObj = Project.getProjectSpentHoursList(true, 1, "", "", [{ user_id: 10, account_id: 1 }, { user_id: 20, account_id: 1 }]); + compare(pieObj.length, 1); + compare(pieObj[0].spentHours, 5.5); + } + + function test_multiUserFilterSQL_allAccountsMultiSelect() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Instance 1', 'https://a1.com', 'db1', 'admin1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Instance 2', 'https://a2.com', 'db2', 'admin2', 0)"); + + // Parvathy in both accounts (uid 10 in acc 1, uid 25 in acc 2) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 1)"); + // Bob only in account 2 (uid 30) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 30, 'Bob Ross', 'bob@example.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Project 2')"); + + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 101)"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (2, 2, 601, 'Task 2', 202)"); + + // Timesheets: + // Parvathy: 3.0 in Project 1 (acc 1), 5.0 in Project 2 (acc 2) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 10, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 601, 25, 5.0, '2026-10-02', 'synced')"); + // Bob: 4.0 in Project 2 (acc 2) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 601, 30, 4.0, '2026-10-02', 'synced')"); + }); + + // Multi-selecting Parvathy (10) and Bob (30) in All Accounts (-1) + var multiFilter = Account.buildUserFilterSQL([10, 30], -1, "aal"); + verify(multiFilter.clause.length > 0); + + // Total spent across all accounts for Parvathy + Bob: 3.0 + 5.0 + 4.0 = 12.0 hrs + var pieProjects = Project.getProjectSpentHoursList(true, -1, "", "", [10, 30]); + var totalSpent = 0; + for (var p = 0; p < pieProjects.length; p++) { + totalSpent += pieProjects[p].spentHours; + } + compare(totalSpent, 12.0); + + // Bar chart: Main.get_projects_spent_hours + var barProjects = Main.get_projects_spent_hours(-1, "", "", [10, 30]); + var barTotal = 0; + for (var b = 0; b < barProjects.length; b++) { + barTotal += barProjects[b].total; + } + compare(barTotal, 12.0); + + // Project task summary: Project.getDashboardProjectTaskSummary + var summary = Project.getDashboardProjectTaskSummary(-1, "", "", [10, 30]); + var summaryHours = 0; + for (var s = 0; s < summary.length; s++) { + summaryHours += summary[s].totalHours; + } + compare(summaryHours, 12.0); + } + + // --- 10. Dynamic Visibility: hasManagerOrAdminAccess --- + function test_hasManagerOrAdminAccess_localAccount() { + // Local Account (0) is single-user offline only - standard access + compare(Account.hasManagerOrAdminAccess(0), false); + } + + function test_hasManagerOrAdminAccess_standardEmployee() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1: Standard Employee + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acct 1', 'https://a1.com', 'db1', 'employee@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 100, 'John Doe', 'employee@test.com', 'employee@test.com', 1)"); + // Project managed by someone else (user_id = 999) + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 1, 101, 'Project 1', 999)"); + // Timesheets only for self (user_id = 100) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 100, 4.0, '2026-10-01', 'synced')"); + }); + + // Standard access account: hide icon + compare(Account.hasManagerOrAdminAccess(1), false); + } + + function test_hasManagerOrAdminAccess_adminUsername() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (2, 'Admin Acct', 'https://a2.com', 'db2', 'admin')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 2, 'Administrator', 'admin@test.com', 'admin', 1)"); + }); + + // Admin account: show icon + compare(Account.hasManagerOrAdminAccess(2), true); + } + + function test_hasManagerOrAdminAccess_adminLogin() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Username in users table is user's email, but res_users_app login is 'admin' + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (3, 'Admin Acct 2', 'https://a3.com', 'db3', 'admin@corp.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (3, 200, 'Corp Admin', 'admin@corp.com', 'admin', 1)"); + }); + + compare(Account.hasManagerOrAdminAccess(3), true); + } + + function test_hasManagerOrAdminAccess_adminUid2() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Odoo default Administrator UID is 2 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (4, 'Acct 4', 'https://a4.com', 'db4', 'odoo_super')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (4, 2, 'Super User', 'super@test.com', 'odoo_super', 1)"); + }); + + compare(Account.hasManagerOrAdminAccess(4), true); + } + + function test_hasManagerOrAdminAccess_projectManager() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 5: User is Project Manager of Project 1 (user_id = 50 matches odoo_record_id = 50) + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (5, 'PM Acct', 'https://a5.com', 'db5', 'pm@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (5, 50, 'PM User', 'pm@test.com', 'pm@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 5, 201, 'Managed Project', 50)"); + // Only own timesheet + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (5, 201, 1, 50, 2.0, '2026-10-01', 'synced')"); + }); + + // Project Manager account: show icon + compare(Account.hasManagerOrAdminAccess(5), true); + } + + function test_hasManagerOrAdminAccess_teamTimesheetManager() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 6: Lead with team timesheets + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (6, 'Team Acct', 'https://a6.com', 'db6', 'lead@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (6, 60, 'Team Lead', 'lead@test.com', 'lead@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (6, 70, 'Subordinate', 'sub@test.com', 'sub@test.com', 1)"); + // Project not managed by lead + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 6, 301, 'Team Project', 999)"); + // Timesheets for multiple employees (lead 60 + subordinate 70) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (6, 301, 1, 60, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (6, 301, 1, 70, 5.0, '2026-10-01', 'synced')"); + }); + + // Team manager with multiple employees' timesheets: show icon + compare(Account.hasManagerOrAdminAccess(6), true); + } + + function test_hasManagerOrAdminAccess_allAccounts() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Standard Account 1 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Standard 1', 'https://a1.com', 'db1', 'u1@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'User 1', 'u1@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 1, 101, 'Proj 1', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 10, 2.0, '2026-10-01', 'synced')"); + + // Standard Account 2 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (2, 'Standard 2', 'https://a2.com', 'db2', 'u2@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (2, 20, 'User 2', 'u2@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (2, 2, 201, 'Proj 2', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 201, 1, 20, 3.0, '2026-10-01', 'synced')"); + }); + + // When all accounts only grant standard access: hide icon + compare(Account.hasManagerOrAdminAccess(-1), false); + + // Now add an Admin account (Account 3) + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (3, 'Admin Acct', 'https://a3.com', 'db3', 'admin')"); + }); + + // When at least one instance account grants manager/admin access: show icon + compare(Account.hasManagerOrAdminAccess(-1), true); + } + + function test_accountSwitching_togglesManagerAccess() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account A (ID 10): Standard Employee + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (10, 'Instance A', 'https://a.com', 'dbA', 'user@company.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (10, 101, 'User A', 'user@company.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 10, 501, 'Proj A', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (10, 501, 1, 101, 3.0, '2026-10-01', 'synced')"); + + // Account B (ID 20): Project Manager + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (20, 'Instance B', 'https://b.com', 'dbB', 'user@company.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (20, 202, 'User B', 'user@company.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (2, 20, 601, 'Proj B', 202)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (20, 601, 1, 202, 5.0, '2026-10-01', 'synced')"); + }); + + // 1. Initial Account A (Standard Access): Icon hidden + var activeAccountId = 10; + var isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 2. User toggles to Account B (Project Manager Access): Icon visible + activeAccountId = 20; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, true); + + // 3. User toggles back to Account A (Standard Access): Icon hidden + activeAccountId = 10; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 4. User toggles to Local Account (0): Icon hidden + activeAccountId = 0; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 5. User toggles to All Accounts (-1): Icon visible because Account B is PM + activeAccountId = -1; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, true); + } + + // --- 11. Cross-Server User ID Isolation --- + function test_usersSharingSameIdAcrossServers_notMixedUp() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Two distinct servers / accounts + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Server 1', 'https://s1.com', 'db1', 'admin1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Server 2', 'https://s2.com', 'db2', 'admin2', 0)"); + + // Both servers have users with ID 2 and ID 6, but DIFFERENT people and emails! + // Server 1: User 2 is Alice (alice@server1.com), User 6 is Alan (alan@server1.com) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 2, 'Alice S1', 'alice@server1.com', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 6, 'Alan S1', 'alan@server1.com', 'alan', 1)"); + + // Server 2: User 2 is Bob (bob@server2.com), User 6 is Brian (brian@server2.com) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 2, 'Bob S2', 'bob@server2.com', 'bob', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 6, 'Brian S2', 'brian@server2.com', 'brian', 1)"); + + // Projects and timesheets + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Proj 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Proj 2')"); + + // Alice (Server 1, user 2): 3 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 2, 3.0, '2026-10-01', 'synced')"); + // Bob (Server 2, user 2): 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 2, 2, 7.0, '2026-10-01', 'synced')"); + }); + + // 1. In combineUsersByEmail across all accounts: + // Alice (S1) and Bob (S2) should be separate users (not combined into one)! + var allUsers = Account.getDashboardFilterUsers(-1); + var aliceEntry = null; + var bobEntry = null; + for (var u = 0; u < allUsers.length; u++) { + if (allUsers[u].email === "alice@server1.com") aliceEntry = allUsers[u]; + if (allUsers[u].email === "bob@server2.com") bobEntry = allUsers[u]; + } + verify(aliceEntry !== null); + verify(bobEntry !== null); + compare(aliceEntry.name, "Alice S1"); + compare(bobEntry.name, "Bob S2"); + compare(aliceEntry.allAccountIds.length, 1); + compare(bobEntry.allAccountIds.length, 1); + + // 2. Filter for Alice using her selection object { user_id: 2, account_id: 1 } across All Accounts (-1): + // Spent hours must ONLY be 3.0 (from Server 1), NOT 10.0 (mixed with Bob)! + var aliceHours = Project.getProjectSpentHoursList(true, -1, "", "", [{ user_id: 2, account_id: 1 }]); + var aliceTotal = 0; + for (var a = 0; a < aliceHours.length; a++) { + aliceTotal += aliceHours[a].spentHours; + } + compare(aliceTotal, 3.0); + + // 3. Filter for Bob using selection { user_id: 2, account_id: 2 } across All Accounts (-1): + // Spent hours must ONLY be 7.0 (from Server 2), NOT 10.0 (mixed with Alice)! + var bobHours = Project.getProjectSpentHoursList(true, -1, "", "", [{ user_id: 2, account_id: 2 }]); + var bobTotal = 0; + for (var b = 0; b < bobHours.length; b++) { + bobTotal += bobHours[b].spentHours; + } + compare(bobTotal, 7.0); + + // 4. getUserNameByOdooId with accountId scopes correctly: + compare(Account.getUserNameByOdooId(2, 1), "Alice S1"); + compare(Account.getUserNameByOdooId(2, 2), "Bob S2"); + compare(Account.getUserNameByOdooId(6, 1), "Alan S1"); + compare(Account.getUserNameByOdooId(6, 2), "Brian S2"); + } + } +} diff --git a/tests/qml/models/tst_database.qml b/tests/qml/models/tst_database.qml new file mode 100644 index 00000000..cee9b67f --- /dev/null +++ b/tests/qml/models/tst_database.qml @@ -0,0 +1,249 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon + +Item { + width: 200 + height: 200 + + TestCase { + name: "DatabaseTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function test_constants() { + verify(DBCommon.NAME !== undefined); + compare(typeof DBCommon.NAME, "string"); + compare(DBCommon.NAME, "myDatabase"); + + verify(DBCommon.VERSION !== undefined); + compare(typeof DBCommon.VERSION, "string"); + compare(DBCommon.VERSION, "1.0"); + + verify(DBCommon.DISPLAY_NAME !== undefined); + compare(typeof DBCommon.DISPLAY_NAME, "string"); + compare(DBCommon.DISPLAY_NAME, "My Database"); + + verify(DBCommon.SIZE !== undefined); + compare(typeof DBCommon.SIZE, "number"); + compare(DBCommon.SIZE, 1000000); + } + + function test_getTimestamp() { + var ts = DBCommon.getTimestamp(); + verify(ts !== undefined); + compare(typeof ts, "string"); + verify(ts.length > 0); + + var parsed = new Date(ts); + verify(!isNaN(parsed.getTime())); + } + + function test_rowToObjectValid() { + var mockRow = { + id: 42, + name: "Test Task", + active: 1, + notes: null + }; + + var obj = DBCommon.rowToObject(mockRow); + verify(obj !== null); + compare(obj.id, 42); + compare(obj.name, "Test Task"); + compare(obj.active, 1); + compare(obj.notes, null); + } + + function test_rowToObjectInvalidInputs() { + var objNull = DBCommon.rowToObject(null); + verify(typeof objNull === "object"); + compare(Object.keys(objNull).length, 0); + + var objUndefined = DBCommon.rowToObject(undefined); + verify(typeof objUndefined === "object"); + compare(Object.keys(objUndefined).length, 0); + + var objPrimitive = DBCommon.rowToObject("not a row"); + verify(typeof objPrimitive === "object"); + } + + function test_createOrUpdateTable() { + var tableName = "tst_db_sample_table"; + var createSQL = "CREATE TABLE IF NOT EXISTS " + tableName + " (id INTEGER PRIMARY KEY, title TEXT)"; + var initialCols = ["title TEXT"]; + + DBCommon.createOrUpdateTable(tableName, createSQL, initialCols); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var res = tx.executeSql("PRAGMA table_info(" + tableName + ")"); + var colNames = []; + for (var i = 0; i < res.rows.length; i++) { + colNames.push(res.rows.item(i).name); + } + verify(colNames.indexOf("id") !== -1); + verify(colNames.indexOf("title") !== -1); + }); + + // Update table with a new column + var updatedCols = ["title TEXT", "priority INTEGER"]; + DBCommon.createOrUpdateTable(tableName, createSQL, updatedCols); + + db.transaction(function(tx) { + var res = tx.executeSql("PRAGMA table_info(" + tableName + ")"); + var colNames = []; + for (var i = 0; i < res.rows.length; i++) { + colNames.push(res.rows.item(i).name); + } + verify(colNames.indexOf("priority") !== -1); + tx.executeSql("DROP TABLE IF EXISTS " + tableName); + }); + } + + function test_ensureDefaultLocalAccountExists() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS users (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, link TEXT NOT NULL, " + + "last_modified datetime, database TEXT NOT NULL, connectwith_id INTEGER, " + + "api_key TEXT, username TEXT NOT NULL, is_default INTEGER DEFAULT 0)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS res_users_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, name TEXT, login TEXT, " + + "email TEXT, work_email TEXT, mobile_phone TEXT, job_title TEXT, company_id INTEGER, " + + "share INTEGER, active INTEGER, status TEXT, odoo_record_id INTEGER)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_stage_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, active INTEGER, create_date TEXT, " + + "write_date TEXT, status TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, stage INTEGER)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_type_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, is_global INTEGER, active INTEGER, " + + "create_date TEXT, write_date TEXT, status TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, state TEXT)"); + }); + + DBCommon.ensureDefaultLocalAccountExists(); + + db.transaction(function(tx) { + var userRes = tx.executeSql("SELECT * FROM users WHERE id = 0"); + verify(userRes.rows.length > 0); + var userRow = userRes.rows.item(0); + compare(userRow.name, "Local"); + compare(userRow.username, "local_user"); + compare(userRow.is_default, 1); + + var resUserRes = tx.executeSql("SELECT * FROM res_users_app WHERE account_id = 0 AND odoo_record_id = -1"); + verify(resUserRes.rows.length > 0); + var resUserRow = resUserRes.rows.item(0); + compare(resUserRow.name, "Local User"); + compare(resUserRow.login, "local_user"); + }); + } + + function test_ensureDefaultLocalActivityTypes() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS mail_activity_type_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, name TEXT, " + + "status TEXT, odoo_record_id INTEGER)"); + }); + + DBCommon.ensureDefaultLocalActivityTypes(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name FROM mail_activity_type_app WHERE account_id = 0"); + verify(res.rows.length >= 4); + + var names = []; + for (var i = 0; i < res.rows.length; i++) { + names.push(res.rows.item(i).name); + } + + verify(names.indexOf("To Do") !== -1); + verify(names.indexOf("Call") !== -1); + verify(names.indexOf("Email") !== -1); + verify(names.indexOf("Meeting") !== -1); + }); + } + + function test_ensureDefaultLocalProjectStages() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_stage_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, active INTEGER, create_date TEXT, " + + "write_date TEXT, status TEXT)"); + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, stage INTEGER)"); + }); + + DBCommon.ensureDefaultLocalProjectStages(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name, odoo_record_id FROM project_project_stage_app WHERE account_id = 0 ORDER BY sequence ASC"); + verify(res.rows.length >= 4); + + var stageNames = []; + for (var i = 0; i < res.rows.length; i++) { + stageNames.push(res.rows.item(i).name); + } + + verify(stageNames.indexOf("Planning") !== -1); + verify(stageNames.indexOf("In Progress") !== -1); + verify(stageNames.indexOf("Completed") !== -1); + verify(stageNames.indexOf("Cancelled") !== -1); + }); + } + + function test_ensureDefaultLocalTaskStages() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_type_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, is_global INTEGER, active INTEGER, " + + "create_date TEXT, write_date TEXT, status TEXT)"); + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, state TEXT)"); + }); + + DBCommon.ensureDefaultLocalTaskStages(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name FROM project_task_type_app WHERE account_id = 0 ORDER BY sequence ASC"); + verify(res.rows.length >= 4); + + var stageNames = []; + for (var i = 0; i < res.rows.length; i++) { + stageNames.push(res.rows.item(i).name); + } + + verify(stageNames.indexOf("New") !== -1); + verify(stageNames.indexOf("In Progress") !== -1); + verify(stageNames.indexOf("Done") !== -1); + verify(stageNames.indexOf("Cancelled") !== -1); + }); + } + } +} diff --git a/tests/qml/models/tst_dbinit.qml b/tests/qml/models/tst_dbinit.qml new file mode 100644 index 00000000..b650c3fe --- /dev/null +++ b/tests/qml/models/tst_dbinit.qml @@ -0,0 +1,116 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit + +Item { + width: 200 + height: 200 + + TestCase { + name: "DbInitTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function test_initializeDatabaseCreatesTables() { + DbInit.initializeDatabase(); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + var expectedTables = [ + "sync_report", + "users", + "notification", + "app_settings", + "project_project_app", + "res_users_app", + "project_task_app", + "account_analytic_line_app", + "mail_activity_app", + "form_drafts" + ]; + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name FROM sqlite_master WHERE type='table'"); + var existingTables = []; + for (var i = 0; i < res.rows.length; i++) { + existingTables.push(res.rows.item(i).name); + } + + for (var j = 0; j < expectedTables.length; j++) { + var tbl = expectedTables[j]; + verify(existingTables.indexOf(tbl) !== -1, "Expected table: " + tbl + " to exist"); + } + }); + } + + function test_initializeAutoSyncSettings() { + DbInit.initializeDatabase(); + DbInit.initializeAutoSyncSettings(); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var resInterval = tx.executeSql("SELECT value FROM app_settings WHERE key = 'sync_interval_minutes'"); + verify(resInterval.rows.length > 0); + compare(resInterval.rows.item(0).value, "15"); + + var resEnabled = tx.executeSql("SELECT value FROM app_settings WHERE key = 'autosync_enabled'"); + verify(resEnabled.rows.length > 0); + compare(resEnabled.rows.item(0).value, "true"); + }); + } + + function test_performPostStartupMaintenance() { + DbInit.initializeDatabase(); + // Verify maintenance executes cleanly without error + DbInit.performPostStartupMaintenance(); + verify(true); + } + + function test_purgeCache() { + DbInit.initializeDatabase(); + // Verify cache purge executes safely + DbInit.purgeCache(); + verify(true); + } + + function test_syncDraftFlags() { + DbInit.initializeDatabase(); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Clear any existing draft records + tx.executeSql("DELETE FROM form_drafts"); + tx.executeSql("DELETE FROM project_task_app WHERE id = 9999"); + + // Seed task with has_draft = 0 + tx.executeSql("INSERT OR REPLACE INTO project_task_app (id, name, account_id, has_draft) VALUES (9999, 'Draft Sync Task', 0, 0)"); + + // Insert a matching draft for task 9999 + tx.executeSql("INSERT INTO form_drafts (draft_type, record_id, account_id, page_identifier, draft_data, created_at, updated_at) " + + "VALUES ('task', 9999, 0, 'test_page', '{\"name\":\"Draft Sync Task\"}', datetime('now'), datetime('now'))"); + }); + + DbInit.syncDraftFlags(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT has_draft FROM project_task_app WHERE id = 9999"); + verify(res.rows.length > 0); + compare(res.rows.item(0).has_draft, 1); + + // Clean up test rows + tx.executeSql("DELETE FROM form_drafts WHERE record_id = 9999"); + tx.executeSql("DELETE FROM project_task_app WHERE id = 9999"); + }); + } + } +} diff --git a/tests/qml/models/tst_draft_manager.qml b/tests/qml/models/tst_draft_manager.qml new file mode 100644 index 00000000..1e8c986d --- /dev/null +++ b/tests/qml/models/tst_draft_manager.qml @@ -0,0 +1,203 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/draft_manager.js" as DraftManager + +Item { + width: 200 + height: 200 + + TestCase { + name: "DraftManagerTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM form_drafts"); + }); + } + + function test_saveDraftValidation() { + var res = DraftManager.saveDraft({}); + verify(!res.success); + compare(res.error, "draftType is required"); + } + + function test_valuesAreEqualPrimitives() { + verify(DraftManager.valuesAreEqual(10, 10)); + verify(!DraftManager.valuesAreEqual(10, 20)); + + verify(DraftManager.valuesAreEqual("hello", "hello")); + verify(!DraftManager.valuesAreEqual("hello", "world")); + + verify(DraftManager.valuesAreEqual(true, true)); + verify(!DraftManager.valuesAreEqual(true, false)); + + verify(DraftManager.valuesAreEqual(null, null)); + verify(DraftManager.valuesAreEqual(undefined, undefined)); + verify(DraftManager.valuesAreEqual(null, undefined)); + verify(!DraftManager.valuesAreEqual(null, "")); + verify(!DraftManager.valuesAreEqual(undefined, "")); + } + + function test_valuesAreEqualArrays() { + verify(DraftManager.valuesAreEqual([1, 2, 3], [1, 2, 3])); + verify(!DraftManager.valuesAreEqual([1, 2, 3], [1, 2])); + verify(!DraftManager.valuesAreEqual([1, 2, 3], [1, 2, 4])); + verify(DraftManager.valuesAreEqual([], [])); + } + + function test_valuesAreEqualObjects() { + verify(DraftManager.valuesAreEqual({a: 1, b: "test"}, {a: 1, b: "test"})); + verify(!DraftManager.valuesAreEqual({a: 1, b: "test"}, {a: 1, b: "other"})); + verify(!DraftManager.valuesAreEqual({a: 1}, {a: 1, b: 2})); + } + + function test_getChangedFields() { + var original = { title: "Original Title", priority: 1, description: "Desc" }; + var modified = { title: "New Title", priority: 1, description: "Updated Desc" }; + + var changed = DraftManager.getChangedFields(modified, original); + verify(changed !== null); + compare(changed.length, 2); + verify(changed.indexOf("title") !== -1); + verify(changed.indexOf("description") !== -1); + verify(changed.indexOf("priority") === -1); + } + + function test_hasUnsavedChanges() { + var original = { name: "Task A", hours: 2.5 }; + var same = { name: "Task A", hours: 2.5 }; + var changed = { name: "Task B", hours: 2.5 }; + + verify(!DraftManager.hasUnsavedChanges(same, original)); + verify(DraftManager.hasUnsavedChanges(changed, original)); + } + + function test_saveAndLoadDraft() { + var saveParams = { + draftType: "task", + recordId: 501, + accountId: 0, + pageIdentifier: "task_edit_page", + formData: { name: "My Draft Task", priority: 3 }, + originalData: { name: "Original Task", priority: 1 } + }; + + var saveRes = DraftManager.saveDraft(saveParams); + verify(saveRes.success); + verify(saveRes.hasChanges); + verify(saveRes.draftId > 0); + + var loadParams = { + draftType: "task", + recordId: 501, + accountId: 0, + pageIdentifier: "task_edit_page" + }; + + var loadRes = DraftManager.loadDraft(loadParams); + verify(loadRes.success); + verify(loadRes.draft !== null); + compare(loadRes.draft.draftType, "task"); + compare(loadRes.draft.recordId, 501); + + var formData = loadRes.draft.formData; + compare(formData.name, "My Draft Task"); + compare(formData.priority, 3); + } + + function test_deleteDraftById() { + var saveParams = { + draftType: "timesheet", + recordId: 601, + accountId: 0, + formData: { unit_amount: 4.0 }, + originalData: { unit_amount: 1.0 } + }; + + var saveRes = DraftManager.saveDraft(saveParams); + verify(saveRes.success); + var draftId = saveRes.draftId; + + var deleteRes = DraftManager.deleteDraft(draftId); + verify(deleteRes.success); + + var loaded = DraftManager.loadDraft({ + draftType: "timesheet", + recordId: 601, + accountId: 0 + }); + compare(loaded.success, false); + compare(loaded.draft, null); + } + + function test_deleteDraftsByParams() { + DraftManager.saveDraft({ + draftType: "project", + recordId: 701, + accountId: 0, + formData: { name: "Project Draft 1" }, + originalData: { name: "Old" } + }); + + DraftManager.saveDraft({ + draftType: "project", + recordId: 702, + accountId: 0, + formData: { name: "Project Draft 2" }, + originalData: { name: "Old" } + }); + + var countBefore = DraftManager.getAllDrafts(0).length; + compare(countBefore, 2); + + var delRes = DraftManager.deleteDrafts({ + draftType: "project", + recordId: 701, + accountId: 0 + }); + verify(delRes); + + var countAfter = DraftManager.getAllDrafts(0).length; + compare(countAfter, 1); + } + + function test_getDraftsSummary() { + DraftManager.saveDraft({ + draftType: "task", + recordId: 801, + accountId: 0, + formData: { name: "Task 1" }, + originalData: {} + }); + + DraftManager.saveDraft({ + draftType: "timesheet", + recordId: 802, + accountId: 0, + formData: { hours: 5 }, + originalData: {} + }); + + var summary = DraftManager.getDraftsSummary(0); + verify(summary !== null); + compare(summary.total, 2); + compare(summary.byType.task, 1); + compare(summary.byType.timesheet, 1); + } + } +} diff --git a/tests/qml/models/tst_global.qml b/tests/qml/models/tst_global.qml new file mode 100644 index 00000000..956f625a --- /dev/null +++ b/tests/qml/models/tst_global.qml @@ -0,0 +1,107 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/global.js" as Global + +Item { + width: 200 + height: 200 + + TestCase { + name: "GlobalStateTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + Global.clearAssigneeFilter(); + Global.clearDateRangeFilter(); + } + + function test_assigneeFilterLifecycle() { + Global.clearAssigneeFilter(); + var initial = Global.getAssigneeFilter(); + compare(initial.enabled, false); + compare(initial.assigneeIds.length, 0); + + // Set filter + Global.setAssigneeFilter(true, [10, 20, 30]); + var active = Global.getAssigneeFilter(); + compare(active.enabled, true); + compare(active.assigneeIds.length, 3); + compare(active.assigneeIds[0], 10); + compare(active.assigneeIds[1], 20); + compare(active.assigneeIds[2], 30); + + // Mutating returned array should not mutate internal state (array copy isolation) + active.assigneeIds.push(999); + var rechecked = Global.getAssigneeFilter(); + compare(rechecked.assigneeIds.length, 3); + + // Clear filter + Global.clearAssigneeFilter(); + var cleared = Global.getAssigneeFilter(); + compare(cleared.enabled, false); + compare(cleared.assigneeIds.length, 0); + } + + function test_lastVisitedPageTracking() { + Global.setLastVisitedPage("Tasks"); + compare(Global.getLastVisitedPage(), "Tasks"); + + Global.setLastVisitedPage("Dashboard"); + compare(Global.getLastVisitedPage(), "Dashboard"); + } + + function test_shouldPreserveAssigneeFilter() { + // Task page group transitions + verify(Global.shouldPreserveAssigneeFilter("Task_Page", "Tasks")); + verify(Global.shouldPreserveAssigneeFilter("Tasks", "Task_Page")); + + // Activity page group transitions + verify(Global.shouldPreserveAssigneeFilter("Activity_Page", "Activities")); + verify(Global.shouldPreserveAssigneeFilter("Activities", "Activity_Page")); + + // External transitions + verify(!Global.shouldPreserveAssigneeFilter("Tasks", "Dashboard")); + verify(!Global.shouldPreserveAssigneeFilter("Activity_Page", "Timesheet")); + verify(!Global.shouldPreserveAssigneeFilter("Dashboard", "Projects")); + } + + function test_dateRangeFilterLifecycle() { + Global.setDateRangeFilter(1, "2026-10-01", "2026-10-07", "This Week"); + + var filter = Global.getDateRangeFilter(); + verify(filter !== null); + compare(filter.presetId, 1); + compare(filter.startDate, "2026-10-01"); + compare(filter.endDate, "2026-10-07"); + compare(filter.presetLabel, "This Week"); + + // Verify persistence in app_settings table + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var res = tx.executeSql("SELECT value FROM app_settings WHERE key = 'dashboard_date_range_filter'"); + verify(res.rows.length > 0); + var data = JSON.parse(res.rows.item(0).value); + compare(data.presetId, 1); + compare(data.startDate, "2026-10-01"); + }); + + // Clear filter + Global.clearDateRangeFilter(); + var cleared = Global.getDateRangeFilter(); + compare(cleared.presetId, -1); + compare(cleared.startDate, ""); + compare(cleared.endDate, ""); + compare(cleared.presetLabel, "No Filter"); + } + } +} diff --git a/tests/qml/models/tst_logger.qml b/tests/qml/models/tst_logger.qml new file mode 100644 index 00000000..8576f3b9 --- /dev/null +++ b/tests/qml/models/tst_logger.qml @@ -0,0 +1,137 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../models/logger.js" as Logger + +Item { + width: 200 + height: 200 + + TestCase { + name: "LoggerTests" + when: windowShown + + function initTestCase() { + compare(Logger.getLogLevel(), Logger.LogLevel.WARN); + } + + function init() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function cleanup() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function test_logLevelEnumValues() { + verify(Logger.LogLevel !== undefined); + compare(Logger.LogLevel.DEBUG, 0); + compare(Logger.LogLevel.INFO, 1); + compare(Logger.LogLevel.WARN, 2); + compare(Logger.LogLevel.ERROR, 3); + compare(Logger.LogLevel.NONE, 4); + } + + function test_setLogLevelValid() { + Logger.setLogLevel(Logger.LogLevel.DEBUG); + compare(Logger.getLogLevel(), Logger.LogLevel.DEBUG); + + Logger.setLogLevel(Logger.LogLevel.INFO); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(Logger.LogLevel.ERROR); + compare(Logger.getLogLevel(), Logger.LogLevel.ERROR); + + Logger.setLogLevel(Logger.LogLevel.NONE); + compare(Logger.getLogLevel(), Logger.LogLevel.NONE); + } + + function test_setLogLevelInvalidIgnoresChange() { + Logger.setLogLevel(Logger.LogLevel.INFO); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + // Out-of-bounds numbers + Logger.setLogLevel(-1); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(5); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(100); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + // Non-number types + Logger.setLogLevel("DEBUG"); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(null); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(undefined); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel({}); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + } + + function test_sanitizePIINonStringPassThrough() { + compare(Logger.sanitizePII(null), null); + compare(Logger.sanitizePII(undefined), undefined); + compare(Logger.sanitizePII(12345), 12345); + compare(Logger.sanitizePII(true), true); + } + + function test_sanitizePIINoEmail() { + var plainText = "Task synchronization completed successfully with 42 records."; + compare(Logger.sanitizePII(plainText), plainText); + } + + function test_sanitizePIIStandardEmail() { + var input = "User email is john.doe@example.com for account."; + var expected = "User email is j***@example.com for account."; + compare(Logger.sanitizePII(input), expected); + } + + function test_sanitizePIIShortUsernameEmail() { + // Username with 2 or fewer characters should mask as **@domain + var input = "Contact us at me@domain.org or ab@test.co."; + var expected = "Contact us at **@domain.org or **@test.co."; + compare(Logger.sanitizePII(input), expected); + + var singleChar = "a@sample.com"; + compare(Logger.sanitizePII(singleChar), "**@sample.com"); + } + + function test_sanitizePIIMultipleEmails() { + var input = "Assigned from lead@corp.com to analyst@service.org."; + var expected = "Assigned from l***@corp.com to a***@service.org."; + compare(Logger.sanitizePII(input), expected); + } + + function test_loggingExecutionAtAllLevels() { + // Enable DEBUG to exercise all log branches + Logger.setLogLevel(Logger.LogLevel.DEBUG); + + // Should execute without errors + Logger.debug("TestCategory", "Debug message with confidential info user@example.com"); + Logger.debug("TestCategory", "Debug with payload", { id: 101, status: "ok" }); + + Logger.info("TestCategory", "Info message with secret@domain.com"); + Logger.info("TestCategory", "Info with payload", [1, 2, 3]); + + Logger.warn("TestCategory", "Warning message with warn@alert.com"); + Logger.warn("TestCategory", "Warning with payload", "details"); + + Logger.error("TestCategory", "Error message with admin@system.org"); + Logger.error("TestCategory", "Error with payload", 500); + + // Filter out all logs with NONE + Logger.setLogLevel(Logger.LogLevel.NONE); + Logger.debug("TestCategory", "Should be silenced"); + Logger.info("TestCategory", "Should be silenced"); + Logger.warn("TestCategory", "Should be silenced"); + Logger.error("TestCategory", "Should be silenced"); + + verify(true); + } + } +} diff --git a/tests/qml/models/tst_main.qml b/tests/qml/models/tst_main.qml new file mode 100644 index 00000000..fa5f5165 --- /dev/null +++ b/tests/qml/models/tst_main.qml @@ -0,0 +1,143 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/Main.js" as MainModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "MainModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM account_analytic_line_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Pure Helper Date Functions --- + + function test_dateHelpers() { + var monday = MainModel.getMondayOfCurrentWeek(); + verify(monday !== ""); + // Matches YYYY-MM-DD + verify(/^\d{4}-\d{2}-\d{2}$/.test(monday)); + + var firstDay = MainModel.getFirstDayOfCurrentMonth(); + verify(firstDay !== ""); + verify(/^\d{4}-\d{2}-01$/.test(firstDay)); + } + + // --- 2. Entity Name Resolvers --- + + function test_getProjectAndTaskName() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90001, 90001, "Main Project Test", 999]); + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90002, 90002, "Main Task Test", 999]); + }); + + compare(MainModel.get_project_name(90001), "Main Project Test"); + compare(MainModel.get_project_name(99999), ""); + + compare(MainModel.get_task_name(90002), "Main Task Test"); + compare(MainModel.get_task_name(99999), ""); + } + + // --- 3. Quadrant and Spent Hours Aggregations --- + + function test_get_quadrant_difference() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, quadrant_id, unit_amount, status) VALUES (?, ?, ?, ?, ?)", + [90011, 999, 0, 3.5, "saved"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, quadrant_id, unit_amount, status) VALUES (?, ?, ?, ?, ?)", + [90012, 999, 1, 4.0, "saved"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, quadrant_id, unit_amount, status) VALUES (?, ?, ?, ?, ?)", + [90013, 999, 2, 2.0, "saved"] + ); + }); + + var quadDiff = MainModel.get_quadrant_difference(); + verify(quadDiff !== null); + compare(quadDiff[0], 3.5); + compare(quadDiff[1], 4.0); + compare(quadDiff[2], 2.0); + } + + function test_get_spent_hours_query() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, unit_amount, record_date, status) VALUES (?, ?, ?, ?, ?, ?)", + [90021, 999, 101, 1.5, "2026-10-10", "saved"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, unit_amount, record_date, status) VALUES (?, ?, ?, ?, ?, ?)", + [90022, 999, 101, 2.5, "2026-10-12", "saved"] + ); + }); + + // Group by project_id + var spentGrouped = MainModel.get_spent_hours({ group_by: "project_id" }, 999); + verify(spentGrouped.length >= 1); + compare(spentGrouped[0].total, 4.0); + + // Filter with dateFilter + var spentFiltered = MainModel.get_spent_hours({ dateFilter: "2026-10-11" }, 999); + compare(spentFiltered.length, 1); + compare(spentFiltered[0].id, 90022); + } + + function test_get_projects_and_tasks_spent_hours() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90100, 90100, "Project Gamma", 999]); + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name, account_id, project_id) VALUES (?, ?, ?, ?, ?)", [90200, 90200, "Task Delta", 999, 90100]); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, unit_amount, record_date, status, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [90031, 999, 90100, 90200, 5.0, "2026-10-13", "saved", 10] + ); + }); + + var projectsSummary = MainModel.get_projects_spent_hours(999, "2026-10-01", "2026-10-31", 10); + verify(projectsSummary.length >= 1); + compare(projectsSummary[0].name, "Project Gamma"); + compare(projectsSummary[0].total, 5.0); + + var tasksSummary = MainModel.get_tasks_spent_hours(999, "2026-10-01", "2026-10-31", 10); + compare(tasksSummary["Task Delta"], 5.0); + } + } +} diff --git a/tests/qml/models/tst_notifications.qml b/tests/qml/models/tst_notifications.qml new file mode 100644 index 00000000..7dc4b0fc --- /dev/null +++ b/tests/qml/models/tst_notifications.qml @@ -0,0 +1,142 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/notifications.js" as NotificationsModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "NotificationsModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM notification WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Notification Creation and Details --- + + function test_addNotification_and_getDetails() { + var payload = { taskId: 123, priority: "high" }; + NotificationsModel.addNotification(999, "Task", "New task assigned", payload); + + var unread = NotificationsModel.getUnreadNotifications(); + verify(unread.length > 0); + + var found = null; + for (var i = 0; i < unread.length; i++) { + if (unread[i].account_id === 999 && unread[i].message === "New task assigned") { + found = unread[i]; + break; + } + } + verify(found !== null); + compare(found.type, "Task"); + compare(found.read_status, 0); + + var details = NotificationsModel.getDetailsOfNotification(found.id); + verify(details !== null); + compare(details.id, found.id); + compare(details.message, "New task assigned"); + + var parsedPayload = JSON.parse(details.payload); + compare(parsedPayload.taskId, 123); + } + + // --- 2. Read Status and Counts --- + + function test_readStatusAndCounts() { + NotificationsModel.addNotification(999, "Project", "Project milestone met", { projectId: 10 }); + NotificationsModel.addNotification(999, "Activity", "Meeting scheduled", { activityId: 20 }); + + var unreadCountBefore = NotificationsModel.getUnreadCount(); + verify(unreadCountBefore >= 2); + + var unreadList = NotificationsModel.getUnreadNotifications(); + var targetId = unreadList[0].id; + + NotificationsModel.markAsRead(targetId); + var details = NotificationsModel.getDetailsOfNotification(targetId); + compare(details.read_status, 1); + + NotificationsModel.markAllAsRead(); + compare(NotificationsModel.getUnreadCount(), 0); + + // Delete all read notifications + NotificationsModel.deleteAllRead(); + var detailsAfterDelete = NotificationsModel.getDetailsOfNotification(targetId); + compare(detailsAfterDelete, null); + } + + // --- 3. Sync vs Normal Notifications --- + + function test_syncVsNormalNotifications() { + NotificationsModel.addNotification(999, "Task", "Normal task notification", { id: 1 }); + NotificationsModel.addNotification(999, "Sync", "Sync failed on network timeout", { error: "timeout" }); + + verify(NotificationsModel.hasSyncNotifications()); + + var normalList = NotificationsModel.getUnreadNormalNotifications(); + verify(normalList.length >= 1); + compare(normalList[0].type !== "Sync", true); + + var syncList = NotificationsModel.getUnreadSyncNotifications(); + verify(syncList.length >= 1); + compare(syncList[0].type, "Sync"); + + var normalCount = NotificationsModel.getUnreadNormalCount(); + var syncCount = NotificationsModel.getUnreadSyncCount(); + verify(normalCount >= 1); + verify(syncCount >= 1); + + // Clear sync notifications for account 999 + var cleared = NotificationsModel.clearSyncNotifications(999); + verify(cleared >= 1); + compare(NotificationsModel.getUnreadSyncCount(), 0); + + // Normal notifications should remain + compare(NotificationsModel.getUnreadNormalCount(), normalCount); + } + + // --- 4. Deletion --- + + function test_deleteNotification_and_deleteAll() { + NotificationsModel.addNotification(999, "Task", "Task to delete", {}); + var unread = NotificationsModel.getUnreadNotifications(); + verify(unread.length > 0); + + var noteId = unread[0].id; + NotificationsModel.deleteNotification(noteId); + compare(NotificationsModel.getDetailsOfNotification(noteId), null); + + NotificationsModel.addNotification(999, "Task", "Task A", {}); + NotificationsModel.addNotification(999, "Task", "Task B", {}); + NotificationsModel.deleteAllNotifications(); + compare(NotificationsModel.getUnreadCount(), 0); + } + } +} diff --git a/tests/qml/models/tst_project.qml b/tests/qml/models/tst_project.qml new file mode 100644 index 00000000..a6298695 --- /dev/null +++ b/tests/qml/models/tst_project.qml @@ -0,0 +1,199 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/project.js" as ProjectModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "ProjectModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_update_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM dl_cache_app WHERE record_id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Cache Functions --- + + function test_cacheOperations() { + compare(ProjectModel.isPresentInCache(90001), false); + compare(ProjectModel.getFromCache(90001), null); + + ProjectModel.putInCache(90001, "SGVsbG8gV29ybGQ="); // base64 + compare(ProjectModel.isPresentInCache(90001), true); + compare(ProjectModel.getFromCache(90001), "SGVsbG8gV29ybGQ="); + + ProjectModel.putInCache(90002, "VXBkYXRlZA=="); + compare(ProjectModel.isPresentInCache(90002), true); + compare(ProjectModel.getFromCache(90002), "VXBkYXRlZA=="); + } + + // --- 2. ID Mapping and Details --- + + function test_getLocalIdFromOdooId() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", + [90010, 44444, "Mapped Project", 999] + ); + }); + + compare(ProjectModel.getLocalIdFromOdooId(44444, 999), 90010); + compare(ProjectModel.getLocalIdFromOdooId(99999, 999), -1); + } + + function test_getProjectName_and_getProjectDetails() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, description, favorites, color_pallet) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [90020, 55555, "Alpha Project", 999, "A detailed project", 0, "#FF0000"] + ); + }); + + var name = ProjectModel.getProjectName(90020, 999); + compare(name, "Alpha Project"); + + var details = ProjectModel.getProjectDetails(90020); + verify(details !== null); + compare(details.id, 90020); + compare(details.name, "Alpha Project"); + compare(details.account_id, 999); + compare(details.description, "A detailed project"); + compare(details.color_pallet, "#FF0000"); + } + + // --- 3. Project Creation, Updating, and Favorites --- + + function test_createUpdateProject() { + var newProject = { + account_id: 999, + name: "Created Project", + parent_id: 0, + planned_start_date: "2026-10-13", + planned_end_date: "2026-10-20", + allocated_hours: "10:00", + favorites: 0, + description: "Test description", + color: "#00FF00", + stage: 1, + status: "normal" + }; + + var createRes = ProjectModel.createUpdateProject(newProject, 0); + verify(createRes.is_success); + var projectId = createRes.record_id; + verify(projectId > 0); + + var details = ProjectModel.getProjectDetails(projectId); + compare(details.name, "Created Project"); + compare(details.account_id, 999); + compare(details.color_pallet, "#00FF00"); + + // Update project + newProject.name = "Updated Project Name"; + newProject.favorites = 1; + var updateRes = ProjectModel.createUpdateProject(newProject, projectId); + verify(updateRes.is_success); + + var updatedDetails = ProjectModel.getProjectDetails(projectId); + compare(updatedDetails.name, "Updated Project Name"); + compare(updatedDetails.favorites, 1); + } + + function test_toggleProjectFavorite() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, favorites, status) VALUES (?, ?, ?, ?, ?, ?)", + [90030, 90030, "Favorite Test Project", 999, 0, "normal"] + ); + }); + + var resFav = ProjectModel.toggleProjectFavorite(90030, true, "updated"); + verify(resFav.success); + var d1 = ProjectModel.getProjectDetails(90030); + compare(d1.favorites, 1); + compare(d1.status, "updated"); + + var resUnfav = ProjectModel.toggleProjectFavorite(90030, false, "updated"); + verify(resUnfav.success); + var d2 = ProjectModel.getProjectDetails(90030); + compare(d2.favorites, 0); + } + + // --- 4. Project Updates / Snapshots --- + + function test_projectUpdatesCRUD() { + var updateData = { + project_id: 90020, + name: "Weekly Status Update", + project_status: "on_track", + progress: 50, + description: "Halfway completed milestones", + account_id: 999, + user_id: 10 + }; + + var res = ProjectModel.createUpdateSnapShot(updateData, 0); + verify(res.is_success); + var updateId = res.record_id; + verify(updateId > 0); + + var updates = ProjectModel.getAllProjectUpdates(999); + verify(updates.length > 0); + + var delRes = ProjectModel.markProjectUpdateAsDeleted(updateId); + verify(delRes.success); + } + + // --- 5. Project Listing --- + + function test_getProjectsForAccount() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90041, 90041, "Account Project 1", 999, "normal"] + ); + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90042, 90042, "Account Project 2", 999, "normal"] + ); + }); + + var projects = ProjectModel.getProjectsForAccount(999); + compare(projects.length, 2); + } + } +} diff --git a/tests/qml/models/tst_task.qml b/tests/qml/models/tst_task.qml new file mode 100644 index 00000000..7fc11fc3 --- /dev/null +++ b/tests/qml/models/tst_task.qml @@ -0,0 +1,268 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/task.js" as TaskModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "TaskModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_type_app WHERE account_id = 999 OR odoo_record_id >= 90000"); + }); + } + + function cleanup() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_type_app WHERE account_id = 999 OR odoo_record_id >= 90000"); + }); + } + + // --- 1. Pure Helper Functions --- + + function test_validId() { + compare(TaskModel.validId(10), 10); + compare(TaskModel.validId(1), 1); + compare(TaskModel.validId(0), null); + compare(TaskModel.validId(-5), null); + compare(TaskModel.validId(undefined), null); + compare(TaskModel.validId(null), null); + } + + function test_parseAssigneeIds() { + compare(TaskModel.parseAssigneeIds("").length, 0); + compare(TaskModel.parseAssigneeIds(null).length, 0); + compare(TaskModel.parseAssigneeIds(undefined).length, 0); + + var parsedSingle = TaskModel.parseAssigneeIds("42"); + compare(parsedSingle.length, 1); + compare(parsedSingle[0], 42); + + var parsedMulti = TaskModel.parseAssigneeIds("10, 25, 30"); + compare(parsedMulti.length, 3); + compare(parsedMulti[0], 10); + compare(parsedMulti[1], 25); + compare(parsedMulti[2], 30); + + var parsedDirty = TaskModel.parseAssigneeIds("10, invalid, -2, 0, 50"); + compare(parsedDirty.length, 2); + compare(parsedDirty[0], 10); + compare(parsedDirty[1], 50); + } + + function test_formatAssigneeIds() { + compare(TaskModel.formatAssigneeIds([]), ""); + compare(TaskModel.formatAssigneeIds(null), ""); + compare(TaskModel.formatAssigneeIds(undefined), ""); + + var assignees = [{ id: 5 }, { id: 12 }, { id: 99 }]; + compare(TaskModel.formatAssigneeIds(assignees), "5,12,99"); + + var single = [{ id: 7 }]; + compare(TaskModel.formatAssigneeIds(single), "7"); + } + + function test_passesSearchFilter() { + var task = { + name: "Implement Dashboard Filter", + description: "Allow filtering tasks by employee and project" + }; + + verify(TaskModel.passesSearchFilter(task, "")); + verify(TaskModel.passesSearchFilter(task, null)); + verify(TaskModel.passesSearchFilter(task, "Dashboard")); + verify(TaskModel.passesSearchFilter(task, "dashboard")); // Case-insensitive + verify(TaskModel.passesSearchFilter(task, "filtering")); + verify(TaskModel.passesSearchFilter(task, "EMPLOYEE")); + verify(!TaskModel.passesSearchFilter(task, "NonexistentKeyword")); + } + + function test_stageClassification() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_task_type_app (odoo_record_id, name, account_id) VALUES (?, ?, ?)", [90001, "Done", 999]); + tx.executeSql("INSERT INTO project_task_type_app (odoo_record_id, name, account_id) VALUES (?, ?, ?)", [90002, "In Progress", 999]); + tx.executeSql("INSERT INTO project_task_type_app (odoo_record_id, name, account_id) VALUES (?, ?, ?)", [90003, "Cancelled", 999]); + }); + + var doneTask = { state: 90001, account_id: 999 }; + var activeTask = { state: 90002, account_id: 999 }; + var cancelledTask = { state: 90003, account_id: 999 }; + + verify(TaskModel.isTaskInDoneStage(doneTask)); + verify(!TaskModel.isTaskInDoneStage(activeTask)); + + verify(TaskModel.isTaskInCancelledStage(cancelledTask)); + verify(!TaskModel.isTaskInCancelledStage(doneTask)); + } + + function test_passesDateFilter_allFilter() { + var taskWithDates = { start_date: "2026-10-13", end_date: "2026-10-14" }; + var taskNoDates = { start_date: null, end_date: null }; + + verify(TaskModel.passesDateFilter(taskWithDates, "all", new Date())); + verify(TaskModel.passesDateFilter(taskNoDates, "all", new Date())); + verify(!TaskModel.passesDateFilter(taskNoDates, "today", new Date())); + } + + function test_dueCalculationsAndOverdue() { + var refDate = new Date(2026, 9, 13); // 2026-10-13 + + var taskToday = { start_date: "2026-10-13", end_date: "2026-10-13" }; + var taskYesterday = { start_date: "2026-10-12", end_date: "2026-10-12" }; + var taskTomorrow = { start_date: "2026-10-14", end_date: "2026-10-14" }; + + // Today task is due today + verify(TaskModel.isTaskDueToday(taskToday, refDate)); + + // Tomorrow task is not due today + verify(!TaskModel.isTaskDueToday(taskTomorrow, refDate)); + + // Yesterday task is overdue (and shown in today view) + verify(TaskModel.isTaskDueToday(taskYesterday, refDate)); + verify(TaskModel.isTaskOverdue(taskYesterday, refDate)); + verify(!TaskModel.isTaskOverdue(taskToday, refDate)); + verify(!TaskModel.isTaskOverdue(taskTomorrow, refDate)); + + var statusOverdue = TaskModel.getTaskDateStatus(taskYesterday, refDate); + verify(statusOverdue.isOverdue); + + var statusToday = TaskModel.getTaskDateStatus(taskToday, refDate); + verify(statusToday.isInRange); + } + + // --- 2. Database CRUD and Query Functions --- + + function test_saveOrUpdateTask_and_getTaskDetails() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, parent_id) VALUES (?, ?, ?, ?, ?)", + [101, 101, "Test Project", 999, 0] + ); + }); + + var taskData = { + name: "Unit Test Task Alpha", + description: "Test task description", + projectId: 101, + priority: 1, + accountId: 999, + assigneeUserId: "10,20", + status: "normal" + }; + + var res = TaskModel.saveOrUpdateTask(taskData); + verify(res.success); + var taskId = res.taskId; + verify(taskId > 0); + + var details = TaskModel.getTaskDetails(taskId); + verify(details !== null); + compare(details.name, "Unit Test Task Alpha"); + compare(details.project_id, 101); + compare(details.account_id, 999); + compare(details.priority, "1"); + + // Now test updating the existing task + taskData.record_id = taskId; + taskData.name = "Unit Test Task Alpha Updated"; + var updateRes = TaskModel.saveOrUpdateTask(taskData); + verify(updateRes.success); + + var updatedDetails = TaskModel.getTaskDetails(taskId); + compare(updatedDetails.name, "Unit Test Task Alpha Updated"); + } + + function test_isUserAssignedToTask() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, user_id, account_id) VALUES (?, ?, ?, ?, ?)", + [90002, 90002, "Multi Assignee Task", "10, 20, 30", 999] + ); + }); + + verify(TaskModel.isUserAssignedToTask(90002, 10)); + verify(TaskModel.isUserAssignedToTask(90002, 20)); + verify(TaskModel.isUserAssignedToTask(90002, 30)); + verify(!TaskModel.isUserAssignedToTask(90002, 40)); + verify(!TaskModel.isUserAssignedToTask(90002, 99)); + } + + function test_setTaskPriority() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, priority, account_id) VALUES (?, ?, ?, ?, ?)", + [90003, 90003, "Priority Test Task", 0, 999] + ); + }); + + var res = TaskModel.setTaskPriority(90003, 3, "updated"); + verify(res.success); + + var details = TaskModel.getTaskDetails(90003); + verify(details !== null); + compare(details.priority, "3"); + compare(details.status, "updated"); + } + + function test_markTaskAsDeleted() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, status, account_id) VALUES (?, ?, ?, ?, ?)", + [90004, 90004, "Task to Delete", "normal", 999] + ); + }); + + var deleted = TaskModel.markTaskAsDeleted(90004); + verify(deleted); + + var details = TaskModel.getTaskDetails(90004); + verify(details !== null); + compare(details.status, "deleted"); + } + + function test_getLocalIdFromOdooId() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", + [90005, 54321, "Odoo Id Mapping Task", 999] + ); + }); + + var localId = TaskModel.getLocalIdFromOdooId(54321, 999); + compare(localId, 90005); + + var nonExistent = TaskModel.getLocalIdFromOdooId(99999, 999); + compare(nonExistent, -1); + } + } +} diff --git a/tests/qml/models/tst_timer_service.qml b/tests/qml/models/tst_timer_service.qml new file mode 100644 index 00000000..f2957ed8 --- /dev/null +++ b/tests/qml/models/tst_timer_service.qml @@ -0,0 +1,260 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/constants.js" as Constants +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/timesheet.js" as Model +import "../../../models/timer_service.js" as TimerService + +Item { + width: 200 + height: 200 + + TestCase { + name: "TimerServiceTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "odoo_record_id INTEGER, " + + "name TEXT, " + + "color_pallet TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "odoo_record_id INTEGER, " + + "name TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS account_analytic_line_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "account_id INTEGER, " + + "project_id INTEGER, " + + "sub_project_id INTEGER, " + + "task_id INTEGER, " + + "name TEXT, " + + "unit_amount REAL, " + + "status TEXT, " + + "last_modified TEXT)"); + }); + } + + function cleanupTestCase() { + TimerService.reset(); + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + TimerService.reset(); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM project_task_app"); + tx.executeSql("DELETE FROM project_project_app"); + tx.executeSql("DELETE FROM account_analytic_line_app"); + + // Seed Project + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, color_pallet) VALUES (1, 101, 'Internal Project', '#EB6E67')"); + + // Seed Task + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name) VALUES (1, 201, 'Core Module Task')"); + + // Seed Timesheets: + // 1: Ready timesheet with 1.0 unit_amount + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, unit_amount, status) VALUES (1, 1, 101, 201, 'Feature Implementation', 1.0, 'draft')"); + + // 2: Secondary ready timesheet with 0.5 unit_amount + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, unit_amount, status) VALUES (2, 1, 101, 201, 'Bug Investigation', 0.5, 'draft')"); + + // 3: Invalid timesheet (no project_id or sub_project_id) + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, sub_project_id, name, unit_amount, status) VALUES (3, 1, 0, 0, 'Unassigned Task', 0.0, 'draft')"); + + // 4: Finalized timesheet + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, unit_amount, status) VALUES (4, 1, 101, 201, 'Approved Timesheet', 2.0, 'updated')"); + }); + } + + function cleanup() { + TimerService.reset(); + } + + function test_initialState() { + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + compare(TimerService.getStartTime(), 0); + compare(TimerService.getActiveTimesheetName(), ""); + compare(TimerService.getElapsedTime("hhmmss"), "00:00:00"); + compare(TimerService.getElapsedTime("hhmm"), "0.00"); + compare(TimerService.getElapsedDuration(), "0:00"); + compare(TimerService.stop(), "00:00:00"); + } + + function test_updateActiveTimesheetName() { + TimerService.updateActiveTimesheetName("Developing Unit Tests"); + compare(TimerService.getActiveTimesheetName(), "Developing Unit Tests"); + + TimerService.updateActiveTimesheetName(" Padded Title "); + compare(TimerService.getActiveTimesheetName(), "Padded Title"); + + TimerService.updateActiveTimesheetName(null); + compare(TimerService.getActiveTimesheetName(), ""); + + TimerService.updateActiveTimesheetName(12345); + compare(TimerService.getActiveTimesheetName(), ""); + } + + function test_startFailsWithoutProject() { + var result = TimerService.start(3); + compare(result.success, false); + compare(result.error, "Unable to start timer, please select a project first"); + compare(TimerService.isRunning(), false); + compare(TimerService.getActiveTimesheetId(), null); + } + + function test_startSuccess() { + var result = TimerService.start(1); + compare(result.success, true); + compare(result.error, ""); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), 1); + compare(TimerService.getActiveTimesheetName(), "Feature Implementation"); + verify(TimerService.getStartTime() > 0); + + // Verify status marked active in DB + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var rs = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 1"); + compare(rs.rows.item(0).status, "active"); + }); + } + + function test_redundantStartSameTimesheetIgnored() { + TimerService.start(1); + var initialStartTime = TimerService.getStartTime(); + + // Calling start on the same active timesheet does not restart + TimerService.start(1); + compare(TimerService.isRunning(), true); + compare(TimerService.getStartTime(), initialStartTime); + } + + function test_pauseAndResume() { + TimerService.start(1); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), false); + + TimerService.pause(); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), true); + + // Calling pause when already paused does nothing + TimerService.pause(); + compare(TimerService.isPaused(), true); + + // Resume + TimerService.resume(); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), false); + } + + function test_startWhilePausedResumes() { + TimerService.start(1); + TimerService.pause(); + compare(TimerService.isPaused(), true); + + // Calling start(1) or start() while paused on the same timesheet should resume + TimerService.start(1); + compare(TimerService.isPaused(), false); + compare(TimerService.isRunning(), true); + } + + function test_stopRunningTimer() { + TimerService.start(1); + var elapsed = TimerService.stop(); + + verify(elapsed !== undefined); + verify(elapsed.length > 0); + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + compare(TimerService.getStartTime(), 0); + + // Verify DB status reverted to draft + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var rs = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 1"); + compare(rs.rows.item(0).status, "draft"); + }); + } + + function test_stopPausedTimer() { + TimerService.start(1); + TimerService.pause(); + var elapsed = TimerService.stop(); + + verify(elapsed !== undefined); + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + } + + function test_switchTimesheetAutoSavesPrevious() { + TimerService.start(1); + compare(TimerService.getActiveTimesheetId(), 1); + + // Switch to timesheet 2 + TimerService.start(2); + compare(TimerService.getActiveTimesheetId(), 2); + compare(TimerService.isRunning(), true); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Timesheet 1 should be reverted to draft and have duration updated + var rs1 = tx.executeSql("SELECT status, unit_amount FROM account_analytic_line_app WHERE id = 1"); + compare(rs1.rows.item(0).status, "draft"); + + // Timesheet 2 should be active + var rs2 = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 2"); + compare(rs2.rows.item(0).status, "active"); + }); + } + + function test_stopFinalizedTimesheetPreservesStatus() { + TimerService.start(1); + compare(TimerService.getActiveTimesheetId(), 1); + + // Simulate timesheet being finalized/updated while timer was active + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("UPDATE account_analytic_line_app SET status = 'updated' WHERE id = 1"); + }); + + // Stop timer + TimerService.stop(); + compare(TimerService.isRunning(), false); + + // Verify status is still updated, NOT reverted to draft + db.transaction(function(tx) { + var rs = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 1"); + compare(rs.rows.item(0).status, "updated"); + }); + } + + function test_resetClearsStateWithoutDbModification() { + TimerService.start(1); + compare(TimerService.isRunning(), true); + + TimerService.reset(); + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + compare(TimerService.getStartTime(), 0); + compare(TimerService.getActiveTimesheetName(), ""); + } + } +} diff --git a/tests/qml/models/tst_timesheet.qml b/tests/qml/models/tst_timesheet.qml new file mode 100644 index 00000000..e536ed5f --- /dev/null +++ b/tests/qml/models/tst_timesheet.qml @@ -0,0 +1,268 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/timesheet.js" as TimesheetModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "TimesheetModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM account_analytic_line_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Creation and Details --- + + function test_createTimesheet_and_getTimeSheetDetails() { + var createRes = TimesheetModel.createTimesheet(999, 10); + verify(createRes.success); + var sheetId = createRes.id; + verify(sheetId > 0); + + var details = TimesheetModel.getTimeSheetDetails(sheetId, 999); + verify(details !== null); + compare(details.id, sheetId); + compare(details.instance_id, 999); + compare(details.user_id, 10); + compare(details.status, "draft"); + } + + function test_createTimesheet_invalidAccount() { + var res = TimesheetModel.createTimesheet(-1, 10); + verify(!res.success); + } + + // --- 2. Readiness and Finalization --- + + function test_readinessChecks() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + // Neither project nor task + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90010, 999, null, null, "draft"] + ); + // Project only + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90011, 999, 101, null, "draft"] + ); + // Project and task + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90012, 999, 101, 201, "draft"] + ); + }); + + // Timer ready requires project only + verify(!TimesheetModel.isTimesheetReadyToStartTimer(90010)); + verify(TimesheetModel.isTimesheetReadyToStartTimer(90011)); + verify(TimesheetModel.isTimesheetReadyToStartTimer(90012)); + + // Record/sync ready requires both project and task + verify(!TimesheetModel.isTimesheetReadyToRecord(90010)); + verify(!TimesheetModel.isTimesheetReadyToRecord(90011)); + verify(TimesheetModel.isTimesheetReadyToRecord(90012)); + } + + function test_isTimesheetFinalized() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90020, 999, "draft"]); + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90021, 999, "active"]); + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90022, 999, "updated"]); + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90023, 999, "saved"]); + }); + + verify(!TimesheetModel.isTimesheetFinalized(90020)); + verify(!TimesheetModel.isTimesheetFinalized(90021)); + verify(TimesheetModel.isTimesheetFinalized(90022)); + verify(TimesheetModel.isTimesheetFinalized(90023)); + } + + // --- 3. Save, Duration, and Deletion --- + + function test_saveTimesheet() { + var createRes = TimesheetModel.createTimesheet(999, 10); + verify(createRes.success); + var sheetId = createRes.id; + + var updateData = { + id: sheetId, + instance_id: 999, + description: "Work on QML Unit Tests", + project: 101, + task: 201, + unit_amount: 2.5, + status: "draft" + }; + + var saveRes = TimesheetModel.saveTimesheet(updateData); + verify(saveRes.success); + + var details = TimesheetModel.getTimeSheetDetails(sheetId, 999); + compare(details.name, "Work on QML Unit Tests"); + compare(details.project_id, 101); + compare(details.task_id, 201); + compare(details.spentHours, "2:30"); + } + + function test_updateDuration_and_getUnitAmount() { + var createRes = TimesheetModel.createTimesheet(999, 10); + var sheetId = createRes.id; + + TimesheetModel.updateTimesheetWithDuration(sheetId, "01:45"); + var amount = TimesheetModel.getTimesheetUnitAmount(sheetId); + compare(amount, 1.75); + } + + function test_markTimesheetAsDeleted() { + var createRes = TimesheetModel.createTimesheet(999, 10); + var sheetId = createRes.id; + + var res = TimesheetModel.markTimesheetAsDeleted(sheetId); + verify(res.success); + + var details = TimesheetModel.getTimeSheetDetails(sheetId, 999); + compare(details.status, "deleted"); + } + + // --- 4. Status Transitions --- + + function test_statusTransitions() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90030, 999, 101, 201, "draft"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90031, 999, 101, 201, "draft"] + ); + }); + + // Mark 90030 as active + TimesheetModel.markTimesheetAsActiveById(90030); + var d1 = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1.status, "active"); + + // Mark 90031 as active -> 90030 should revert to draft + TimesheetModel.markTimesheetAsActiveById(90031); + var d1Reverted = TimesheetModel.getTimeSheetDetails(90030, 999); + var d2Active = TimesheetModel.getTimeSheetDetails(90031, 999); + compare(d1Reverted.status, "draft"); + compare(d2Active.status, "active"); + + // Mark 90030 as saved + TimesheetModel.markTimesheetAsSavedById(90030); + var d1Saved = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1Saved.status, "saved"); + + // Mark 90030 as draft + TimesheetModel.markTimesheetAsDraftById(90030); + var d1Draft = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1Draft.status, "draft"); + + // Mark 90030 as ready (with project and task present) + var readyRes = TimesheetModel.markTimesheetAsReadyById(90030); + verify(readyRes.success); + var d1Ready = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1Ready.status, "updated"); + } + + // --- 5. Display Names and Matching --- + + function test_getTimesheetDisplayName_and_Matching() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90101, 90101, "Test Project", 999]); + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90201, 90201, "Test Task", 999]); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, status) VALUES (?, ?, ?, ?, ?, ?)", + [90040, 999, 90101, 90201, "Custom Work Description", "active"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, status) VALUES (?, ?, ?, ?, ?, ?)", + [90041, 999, 90101, 90201, "", "draft"] + ); + }); + + // When name is present, display name is the description + var name1 = TimesheetModel.getTimesheetDisplayName(90040); + compare(name1, "Custom Work Description"); + + // When name is empty, display name falls back to "Project - Task" + var name2 = TimesheetModel.getTimesheetDisplayName(90041); + compare(name2, "Test Project - Test Task"); + + // Matching active sheet + verify(TimesheetModel.doesProjectIdMatchSheetInActive(90101, 90040)); + verify(!TimesheetModel.doesProjectIdMatchSheetInActive(99999, 90040)); + + verify(TimesheetModel.doesTaskIdMatchSheetInActive(90201, 90040)); + verify(!TimesheetModel.doesTaskIdMatchSheetInActive(99999, 90040)); + + // getTimesheetAccountId + compare(TimesheetModel.getTimesheetAccountId(90040), 999); + } + + // --- 6. Fetching by Status --- + + function test_fetchTimesheetsByStatus() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, status, record_date) VALUES (?, ?, ?, ?)", + [90051, 999, "draft", "2026-10-13"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, status, record_date) VALUES (?, ?, ?, ?)", + [90052, 999, "saved", "2026-10-13"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, status, record_date) VALUES (?, ?, ?, ?)", + [90053, 999, "deleted", "2026-10-13"] + ); + }); + + var drafts = TimesheetModel.fetchTimesheetsByStatus("draft", 999); + // In fetchTimesheetsByStatus, status === "draft" matches both "draft" and "saved" + compare(drafts.length, 2); + + var all = TimesheetModel.fetchTimesheetsByStatus("all", 999); + // "all" returns non-deleted records + compare(all.length, 2); + } + } +} diff --git a/tests/qml/models/tst_utils.qml b/tests/qml/models/tst_utils.qml new file mode 100644 index 00000000..cf851da1 --- /dev/null +++ b/tests/qml/models/tst_utils.qml @@ -0,0 +1,259 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../models/utils.js" as Utils + +Item { + width: 200 + height: 200 + + TestCase { + name: "UtilsTests" + when: windowShown + + // --- URL Validation and Cleaning --- + function test_validateAndCleanOdooURL_invalid() { + var emptyRes = Utils.validateAndCleanOdooURL(""); + compare(emptyRes.isValid, false); + compare(emptyRes.cleanedUrl, ""); + + var nullRes = Utils.validateAndCleanOdooURL(null); + compare(nullRes.isValid, false); + + var undefinedRes = Utils.validateAndCleanOdooURL(undefined); + compare(undefinedRes.isValid, false); + + var protoOnly = Utils.validateAndCleanOdooURL("https://"); + compare(protoOnly.isValid, false); + + var spaces = Utils.validateAndCleanOdooURL("not a valid url"); + compare(spaces.isValid, false); + } + + function test_validateAndCleanOdooURL_valid() { + var standard = Utils.validateAndCleanOdooURL("example.odoo.com"); + compare(standard.isValid, true); + compare(standard.cleanedUrl, "https://example.odoo.com"); + + var withHttp = Utils.validateAndCleanOdooURL("http://myerp.local/"); + compare(withHttp.isValid, true); + compare(withHttp.cleanedUrl, "http://myerp.local"); + + var localhostWithPort = Utils.validateAndCleanOdooURL("localhost:8069///"); + compare(localhostWithPort.isValid, true); + compare(localhostWithPort.cleanedUrl, "https://localhost:8069"); + + var ipAddr = Utils.validateAndCleanOdooURL("http://192.168.1.50:8069"); + compare(ipAddr.isValid, true); + compare(ipAddr.cleanedUrl, "http://192.168.1.50:8069"); + } + + // --- Text Manipulation Utilities --- + function test_stripHtmlTags() { + compare(Utils.stripHtmlTags(""), ""); + compare(Utils.stripHtmlTags(null), ""); + compare(Utils.stripHtmlTags(undefined), ""); + compare(Utils.stripHtmlTags("Plain text content"), "Plain text content"); + compare(Utils.stripHtmlTags("

Paragraph with bold and italic

"), "Paragraph with bold and italic"); + compare(Utils.stripHtmlTags("
"), "Link"); + } + + function test_cleanText() { + compare(Utils.cleanText(null), ""); + compare(Utils.cleanText(undefined), ""); + compare(Utils.cleanText(12345), ""); + compare(Utils.cleanText(" Hello World "), "Hello World"); + compare(Utils.cleanText("Special space test"), "Special space test"); + // Control characters stripping + var withControlChars = "Clean\u0000ed\u0007 text\u200B"; + compare(Utils.cleanText(withControlChars), "Cleaned text"); + } + + function test_truncateText() { + compare(Utils.truncateText(null, 10), ""); + compare(Utils.truncateText("Short", 10), "Short"); + compare(Utils.truncateText("Exact length", 12), "Exact length"); + compare(Utils.truncateText("This is a much longer description text", 10), "This is a..."); + // HTML within truncated text + var htmlInput = "

Description with formatting that needs truncation

"; + var truncated = Utils.truncateText(htmlInput, 15); + verify(truncated.indexOf("<") === -1); + verify(truncated.indexOf("...") !== -1); + } + + function test_getColorFromOdooIndex() { + compare(Utils.getColorFromOdooIndex(0), "transparent"); + compare(Utils.getColorFromOdooIndex(1), "#EB6E67"); + compare(Utils.getColorFromOdooIndex(2), "#F39C5A"); + compare(Utils.getColorFromOdooIndex(11), "#9B6CC3"); + // Modulo wrapping + compare(Utils.getColorFromOdooIndex(12), "transparent"); + compare(Utils.getColorFromOdooIndex(13), "#EB6E67"); + } + + // --- Time and Duration Conversions --- + function test_convertHHMMtoDecimalHours() { + // Number pass-through + compare(Utils.convertHHMMtoDecimalHours(3.5), 3.5); + + // Invalid or empty inputs + compare(Utils.convertHHMMtoDecimalHours(""), 0); + compare(Utils.convertHHMMtoDecimalHours(null), 0); + compare(Utils.convertHHMMtoDecimalHours("invalid"), 0); + + // Numeric string without colon + compare(Utils.convertHHMMtoDecimalHours("4.25"), 4.25); + + // HH:MM format + compare(Utils.convertHHMMtoDecimalHours("1:30"), 1.5); + compare(Utils.convertHHMMtoDecimalHours("0:45"), 0.75); + compare(Utils.convertHHMMtoDecimalHours("2:15"), 2.25); + compare(Utils.convertHHMMtoDecimalHours("0:00"), 0); + + // HH:MM:SS format + compare(Utils.convertHHMMtoDecimalHours("1:30:00"), 1.5); + compare(Utils.convertHHMMtoDecimalHours("0:30:36"), 0.51); + + // Malformed colon strings + compare(Utils.convertHHMMtoDecimalHours("foo:bar"), 0); + compare(Utils.convertHHMMtoDecimalHours("1:2:3:4"), 0); + } + + function test_convertDecimalHoursToHHMM() { + compare(Utils.convertDecimalHoursToHHMM(0), "0:00"); + compare(Utils.convertDecimalHoursToHHMM(1.5), "1:30"); + compare(Utils.convertDecimalHoursToHHMM(2.25), "2:15"); + compare(Utils.convertDecimalHoursToHHMM(11.8333), "11:50"); + compare(Utils.convertDecimalHoursToHHMM(0.0833), "0:05"); + } + + function test_convertDurationToFloat() { + compare(Utils.convertDurationToFloat(2.5), 2.5); + compare(Utils.convertDurationToFloat(""), 0); + compare(Utils.convertDurationToFloat(null), 0); + compare(Utils.convertDurationToFloat("2:30"), 2.5); + compare(Utils.convertDurationToFloat("1:15"), 1.25); + compare(Utils.convertDurationToFloat("1:30:36"), 1.51); + } + + // --- Date Formatting and Conversions --- + function test_formatDate() { + var date = new Date(2026, 8, 25); // Month is 0-indexed: 8 = September + compare(Utils.formatDate(date), "9/25/2026"); + + var newYears = new Date(2027, 0, 1); + compare(Utils.formatDate(newYears), "1/1/2027"); + } + + function test_convertToISODate() { + compare(Utils.convertToISODate(""), ""); + compare(Utils.convertToISODate(null), ""); + compare(Utils.convertToISODate("9/24/2025"), "2025-09-24"); + compare(Utils.convertToISODate("12/5/2026"), "2026-12-05"); + compare(Utils.convertToISODate("01/02/2025"), "2025-01-02"); + compare(Utils.convertToISODate("invalid-format"), "invalid-format"); + } + + function test_extractDate() { + compare(Utils.extractDate("2025-06-23T13:53:42.834Z"), "2025-06-23"); + compare(Utils.extractDate("2026-12-31T00:00:00.000"), "2026-12-31"); + } + + function test_getTodayAndTomorrowFormat() { + var today = Utils.getToday(); + compare(typeof today, "string"); + compare(today.length, 10); + verify(today.match(/^\d{4}-\d{2}-\d{2}$/) !== null); + + var tomorrow = Utils.getTomorrow(); + compare(typeof tomorrow, "string"); + compare(tomorrow.length, 10); + verify(tomorrow.match(/^\d{4}-\d{2}-\d{2}$/) !== null); + + var diffDays = (new Date(tomorrow) - new Date(today)) / (1000 * 60 * 60 * 24); + compare(Math.round(diffDays), 1); + } + + function test_getYesterday() { + var yesterday = Utils.getYesterday(); + compare(typeof yesterday, "string"); + verify(yesterday.indexOf("T") !== -1); + verify(new Date(yesterday).getTime() < Date.now()); + } + + function test_getNextWeekRange() { + var range = Utils.getNextWeekRange(); + verify(range.start !== undefined); + verify(range.end !== undefined); + compare(range.start.length, 10); + compare(range.end.length, 10); + + var startParts = range.start.split("-"); + var localStartDate = new Date(parseInt(startParts[0]), parseInt(startParts[1]) - 1, parseInt(startParts[2])); + var endParts = range.end.split("-"); + var localEndDate = new Date(parseInt(endParts[0]), parseInt(endParts[1]) - 1, parseInt(endParts[2])); + + verify(localStartDate <= localEndDate); + compare(localStartDate.getDay(), 1); + } + + function test_getNextWeekSameDay() { + var nextWeek = Utils.getNextWeekSameDay("2026-09-10"); + compare(nextWeek, "2026-09-17"); + + var leapYear = Utils.getNextWeekSameDay("2024-02-25"); + compare(leapYear, "2024-03-03"); + } + + function test_getNextMonthRange() { + var range = Utils.getNextMonthRange(); + verify(range.start !== undefined); + verify(range.end !== undefined); + compare(range.start.slice(8, 10), "01"); + verify(new Date(range.start) <= new Date(range.end)); + } + + function test_getNextMonthSameDay() { + var nextMonth = Utils.getNextMonthSameDay("2026-01-15"); + compare(nextMonth, "2026-02-15"); + + // January 31 -> February end clamping + var clamped = Utils.getNextMonthSameDay("2026-01-31"); + compare(clamped, "2026-02-28"); + + // December to January year rollover + var yearRoll = Utils.getNextMonthSameDay("2026-12-10"); + compare(yearRoll, "2027-01-10"); + } + + function test_getFormattedTimestampUTC() { + var timestamp = Utils.getFormattedTimestampUTC(); + compare(typeof timestamp, "string"); + verify(timestamp.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) !== null); + } + + function test_getTimeStatusInText() { + compare(Utils.getTimeStatusInText(null), "N/A"); + compare(Utils.getTimeStatusInText(""), "N/A"); + compare(Utils.getTimeStatusInText("not-a-date"), "Invalid"); + + // Today + var todayStr = new Date().toISOString(); + compare(Utils.getTimeStatusInText(todayStr), "Due today"); + + // Tomorrow + var tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + compare(Utils.getTimeStatusInText(tomorrow.toISOString()), "1 day remaining"); + + // Future 5 days + var future = new Date(); + future.setDate(future.getDate() + 5); + compare(Utils.getTimeStatusInText(future.toISOString()), "5 days remaining"); + + // Past 3 days overdue + var past = new Date(); + past.setDate(past.getDate() - 3); + compare(Utils.getTimeStatusInText(past.toISOString()), "3 days overdue"); + } + } +} diff --git a/tests/qml/scripts/tst_chartutils.qml b/tests/qml/scripts/tst_chartutils.qml new file mode 100644 index 00000000..6a3a34af --- /dev/null +++ b/tests/qml/scripts/tst_chartutils.qml @@ -0,0 +1,146 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../qml/features/dashboard/js/chartUtils.js" as ChartUtils + +Item { + width: 200 + height: 200 + + TestCase { + name: "ChartUtilsTests" + when: windowShown + + function test_formatHours() { + compare(ChartUtils.formatHours(2.5), "2.5 h"); + compare(ChartUtils.formatHours(0), "0.0 h"); + compare(ChartUtils.formatHours(null), "0.0 h"); + compare(ChartUtils.formatHours(undefined), "0.0 h"); + compare(ChartUtils.formatHours(10.123), "10.1 h"); + } + + function test_calcPercent_and_percentLabel() { + compare(ChartUtils.calcPercent(25, 100), 25); + compare(ChartUtils.calcPercent(0, 100), 0); + compare(ChartUtils.calcPercent(5, 0), 0); + compare(ChartUtils.calcPercent(5, -10), 0); + compare(ChartUtils.calcPercent(null, 100), 0); + + compare(ChartUtils.percentLabel(25, 100), "25.0%"); + compare(ChartUtils.percentLabel(1, 3), "33.3%"); + compare(ChartUtils.percentLabel(0, 0), "0.0%"); + } + + function test_averageLabel() { + compare(ChartUtils.averageLabel(10, 2), "5.0 h"); + compare(ChartUtils.averageLabel(10, 0), "0.0 h"); + compare(ChartUtils.averageLabel(10, -5), "0.0 h"); + compare(ChartUtils.averageLabel(null, 2), "0.0 h"); + } + + function test_projectSummaries() { + var projects = [ + { id: 1, name: "Project Alpha", totalHours: 12.5, taskCount: 3 }, + { id: 2, name: "Project Beta", totalHours: 8.0, taskCount: 2 }, + { id: 3, name: "Project Gamma", totalHours: 20.0, taskCount: 5 } + ]; + + compare(ChartUtils.sumProjectHours(projects), 40.5); + compare(ChartUtils.maxProjectHours(projects), 20.0); + compare(ChartUtils.projectSubtitle(projects[0], 3), "3 tasks | 12.5 h"); + compare(ChartUtils.projectSubtitle(null, 0), ""); + } + + function test_taskSummaries() { + var tasks = [ + { id: 10, name: "Refactor API", totalHours: 4.5 }, + { id: 20, name: "Fix UI Glitch", totalHours: 9.0 }, + { id: 30, name: "Write Docs", totalHours: 2.0 } + ]; + + compare(ChartUtils.maxTaskHours(tasks), 9.0); + compare(ChartUtils.topTaskName(tasks), "Fix UI Glitch"); + compare(ChartUtils.topTaskName([]), "None"); + compare(ChartUtils.topTaskName(null), "None"); + + var top2 = ChartUtils.topTasks(tasks, 2); + compare(top2.length, 2); + compare(top2[0].name, "Fix UI Glitch"); + compare(top2[1].name, "Refactor API"); + } + + function test_prepareProjects_sorting_and_filtering() { + var projects = [ + { id: 1, name: "Backend Work", totalHours: 10.0, taskCount: 2 }, + { id: 2, name: "Frontend UI", totalHours: 25.0, taskCount: 8 }, + { id: 3, name: "DevOps Pipeline", totalHours: 15.0, taskCount: 1 } + ]; + + // Default sort: totalHours desc + var sortedByHours = ChartUtils.prepareProjects(projects, "hours", ""); + compare(sortedByHours.length, 3); + compare(sortedByHours[0].projectData.name, "Frontend UI"); + compare(sortedByHours[1].projectData.name, "DevOps Pipeline"); + compare(sortedByHours[2].projectData.name, "Backend Work"); + + // Sort by tasks desc + var sortedByTasks = ChartUtils.prepareProjects(projects, "tasks", ""); + compare(sortedByTasks[0].projectData.name, "Frontend UI"); // 8 tasks + compare(sortedByTasks[1].projectData.name, "Backend Work"); // 2 tasks + compare(sortedByTasks[2].projectData.name, "DevOps Pipeline"); // 1 task + + // Sort by name asc + var sortedByName = ChartUtils.prepareProjects(projects, "name", ""); + compare(sortedByName[0].projectData.name, "Backend Work"); + compare(sortedByName[1].projectData.name, "DevOps Pipeline"); + compare(sortedByName[2].projectData.name, "Frontend UI"); + + // Filter with search text + var filtered = ChartUtils.prepareProjects(projects, "hours", "backend"); + compare(filtered.length, 1); + compare(filtered[0].projectData.name, "Backend Work"); + } + + function test_prepareTasks_sorting() { + var tasks = [ + { id: 1, name: "Task B", totalHours: 5.0 }, + { id: 2, name: "Task A", totalHours: 10.0 }, + { id: 3, name: "Task C", totalHours: 5.0 } + ]; + + var sorted = ChartUtils.prepareTasks(tasks); + compare(sorted[0].name, "Task A"); // 10.0 hours + compare(sorted[1].name, "Task B"); // 5.0 hours, alphabetical A before C + compare(sorted[2].name, "Task C"); // 5.0 hours + } + + function test_findTaskById() { + var tasks = [ + { id: 101, name: "Design mockups" }, + { id: 102, name: "Implement code" } + ]; + + var found = ChartUtils.findTaskById(tasks, 102); + verify(found !== null); + compare(found.name, "Implement code"); + + compare(ChartUtils.findTaskById(tasks, 999), null); + } + + function test_elide() { + compare(ChartUtils.elide("Short", 10), "Short"); + compare(ChartUtils.elide("Long text that should be truncated", 12), "Long text..."); + compare(ChartUtils.elide("", 5), ""); + compare(ChartUtils.elide(null, 5), ""); + } + + function test_chartSignature() { + var tasks = [ + { id: 1, totalHours: 3.5 }, + { id: 2, totalHours: 7.0 } + ]; + + var sig = ChartUtils.chartSignature(tasks, "#ff0000", 0); + compare(sig, "#ff0000|0|1:3.5|2:7"); + } + } +} diff --git a/tests/qml/scripts/tst_chartworker.qml b/tests/qml/scripts/tst_chartworker.qml new file mode 100644 index 00000000..6b0bf38d --- /dev/null +++ b/tests/qml/scripts/tst_chartworker.qml @@ -0,0 +1,117 @@ +import QtQuick 2.7 +import QtTest 1.0 + +Item { + id: root + width: 200 + height: 200 + + property var lastMessage: null + + WorkerScript { + id: chartWorker + source: "../../../qml/features/dashboard/js/chartWorker.js" + onMessage: { + root.lastMessage = messageObject; + } + } + + SignalSpy { + id: messageSpy + target: chartWorker + signalName: "message" + } + + TestCase { + name: "ChartWorkerTests" + when: windowShown + + function init() { + root.lastMessage = null; + messageSpy.clear(); + } + + function test_projectsMessageProcessing() { + var projects = [ + { id: 1, name: "Backend Database", totalHours: 10.0, taskCount: 2 }, + { id: 2, name: "Frontend UI", totalHours: 30.0, taskCount: 8 }, + { id: 3, name: "DevOps Infrastructure", totalHours: 15.0, taskCount: 1 } + ]; + + // 1. Sort by totalHours descending + chartWorker.sendMessage({ + mode: "projects", + projects: projects, + sortMode: "hours", + searchText: "" + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.mode, "projects"); + compare(root.lastMessage.items.length, 3); + compare(root.lastMessage.items[0].projectData.name, "Frontend UI"); + compare(root.lastMessage.items[1].projectData.name, "DevOps Infrastructure"); + compare(root.lastMessage.items[2].projectData.name, "Backend Database"); + + // 2. Filter by search query + messageSpy.clear(); + root.lastMessage = null; + chartWorker.sendMessage({ + mode: "projects", + projects: projects, + sortMode: "hours", + searchText: "backend" + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.items.length, 1); + compare(root.lastMessage.items[0].projectData.name, "Backend Database"); + } + + function test_tasksMessageProcessing() { + var tasks = [ + { id: 10, name: "Low priority task", totalHours: 3.0 }, + { id: 20, name: "High priority task", totalHours: 18.5 }, + { id: 30, name: "Medium priority task", totalHours: 8.0 } + ]; + + chartWorker.sendMessage({ + mode: "tasks", + projectId: 99, + tasks: tasks + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.mode, "tasks"); + compare(root.lastMessage.projectId, 99); + compare(root.lastMessage.items.length, 3); + compare(root.lastMessage.items[0].name, "High priority task"); + compare(root.lastMessage.items[1].name, "Medium priority task"); + compare(root.lastMessage.items[2].name, "Low priority task"); + } + + function test_tasksTieBreaking() { + var tasks = [ + { id: 1, name: "Task Beta", totalHours: 5.0 }, + { id: 2, name: "Task Alpha", totalHours: 5.0 }, + { id: 3, name: "Task Gamma", totalHours: 10.0 } + ]; + + chartWorker.sendMessage({ + mode: "tasks", + projectId: 50, + tasks: tasks + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.items.length, 3); + compare(root.lastMessage.items[0].name, "Task Gamma"); + compare(root.lastMessage.items[1].name, "Task Alpha"); + compare(root.lastMessage.items[2].name, "Task Beta"); + } + } +} diff --git a/tests/qml/scripts/tst_htmlsanitizer.qml b/tests/qml/scripts/tst_htmlsanitizer.qml new file mode 100644 index 00000000..bc5e6925 --- /dev/null +++ b/tests/qml/scripts/tst_htmlsanitizer.qml @@ -0,0 +1,106 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../qml/components/richtext/js/html-sanitizer.js" as HtmlSanitizer + +Item { + width: 200 + height: 200 + + TestCase { + name: "HtmlSanitizerTests" + when: windowShown + + function test_extractBodyContent() { + var fullDoc = '

Clean paragraph

'; + compare(HtmlSanitizer.extractBodyContent(fullDoc), "

Clean paragraph

"); + + var plainHtml = "
Normal content
"; + compare(HtmlSanitizer.extractBodyContent(plainHtml), "
Normal content
"); + + compare(HtmlSanitizer.extractBodyContent(""), ""); + compare(HtmlSanitizer.extractBodyContent(null), ""); + } + + function test_cleanQtStyles() { + var qtStyled = '

Styled text

'; + var cleaned = HtmlSanitizer.cleanQtStyles(qtStyled); + verify(cleaned.indexOf("-qt-") === -1); + verify(cleaned.indexOf("margin-top:12px") === -1); + verify(cleaned.indexOf("text-indent:0px") === -1); + verify(cleaned.indexOf("color:#ff0000;") !== -1); + } + + function test_normalizeColors() { + var rgbHtml = 'Red'; + compare(HtmlSanitizer.normalizeColors(rgbHtml), 'Red'); + + var rgbaHtml = '
Blue
'; + compare(HtmlSanitizer.normalizeColors(rgbaHtml), '
Blue
'); + } + + function test_normalizeOdooClasses() { + var odooHtml = '

Heading

'; + var normalized = HtmlSanitizer.normalizeOdooClasses(odooHtml); + verify(normalized.indexOf("font-size: 28px;") !== -1); + verify(normalized.indexOf("color: #714b67;") !== -1); + } + + function test_convertFontTagsToSpans() { + var legacyFont = 'Green text'; + var converted = HtmlSanitizer.convertFontTagsToSpans(legacyFont); + verify(converted.startsWith('") !== -1); + } + + function test_validate() { + var safeHtml = "

Safe text with bold element

"; + var validResult = HtmlSanitizer.validate(safeHtml); + verify(validResult.isValid); + compare(validResult.issues.length, 0); + + var unsafeScript = '

Test

'; + var scriptResult = HtmlSanitizer.validate(unsafeScript); + verify(!scriptResult.isValid); + verify(scriptResult.issues.indexOf("Contains script tags") !== -1); + + var unsafeEditor = '
window.editor.getContent()
'; + var editorResult = HtmlSanitizer.validate(unsafeEditor); + verify(!editorResult.isValid); + verify(editorResult.issues.indexOf("Contains editor JavaScript code") !== -1); + } + + function test_needsSanitization() { + verify(HtmlSanitizer.needsSanitization('Test')); + verify(HtmlSanitizer.needsSanitization('

Qt paragraph

')); + verify(HtmlSanitizer.needsSanitization('Color')); + verify(HtmlSanitizer.needsSanitization('Font tag')); + verify(HtmlSanitizer.needsSanitization('

Odoo Heading

')); + + // Clean plain text or simple semantic HTML does not need sanitization + verify(!HtmlSanitizer.needsSanitization("

Plain simple paragraph

")); + verify(!HtmlSanitizer.needsSanitization("")); + verify(!HtmlSanitizer.needsSanitization(null)); + } + + function test_toPlainText() { + var html = '

Hello World!


Second   line & <escaped>

'; + var text = HtmlSanitizer.toPlainText(html); + verify(text.indexOf("Hello World!") !== -1); + verify(text.indexOf("Second line & ") !== -1); + verify(text.indexOf("

") === -1); + verify(text.indexOf("
") === -1); + } + + function test_sanitize_fullPipeline() { + var rawInput = '

Text with deleted and red font

'; + var clean = HtmlSanitizer.sanitize(rawInput); + verify(clean.indexOf("deleted") !== -1); + verify(clean.indexOf("#ff0000") !== -1); + verify(clean.indexOf(" 0); + verify(typeof item.iconColor === "string" && item.iconColor.startsWith("#")); + verify(typeof item.pageUrl === "string" && item.pageUrl.endsWith(".qml")); + verify(typeof item.pageNum === "number" && item.pageNum >= 0); + } + } + + function test_menuItemsImmutability() { + var items1 = NavigationRoutes.menuItems(); + items1.pop(); // Modify returned array + var items2 = NavigationRoutes.menuItems(); + compare(items2.length, 9); // Original must not be modified + } + + function test_resolvePageKey() { + compare(NavigationRoutes.resolvePageKey(0, ""), "dashboard"); + compare(NavigationRoutes.resolvePageKey(1, ""), "timesheet_list"); + compare(NavigationRoutes.resolvePageKey(2, ""), "activity"); + + // Page 3: distinguishes My Tasks vs All Tasks + compare(NavigationRoutes.resolvePageKey(3, "features/tasks/pages/MyTasksPage.qml"), "my_tasks"); + compare(NavigationRoutes.resolvePageKey(3, "features/tasks/pages/Task_Page.qml"), "task"); + compare(NavigationRoutes.resolvePageKey(3, ""), "task"); + + compare(NavigationRoutes.resolvePageKey(4, ""), "project"); + compare(NavigationRoutes.resolvePageKey(5, ""), "updates"); + compare(NavigationRoutes.resolvePageKey(6, ""), "settings"); + compare(NavigationRoutes.resolvePageKey(7, ""), "about"); + + // Unknown page numbers fall back to "dynamic" + compare(NavigationRoutes.resolvePageKey(8, ""), "dynamic"); + compare(NavigationRoutes.resolvePageKey(-1, ""), "dynamic"); + compare(NavigationRoutes.resolvePageKey(99, ""), "dynamic"); + } + + function test_pageUrlForNavType() { + compare(NavigationRoutes.pageUrlForNavType("Task"), "features/tasks/pages/Tasks.qml"); + compare(NavigationRoutes.pageUrlForNavType("Activity"), "features/activities/pages/Activities.qml"); + compare(NavigationRoutes.pageUrlForNavType("ProjectUpdate"), "features/updates/pages/Updates.qml"); + compare(NavigationRoutes.pageUrlForNavType("Project"), "features/projects/pages/Projects.qml"); + compare(NavigationRoutes.pageUrlForNavType("Timesheet"), "features/timesheets/pages/Timesheet.qml"); + + // Unknown nav types return empty string + compare(NavigationRoutes.pageUrlForNavType("Unknown"), ""); + compare(NavigationRoutes.pageUrlForNavType(""), ""); + compare(NavigationRoutes.pageUrlForNavType(null), ""); + compare(NavigationRoutes.pageUrlForNavType(undefined), ""); + } + } +} diff --git a/tests/qml/scripts/tst_squirebridge.qml b/tests/qml/scripts/tst_squirebridge.qml new file mode 100644 index 00000000..2e18b02c --- /dev/null +++ b/tests/qml/scripts/tst_squirebridge.qml @@ -0,0 +1,137 @@ +import QtQuick 2.7 +import QtTest 1.0 + +Item { + id: root + width: 200 + height: 200 + + property var mockWindow: null + property var mockDocument: null + + TestCase { + name: "SquireBridgeTests" + when: windowShown + + function init() { + root.mockDocument = { + title: "" + }; + root.mockWindow = { + location: { hash: "", pathname: "/editor", search: "" }, + history: { + replaceState: function(state, title, url) { + root.mockWindow.location.pathname = url; + } + } + }; + } + + function createBridge() { + var win = root.mockWindow; + var doc = root.mockDocument; + + var xhr = new XMLHttpRequest(); + xhr.open("GET", Qt.resolvedUrl("../../../qml/components/richtext/js/squire-qtwebengine-bridge.js"), false); + xhr.send(); + var bridgeCode = xhr.responseText; + + var factory = new Function("window", "document", "setTimeout", bridgeCode); + factory(win, doc, function(cb, delay) {}); + return win.qtBridge; + } + + function test_handlerRegistration() { + var bridge = createBridge(); + compare(bridge.getHandlers().length, 0); + + bridge.addMessageHandler("contentChanged", function(msg) {}); + bridge.addMessageHandler("formatUpdate", function(msg) {}); + compare(bridge.getHandlers().length, 2); + verify(bridge.getHandlers().indexOf("contentChanged") !== -1); + verify(bridge.getHandlers().indexOf("formatUpdate") !== -1); + + // Invalid arguments should be ignored + bridge.addMessageHandler(123, function() {}); + bridge.addMessageHandler("invalid", "not-a-func"); + compare(bridge.getHandlers().length, 2); + + bridge.clearHandlers(); + compare(bridge.getHandlers().length, 0); + } + + function test_sendMessage_singleChunk() { + var bridge = createBridge(); + bridge.sendMessage("ready", { initialized: true }); + + verify(root.mockDocument.title.startsWith("qtevent:ready:")); + var parts = root.mockDocument.title.split(":"); + compare(parts[0], "qtevent"); + compare(parts[1], "ready"); + var decoded = JSON.parse(decodeURIComponent(parts[2])); + compare(decoded.type, "ready"); + compare(decoded.payload.initialized, true); + } + + function test_sendMessage_chunked() { + var bridge = createBridge(); + var longText = ""; + for (var i = 0; i < 300; i++) { + longText += "abcdefghij"; // 3000 chars + } + + bridge.sendMessage("largeData", { text: longText }); + verify(root.mockDocument.title.startsWith("qteventchunk:")); + } + + function test_processMessage() { + var bridge = createBridge(); + var receivedPayload = null; + + bridge.addMessageHandler("setContent", function(msg) { + receivedPayload = msg.payload; + }); + + bridge.processMessage("setContent", { html: "

Hello

" }); + verify(receivedPayload !== null); + compare(receivedPayload.html, "

Hello

"); + + // Calling unregistered handler should not throw + bridge.processMessage("unregistered", {}); + } + + function test_processMessageWithReply_success() { + var bridge = createBridge(); + + bridge.addMessageHandler("getWordCount", function(msg) { + msg.reply({ count: 42 }); + }); + + bridge.processMessageWithReply("getWordCount", {}, "call_123"); + + verify(root.mockWindow.location.hash.startsWith("#qtreply:call_123:")); + var encoded = root.mockWindow.location.hash.replace("#qtreply:call_123:", ""); + var reply = JSON.parse(decodeURIComponent(encoded)); + verify(reply.success); + compare(reply.data.count, 42); + } + + function test_processMessageWithReply_noHandler() { + var bridge = createBridge(); + bridge.processMessageWithReply("missingFunc", {}, "call_999"); + + verify(root.mockWindow.location.hash.startsWith("#qtreply:call_999:")); + var encoded = root.mockWindow.location.hash.replace("#qtreply:call_999:", ""); + var reply = JSON.parse(decodeURIComponent(encoded)); + compare(reply.success, false); + compare(reply.error.code, "NO_HANDLER"); + } + + function test_oxideCompatibilityAlias() { + createBridge(); + verify(root.mockWindow.oxide !== undefined); + verify(typeof root.mockWindow.oxide.addMessageHandler === "function"); + verify(typeof root.mockWindow.oxide.sendMessage === "function"); + } + } +} diff --git a/tests/qml/scripts/tst_taskformutils.qml b/tests/qml/scripts/tst_taskformutils.qml new file mode 100644 index 00000000..cf6e3f2a --- /dev/null +++ b/tests/qml/scripts/tst_taskformutils.qml @@ -0,0 +1,164 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../qml/features/tasks/js/taskFormUtils.js" as TaskFormUtils + +Item { + width: 200 + height: 200 + + TestCase { + name: "TaskFormUtilsTests" + when: windowShown + + function test_normalizeIdForRestore() { + compare(TaskFormUtils.normalizeIdForRestore(null), -1); + compare(TaskFormUtils.normalizeIdForRestore(undefined), -1); + compare(TaskFormUtils.normalizeIdForRestore(""), -1); + compare(TaskFormUtils.normalizeIdForRestore("invalid"), -1); + compare(TaskFormUtils.normalizeIdForRestore(42), 42); + compare(TaskFormUtils.normalizeIdForRestore("42"), 42); + compare(TaskFormUtils.normalizeIdForRestore(0), 0); + } + + function test_validateHoursInput() { + // HH:MM format + verify(TaskFormUtils.validateHoursInput("01:30")); + verify(TaskFormUtils.validateHoursInput("0:00")); + verify(TaskFormUtils.validateHoursInput("999:59")); + verify(!TaskFormUtils.validateHoursInput("1:60")); // minutes >= 60 invalid + verify(!TaskFormUtils.validateHoursInput("1000:00")); // hours > 999 invalid + verify(!TaskFormUtils.validateHoursInput("-1:00")); + + // Decimal format + verify(TaskFormUtils.validateHoursInput("2.5")); + verify(TaskFormUtils.validateHoursInput("0")); + verify(TaskFormUtils.validateHoursInput("999")); + verify(!TaskFormUtils.validateHoursInput("1000")); // > 999 invalid + verify(!TaskFormUtils.validateHoursInput("-5")); + + // Invalid non-numeric strings + verify(!TaskFormUtils.validateHoursInput("")); + verify(!TaskFormUtils.validateHoursInput("abc")); + verify(!TaskFormUtils.validateHoursInput("12:abc")); + } + + function test_formatHoursDisplay() { + compare(TaskFormUtils.formatHoursDisplay("1:05"), "01:05"); + compare(TaskFormUtils.formatHoursDisplay("10:30"), "10:30"); + compare(TaskFormUtils.formatHoursDisplay("2.5"), "2:30"); + compare(TaskFormUtils.formatHoursDisplay("invalid"), "invalid"); + } + + function test_resolveSingleAssigneeId() { + compare(TaskFormUtils.resolveSingleAssigneeId(undefined), -1); + compare(TaskFormUtils.resolveSingleAssigneeId(null), -1); + compare(TaskFormUtils.resolveSingleAssigneeId(""), -1); + compare(TaskFormUtils.resolveSingleAssigneeId(42), 42); + compare(TaskFormUtils.resolveSingleAssigneeId("42"), 42); + compare(TaskFormUtils.resolveSingleAssigneeId("10, 20, 30"), 10); + compare(TaskFormUtils.resolveSingleAssigneeId("invalid"), -1); + } + + function test_buildSaveData_remoteAccount() { + var params = { + ids: { + account_id: 999, + project_id: 101, + subproject_id: 0, + task_id: 201, + assignee_id: "15" + }, + name: "Unit Test Task", + recordId: 50, + startDate: "2026-10-13", + endDate: "2026-10-15", + deadline: "2026-10-20", + priority: 2, + plannedHours: "03:30", + description: "Test description", + selectedStageOdooRecordId: 5, + selectedPersonalStageOdooRecordId: null, + stageListCount: 3 + }; + + var data = TaskFormUtils.buildSaveData(params); + compare(data.accountId, 999); + compare(data.name, "Unit Test Task"); + compare(data.record_id, 50); + compare(data.projectId, 101); + compare(data.parentId, 201); + compare(data.deadline, "2026-10-20"); + compare(data.priority, "2"); + compare(data.plannedHours, 3.5); + compare(data.status, "updated"); // Remote account gets "updated" + compare(data.stageOdooRecordId, 5); + } + + function test_buildSaveData_localAccount() { + var params = { + ids: { + account_id: 0, + project_id: 10, + subproject_id: 0, + task_id: 0, + assignee_id: "" + }, + name: "Local Task", + recordId: 0, + startDate: "2026-10-13", + endDate: "2026-10-13", + deadline: "Not set", + priority: 0, + plannedHours: "1.0", + description: "", + selectedStageOdooRecordId: -1, + selectedPersonalStageOdooRecordId: null, + stageListCount: 1 + }; + + var data = TaskFormUtils.buildSaveData(params); + compare(data.accountId, 0); + compare(data.status, "saved"); // Local account gets "saved" + compare(data.deadline, ""); // "Not set" deadline becomes empty string + compare(data.plannedHours, 1.0); + compare(data.stageOdooRecordId, -1); + } + + function test_buildSaveData_multipleAssigneesAndStageFallback() { + var params = { + ids: { + account_id: 10, + project_id: 5, + subproject_id: 0, + task_id: 0, + assignee_id: "101", + multiple_assignees: [ + { id: 101, name: "Alice" }, + { id: 102, name: "Bob" } + ] + }, + name: "New Task with Fallback Stage", + recordId: 0, + startDate: "2026-10-14", + endDate: "2026-10-14", + deadline: "", + priority: 1, + plannedHours: "2.0", + description: "", + selectedStageOdooRecordId: null, + selectedPersonalStageOdooRecordId: 44, + stageListCount: 2, + firstStage: { odoo_record_id: 7, name: "Backlog" }, + enableMultipleAssignees: true + }; + + var data = TaskFormUtils.buildSaveData(params); + compare(data.stageOdooRecordId, 7); // Falls back to firstStage + compare(data.personalStageOdooRecordId, 44); + verify(Array.isArray(data.multipleAssignees)); + compare(data.multipleAssignees.length, 2); + compare(data.multipleAssignees[0].id, 101); + compare(data.multipleAssignees[1].id, 102); + } + } +} diff --git a/tests/qml/tst_accountselectordialog.qml b/tests/qml/tst_accountselectordialog.qml new file mode 100644 index 00000000..2ea86bb4 --- /dev/null +++ b/tests/qml/tst_accountselectordialog.qml @@ -0,0 +1,135 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/dialogs" +import "../../models/dbinit.js" as DbInit + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + AccountSelectorDialog { + id: testAccountSelector + } + + SignalSpy { + id: acceptedSpy + target: testAccountSelector + signalName: "accepted" + } + + SignalSpy { + id: canceledSpy + target: testAccountSelector + signalName: "canceled" + } + + TestCase { + name: "AccountSelectorDialogTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + } + + function init() { + testAccountSelector.titleText = "Select Account"; + testAccountSelector.restrictToLocalOnly = false; + testAccountSelector.selectedAccountId = -1; + testAccountSelector.selectedAccountName = ""; + testAccountSelector.lastRemoteAccountId = -1; + acceptedSpy.clear(); + canceledSpy.clear(); + } + + function cleanup() { + acceptedSpy.clear(); + canceledSpy.clear(); + } + + function test_initialProperties() { + compare(testAccountSelector.titleText, "Select Account"); + compare(testAccountSelector.restrictToLocalOnly, false); + compare(acceptedSpy.count, 0); + compare(canceledSpy.count, 0); + } + + function test_customTitleText() { + testAccountSelector.titleText = "Switch Active Account"; + compare(testAccountSelector.titleText, "Switch Active Account"); + } + + function test_restrictToLocalOnlyToggle() { + testAccountSelector.restrictToLocalOnly = true; + compare(testAccountSelector.restrictToLocalOnly, true); + + testAccountSelector.restrictToLocalOnly = false; + compare(testAccountSelector.restrictToLocalOnly, false); + } + + function test_accountListPickAndSignalEmission() { + var targetId = 2; + var targetName = "Acme Corp Odoo"; + + testAccountSelector.selectedAccountId = targetId; + testAccountSelector.selectedAccountName = targetName; + testAccountSelector.accepted(targetId, targetName); + + compare(testAccountSelector.selectedAccountId, targetId); + compare(testAccountSelector.selectedAccountName, targetName); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], targetId); + compare(acceptedSpy.signalArguments[0][1], targetName); + } + + function test_switchSignalMultipleAccounts() { + testAccountSelector.accepted(0, "Local Account"); + testAccountSelector.accepted(1, "Dev Server"); + testAccountSelector.accepted(-1, "All Accounts"); + + compare(acceptedSpy.count, 3); + compare(acceptedSpy.signalArguments[0][0], 0); + compare(acceptedSpy.signalArguments[0][1], "Local Account"); + compare(acceptedSpy.signalArguments[1][0], 1); + compare(acceptedSpy.signalArguments[1][1], "Dev Server"); + compare(acceptedSpy.signalArguments[2][0], -1); + compare(acceptedSpy.signalArguments[2][1], "All Accounts"); + } + + function test_canceledSignalEmission() { + compare(canceledSpy.count, 0); + testAccountSelector.canceled(); + compare(canceledSpy.count, 1); + compare(acceptedSpy.count, 0); + } + + function test_toggleLocalModeSwitchesToLocalAndEmitsSignal() { + testAccountSelector.selectedAccountId = 3; + testAccountSelector.selectedAccountName = "Remote Server"; + + var result = testAccountSelector.toggleLocalMode(true); + verify(result); + compare(testAccountSelector.selectedAccountId, 0); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], 0); + } + + function test_toggleLocalModeWhenAlreadyLocalDoesNotReEmit() { + testAccountSelector.selectedAccountId = 0; + testAccountSelector.selectedAccountName = "Local"; + acceptedSpy.clear(); + + var result = testAccountSelector.toggleLocalMode(true); + verify(result); + compare(acceptedSpy.count, 0); + } + + function test_openWithInitialAccountId() { + testAccountSelector.open(5); + // Verify open() sets requested initial id or opens cleanly + verify(testAccountSelector !== null); + } + } +} diff --git a/tests/qml/tst_activitydetailscard.qml b/tests/qml/tst_activitydetailscard.qml new file mode 100644 index 00000000..f7abb2b6 --- /dev/null +++ b/tests/qml/tst_activitydetailscard.qml @@ -0,0 +1,248 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/cards" +import "../../models/dbinit.js" as DbInit + +MainView { + id: testRoot + width: units.gu(50) + height: units.gu(80) + + ActivityDetailsCard { + id: testCard + account_id: 1 + odoo_record_id: 201 + } + + SignalSpy { + id: cardClickedSpy + target: testCard + signalName: "cardClicked" + } + + SignalSpy { + id: editRequestedSpy + target: testCard + signalName: "editRequested" + } + + SignalSpy { + id: viewRequestedSpy + target: testCard + signalName: "viewRequested" + } + + SignalSpy { + id: markAsDoneSpy + target: testCard + signalName: "markAsDone" + } + + SignalSpy { + id: createFollowupSpy + target: testCard + signalName: "createFollowup" + } + + SignalSpy { + id: dateChangedSpy + target: testCard + signalName: "dateChanged" + } + + TestCase { + name: "ActivityDetailsCardTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + } + + function init() { + testCard.notes = ""; + testCard.activity_type_name = ""; + testCard.summary = ""; + testCard.activity_type_id = -1; + testCard.user = ""; + testCard.due_date = ""; + testCard.state = ""; + testCard.id = -1; + testCard.odoo_record_id = 201; + testCard.account_id = 1; + testCard.colorPallet = 0; + testCard.hasDraft = false; + + cardClickedSpy.clear(); + editRequestedSpy.clear(); + viewRequestedSpy.clear(); + markAsDoneSpy.clear(); + createFollowupSpy.clear(); + dateChangedSpy.clear(); + } + + function cleanup() { + cardClickedSpy.clear(); + editRequestedSpy.clear(); + viewRequestedSpy.clear(); + markAsDoneSpy.clear(); + createFollowupSpy.clear(); + dateChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testCard.notes, ""); + compare(testCard.activity_type_name, ""); + compare(testCard.summary, ""); + compare(testCard.user, ""); + compare(testCard.due_date, ""); + compare(testCard.state, ""); + compare(testCard.hasDraft, false); + compare(testCard.account_id, 1); + compare(testCard.odoo_record_id, 201); + } + + function test_stripHtmlTagsComprehensive() { + var input = "

Meeting with client & discuss options.

"; + var stripped = testCard.stripHtmlTags(input); + verify(stripped.indexOf("

") === -1); + verify(stripped.indexOf("") === -1); + verify(stripped.indexOf("