Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,24 +119,23 @@
</VFlex>
<VFlex
xs12
sm6
md3
class="align-center d-flex px-3"
md6
class="px-3 toggle-filters"
>
<Checkbox
v-model="hasPublishedFilter"
label="Has published a channel"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="align-center d-flex px-3"
>
<Checkbox
v-model="hasEditsFilter"
label="Has Studio edits"
label="Has Studio activity"
/>
<KButton
appearance="basic-link"
text="Clear filters"
data-test="clear-filters"
:disabled="!hasActiveFilters"
@click="clearFilters"
/>
</VFlex>
</VLayout>
Expand Down Expand Up @@ -208,15 +207,18 @@

import { ref, onMounted, computed, getCurrentInstance } from 'vue';
import { mapGetters } from 'vuex';
import pick from 'lodash/pick';
import transform from 'lodash/transform';
import { saveAs } from 'file-saver';
import { useRoute } from 'vue-router/composables';
import { useTable } from '../../composables/useTable';
import { RouteNames, rowsPerPageItems } from '../../constants';
import EmailUsersDialog from './EmailUsersDialog';
import UserItem from './UserItem';
import client from 'shared/client';
import { useFilter } from 'shared/composables/useFilter';
import { useKeywordSearch } from 'shared/composables/useKeywordSearch';
import { useQueryParams } from 'shared/composables/useQueryParams';
import { routerMixin } from 'shared/mixins';
import IconButton from 'shared/views/IconButton';
import Checkbox from 'shared/views/form/Checkbox';
Expand All @@ -230,6 +232,19 @@
sushichef: { label: 'Sushi chef', params: { chef: true } },
};

const TABLE_STATE_QUERY_PARAMS = ['page', 'page_size', 'sortBy', 'descending'];

// Mirrors the defaultValue each filter below declares.
const FILTER_DEFAULTS = {
userType: undefined,
location: undefined,
keywords: undefined,
joinedWithin: 'any',
activeWithin: 'any',
hasPublished: 'no',
hasEdits: 'no',
};

const DATE_WINDOWS = [
{ key: 'any', label: 'Any time', months: null },
{ key: '1mo', label: 'Last month', months: 1 },
Expand Down Expand Up @@ -301,6 +316,8 @@
setup() {
const { proxy } = getCurrentInstance();
const store = proxy.$store;
const route = useRoute();
const { updateQueryParams } = useQueryParams();

const {
filter: _userTypeFilter,
Expand Down Expand Up @@ -368,7 +385,7 @@
const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } =
useBooleanFilter({
name: 'hasEdits',
label: 'Has Studio edits',
label: 'Has Studio activity',
paramName: 'has_edits',
});

Expand Down Expand Up @@ -401,6 +418,16 @@
};
});

const hasActiveFilters = computed(() =>
Object.entries(FILTER_DEFAULTS).some(
([name, defaultValue]) => (route.query[name] ?? defaultValue) !== defaultValue,
),
);

function clearFilters() {
updateQueryParams(pick(route.query, TABLE_STATE_QUERY_PARAMS));
}

function loadUsers(fetchParams) {
return store.dispatch('userAdmin/loadUsers', fetchParams);
}
Expand All @@ -424,6 +451,8 @@
activeWithinOptions,
hasPublishedFilter,
hasEditsFilter,
hasActiveFilters,
clearFilters,
pagination,
loading,
loadItems,
Expand Down Expand Up @@ -525,4 +554,13 @@
</script>


<style lang="scss" scoped></style>
<style lang="scss" scoped>

.toggle-filters {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}

</style>
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,90 @@ describe('userTable', () => {
});
});

describe('clearing filters', () => {
it('is disabled while no filter is applied', () => {
expect(wrapper.vm.hasActiveFilters).toBe(false);
expect(wrapper.findComponent('[data-test="clear-filters"]').props().disabled).toBe(true);
});

it('is enabled once a filter is applied', async () => {
wrapper.vm.userTypeFilter = 'administrator';
await wrapper.vm.$nextTick();

expect(wrapper.vm.hasActiveFilters).toBe(true);
expect(wrapper.findComponent('[data-test="clear-filters"]').props().disabled).toBe(false);
});

it('is enabled by a user type of "All", which narrows nothing but is still a selection', async () => {
wrapper.vm.userTypeFilter = 'all';
await wrapper.vm.$nextTick();

expect(wrapper.vm.filterFetchQueryParams).toEqual({});
expect(wrapper.vm.hasActiveFilters).toBe(true);
expect(wrapper.findComponent('[data-test="clear-filters"]').props().disabled).toBe(false);
});

it('stays disabled for date windows left at their default', async () => {
wrapper.vm.joinedWithinFilter = 'any';
wrapper.vm.activeWithinFilter = 'any';
await wrapper.vm.$nextTick();

expect(wrapper.vm.hasActiveFilters).toBe(false);
});

it('stays disabled after a checkbox is ticked and unticked again', async () => {
wrapper.vm.hasPublishedFilter = true;
await wrapper.vm.$nextTick();
expect(wrapper.vm.hasActiveFilters).toBe(true);

wrapper.vm.hasPublishedFilter = false;
await wrapper.vm.$nextTick();

expect(wrapper.vm.hasActiveFilters).toBe(false);
});

it('drops every filter, including the keyword search', async () => {
jest.useFakeTimers();
wrapper.vm.keywordInput = 'keyword test';
wrapper.vm.setKeywords();
jest.runAllTimers();
jest.useRealTimers();

wrapper.vm.userTypeFilter = 'administrator';
wrapper.vm.locationFilter = 'Afghanistan';
wrapper.vm.joinedWithinFilter = '3mo';
wrapper.vm.activeWithinFilter = '1mo';
wrapper.vm.hasPublishedFilter = true;
wrapper.vm.hasEditsFilter = true;
await wrapper.vm.$nextTick();
expect(wrapper.vm.filterFetchQueryParams).not.toEqual({});

await wrapper.findComponent('[data-test="clear-filters"]').trigger('click');
await wrapper.vm.$nextTick();

expect(wrapper.vm.filterFetchQueryParams).toEqual({});
expect(wrapper.vm.keywordInput).toBe('');
expect(Object.keys(router.currentRoute.query).sort()).toEqual([
'descending',
'page',
'page_size',
'sortBy',
]);
});

it('preserves pagination and sorting', async () => {
wrapper.vm.pagination = { ...wrapper.vm.pagination, page: 3, sortBy: 'email' };
wrapper.vm.userTypeFilter = 'administrator';
await wrapper.vm.$nextTick();

wrapper.vm.clearFilters();
await wrapper.vm.$nextTick();

expect(router.currentRoute.query.sortBy).toBe('email');
expect(router.currentRoute.query.userType).toBeUndefined();
});
});

describe('selection', () => {
it('selectAll should set selected to channel list', () => {
wrapper.vm.selectAll = true;
Expand Down
57 changes: 57 additions & 0 deletions contentcuration/contentcuration/tests/views/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,63 @@ def setUp(self):
self.view.request = mock.Mock()
self.view.request.user = testdata.user(email="tester@tester.com")

def _form(self, **overrides):
data = dict(
storage="storage",
kind="kind",
resource_count="resource_count",
resource_size="resource_size",
creators="creators",
sample_link="sample_link",
license="license",
public="channel1, channel2",
audience="audience",
import_count="import_count",
location="location",
uploading_for="uploading_for",
organization_type="organization_type",
time_constraint="time_constraint",
message="message",
)
data.update(overrides)
form = StorageRequestForm(data=data)
self.assertTrue(form.is_valid())
return form

def test_storage_request_records_requested_storage(self):
user = self.view.request.user
user.information = {"space_needed": "500MB", "heard_from": "newsletter"}
user.save()

with mock.patch("contentcuration.views.settings.send_mail"):
self.view.form_valid(self._form(storage="10GB"))

user.refresh_from_db()
self.assertEqual(user.information["latest_storage_request"], "10GB")
self.assertEqual(user.information["space_needed"], "500MB")
self.assertEqual(user.information["heard_from"], "newsletter")

def test_storage_request_records_requested_storage_without_prior_information(self):
user = self.view.request.user
user.information = None
user.save()

with mock.patch("contentcuration.views.settings.send_mail"):
self.view.form_valid(self._form(storage="1TB"))

user.refresh_from_db()
self.assertEqual(user.information["latest_storage_request"], "1TB")

def test_storage_request_overwrites_the_previous_request(self):
user = self.view.request.user

with mock.patch("contentcuration.views.settings.send_mail"):
self.view.form_valid(self._form(storage="1GB"))
self.view.form_valid(self._form(storage="2GB"))

user.refresh_from_db()
self.assertEqual(user.information["latest_storage_request"], "2GB")

def test_storage_request(self):

with mock.patch("contentcuration.views.settings.send_mail") as send_mail:
Expand Down
20 changes: 20 additions & 0 deletions contentcuration/contentcuration/tests/viewsets/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,26 @@ def test_admin_users_download_csv_streams_filtered_users(self):
self.assertIn("United States", body)
self.assertIn("Mexico", body)

def test_admin_users_download_csv_prefers_the_latest_storage_request(self):
target = testdata.user(email="csv-storage@e.com")
target.information = {
"space_needed": "500MB",
"latest_storage_request": "10GB",
}
target.save()

self.user.is_admin = True
self.user.save()
self.client.force_authenticate(user=self.user)

response = self.client.get(self._csv_url() + f"?ids={target.id}")
self.assertEqual(response.status_code, 200)

body = self._csv_body(response)
self.assertIn("Has Studio activity", body)
self.assertIn("10GB", body)
self.assertNotIn("500MB", body)

def test_admin_users_download_csv_handles_null_information(self):
user_no_info = testdata.user(email="no-info@e.com")
user_no_info.information = None
Expand Down
9 changes: 9 additions & 0 deletions contentcuration/contentcuration/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ class StorageSettingsView(PostFormMixin, FormView):
form_class = StorageRequestForm

def form_valid(self, form):
self.record_storage_request(self.request.user, form.cleaned_data["storage"])

channels = [c for c in form.cleaned_data["public"].split(", ") if c]
message = render_to_string(
"settings/storage_request_email.txt",
Expand All @@ -194,6 +196,13 @@ def form_valid(self, form):
[ccsettings.SPACE_REQUEST_EMAIL, self.request.user.email],
)

@staticmethod
def record_storage_request(user, storage):
information = user.information or {}
information["latest_storage_request"] = storage
user.information = information
user.save(update_fields=["information"])


class PolicyAcceptView(PostFormMixin, FormView):
form_class = PolicyAcceptForm
Expand Down
8 changes: 6 additions & 2 deletions contentcuration/contentcuration/viewsets/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ class AdminUserCSVFilter(AdminUserFilter, RequiredFilterSet):
"Has viewable channels",
"Has published a channel",
"Most recent publish date",
"Has Studio edits",
"Has Studio activity",
"Locations (country names)",
"Primary location",
"Location count",
Expand Down Expand Up @@ -495,6 +495,10 @@ def _iso_date(value):
return value.date().isoformat() if hasattr(value, "date") else value.isoformat()


def _storage_needed(info):
return info.get("latest_storage_request") or info.get("space_needed") or ""


def _build_csv_row(values, country_names):
"""Translate one user .values() dict to a CSV row.

Expand Down Expand Up @@ -523,7 +527,7 @@ def _build_csv_row(values, country_names):
", ".join(location_names),
location_names[0] if location_names else "",
len(location_codes),
info.get("space_needed") or "",
_storage_needed(info),
info.get("heard_from") or "",
]

Expand Down
Loading