This commit is contained in:
AgostonSzepessy 2026-03-09 18:59:34 +01:00 committed by GitHub
commit b51c75e7c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1091 additions and 658 deletions

View file

@ -3806,6 +3806,10 @@ Supported extensions are: %1.</source>
<source>Search toggle for this and sub groups</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Exclude from database reports</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EditWidgetIcons</name>
@ -9499,6 +9503,47 @@ This option is deprecated, use --set-key-file instead.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetBase</name>
<message>
<source>Please wait, report is being calculated</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit Entry</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Expire Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message numerus="yes">
<source>Delete Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Exclude Entry(s) from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Exclude Group(s) from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The Group for &quot;%1&quot; is excluded. Would you like to include all Entries from there as well?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Include Group?</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetBrowserStatistics</name>
<message>
@ -9533,10 +9578,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>This entry is being excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Please wait, browser statistics is being calculated</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No entries with a URL, or none has browser extension settings saved.</source>
<translation type="unfinished"></translation>
@ -9553,28 +9594,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>URLs</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit Entry</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Delete Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Exclude from reports</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Expire Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Only show entries that have a URL</source>
<translation type="unfinished"></translation>
@ -9598,6 +9617,14 @@ This option is deprecated, use --set-key-file instead.</source>
<numerusform></numerusform>
</translation>
</message>
<message>
<source> (Group Excluded)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The group for this entry is being excluded from reports</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetHealthcheck</name>
@ -9633,10 +9660,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>This entry is being excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Please wait, health data is being calculated</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Congratulations, everything is healthy!</source>
<translation type="unfinished"></translation>
@ -9657,32 +9680,18 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Reason</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit Entry</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Delete Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Exclude from reports</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Expire Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Show entries that have been excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source> (Group Excluded)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The group for this entry is being excluded from reports</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetHibp</name>
@ -9767,27 +9776,13 @@ This option is deprecated, use --set-key-file instead.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit Entry</source>
<source> (Group Excluded)</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Delete Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Exclude from reports</source>
<source>The group for this entry is being excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Expire Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
</context>
<context>
<name>ReportsWidgetPasskeys</name>
@ -9850,10 +9845,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>The passkey file will be vulnerable to theft and unauthorized use, if left unsecured. Are you sure you want to continue?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Please wait, list of entries with passkeys is being updated</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No entries with passkeys.</source>
<translation type="unfinished"></translation>
@ -9988,6 +9979,14 @@ This option is deprecated, use --set-key-file instead.</source>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Groups excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Excluding entire groups from reports isn&apos;t necessarily a problem but please exercise caution when excluding entire groups.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SSHAgent</name>

View file

@ -194,6 +194,7 @@ set(gui_SOURCES
gui/remote/RemoteProcess.cpp
gui/remote/RemoteSettings.cpp
gui/reports/ReportsWidget.cpp
gui/reports/ReportsWidgetBase.cpp
gui/reports/ReportsDialog.cpp
gui/reports/ReportsWidgetHealthcheck.cpp
gui/reports/ReportsPageHealthcheck.cpp

View file

@ -76,6 +76,10 @@ void DatabaseStats::gatherStats(const QList<Group*>& groups)
++groupCount;
if (group->excludeFromReports()) {
++excludedGroups;
}
for (const auto* entry : group->entries()) {
// Don't count anything in the recycle bin
if (entry->isRecycled()) {
@ -107,7 +111,7 @@ void DatabaseStats::gatherStats(const QList<Group*>& groups)
++weakPasswords;
}
if (entry->excludeFromReports()) {
if (entry->excludeFromReports() || group->excludeFromReports()) {
++excludedEntries;
}

View file

@ -30,6 +30,7 @@ public:
int entryCount = 0; // Number of entries (across all groups)
int expiredEntries = 0; // Number of expired entries
int excludedEntries = 0; // Number of known bad entries
int excludedGroups = 0; // Number of excluded groups from reports
int weakPasswords = 0; // Number of weak or poor passwords
int shortPasswords = 0; // Number of passwords 8 characters or less in size
int uniquePasswords = 0; // Number of unique passwords

View file

@ -1281,6 +1281,33 @@ void Group::setPreviousParentGroup(const Group* group)
setPreviousParentGroupUuid(group ? group->uuid() : QUuid());
}
void Group::setExcludeFromReports(bool excluded)
{
customData()->set(CustomData::ExcludeFromReportsLegacy, excluded ? TRUE_STR : FALSE_STR);
// Clear out the exclusion flag on entries when we set it on the
// group because it'll make it easier to individually set it for an
// entry later on
if (excluded) {
for (auto& entry : m_entries) {
entry->setExcludeFromReports(false);
}
}
}
void Group::markAllEntriesExcludedFromReports()
{
for (auto& entry : m_entries) {
entry->setExcludeFromReports(true);
}
}
bool Group::excludeFromReports() const
{
return customData()->contains(CustomData::ExcludeFromReportsLegacy)
&& customData()->value(CustomData::ExcludeFromReportsLegacy) == TRUE_STR;
}
bool Group::GroupData::operator==(const Group::GroupData& other) const
{
return equals(other, CompareItemDefault);

View file

@ -107,6 +107,7 @@ public:
QString resolveCustomDataString(const QString& key, bool checkParent = true) const;
const Group* previousParentGroup() const;
QUuid previousParentGroupUuid() const;
bool excludeFromReports() const;
bool equals(const Group* other, CompareItemOptions options) const;
@ -140,6 +141,8 @@ public:
void setMergeMode(MergeMode newMode);
void setPreviousParentGroup(const Group* group);
void setPreviousParentGroupUuid(const QUuid& uuid);
void setExcludeFromReports(bool exclude);
void markAllEntriesExcludedFromReports();
bool canUpdateTimeinfo() const;
void setUpdateTimeinfo(bool value);

View file

@ -126,6 +126,7 @@ void EditGroupWidget::setupModifiedTracking()
connect(m_mainUi->autoTypeSequenceInherit, SIGNAL(toggled(bool)), SLOT(setModified()));
connect(m_mainUi->autoTypeSequenceCustomRadio, SIGNAL(toggled(bool)), SLOT(setModified()));
connect(m_mainUi->autoTypeSequenceCustomEdit, SIGNAL(textChanged(QString)), SLOT(setModified()));
connect(m_mainUi->excludeReportsCheckBox, SIGNAL(stateChanged(int)), SLOT(setModified()));
// Icon tab
connect(m_editGroupWidgetIcons, SIGNAL(widgetUpdated()), SLOT(setModified()));
@ -171,6 +172,7 @@ void EditGroupWidget::loadGroup(Group* group, bool create, const QSharedPointer<
m_mainUi->autoTypeSequenceCustomRadio->setChecked(true);
}
m_mainUi->autoTypeSequenceCustomEdit->setText(group->effectiveAutoTypeSequence());
m_mainUi->excludeReportsCheckBox->setChecked(m_group->excludeFromReports());
if (config()->get(Config::GUI_MonospaceNotes).toBool()) {
m_mainUi->editNotes->setFont(Font::fixedFont());
@ -265,6 +267,7 @@ void EditGroupWidget::apply()
m_temporaryGroup->setSearchingEnabled(triStateFromIndex(m_mainUi->searchComboBox->currentIndex()));
m_temporaryGroup->setAutoTypeEnabled(triStateFromIndex(m_mainUi->autotypeComboBox->currentIndex()));
m_temporaryGroup->setExcludeFromReports(m_mainUi->excludeReportsCheckBox->isChecked());
if (m_mainUi->autoTypeSequenceInherit->isChecked()) {
m_temporaryGroup->setDefaultAutoTypeSequence(QString());

View file

@ -37,7 +37,7 @@
<height>523</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout" rowstretch="0,0,0,0,0,0,0,0,0,1" rowminimumheight="0,0,0,0,0,0,0,0,0,1">
<layout class="QGridLayout" name="gridLayout" rowstretch="0,0,0,0,0,0,0,0,0,1,0" rowminimumheight="0,0,0,0,0,0,0,0,0,1,0">
<property name="leftMargin">
<number>0</number>
</property>
@ -56,7 +56,7 @@
<property name="verticalSpacing">
<number>8</number>
</property>
<item row="3" column="0">
<item row="3" column="0" alignment="Qt::AlignRight">
<widget class="QCheckBox" name="expireCheck">
<property name="accessibleName">
<string>Toggle expiration</string>
@ -66,10 +66,10 @@
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="editName">
<property name="accessibleName">
<string>Name field</string>
<item row="7" column="1">
<widget class="QRadioButton" name="autoTypeSequenceCustomRadio">
<property name="text">
<string>Set default Auto-Type sequence</string>
</property>
</widget>
</item>
@ -86,30 +86,10 @@
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QRadioButton" name="autoTypeSequenceInherit">
<property name="text">
<string>Use default Auto-Type sequence of parent group</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="autotypeLabel">
<property name="text">
<string>Auto-Type:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="searchLabel">
<property name="text">
<string>Search:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
<item row="0" column="1">
<widget class="QLineEdit" name="editName">
<property name="accessibleName">
<string>Name field</string>
</property>
</widget>
</item>
@ -120,6 +100,13 @@
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QComboBox" name="searchComboBox">
<property name="accessibleName">
<string>Search toggle for this and sub groups</string>
</property>
</widget>
</item>
<item row="1" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
@ -199,31 +186,7 @@
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="labelName">
<property name="text">
<string>Name:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QRadioButton" name="autoTypeSequenceCustomRadio">
<property name="text">
<string>Set default Auto-Type sequence</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QComboBox" name="searchComboBox">
<property name="accessibleName">
<string>Search toggle for this and sub groups</string>
</property>
</widget>
</item>
<item row="9" column="0">
<item row="10" column="0">
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
@ -236,6 +199,50 @@
</property>
</spacer>
</item>
<item row="4" column="0">
<widget class="QLabel" name="searchLabel">
<property name="text">
<string>Search:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="autotypeLabel">
<property name="text">
<string>Auto-Type:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QRadioButton" name="autoTypeSequenceInherit">
<property name="text">
<string>Use default Auto-Type sequence of parent group</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="labelName">
<property name="text">
<string>Name:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QCheckBox" name="excludeReportsCheckBox">
<property name="text">
<string>Exclude from database reports</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>

View file

@ -0,0 +1,75 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_PROXYMODELS_H
#define KEEPASSXC_PROXYMODELS_H
#include <QModelIndex>
#include <QSortFilterProxyModel>
enum class SortProxyModelKind
{
Default = 0,
Hibp,
Healthcheck,
};
class HibpReportSortProxyModel : public QSortFilterProxyModel
{
public:
HibpReportSortProxyModel(QObject* parent)
: QSortFilterProxyModel(parent)
{
}
~HibpReportSortProxyModel() override = default;
protected:
bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
{
// Sort count column by user data
if (left.column() == 2) {
return sourceModel()->data(left, Qt::UserRole).toInt() < sourceModel()->data(right, Qt::UserRole).toInt();
}
// Otherwise use default sorting
return QSortFilterProxyModel::lessThan(left, right);
}
};
class HealthcheckReportSortProxyModel : public QSortFilterProxyModel
{
public:
HealthcheckReportSortProxyModel(QObject* parent)
: QSortFilterProxyModel(parent)
{
}
~HealthcheckReportSortProxyModel() override = default;
protected:
bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
{
// Check if the display data is a number, convert and compare if so
bool ok = false;
int leftInt = sourceModel()->data(left).toString().toInt(&ok);
if (ok) {
return leftInt < sourceModel()->data(right).toString().toInt();
}
// Otherwise use default sorting
return QSortFilterProxyModel::lessThan(left, right);
}
};
#endif // KEEPASSXC_PROXYMODELS_H

View file

@ -0,0 +1,259 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ReportsWidgetBase.h"
#include "core/Group.h"
#include "core/Metadata.h"
#include "gui/GuiTools.h"
#include "gui/Icons.h"
#include "gui/MessageBox.h"
#include <QAction>
#include <QIcon>
#include <QMenu>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
#include <QTableView>
ReportsWidgetBase::ReportsWidgetBase(QWidget* parent, SortProxyModelKind proxyModel)
: QWidget{parent}
, m_referencesModel(new QStandardItemModel(this))
{
// We have to initialize this here; if we do it in the constructor initializer list,
// the base object isn't setup enough and the constructor for QSortFilterProxyModel
// crashes.
switch (proxyModel) {
case SortProxyModelKind::Default:
m_modelProxy.reset(new QSortFilterProxyModel(this));
break;
case SortProxyModelKind::Healthcheck:
m_modelProxy.reset(new HealthcheckReportSortProxyModel(this));
break;
case SortProxyModelKind::Hibp:
m_modelProxy.reset(new HibpReportSortProxyModel(this));
break;
}
}
ReportsWidgetBase::~ReportsWidgetBase()
{
}
void ReportsWidgetBase::loadSettings(QSharedPointer<Database> db)
{
m_db = std::move(db);
m_widgetDataCalculated = false;
m_referencesModel->clear();
m_rowToEntry.clear();
auto row = QList<QStandardItem*>();
row << new QStandardItem(tr("Please wait, report is being calculated…"));
m_referencesModel->appendRow(row);
}
void ReportsWidgetBase::saveSettings()
{
// Most report tabs are passive, so override them in derived classes if they need to
// save settings
}
QMenu* ReportsWidgetBase::customMenuRequestedBase()
{
auto selected = getTableView()->selectionModel()->selectedRows();
if (selected.isEmpty()) {
return nullptr;
}
// Create the context menu
const auto menu = new QMenu(this);
menu->setObjectName("customMenu");
// Create the "edit entry" menu item (only if 1 row is selected)
if (selected.size() == 1) {
const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this);
edit->setObjectName("contextMenuEditAction");
menu->addAction(edit);
connect(edit, &QAction::triggered, edit, [this, selected] {
auto row = m_modelProxy->mapToSource(selected[0]).row();
auto entry = m_rowToEntry[row].second;
emit entryActivated(entry);
});
}
// Create the "Expire entry" menu item
const auto expEntry = new QAction(icons()->icon("entry-expire"), tr("Expire Entry(s)…", "", selected.size()), this);
expEntry->setObjectName("contextMenuExpireAction");
menu->addAction(expEntry);
connect(expEntry, &QAction::triggered, this, &ReportsWidgetBase::expireSelectedEntries);
// Create the "delete entry" menu item
const auto delEntry = new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this);
menu->addAction(delEntry);
connect(delEntry, &QAction::triggered, this, &ReportsWidgetBase::deleteSelectedEntries);
// Create the "exclude from reports" menu item
const auto excludeAction = new QAction(icons()->icon("reports-exclude"), tr("Exclude Entry(s) from reports"), this);
excludeAction->setObjectName("contextMenuExcludeAction");
const auto excludeGroupsAction =
new QAction(icons()->icon("reports-exclude"), tr("Exclude Group(s) from reports"), this);
excludeGroupsAction->setObjectName("contextMenuExcludeGroupAction");
bool isExcluded = false;
bool isGroupExcluded = false;
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
// If at least one entry is excluded switch to inclusion
if (entry->excludeFromReports() || entry->group()->excludeFromReports()) {
isExcluded = true;
}
if (entry->group()->excludeFromReports()) {
isGroupExcluded = true;
}
break;
}
}
excludeAction->setCheckable(true);
excludeAction->setChecked(isExcluded);
excludeGroupsAction->setCheckable(true);
excludeGroupsAction->setChecked(isGroupExcluded);
menu->addAction(excludeAction);
connect(excludeAction, &QAction::toggled, excludeAction, [this, selected](bool checked) {
QSet<Group*> groups;
// If we are including entries (checked is false) but a group is excluded, ask the user if they
// would like to include the rest of the group as well (or keep it excluded).
// If they choose "No", we need to include the whole group, and then exclude
// the entries that aren't selected here.
if (!checked) {
for (const auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
auto* group = entry->group();
if (group->excludeFromReports() && !groups.contains(group)) {
QString msg = tr("The Group for \"%1\" is excluded. Would you like to include all Entries from "
"there as well?")
.arg(entry->title());
auto response = MessageBox::question(this,
tr("Include Group?"),
msg,
MessageBox::Yes | MessageBox::No | MessageBox::Cancel,
MessageBox::No);
if (response == MessageBox::Cancel) {
return;
} else if (response == MessageBox::Yes) {
group->setExcludeFromReports(false);
} else if (response == MessageBox::No) {
// We'll exclude all entries from the group here and then
// include the selected ones below
group->setExcludeFromReports(false);
group->markAllEntriesExcludedFromReports();
}
groups.insert(group);
}
}
}
}
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
// If the containing group is excluded but the user wants to include
// this entry, ask if they want to keep the remaining items in the group
// excluded or included
if (entry) {
entry->setExcludeFromReports(checked);
}
}
updateWidget();
});
menu->addAction(excludeGroupsAction);
connect(excludeGroupsAction, &QAction::toggled, excludeGroupsAction, [this, selected](bool checked) {
for (const auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
entry->group()->setExcludeFromReports(checked);
}
}
updateWidget();
});
return menu;
}
QList<Entry*> ReportsWidgetBase::getSelectedEntries() const
{
QList<Entry*> selectedEntries;
for (auto index : getTableView()->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
selectedEntries << entry;
}
}
return selectedEntries;
}
void ReportsWidgetBase::expireSelectedEntries()
{
for (auto entry : getSelectedEntries()) {
entry->expireNow();
}
updateWidget();
}
void ReportsWidgetBase::deleteSelectedEntries()
{
const auto& selectedEntries = getSelectedEntries();
bool permanent = !m_db->metadata()->recycleBinEnabled();
if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) {
GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent);
}
updateWidget();
}
void ReportsWidgetBase::emitEntryActivated(const QModelIndex& index)
{
if (!index.isValid()) {
return;
}
auto mappedIndex = m_modelProxy->mapToSource(index);
const auto row = m_rowToEntry[mappedIndex.row()];
const auto group = row.first;
const auto entry = row.second;
if (group && entry) {
emit entryActivated(const_cast<Entry*>(entry));
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_REPORTSWIDGETBASE_H
#define KEEPASSXC_REPORTSWIDGETBASE_H
#include <QWidget>
#include "gui/entry/EntryModel.h"
#include "gui/reports/ProxyModels.h"
class Database;
class Entry;
class Group;
class PasswordHealth;
class QSortFilterProxyModel;
class QStandardItemModel;
class QMenu;
class QTableView;
/**
* @brief The ReportsWidgetBase class implements functionality common across the various
* database report widgets.
*/
class ReportsWidgetBase : public QWidget
{
Q_OBJECT
public:
explicit ReportsWidgetBase(QWidget* parent, SortProxyModelKind);
virtual ~ReportsWidgetBase();
virtual void loadSettings(QSharedPointer<Database> db);
virtual void saveSettings();
protected:
virtual QTableView* getTableView() const = 0;
virtual void updateWidget() = 0;
QMenu* customMenuRequestedBase();
public slots:
QList<Entry*> getSelectedEntries() const;
void expireSelectedEntries();
void deleteSelectedEntries();
void emitEntryActivated(const QModelIndex& index);
signals:
void entryActivated(Entry*);
protected:
bool m_widgetDataCalculated = false;
QScopedPointer<QStandardItemModel> m_referencesModel;
QScopedPointer<QSortFilterProxyModel> m_modelProxy;
QSharedPointer<Database> m_db;
QList<QPair<Group*, Entry*>> m_rowToEntry;
};
#endif // KEEPASSXC_REPORTSWIDGETBASE_H

View file

@ -50,7 +50,7 @@ namespace
, entry(e)
, hasUrls(hU)
, hasSettings(hS)
, exclude(e->excludeFromReports())
, exclude(e->excludeFromReports() || g->excludeFromReports())
{
}
};
@ -92,10 +92,8 @@ BrowserStatistics::BrowserStatistics(QSharedPointer<Database> db)
}
ReportsWidgetBrowserStatistics::ReportsWidgetBrowserStatistics(QWidget* parent)
: QWidget(parent)
: ReportsWidgetBase(parent, SortProxyModelKind::Default)
, m_ui(new Ui::ReportsWidgetBrowserStatistics())
, m_referencesModel(new QStandardItemModel(this))
, m_modelProxy(new QSortFilterProxyModel(this))
{
m_ui->setupUi(this);
@ -141,7 +139,11 @@ void ReportsWidgetBrowserStatistics::addStatisticsRow(bool hasUrls,
auto title = entry->title();
if (excluded) {
title.append(tr(" (Excluded)"));
if (group->excludeFromReports()) {
title.append(tr(" (Group Excluded)"));
} else {
title.append(tr(" (Excluded)"));
}
}
if (entry->isExpired()) {
title.append(tr(" (Expired)"));
@ -159,7 +161,11 @@ void ReportsWidgetBrowserStatistics::addStatisticsRow(bool hasUrls,
row[3]->setToolTip(allowedUrlsToolTip);
row[4]->setToolTip(deniedUrlsToolTip);
if (excluded) {
row[0]->setToolTip(tr("This entry is being excluded from reports"));
if (group->excludeFromReports()) {
row[0]->setToolTip(tr("The group for this entry is being excluded from reports"));
} else {
row[0]->setToolTip(tr("This entry is being excluded from reports"));
}
}
// Store entry pointer per table row (used in double click handler)
@ -167,25 +173,13 @@ void ReportsWidgetBrowserStatistics::addStatisticsRow(bool hasUrls,
m_rowToEntry.append({group, entry});
}
void ReportsWidgetBrowserStatistics::loadSettings(QSharedPointer<Database> db)
{
m_db = std::move(db);
m_statisticsCalculated = false;
m_referencesModel->clear();
m_rowToEntry.clear();
auto row = QList<QStandardItem*>();
row << new QStandardItem(tr("Please wait, browser statistics is being calculated…"));
m_referencesModel->appendRow(row);
}
void ReportsWidgetBrowserStatistics::showEvent(QShowEvent* event)
{
QWidget::showEvent(event);
if (!m_statisticsCalculated) {
if (!m_widgetDataCalculated) {
// Perform stats calculation on next event loop to allow widget to appear
m_statisticsCalculated = true;
m_widgetDataCalculated = true;
QTimer::singleShot(0, this, SLOT(calculateBrowserStatistics()));
}
}
@ -238,134 +232,30 @@ void ReportsWidgetBrowserStatistics::calculateBrowserStatistics()
m_ui->browserStatisticsTableView->resizeColumnsToContents();
}
void ReportsWidgetBrowserStatistics::emitEntryActivated(const QModelIndex& index)
{
if (!index.isValid()) {
return;
}
auto mappedIndex = m_modelProxy->mapToSource(index);
const auto row = m_rowToEntry[mappedIndex.row()];
const auto group = row.first;
const auto entry = row.second;
if (group && entry) {
emit entryActivated(const_cast<Entry*>(entry));
}
}
void ReportsWidgetBrowserStatistics::customMenuRequested(QPoint pos)
{
auto selected = m_ui->browserStatisticsTableView->selectionModel()->selectedRows();
if (selected.isEmpty()) {
auto menu = customMenuRequestedBase();
if (!menu) {
return;
}
// Create the context menu
const auto menu = new QMenu(this);
// Create the "edit entry" menu item (only if 1 row is selected)
if (selected.size() == 1) {
const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this);
menu->addAction(edit);
connect(edit, &QAction::triggered, edit, [this, selected] {
auto row = m_modelProxy->mapToSource(selected[0]).row();
auto entry = m_rowToEntry[row].second;
emit entryActivated(entry);
});
}
// Create the "expire entry" menu item
const auto expEntry = new QAction(icons()->icon("entry-expire"), tr("Expire Entry(s)…", "", selected.size()), this);
menu->addAction(expEntry);
connect(expEntry, &QAction::triggered, this, &ReportsWidgetBrowserStatistics::expireSelectedEntries);
// Create the "delete entry" menu item
const auto deleteEntry =
new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this);
menu->addAction(deleteEntry);
connect(deleteEntry, &QAction::triggered, this, &ReportsWidgetBrowserStatistics::deleteSelectedEntries);
auto selected = getTableView()->selectionModel()->selectedRows();
// Create the "delete plugin data" menu item
const auto deletePluginData =
new QAction(icons()->icon("entry-delete"), tr("Delete plugin data from Entry(s)…", "", selected.size()), this);
menu->addAction(deletePluginData);
menu->insertAction(menu->actions().at(3),
deletePluginData); // Index 3 is the one after "Delete Entry" so place "Delete plugin" before it
connect(deletePluginData,
&QAction::triggered,
this,
&ReportsWidgetBrowserStatistics::deletePluginDataFromSelectedEntries);
// Create the "exclude from reports" menu item
const auto exclude = new QAction(icons()->icon("reports-exclude"), tr("Exclude from reports"), this);
bool isExcluded = false;
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry && entry->excludeFromReports()) {
// If at least one entry is excluded switch to inclusion
isExcluded = true;
break;
}
}
exclude->setCheckable(true);
exclude->setChecked(isExcluded);
menu->addAction(exclude);
connect(exclude, &QAction::toggled, exclude, [this, selected](bool state) {
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
entry->setExcludeFromReports(state);
}
}
calculateBrowserStatistics();
});
// Show the context menu
menu->popup(m_ui->browserStatisticsTableView->viewport()->mapToGlobal(pos));
}
void ReportsWidgetBrowserStatistics::saveSettings()
{
// Nothing to do - the tab is passive
}
QList<Entry*> ReportsWidgetBrowserStatistics::getSelectedEntries()
{
QList<Entry*> selectedEntries;
for (auto index : m_ui->browserStatisticsTableView->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
selectedEntries << entry;
}
}
return selectedEntries;
}
void ReportsWidgetBrowserStatistics::expireSelectedEntries()
{
for (auto entry : getSelectedEntries()) {
entry->expireNow();
}
calculateBrowserStatistics();
}
void ReportsWidgetBrowserStatistics::deleteSelectedEntries()
{
const auto& selectedEntries = getSelectedEntries();
bool permanent = !m_db->metadata()->recycleBinEnabled();
if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) {
GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent);
}
calculateBrowserStatistics();
}
void ReportsWidgetBrowserStatistics::deletePluginDataFromSelectedEntries()
{
const auto& selectedEntries = getSelectedEntries();
@ -414,16 +304,12 @@ QMap<QString, QStringList> ReportsWidgetBrowserStatistics::getBrowserConfigFromE
return configList;
}
QList<Entry*> ReportsWidgetBrowserStatistics::getSelectedEntries() const
QTableView* ReportsWidgetBrowserStatistics::getTableView() const
{
QList<Entry*> selectedEntries;
for (auto index : m_ui->browserStatisticsTableView->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
selectedEntries << entry;
}
}
return selectedEntries;
return m_ui->browserStatisticsTableView;
}
void ReportsWidgetBrowserStatistics::updateWidget()
{
calculateBrowserStatistics();
}

View file

@ -19,6 +19,7 @@
#define KEEPASSXC_REPORTSWIDGETBROWSERSTATISTICS_H
#include "gui/entry/EntryModel.h"
#include "gui/reports/ReportsWidgetBase.h"
#include <QWidget>
class Database;
@ -33,43 +34,28 @@ namespace Ui
class ReportsWidgetBrowserStatistics;
}
class ReportsWidgetBrowserStatistics : public QWidget
class ReportsWidgetBrowserStatistics : public ReportsWidgetBase
{
Q_OBJECT
public:
explicit ReportsWidgetBrowserStatistics(QWidget* parent = nullptr);
~ReportsWidgetBrowserStatistics() override;
void loadSettings(QSharedPointer<Database> db);
void saveSettings();
protected:
void showEvent(QShowEvent* event) override;
signals:
void entryActivated(Entry*);
void updateWidget() override;
QTableView* getTableView() const override;
public slots:
void calculateBrowserStatistics();
void emitEntryActivated(const QModelIndex& index);
void customMenuRequested(QPoint);
QList<Entry*> getSelectedEntries();
void expireSelectedEntries();
void deleteSelectedEntries();
void deletePluginDataFromSelectedEntries();
private:
void addStatisticsRow(bool hasUrls, bool hasSettings, Group*, Entry*, bool);
QList<Entry*> getSelectedEntries() const;
QMap<QString, QStringList> getBrowserConfigFromEntry(Entry* entry) const;
QScopedPointer<Ui::ReportsWidgetBrowserStatistics> m_ui;
bool m_statisticsCalculated = false;
QScopedPointer<QStandardItemModel> m_referencesModel;
QScopedPointer<QSortFilterProxyModel> m_modelProxy;
QSharedPointer<Database> m_db;
QList<QPair<Group*, Entry*>> m_rowToEntry;
};
#endif // KEEPASSXC_REPORTSWIDGETBROWSERSTATISTICS_H

View file

@ -24,11 +24,11 @@
#include "core/PasswordHealth.h"
#include "gui/GuiTools.h"
#include "gui/Icons.h"
#include "gui/reports/ProxyModels.h"
#include "gui/styles/StateColorPalette.h"
#include <QMenu>
#include <QShortcut>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
namespace
@ -47,7 +47,7 @@ namespace
: group(g)
, entry(e)
, health(h)
, exclude(e->excludeFromReports())
, exclude(e->excludeFromReports() || g->excludeFromReports())
{
}
@ -75,27 +75,6 @@ namespace
QList<QSharedPointer<Item>> m_items;
bool m_anyExcludedEntries = false;
};
class ReportSortProxyModel : public QSortFilterProxyModel
{
public:
ReportSortProxyModel(QObject* parent)
: QSortFilterProxyModel(parent){};
~ReportSortProxyModel() override = default;
protected:
bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
{
// Check if the display data is a number, convert and compare if so
bool ok = false;
int leftInt = sourceModel()->data(left).toString().toInt(&ok);
if (ok) {
return leftInt < sourceModel()->data(right).toString().toInt();
}
// Otherwise use default sorting
return QSortFilterProxyModel::lessThan(left, right);
}
};
} // namespace
Health::Health(QSharedPointer<Database> db)
@ -137,10 +116,8 @@ Health::Health(QSharedPointer<Database> db)
}
ReportsWidgetHealthcheck::ReportsWidgetHealthcheck(QWidget* parent)
: QWidget(parent)
: ReportsWidgetBase(parent, SortProxyModelKind::Healthcheck)
, m_ui(new Ui::ReportsWidgetHealthcheck())
, m_referencesModel(new QStandardItemModel(this))
, m_modelProxy(new ReportSortProxyModel(this))
{
m_ui->setupUi(this);
@ -197,7 +174,11 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer<PasswordHealth> healt
auto title = entry->title();
if (excluded) {
title.append(tr(" (Excluded)"));
if (group->excludeFromReports()) {
title.append(tr(" (Group Excluded)"));
} else {
title.append(tr(" (Excluded)"));
}
}
if (entry->isExpired()) {
title.append(tr(" (Expired)"));
@ -219,7 +200,11 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer<PasswordHealth> healt
// Set tooltips
row[0]->setToolTip(tip);
if (excluded) {
row[1]->setToolTip(tr("This entry is being excluded from reports"));
if (group->excludeFromReports()) {
row[1]->setToolTip(tr("The group for this entry is being excluded from reports"));
} else {
row[1]->setToolTip(tr("This entry is being excluded from reports"));
}
}
row[4]->setToolTip(health->scoreDetails());
@ -230,15 +215,7 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer<PasswordHealth> healt
void ReportsWidgetHealthcheck::loadSettings(QSharedPointer<Database> db)
{
m_db = std::move(db);
m_healthCalculated = false;
m_referencesModel->clear();
m_rowToEntry.clear();
auto row = QList<QStandardItem*>();
row << new QStandardItem(tr("Please wait, health data is being calculated…"));
m_referencesModel->appendRow(row);
// Default sort by first column (health score)
ReportsWidgetBase::loadSettings(db);
m_ui->healthcheckTableView->sortByColumn(0, Qt::AscendingOrder);
}
@ -246,9 +223,9 @@ void ReportsWidgetHealthcheck::showEvent(QShowEvent* event)
{
QWidget::showEvent(event);
if (!m_healthCalculated) {
if (!m_widgetDataCalculated) {
// Perform stats calculation on next event loop to allow widget to appear
m_healthCalculated = true;
m_widgetDataCalculated = true;
QTimer::singleShot(0, this, SLOT(calculateHealth()));
}
}
@ -294,120 +271,28 @@ void ReportsWidgetHealthcheck::calculateHealth()
// Only show the "show excluded" checkbox if there are any excluded entries in the database
m_ui->showExcluded->setVisible(health->anyExcludedEntries());
}
void ReportsWidgetHealthcheck::emitEntryActivated(const QModelIndex& index)
{
if (!index.isValid()) {
return;
}
auto mappedIndex = m_modelProxy->mapToSource(index);
const auto row = m_rowToEntry[mappedIndex.row()];
const auto group = row.first;
const auto entry = row.second;
if (group && entry) {
emit entryActivated(const_cast<Entry*>(entry));
}
emit tablePopulated();
}
void ReportsWidgetHealthcheck::customMenuRequested(QPoint pos)
{
auto selected = m_ui->healthcheckTableView->selectionModel()->selectedRows();
if (selected.isEmpty()) {
auto menu = customMenuRequestedBase();
if (!menu) {
return;
}
// Create the context menu
const auto menu = new QMenu(this);
// Create the "edit entry" menu item (only if 1 row is selected)
if (selected.size() == 1) {
const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this);
menu->addAction(edit);
connect(edit, &QAction::triggered, edit, [this, selected] {
auto row = m_modelProxy->mapToSource(selected[0]).row();
auto entry = m_rowToEntry[row].second;
emit entryActivated(entry);
});
}
// Create the "Expire entry" menu item
const auto expEntry = new QAction(icons()->icon("entry-expire"), tr("Expire Entry(s)…", "", selected.size()), this);
menu->addAction(expEntry);
connect(expEntry, &QAction::triggered, this, &ReportsWidgetHealthcheck::expireSelectedEntries);
// Create the "delete entry" menu item
const auto delEntry = new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this);
menu->addAction(delEntry);
connect(delEntry, &QAction::triggered, this, &ReportsWidgetHealthcheck::deleteSelectedEntries);
// Create the "exclude from reports" menu item
const auto exclude = new QAction(icons()->icon("reports-exclude"), tr("Exclude from reports"), this);
bool isExcluded = false;
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry && entry->excludeFromReports()) {
// If at least one entry is excluded switch to inclusion
isExcluded = true;
break;
}
}
exclude->setCheckable(true);
exclude->setChecked(isExcluded);
menu->addAction(exclude);
connect(exclude, &QAction::toggled, exclude, [this, selected](bool state) {
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
entry->setExcludeFromReports(state);
}
}
calculateHealth();
});
// Show the context menu
menu->popup(m_ui->healthcheckTableView->viewport()->mapToGlobal(pos));
}
void ReportsWidgetHealthcheck::saveSettings()
void ReportsWidgetHealthcheck::updateWidget()
{
// nothing to do - the tab is passive
}
QList<Entry*> ReportsWidgetHealthcheck::getSelectedEntries()
{
QList<Entry*> selectedEntries;
for (auto index : m_ui->healthcheckTableView->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
selectedEntries << entry;
}
}
return selectedEntries;
}
void ReportsWidgetHealthcheck::expireSelectedEntries()
{
for (auto entry : getSelectedEntries()) {
entry->expireNow();
}
calculateHealth();
}
void ReportsWidgetHealthcheck::deleteSelectedEntries()
QTableView* ReportsWidgetHealthcheck::getTableView() const
{
QList<Entry*> selectedEntries = getSelectedEntries();
bool permanent = !m_db->metadata()->recycleBinEnabled();
if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) {
GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent);
}
calculateHealth();
return m_ui->healthcheckTableView;
}

View file

@ -19,6 +19,7 @@
#define KEEPASSXC_REPORTSWIDGETHEALTHCHECK_H
#include "gui/entry/EntryModel.h"
#include "gui/reports/ReportsWidgetBase.h"
#include <QWidget>
class Database;
@ -27,46 +28,38 @@ class Group;
class PasswordHealth;
class QSortFilterProxyModel;
class QStandardItemModel;
class QTableView;
namespace Ui
{
class ReportsWidgetHealthcheck;
}
class ReportsWidgetHealthcheck : public QWidget
class ReportsWidgetHealthcheck : public ReportsWidgetBase
{
Q_OBJECT
public:
explicit ReportsWidgetHealthcheck(QWidget* parent = nullptr);
~ReportsWidgetHealthcheck() override;
void loadSettings(QSharedPointer<Database> db);
void saveSettings();
void loadSettings(QSharedPointer<Database> db) override;
protected:
void showEvent(QShowEvent* event) override;
void updateWidget() override;
QTableView* getTableView() const override;
signals:
void entryActivated(Entry*);
void tablePopulated();
public slots:
void calculateHealth();
void emitEntryActivated(const QModelIndex& index);
void customMenuRequested(QPoint);
QList<Entry*> getSelectedEntries();
void expireSelectedEntries();
void deleteSelectedEntries();
private:
void addHealthRow(QSharedPointer<PasswordHealth>, Group*, Entry*, bool excluded);
QScopedPointer<Ui::ReportsWidgetHealthcheck> m_ui;
bool m_healthCalculated = false;
QScopedPointer<QStandardItemModel> m_referencesModel;
QScopedPointer<QSortFilterProxyModel> m_modelProxy;
QSharedPointer<Database> m_db;
QList<QPair<Group*, Entry*>> m_rowToEntry;
};
#endif // KEEPASSXC_REPORTSWIDGETHEALTHCHECK_H

View file

@ -23,42 +23,17 @@
#include "core/Metadata.h"
#include "gui/GuiTools.h"
#include "gui/Icons.h"
#include "gui/reports/ProxyModels.h"
#include <QMenu>
#include <QShortcut>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
#include <algorithm>
namespace
{
class ReportSortProxyModel : public QSortFilterProxyModel
{
public:
ReportSortProxyModel(QObject* parent)
: QSortFilterProxyModel(parent){};
~ReportSortProxyModel() override = default;
protected:
bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
{
// Sort count column by user data
if (left.column() == 2) {
return sourceModel()->data(left, Qt::UserRole).toInt()
< sourceModel()->data(right, Qt::UserRole).toInt();
}
// Otherwise use default sorting
return QSortFilterProxyModel::lessThan(left, right);
}
};
} // namespace
ReportsWidgetHibp::ReportsWidgetHibp(QWidget* parent)
: QWidget(parent)
: ReportsWidgetBase(parent, SortProxyModelKind::Hibp)
, m_ui(new Ui::ReportsWidgetHibp())
, m_referencesModel(new QStandardItemModel(this))
, m_modelProxy(new ReportSortProxyModel(this))
{
m_ui->setupUi(this);
@ -85,13 +60,6 @@ ReportsWidgetHibp::~ReportsWidgetHibp() = default;
void ReportsWidgetHibp::loadSettings(QSharedPointer<Database> db)
{
// Re-initialize
m_db = std::move(db);
m_referencesModel->clear();
m_pwndPasswords.clear();
m_error.clear();
m_rowToEntry.clear();
m_editedEntry = nullptr;
#ifdef WITH_XC_NETWORKING
m_ui->stackedWidget->setCurrentIndex(0);
m_ui->validationButton->setEnabled(true);
@ -100,6 +68,9 @@ void ReportsWidgetHibp::loadSettings(QSharedPointer<Database> db)
// Compiled without networking, can't do anything
m_ui->stackedWidget->setCurrentIndex(2);
#endif
ReportsWidgetBase::loadSettings(db);
m_referencesModel->clear();
}
/*
@ -153,13 +124,17 @@ void ReportsWidgetHibp::makeHibpTable()
auto title = entry->title();
// Hide entry if excluded unless explicitly requested
if (entry->excludeFromReports()) {
if (entry->excludeFromReports() || entry->group()->excludeFromReports()) {
anyExcluded = true;
if (!showExcluded) {
continue;
}
title.append(tr(" (Excluded)"));
if (group->excludeFromReports()) {
title.append(tr(" (Group Excluded)"));
} else {
title.append(tr(" (Excluded)"));
}
}
auto row = QList<QStandardItem*>();
@ -169,6 +144,8 @@ void ReportsWidgetHibp::makeHibpTable()
if (entry->excludeFromReports()) {
row[1]->setToolTip(tr("This entry is being excluded from reports"));
} else if (entry->group()->excludeFromReports()) {
row[1]->setToolTip(tr("The group for this entry is being excluded from reports"));
}
row[2]->setForeground(red);
@ -176,7 +153,7 @@ void ReportsWidgetHibp::makeHibpTable()
m_referencesModel->appendRow(row);
// Store entry pointer per table row (used in double click handler)
m_rowToEntry.append(entry);
m_rowToEntry.append({group, entry});
}
// If there was an error, append the error message to the table
@ -311,12 +288,12 @@ void ReportsWidgetHibp::emitEntryActivated(const QModelIndex& index)
// Find which database entry was double-clicked
auto mappedIndex = m_modelProxy->mapToSource(index);
const auto entry = m_rowToEntry[mappedIndex.row()];
if (entry) {
if (entry.second) {
// Found it, invoke entry editor
m_editedEntry = entry;
m_editedPassword = entry->password();
m_editedExcluded = entry->excludeFromReports();
emit entryActivated(const_cast<Entry*>(entry));
m_editedEntry = entry.second;
m_editedPassword = entry.second->password();
m_editedExcluded = entry.second->excludeFromReports();
emit entryActivated(const_cast<Entry*>(entry.second));
}
}
@ -355,101 +332,23 @@ void ReportsWidgetHibp::refreshAfterEdit()
void ReportsWidgetHibp::customMenuRequested(QPoint pos)
{
auto selected = m_ui->hibpTableView->selectionModel()->selectedRows();
if (selected.isEmpty()) {
// Create the context menu
const auto menu = customMenuRequestedBase();
if (!menu) {
return;
}
// Create the context menu
const auto menu = new QMenu(this);
// Create the "edit entry" menu item if 1 row is selected
if (selected.size() == 1) {
const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this);
menu->addAction(edit);
connect(edit, &QAction::triggered, edit, [this, selected] {
auto row = m_modelProxy->mapToSource(selected[0]).row();
auto entry = m_rowToEntry[row];
emit entryActivated(entry);
});
}
// Create the "Expire entry" menu item
const auto expEntry = new QAction(icons()->icon("entry-expire"), tr("Expire Entry(s)…", "", selected.size()), this);
menu->addAction(expEntry);
connect(expEntry, &QAction::triggered, this, &ReportsWidgetHibp::expireSelectedEntries);
// Create the "delete entry" menu item
const auto delEntry = new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this);
menu->addAction(delEntry);
connect(delEntry, &QAction::triggered, this, &ReportsWidgetHibp::deleteSelectedEntries);
// Create the "exclude from reports" menu item
const auto exclude = new QAction(icons()->icon("reports-exclude"), tr("Exclude from reports"), this);
bool isExcluded = false;
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row];
if (entry && entry->excludeFromReports()) {
// If at least one entry is excluded switch to inclusion
isExcluded = true;
break;
}
}
exclude->setCheckable(true);
exclude->setChecked(isExcluded);
menu->addAction(exclude);
connect(exclude, &QAction::toggled, exclude, [this, selected](bool state) {
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row];
if (entry) {
entry->setExcludeFromReports(state);
}
}
makeHibpTable();
});
// Show the context menu
menu->popup(m_ui->hibpTableView->viewport()->mapToGlobal(pos));
}
QList<Entry*> ReportsWidgetHibp::getSelectedEntries()
void ReportsWidgetHibp::updateWidget()
{
QList<Entry*> selectedEntries;
for (auto index : m_ui->hibpTableView->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row];
if (entry) {
selectedEntries << entry;
}
}
return selectedEntries;
}
void ReportsWidgetHibp::expireSelectedEntries()
{
for (auto entry : getSelectedEntries()) {
entry->expireNow();
}
makeHibpTable();
}
void ReportsWidgetHibp::deleteSelectedEntries()
QTableView* ReportsWidgetHibp::getTableView() const
{
QList<Entry*> selectedEntries = getSelectedEntries();
bool permanent = !m_db->metadata()->recycleBinEnabled();
if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) {
GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent);
}
makeHibpTable();
}
void ReportsWidgetHibp::saveSettings()
{
// nothing to do - the tab is passive
return m_ui->hibpTableView;
}

View file

@ -20,6 +20,7 @@
#include "config-keepassx.h"
#include "gui/entry/EntryModel.h"
#include "gui/reports/ReportsWidgetBase.h"
#include <QWidget>
@ -32,25 +33,26 @@ class Entry;
class Group;
class QSortFilterProxyModel;
class QStandardItemModel;
class QTableView;
namespace Ui
{
class ReportsWidgetHibp;
}
class ReportsWidgetHibp : public QWidget
class ReportsWidgetHibp : public ReportsWidgetBase
{
Q_OBJECT
public:
explicit ReportsWidgetHibp(QWidget* parent = nullptr);
~ReportsWidgetHibp() override;
void loadSettings(QSharedPointer<Database> db);
void saveSettings();
void loadSettings(QSharedPointer<Database> db) override;
void refreshAfterEdit();
signals:
void entryActivated(Entry*);
protected:
void updateWidget() override;
QTableView* getTableView() const override;
public slots:
void emitEntryActivated(const QModelIndex&);
@ -58,22 +60,15 @@ public slots:
void fetchFailed(const QString& error);
void makeHibpTable();
void customMenuRequested(QPoint);
QList<Entry*> getSelectedEntries();
void expireSelectedEntries();
void deleteSelectedEntries();
private:
void startValidation();
static QString countToText(int count);
QScopedPointer<Ui::ReportsWidgetHibp> m_ui;
QScopedPointer<QStandardItemModel> m_referencesModel;
QScopedPointer<QSortFilterProxyModel> m_modelProxy;
QSharedPointer<Database> m_db;
QMap<QString, int> m_pwndPasswords; // Passwords we found to have been pwned (value is pwn count)
QString m_error; // Error message if download failed, else empty
QList<Entry*> m_rowToEntry; // List index is table row
QPointer<Entry> m_editedEntry; // The entry we're currently editing
QString m_editedPassword; // The old password of the entry we're editing
bool m_editedExcluded; // The old "known bad" flag of the entry we're editing

View file

@ -29,6 +29,7 @@
#include "gui/MessageBox.h"
#include "gui/passkeys/PasskeyExporter.h"
#include "gui/passkeys/PasskeyImporter.h"
#include "gui/reports/ProxyModels.h"
#include "gui/styles/StateColorPalette.h"
#include <QMenu>
@ -87,10 +88,8 @@ PasskeyList::PasskeyList(const QSharedPointer<Database>& db)
}
ReportsWidgetPasskeys::ReportsWidgetPasskeys(QWidget* parent)
: QWidget(parent)
: ReportsWidgetBase(parent, SortProxyModelKind::Default)
, m_ui(new Ui::ReportsWidgetPasskeys())
, m_referencesModel(new QStandardItemModel(this))
, m_modelProxy(new QSortFilterProxyModel(this))
{
m_ui->setupUi(this);
@ -146,25 +145,13 @@ void ReportsWidgetPasskeys::addPasskeyRow(Group* group, Entry* entry)
m_rowToEntry.append({group, entry});
}
void ReportsWidgetPasskeys::loadSettings(QSharedPointer<Database> db)
{
m_db = std::move(db);
m_entriesUpdated = false;
m_referencesModel->clear();
m_rowToEntry.clear();
auto row = QList<QStandardItem*>();
row << new QStandardItem(tr("Please wait, list of entries with passkeys is being updated…"));
m_referencesModel->appendRow(row);
}
void ReportsWidgetPasskeys::showEvent(QShowEvent* event)
{
QWidget::showEvent(event);
if (!m_entriesUpdated) {
if (!m_widgetDataCalculated) {
// Perform stats calculation on next event loop to allow widget to appear
m_entriesUpdated = true;
m_widgetDataCalculated = true;
QTimer::singleShot(0, this, SLOT(updateEntries()));
}
}
@ -200,22 +187,6 @@ void ReportsWidgetPasskeys::updateEntries()
m_ui->passkeysTableView->resizeColumnsToContents();
}
void ReportsWidgetPasskeys::emitEntryActivated(const QModelIndex& index)
{
if (!index.isValid()) {
return;
}
auto mappedIndex = m_modelProxy->mapToSource(index);
const auto row = m_rowToEntry[mappedIndex.row()];
const auto group = row.first;
const auto entry = row.second;
if (group && entry) {
emit entryActivated(entry);
}
}
void ReportsWidgetPasskeys::customMenuRequested(QPoint pos)
{
auto selected = m_ui->passkeysTableView->selectionModel()->selectedRows();
@ -246,37 +217,6 @@ void ReportsWidgetPasskeys::customMenuRequested(QPoint pos)
menu->popup(m_ui->passkeysTableView->viewport()->mapToGlobal(pos));
}
void ReportsWidgetPasskeys::saveSettings()
{
// Nothing to do - the tab is passive
}
void ReportsWidgetPasskeys::deleteSelectedEntries()
{
auto selectedEntries = getSelectedEntries();
bool permanent = !m_db->metadata()->recycleBinEnabled();
if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) {
GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent);
}
updateEntries();
}
QList<Entry*> ReportsWidgetPasskeys::getSelectedEntries()
{
QList<Entry*> selectedEntries;
for (auto index : m_ui->passkeysTableView->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
selectedEntries << entry;
}
}
return selectedEntries;
}
void ReportsWidgetPasskeys::selectionChanged()
{
m_ui->exportButton->setEnabled(!m_ui->passkeysTableView->selectionModel()->selectedIndexes().isEmpty());
@ -305,3 +245,13 @@ void ReportsWidgetPasskeys::exportPasskey()
PasskeyExporter passkeyExporter(this);
passkeyExporter.showExportDialog(getSelectedEntries());
}
QTableView* ReportsWidgetPasskeys::getTableView() const
{
return m_ui->passkeysTableView;
}
void ReportsWidgetPasskeys::updateWidget()
{
updateEntries();
}

View file

@ -19,6 +19,7 @@
#define KEEPASSXC_REPORTSWIDGETPASSKEYS_H
#include "gui/entry/EntryModel.h"
#include "gui/reports/ReportsWidgetBase.h"
#include <QWidget>
class Database;
@ -33,27 +34,21 @@ namespace Ui
class ReportsWidgetPasskeys;
}
class ReportsWidgetPasskeys : public QWidget
class ReportsWidgetPasskeys : public ReportsWidgetBase
{
Q_OBJECT
public:
explicit ReportsWidgetPasskeys(QWidget* parent = nullptr);
~ReportsWidgetPasskeys() override;
void loadSettings(QSharedPointer<Database> db);
void saveSettings();
protected:
void showEvent(QShowEvent* event) override;
signals:
void entryActivated(Entry*);
void updateWidget() override;
QTableView* getTableView() const override;
public slots:
void updateEntries();
void emitEntryActivated(const QModelIndex& index);
void customMenuRequested(QPoint);
void deleteSelectedEntries();
private slots:
void selectionChanged();
@ -62,15 +57,8 @@ private slots:
private:
void addPasskeyRow(Group*, Entry*);
QList<Entry*> getSelectedEntries();
QScopedPointer<Ui::ReportsWidgetPasskeys> m_ui;
bool m_entriesUpdated = false;
QScopedPointer<QStandardItemModel> m_referencesModel;
QScopedPointer<QSortFilterProxyModel> m_modelProxy;
QSharedPointer<Database> m_db;
QList<QPair<Group*, Entry*>> m_rowToEntry;
};
#endif // KEEPASSXC_REPORTSWIDGETPASSKEYS_H

View file

@ -121,6 +121,11 @@ void ReportsWidgetStatistics::calculateStats()
stats->excludedEntries > 0,
tr("Excluding entries from reports, e. g. because they are known to have a poor password, isn't "
"necessarily a problem but you should keep an eye on them."));
addStatsRow(tr("Groups excluded from reports"),
QString::number(stats->excludedGroups),
stats->excludedGroups > 0,
tr("Excluding entire groups from reports isn't necessarily a problem but please exercise caution "
"when excluding entire groups."));
addStatsRow(tr("Average password length"),
tr("%1 character(s)", "", stats->averagePwdLength()).arg(stats->averagePwdLength()),
stats->isAvgPwdTooShort(),

View file

@ -62,6 +62,8 @@
#include "gui/group/GroupModel.h"
#include "gui/group/GroupView.h"
#include "gui/remote/RemoteHandler.h"
#include "gui/reports/ReportsDialog.h"
#include "gui/reports/ReportsWidgetHealthcheck.h"
#include "gui/tag/TagsEdit.h"
#include "gui/wizard/NewDatabaseWizard.h"
#include "keys/FileKey.h"
@ -2472,6 +2474,349 @@ void TestGui::testMenuActionStates()
QVERIFY(isActionEnabled("actionPasswordGenerator"));
}
void TestGui::testDatabaseReports()
{
addGroup("Finance");
addGroup("Entertainment");
addEntry("Finance", "Chase", "user1", "password");
addEntry("Finance", "Amex", "user1", "password123");
addEntry("Finance", "Capital One", "user1", "password456");
addEntry("Entertainment", "Netflix", "user1", "password");
addEntry("Entertainment", "Hulu", "user1", "password321");
addEntry("Entertainment", "Apple TV", "user1", "password123");
Group* entertainmentGroup = m_dbWidget->currentGroup()->findChildByName("Entertainment");
QCOMPARE(entertainmentGroup->entries().size(), 3);
Group* financeGroup = m_dbWidget->currentGroup()->findChildByName("Finance");
QCOMPARE(financeGroup->entries().size(), 3);
auto* actionReports = m_mainWindow->findChild<QAction*>("actionReports");
QVERIFY(actionReports->isEnabled());
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QVERIFY(toolBar);
QWidget* actionReportsWidget = toolBar->widgetForAction(actionReports);
QVERIFY(actionReportsWidget);
QVERIFY(actionReportsWidget->isVisible());
QVERIFY(actionReportsWidget->isEnabled());
QTest::mouseClick(actionReportsWidget, Qt::LeftButton);
auto* reportsDialog = m_dbWidget->findChild<ReportsDialog*>("reportsDialog");
QVERIFY(reportsDialog);
CategoryListWidget* categoryList = reportsDialog->findChild<CategoryListWidget*>("categoryList");
categoryList->setCurrentCategory(1);
QStackedWidget* stackedWidget = reportsDialog->findChild<QStackedWidget*>("stackedWidget");
QVERIFY(stackedWidget);
stackedWidget->setCurrentIndex(1);
ReportsWidgetHealthcheck* healthCheckWidget = reportsDialog->findChild<ReportsWidgetHealthcheck*>();
QVERIFY(healthCheckWidget);
QTest::mouseClick(healthCheckWidget, Qt::LeftButton);
QTableView* healthTable = healthCheckWidget->findChild<QTableView*>("healthcheckTableView");
QVERIFY(healthTable);
QSignalSpy healthCheckWidgetSpy(healthCheckWidget, &ReportsWidgetHealthcheck::tablePopulated);
QAbstractItemModel* healthModel = healthTable->model();
QVERIFY(healthModel);
QTRY_COMPARE(healthCheckWidgetSpy.count(), 1);
QCOMPARE(healthModel->rowCount(), 8); // account for 2 existing passwords at the start of each test case
auto* reportsDialogButtonBox = reportsDialog->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(reportsDialogButtonBox->button(QDialogButtonBox::Close), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
}
void TestGui::testExcludedGroupEntryInReports()
{
addGroup("Finance");
addGroup("Entertainment");
// Use bad passwords to make sure they all show up in health report
addEntry("Finance", "Chase", "user1", "password");
addEntry("Finance", "Amex", "user1", "password123");
addEntry("Finance", "Capital One", "user1", "password456");
addEntry("Entertainment", "Netflix", "user1", "password");
addEntry("Entertainment", "Hulu", "user1", "password321");
addEntry("Entertainment", "Apple TV", "user1", "password123");
Group* entertainmentGroup = m_dbWidget->currentGroup()->findChildByName("Entertainment");
m_dbWidget->groupView()->setCurrentGroup(entertainmentGroup);
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QVERIFY(toolBar);
auto* editGroupAction = m_mainWindow->findChild<QAction*>("actionGroupEdit");
QVERIFY(editGroupAction->isEnabled());
triggerAction("actionGroupEdit");
auto* editGroupWidget = m_dbWidget->findChild<EditGroupWidget*>("editGroupWidget");
QVERIFY(editGroupWidget);
// Bring up group edit page
QTest::mouseClick(editGroupWidget, Qt::LeftButton);
QLineEdit* nameEdit = editGroupWidget->findChild<QLineEdit*>("editName");
QCOMPARE(nameEdit->text(), QString("Entertainment"));
// Find database report exclusion checkbox and check it
QCheckBox* excludeGroupFromReportsCheckbox = editGroupWidget->findChild<QCheckBox*>("excludeReportsCheckBox");
QVERIFY(excludeGroupFromReportsCheckbox);
excludeGroupFromReportsCheckbox->setChecked(true);
auto* editGroupWidgetButtonBox = editGroupWidget->findChild<QDialogButtonBox*>("buttonBox");
QVERIFY(editGroupWidgetButtonBox);
// Apply and go back to main view
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Apply), Qt::LeftButton);
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
QVERIFY(entertainmentGroup->excludeFromReports());
// Verify they don't show up in the report
auto* actionReports = m_mainWindow->findChild<QAction*>("actionReports");
QVERIFY(actionReports->isEnabled());
QWidget* actionReportsWidget = toolBar->widgetForAction(actionReports);
QVERIFY(actionReportsWidget);
QVERIFY(actionReportsWidget->isVisible());
QVERIFY(actionReportsWidget->isEnabled());
QTest::mouseClick(actionReportsWidget, Qt::LeftButton);
auto* reportsDialog = m_dbWidget->findChild<ReportsDialog*>("reportsDialog");
QVERIFY(reportsDialog);
CategoryListWidget* categoryList = reportsDialog->findChild<CategoryListWidget*>("categoryList");
categoryList->setCurrentCategory(1);
QStackedWidget* stackedWidget = reportsDialog->findChild<QStackedWidget*>("stackedWidget");
QVERIFY(stackedWidget);
stackedWidget->setCurrentIndex(1);
ReportsWidgetHealthcheck* healthCheckWidget = reportsDialog->findChild<ReportsWidgetHealthcheck*>();
QVERIFY(healthCheckWidget);
QTest::mouseClick(healthCheckWidget, Qt::LeftButton);
QTableView* healthTable = healthCheckWidget->findChild<QTableView*>("healthcheckTableView");
QVERIFY(healthTable);
QSignalSpy healthCheckWidgetSpy(healthCheckWidget, &ReportsWidgetHealthcheck::tablePopulated);
QAbstractItemModel* healthModel = healthTable->model();
QVERIFY(healthModel);
// There should be 3 showing
QTRY_COMPARE(healthCheckWidgetSpy.count(), 1);
QCOMPARE(healthModel->rowCount(), 5); // account for 2 existing passwords at the start of each test case
QCheckBox* showExcludedCheckBox = healthCheckWidget->findChild<QCheckBox*>("showExcluded");
QVERIFY(showExcludedCheckBox);
QCOMPARE(showExcludedCheckBox->isChecked(), false);
showExcludedCheckBox->click();
QVERIFY(showExcludedCheckBox->isChecked());
QTRY_COMPARE(healthCheckWidgetSpy.count(), 2);
healthModel = healthTable->model();
QCOMPARE(healthModel->rowCount(), 8); // account for 2 existing passwords at the start of each test case
for (int i = 0; i < healthModel->rowCount(); ++i) {
QModelIndex index = healthModel->index(i, 1);
QVariant data = healthModel->data(index);
if (data.toString().contains("Netflix")) {
auto rect = healthTable->visualRect(index);
auto centerPoint = rect.center();
QTest::mouseClick(healthTable->viewport(), Qt::LeftButton, Qt::NoModifier, centerPoint);
healthCheckWidget->customMenuRequested(centerPoint);
QMenu* menu = healthCheckWidget->findChild<QMenu*>("customMenu");
QVERIFY(menu);
QAction* excludeEntryAction = healthCheckWidget->findChild<QAction*>("contextMenuExcludeAction");
QVERIFY(excludeEntryAction);
MessageBox::setNextAnswer(MessageBox::No);
excludeEntryAction->trigger();
QApplication::processEvents();
break;
}
}
QTRY_COMPARE(healthCheckWidgetSpy.count(), 3);
for (int i = 0; i < healthModel->rowCount(); ++i) {
QModelIndex index = healthModel->index(i, 1);
QVariant data = healthModel->data(index);
if (data.toString().contains("Netflix")) {
QVERIFY(!data.toString().contains("(Group Excluded)"));
break;
}
}
showExcludedCheckBox->click();
QVERIFY(!showExcludedCheckBox->isChecked());
QTRY_COMPARE(healthCheckWidgetSpy.count(), 4);
QCOMPARE(healthModel->rowCount(), 6); // 2 existing passwords from start, 3 from Finance, 1 from Entertainment
auto* reportsDialogButtonBox = reportsDialog->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(reportsDialogButtonBox->button(QDialogButtonBox::Close), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
}
void TestGui::testExcludedGroupReincludeAllEntries()
{
addGroup("Finance");
addGroup("Entertainment");
// Use bad passwords to make sure they all show up in health report
addEntry("Finance", "Chase", "user1", "password");
addEntry("Finance", "Amex", "user1", "password123");
addEntry("Finance", "Capital One", "user1", "password456");
addEntry("Entertainment", "Netflix", "user1", "password");
addEntry("Entertainment", "Hulu", "user1", "password321");
addEntry("Entertainment", "Apple TV", "user1", "password123");
Group* entertainmentGroup = m_dbWidget->currentGroup()->findChildByName("Entertainment");
m_dbWidget->groupView()->setCurrentGroup(entertainmentGroup);
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QVERIFY(toolBar);
auto* editGroupAction = m_mainWindow->findChild<QAction*>("actionGroupEdit");
QVERIFY(editGroupAction->isEnabled());
triggerAction("actionGroupEdit");
auto* editGroupWidget = m_dbWidget->findChild<EditGroupWidget*>("editGroupWidget");
QVERIFY(editGroupWidget);
// Bring up group edit page
QTest::mouseClick(editGroupWidget, Qt::LeftButton);
QLineEdit* nameEdit = editGroupWidget->findChild<QLineEdit*>("editName");
QCOMPARE(nameEdit->text(), QString("Entertainment"));
// Find database report exclusion checkbox and check it
QCheckBox* excludeGroupFromReportsCheckbox = editGroupWidget->findChild<QCheckBox*>("excludeReportsCheckBox");
QVERIFY(excludeGroupFromReportsCheckbox);
excludeGroupFromReportsCheckbox->setChecked(true);
auto* editGroupWidgetButtonBox = editGroupWidget->findChild<QDialogButtonBox*>("buttonBox");
QVERIFY(editGroupWidgetButtonBox);
// Apply and go back to main view
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Apply), Qt::LeftButton);
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
QVERIFY(entertainmentGroup->excludeFromReports());
// Verify they don't show up in the report
auto* actionReports = m_mainWindow->findChild<QAction*>("actionReports");
QVERIFY(actionReports->isEnabled());
QWidget* actionReportsWidget = toolBar->widgetForAction(actionReports);
QVERIFY(actionReportsWidget);
QVERIFY(actionReportsWidget->isVisible());
QVERIFY(actionReportsWidget->isEnabled());
QTest::mouseClick(actionReportsWidget, Qt::LeftButton);
auto* reportsDialog = m_dbWidget->findChild<ReportsDialog*>("reportsDialog");
QVERIFY(reportsDialog);
CategoryListWidget* categoryList = reportsDialog->findChild<CategoryListWidget*>("categoryList");
categoryList->setCurrentCategory(1);
QStackedWidget* stackedWidget = reportsDialog->findChild<QStackedWidget*>("stackedWidget");
QVERIFY(stackedWidget);
stackedWidget->setCurrentIndex(1);
ReportsWidgetHealthcheck* healthCheckWidget = reportsDialog->findChild<ReportsWidgetHealthcheck*>();
QVERIFY(healthCheckWidget);
QTest::mouseClick(healthCheckWidget, Qt::LeftButton);
QTableView* healthTable = healthCheckWidget->findChild<QTableView*>("healthcheckTableView");
QVERIFY(healthTable);
QSignalSpy healthCheckWidgetSpy(healthCheckWidget, &ReportsWidgetHealthcheck::tablePopulated);
QAbstractItemModel* healthModel = healthTable->model();
QVERIFY(healthModel);
// There should be 3 showing
QTRY_COMPARE(healthCheckWidgetSpy.count(), 1);
QCOMPARE(healthModel->rowCount(), 5); // account for 2 existing passwords at the start of each test case
QCheckBox* showExcludedCheckBox = healthCheckWidget->findChild<QCheckBox*>("showExcluded");
QVERIFY(showExcludedCheckBox);
QCOMPARE(showExcludedCheckBox->isChecked(), false);
showExcludedCheckBox->click();
QVERIFY(showExcludedCheckBox->isChecked());
QTRY_COMPARE(healthCheckWidgetSpy.count(), 2);
healthModel = healthTable->model();
QCOMPARE(healthModel->rowCount(), 8); // account for 2 existing passwords at the start of each test case
for (int i = 0; i < healthModel->rowCount(); ++i) {
QModelIndex index = healthModel->index(i, 1);
QVariant data = healthModel->data(index);
if (data.toString().contains("Netflix")) {
auto rect = healthTable->visualRect(index);
auto centerPoint = rect.center();
QTest::mouseClick(healthTable->viewport(), Qt::LeftButton, Qt::NoModifier, centerPoint);
healthCheckWidget->customMenuRequested(centerPoint);
QMenu* menu = healthCheckWidget->findChild<QMenu*>("customMenu");
QVERIFY(menu);
QAction* excludeEntryAction = healthCheckWidget->findChild<QAction*>("contextMenuExcludeAction");
QVERIFY(excludeEntryAction);
MessageBox::setNextAnswer(MessageBox::Yes);
excludeEntryAction->trigger();
QApplication::processEvents();
break;
}
}
QTRY_COMPARE(healthCheckWidgetSpy.count(), 3);
for (int i = 0; i < healthModel->rowCount(); ++i) {
QModelIndex index = healthModel->index(i, 1);
QVariant data = healthModel->data(index);
QVERIFY(!data.toString().contains("(Group Excluded)"));
}
showExcludedCheckBox->click();
QVERIFY(!showExcludedCheckBox->isChecked());
QTRY_COMPARE(healthCheckWidgetSpy.count(), 4);
QCOMPARE(healthModel->rowCount(), 8); // 2 existing passwords from start, 3 from Finance, 3 from Entertainment
auto* reportsDialogButtonBox = reportsDialog->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(reportsDialogButtonBox->button(QDialogButtonBox::Close), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
}
void TestGui::addCannedEntries()
{
// Find buttons
@ -2502,6 +2847,51 @@ void TestGui::addCannedEntries()
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
}
void TestGui::addGroup(const QString& name)
{
// Find buttons for group creation
auto* editGroupWidget = m_dbWidget->findChild<EditGroupWidget*>("editGroupWidget");
auto* nameEdit = editGroupWidget->findChild<QLineEdit*>("editName");
auto* editGroupWidgetButtonBox = editGroupWidget->findChild<QDialogButtonBox*>("buttonBox");
// Add group with specified name
Group* rootGroup = m_db->rootGroup();
m_dbWidget->groupView()->setCurrentGroup(rootGroup); // Add group on root level
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, name);
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(rootGroup); // Reset to root level
}
void TestGui::addEntry(const QString& groupName, const QString& title, const QString& username, const QString& password)
{
// Find buttons
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QWidget* entryNewWidget = toolBar->widgetForAction(m_mainWindow->findChild<QAction*>("actionEntryNew"));
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
auto* usernameComboBox = editEntryWidget->findChild<QComboBox*>("usernameComboBox");
auto* passwordEdit =
editEntryWidget->findChild<PasswordWidget*>("passwordEdit")->findChild<QLineEdit*>("passwordEdit");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
// Add entry to specified group
QVERIFY(m_dbWidget->currentGroup());
Group* group = m_dbWidget->currentGroup()->findChildByName(groupName);
m_dbWidget->groupView()->setCurrentGroup(group);
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditEntryMode);
QTest::keyClicks(titleEdit, title);
QTest::keyClicks(usernameComboBox, username);
QTest::keyClicks(passwordEdit, password);
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
m_dbWidget->groupView()->setCurrentGroup(m_db->rootGroup());
}
void TestGui::checkDatabase(const QString& filePath, const QString& expectedDbName)
{
auto key = QSharedPointer<CompositeKey>::create();

View file

@ -71,9 +71,14 @@ private slots:
void testTrayRestoreHide();
void testShortcutConfig();
void testMenuActionStates();
void testDatabaseReports();
void testExcludedGroupEntryInReports();
void testExcludedGroupReincludeAllEntries();
private:
void addCannedEntries();
void addGroup(const QString& name);
void addEntry(const QString& groupName, const QString& title, const QString& username, const QString& password);
void checkDatabase(const QString& filePath, const QString& expectedDbName);
void checkDatabase(const QString& filePath = {});
void triggerAction(const QString& name);