Skip to content
Merged
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
Empty file.
29 changes: 29 additions & 0 deletions contentcuration/contentcuration/constants/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import json
import os

import jsonschema
from django.core.exceptions import ValidationError


def _schema():
"""
Loads JSON schema file
"""
file = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../static/feature_flags.json')
with open(file) as f:
data = json.load(f)
return data


SCHEMA = _schema()


def validate(data):
"""
:param data: Dictionary of data to validate
:raises: ValidationError: When invalid
"""
try:
jsonschema.validate(instance=data, schema=SCHEMA)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid feature flags data") from e
20 changes: 20 additions & 0 deletions contentcuration/contentcuration/db/models/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,23 @@ class ArrayRemove(Func):
"""
function = "ARRAY_REMOVE"
arity = 2


class JSONObjectKeys(Func):
"""
Returns result set of all JSON object keys for a JSONB field

Example:
.annotate(
my_field=JSONObjectKeys("some_jsonb_col_name")
)
.values("my_field")
.distinct()
=> my_field
-------------
key1
other_key
...
"""
function = "JSONB_OBJECT_KEYS"
arity = 1
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,42 @@
</p>
<UserStorage :value="user.disk_space" :userId="userId" />

<!-- Feature flags -->
<h2 class="mb-2 mt-5">
Feature flags
</h2>
<VDataTable
:headers="featureFlagHeaders"
:items="featureFlags"
class="user-table"
hide-actions
>
<template #items="{ item }">
<tr>
<td>{{ item.title }}</td>
<td>{{ item.description }}</td>
<td>
<KSwitch
:value="featureFlagValue(item.key)"
:disabled="loading"
title="Toggle feature"
@input="handleFeatureFlagChange(item.key, $event)"
/>
</td>
</tr>
</template>
</VDataTable>

<!-- Policies -->
<h2 class="mb-2 mt-5">
Policies accepted
</h2>
<VDataTable :headers="policyHeaders" :items="policies" hide-actions>
<VDataTable
:headers="policyHeaders"
:items="policies"
class="user-table"
hide-actions
>
<template #items="{ item }">
<tr>
<td>{{ item.name }}</td>
Expand Down Expand Up @@ -139,7 +170,12 @@
import FullscreenModal from 'shared/views/FullscreenModal';
import DetailsRow from 'shared/views/details/DetailsRow';
import Banner from 'shared/views/Banner';
import { createPolicyKey, policyDates, requiredPolicies } from 'shared/constants';
import {
createPolicyKey,
policyDates,
requiredPolicies,
FeatureFlagsSchema,
} from 'shared/constants';

function getPolicyDate(dateString) {
const [date, time] = dateString.split(' ');
Expand Down Expand Up @@ -270,6 +306,29 @@
{ text: 'Signed on', align: 'left', sortable: false },
];
},
featureFlags() {
return Object.entries(FeatureFlagsSchema.properties)
.map(([key, schema]) => ({
key,
...schema,
}))
.filter(featureFlag => {
// Exclude those with `$env` flag that doesn't match current env
return !featureFlag['$env'] || featureFlag['$env'] === process.env.NODE_ENV;
});
},
featureFlagHeaders() {
return [
{ text: 'Feature', align: 'left', sortable: false },
{ text: 'Description', align: 'left', sortable: false },
{ text: 'Visibility', align: 'left', sortable: false },
];
},
featureFlagValue() {
return function(key) {
return this.loading ? false : this.details.feature_flags[key] || false;
};
},
},
beforeRouteEnter(to, from, next) {
next(vm => {
Expand All @@ -283,7 +342,7 @@
return this.load();
},
methods: {
...mapActions('userAdmin', ['loadUser', 'loadUserDetails']),
...mapActions('userAdmin', ['loadUser', 'loadUserDetails', 'updateUser']),
channelUrl(channel) {
return window.Urls.channel(channel.id);
},
Expand All @@ -309,6 +368,31 @@
this.$store.dispatch('errors/handleAxiosError', error);
});
},
handleFeatureFlagChange(key, value) {
// Don't try to update on server if it hasn't changed
if (Boolean(this.details.feature_flags[key]) === value) {
return;
}

// Merge current state
const update = { [key]: value };
this.details.feature_flags = {
...(this.details.feature_flags || {}),
...update,
};

// Only send updated values since it will require validation and lingering
// flags could exist in user's flags data
return this.updateUser({
id: this.userId,
feature_flags: update,
}).then(() => {
this.$store.dispatch(
'showSnackbarSimple',
value ? 'Feature enabled' : 'Feature disabled'
);
});
},
},
};

Expand All @@ -317,4 +401,8 @@

<style lang="less" scoped>

.user-table {
max-width: 1024px;
}

</style>
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('user admin actions', () => {
});
it('loadUserDetails should call client.get with get_user_details', () => {
return store.dispatch('userAdmin/loadUserDetails', userId).then(() => {
expect(client.get).toHaveBeenCalledWith('get_user_details');
expect(client.get).toHaveBeenCalledWith('admin_users_metadata');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function loadUser(context, id) {
}

export function loadUserDetails(context, id) {
return client.get(window.Urls.get_user_details(id)).then(response => {
return client.get(window.Urls.admin_users_metadata(id)).then(response => {
return response.data;
});
}
Expand Down
12 changes: 12 additions & 0 deletions contentcuration/contentcuration/frontend/shared/constants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import featureFlagsSchema from 'static/feature_flags.json';

export const ContentDefaults = {
author: 'author',
provider: 'provider',
Expand Down Expand Up @@ -152,3 +154,13 @@ export const ValidationErrors = {
NO_VALID_PRIMARY_FILES: 'NO_VALID_PRIMARY_FILES',
...fileErrors,
};

export const FeatureFlagsSchema = featureFlagsSchema;

export const FeatureFlagKeys = Object.keys(FeatureFlagsSchema).reduce(
(featureFlags, featureFlag) => {
featureFlags[featureFlag] = featureFlag;
return featureFlags;
},
{}
);
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ export default {
isAdmin(state) {
return state.currentUser.is_admin;
},
featureFlags(state) {
return state.currentUser.feature_flags || {};
},
hasFeatureEnabled(state, getters) {
/**
* @param {string} flag - shared.constants.FeatureFlagKeys.*
* @return {Boolean}
*/
return function(flag) {
return getters.isAdmin || Boolean(getters.featureFlags[flag]);
};
},
},
actions: {
async saveSession(context, currentUser) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"""
from django.core.management.base import BaseCommand

from contentcuration.utils.garbage_collect import clean_up_contentnodes, clean_up_deleted_chefs
from contentcuration.utils.garbage_collect import clean_up_contentnodes
from contentcuration.utils.garbage_collect import clean_up_deleted_chefs
from contentcuration.utils.garbage_collect import clean_up_feature_flags


class Command(BaseCommand):
Expand All @@ -20,3 +22,4 @@ def handle(self, *args, **options):
# with the orphan tree
clean_up_contentnodes()
clean_up_deleted_chefs()
clean_up_feature_flags()
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2021-04-27 20:39
from __future__ import unicode_literals

import django.contrib.postgres.fields.jsonb
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('contentcuration', '0123_auto_20210407_0057'),
]

operations = [
migrations.AddField(
model_name='user',
name='feature_flags',
field=django.contrib.postgres.fields.jsonb.JSONField(null=True),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2021-04-27 20:39
from __future__ import unicode_literals

import django.contrib.postgres.fields.jsonb
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('contentcuration', '0124_user_feature_flags'),
]

operations = [
migrations.AlterField(
model_name='user',
name='feature_flags',
field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, null=True),
),
]
47 changes: 30 additions & 17 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class User(AbstractBaseUser, PermissionsMixin):
information = JSONField(null=True)
content_defaults = JSONField(default=dict)
policies = JSONField(default=dict, null=True)
feature_flags = JSONField(default=dict, null=True)

_field_updates = FieldTracker(fields=[
# Field to watch for changes
Expand Down Expand Up @@ -314,26 +315,38 @@ class Meta:
def filter_view_queryset(cls, queryset, user):
if user.is_anonymous():
return queryset.none()
channel_list = Channel.objects.filter(
Q(
pk__in=user.editable_channels.values_list(
"pk", flat=True
)
)
| Q(
pk__in=user.view_only_channels.values_list(
"pk", flat=True
)
)
).values_list("pk", flat=True)

if user.is_admin:
return queryset

# all shared editors
all_editable = User.editable_channels.through.objects.all()
editable = all_editable.filter(
channel_id__in=all_editable.filter(user_id=user.pk).values_list("channel_id", flat=True)
)

# all shared viewers
all_view_only = User.view_only_channels.through.objects.all()
view_only = all_view_only.filter(
channel_id__in=all_view_only.filter(user_id=user.pk).values_list("channel_id", flat=True)
)

return queryset.filter(
id__in=User.objects.filter(
Q(pk=user.pk)
| Q(editable_channels__pk__in=channel_list)
| Q(view_only_channels__pk__in=channel_list)
)
Q(pk=user.pk)
| Q(pk__in=editable.values_list("user_id", flat=True))
| Q(pk__in=view_only.values_list("user_id", flat=True))
)

@classmethod
def filter_edit_queryset(cls, queryset, user):
if user.is_anonymous():
return queryset.none()

if user.is_admin:
return queryset

return queryset.filter(pk=user.pk)

@classmethod
def get_for_email(cls, email, **filters):
"""
Expand Down
16 changes: 16 additions & 0 deletions contentcuration/contentcuration/static/feature_flags.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"type": "object",
"description": "Schema for supported feature flags",
"additionalProperties": false,
"properties": {
"test_dev_feature": {
"type": "boolean",
"title": "Test development feature",
"description": "This no-op feature flag is excluded from non-dev environments",
"$env": "development"
}
},
"examples": [
{"test_dev_feature": true}
]
}
Loading