Add a Delete action to the applications list and withdraw the app policy
The Applications page had no delete control even though the endpoint and the API client both already existed, so the gap was UI-only. The button is gated on applications.delete via hasPermission rather than on the admin role, mirroring the backend guard: admins bypass it anyway, and a non-admin role granted the permission should get the button instead of a control that would only 403. Deleting also withdraws the generated install policy. Delete here is a soft delete, and the policy is a separate row that did not follow, so a deactivated application kept installing itself across the fleet with nothing on screen to explain why. A customised policy is still left alone, on the same ownership terms as unticking the box.
This commit is contained in:
@@ -73,6 +73,13 @@
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
<button
|
||||
v-if="candelete"
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="deleteApplication(app)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="applications.length === 0">
|
||||
@@ -98,14 +105,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { applicationsApi } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import { fileHref } from '@/utils/basePath'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Mirrors the backend guard on the endpoint rather than checking for the admin
|
||||
// role, so a non-admin role granted applications.delete gets the button too and
|
||||
// nobody is shown a control that would only 403.
|
||||
const authStore = useAuthStore()
|
||||
const candelete = computed(() => authStore.hasPermission('applications.delete'))
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
|
||||
const filter = ref('installable')
|
||||
const totalPages = ref(1)
|
||||
@@ -164,6 +178,27 @@ function debouncedSearch() {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// The server deactivates rather than drops the row, so this is reversible and
|
||||
// the prompt does not claim otherwise. It does warn about the fleet-wide
|
||||
// consequence, which is the part that is not obvious from a Delete button.
|
||||
async function deleteApplication(app) {
|
||||
const warning = app.isrequired
|
||||
? `\n\n"${app.appname}" is currently required on all PCs. Removing it also `
|
||||
+ 'withdraws the policy that installs it.'
|
||||
: ''
|
||||
|
||||
if (!confirm(`Remove "${app.appname}" from the catalogue?${warning}`)) return
|
||||
|
||||
try {
|
||||
await applicationsApi.delete(app.appid)
|
||||
loadApplications()
|
||||
} catch (error) {
|
||||
console.error('Error deleting application:', error)
|
||||
alert(error.response?.data?.data?.error?.message
|
||||
|| 'Failed to remove the application.')
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadApplications()
|
||||
|
||||
@@ -411,6 +411,13 @@ def delete_application(app_id: int):
|
||||
|
||||
app.isactive = False
|
||||
|
||||
# Withdraw the generated policy too. Deactivating an application while its
|
||||
# policy still runs would keep installing something the catalogue says is
|
||||
# gone, and nothing in the UI would explain why. A customised policy is
|
||||
# left alone, same as unticking the box.
|
||||
app.isrequired = False
|
||||
_sync_required_app_policy(app)
|
||||
|
||||
AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
98
tests/test_core/test_application_delete.py
Normal file
98
tests/test_core/test_application_delete.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for deactivating an application and what happens to its policy.
|
||||
|
||||
Delete here is a soft delete: the row is deactivated, not dropped. That is easy
|
||||
to forget, because the generated install policy is a separate row that does NOT
|
||||
follow automatically. Left alone it would keep installing an application the
|
||||
catalogue reports as gone, and nothing on screen would explain why.
|
||||
|
||||
So the deactivation withdraws the policy as well, on the same ownership terms as
|
||||
unticking the box: ours to remove, theirs to keep.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
from plugins.computers.models import (ConfigurationPolicy, POLICY_SOURCE_LOCAL,
|
||||
POLICY_SOURCE_AUTOAPP)
|
||||
from plugins.computers.services.apppolicy import auto_policy_name
|
||||
|
||||
|
||||
def _required_app(client, auth_headers, name='Fleet App'):
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': name, 'isrequired': True,
|
||||
'acknowledgepolicy': True},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201, response.get_json()
|
||||
return Application.query.filter_by(appname=name).first()
|
||||
|
||||
|
||||
def test_deleting_an_application_deactivates_rather_than_drops_it(
|
||||
client, db, auth_headers):
|
||||
app = Application(appname='Plain App')
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
appid = app.appid
|
||||
|
||||
response = client.delete(f'/api/applications/{appid}', headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert db.session.get(Application, appid).isactive is False
|
||||
|
||||
|
||||
def test_deleting_a_required_application_withdraws_its_policy(
|
||||
client, db, auth_headers):
|
||||
app = _required_app(client, auth_headers)
|
||||
assert ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first() is not None
|
||||
|
||||
response = client.delete(f'/api/applications/{app.appid}',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
# The policy must not outlive the application it installs.
|
||||
assert ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first() is None
|
||||
|
||||
|
||||
def test_deleting_an_application_keeps_a_customised_policy(
|
||||
client, db, auth_headers):
|
||||
# Same rule as unticking the box: once an admin has edited the policy it is
|
||||
# theirs, and a delete on another screen must not discard their work.
|
||||
app = _required_app(client, auth_headers)
|
||||
policy = ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first()
|
||||
policy.source = POLICY_SOURCE_LOCAL
|
||||
db.session.commit()
|
||||
|
||||
response = client.delete(f'/api/applications/{app.appid}',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
survivor = ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first()
|
||||
assert survivor is not None
|
||||
assert survivor.source == POLICY_SOURCE_LOCAL
|
||||
|
||||
|
||||
def test_deleting_clears_the_required_flag(client, db, auth_headers):
|
||||
# Otherwise reactivating the application later would show it as required
|
||||
# while no policy enforces it.
|
||||
app = _required_app(client, auth_headers)
|
||||
|
||||
client.delete(f'/api/applications/{app.appid}', headers=auth_headers)
|
||||
|
||||
assert db.session.get(Application, app.appid).isrequired is False
|
||||
|
||||
|
||||
def test_delete_requires_the_delete_permission(client, db, auth_headers,
|
||||
member_headers):
|
||||
# Authenticated but unprivileged. The button is hidden for this user in the
|
||||
# UI; this pins that the endpoint refuses them regardless.
|
||||
app = Application(appname='Plain App')
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
|
||||
response = client.delete(f'/api/applications/{app.appid}',
|
||||
headers=member_headers)
|
||||
|
||||
assert response.status_code in (401, 403), response.get_json()
|
||||
assert db.session.get(Application, app.appid).isactive is not False
|
||||
Reference in New Issue
Block a user