Merge pull request #144 from Osmose/actions

WIP: Move actions into service repo
This commit is contained in:
R&D 2016-05-30 18:07:04 -04:00
Родитель 92ec5e8870 988ee3dff2
Коммит 15633b971e
32 изменённых файлов: 1491 добавлений и 255 удалений

Просмотреть файл

@ -9,3 +9,4 @@ pip-cache
site-packages
venv
webpack-stats.json
webpack-stats-actions.json

1
.gitignore поставляемый
Просмотреть файл

@ -2,6 +2,7 @@
db.sqlite3
.DS_Store
webpack-stats.json
webpack-stats-actions.json
__pycache__/
/assets/
/static/

211
docs/dev/driver.rst Normal file
Просмотреть файл

@ -0,0 +1,211 @@
Driver API
==========
The Normandy driver is an object passed to all actions when they are created. It
provides methods and attributes for performing operations that require more
privileges than JavaScript is normally given. For example, the driver might
provide a function for showing a notification bar within the Firefox UI,
something normal JavaScript can't trigger.
Environments in which actions are run (such as the `Normandy system add-on`_)
implement the driver and pass it to the actions before executing them.
.. _Normandy system add-on: https://github.com/mozilla/normandy-addon
Driver
------
The driver object contains the following attributes:
.. js:data:: testing
Boolean describing whether the action is being executed in "testing" mode.
Testing mode is mainly used when previewing recipes within the Normandy
admin interface.
.. js:data:: locale
String containing the locale code of the user's preferred language.
.. js:function:: log(message, level='debug')
Log a message to an appropriate location. It's up to the driver where these
messages are stored; they could go to the browser console, or to a remote
logging service, or somewhere else. If level is ``'debug'``, then messages
should only be logged if ``testing`` is true.
:param message: Message to log
:param level: Level to log message at, such as ``debug``, ``info``, or
``error``. Defaults to ``debug``.
.. js:function:: showHeartbeat(options)
Displays a Heartbeat survey to the user. Appears as a notification bar with
a 5-star rating input for users to vote with. Also contains a "Learn More"
button on the side. After voting, users are shown a thanks message and
optionally a new tab is opened to a specific URL.
:param message: Primary message to display alongside rating stars.
:param engagementButtonLabel: Message to display on the engagement button.
If specified, a button will be shown instead of the rating stars.
:param thanksMessage: Message to show after user submits a rating.
:param flowId: A UUID that should be unique to each call to this function.
Used to track metrics related to this user interaction.
:param postAnswerUrl: URL to show users after they submit a rating. If empty,
the user won't be shown anything.
:param learnMoreMessage: Text to show on the "Learn More" button.
:param learnMoreUrl: URL to open when the "Learn More" button is clicked.
:param extraTelemetryArgs: Object containing extra arguments to send to
telemetry associated with this heartbeat call. Defaults to an empty
object.
:returns: A Promise that resolves with an event emitter.
The emitter returned by this function can be subscribed to using ``on``
method. For example:
.. code-block:: javascript
let heartbeat = await Normandy.showHeartbeat(options);
heartbeat.on('NotificationOffered', function(data) {
// Do something!
});
All events are given a data object with the following attributes:
flowId
The ``flowId`` passed into ``showHeartbeat``.
timestamp
Timestamp (number of milliseconds since Unix epoch) of when the event
being emitted occurred.
The events emitted by the emitter include:
NotificationOffered
Emitted after the notification bar is shown to the user.
NotificationClosed
Emitted after the notification bar closes, either by being closed
manually by the user, or automatically after voting.
LearnMore
Emitted when the user clicks the "Learn More" link.
Voted
Emitted when the user clicks the star rating bar and submits a rating.
An extra ``score`` attribute is included on the data object for this
event containing the rating the user submitted.
TelemetrySent
Emitted after Heartbeat has sent flow data to the Telemetry servers. Only
available on Firefox 46 and higher.
.. js:function:: uuid()
Generates a v4 UUID. The UUID is randomly generated.
:returns: String containing the UUID.
.. js:function:: createStorage(keyPrefix)
Creates a storage object that can be used to store data on the client.
:param keyPrefix: Prefix to append to keys before storing them, to avoid
collision with other actions using the storage.
:returns: :js:class:`Storage`
.. js:function:: location()
Retrieves information about where the user is located.
:returns: A Promise that resolves with a location object.
The location object has the following fields:
countryCode
ISO 3166-1 country code for the country the user has been geolocated to.
.. js:function:: saveHeartbeatFlow(data)
Sends flow data from Heartbeat to the Input server. See the
`Input documentation`_ for details about the data expected.
:param data: Object containing Heartbeat flow data.
.. _Input Documentation: http://fjord.readthedocs.org/en/latest/hb_api.html
.. js:function:: client()
Retrieves information about the user's browser.
:returns: Promise that resolves with a client data object.
The client data object includes the following fields:
version
String containing the Firefox version.
channel
String containing the update channel. Valid values include, but are not
limited to:
* ``'release'``
* ``'aurora'``
* ``'beta'``
* ``'nightly'``
* ``'default'`` (self-built or automated testing builds)
isDefaultBrowser
Boolean specifying whether Firefox is set as the user's default browser.
searchEngine
String containing the user's default search engine identifier.
syncSetup
Boolean containing whether the user has set up Firefox Sync.
plugins
An object mapping of plugin names to :js:class:`Plugin` objects describing
the plugins installed on the client.
Plugins
-------
.. js:class:: Plugin
A simple object describing a plugin installed on the client. This is **not**
the same object as returned by ``navigator.plugins``, but it is similar.
.. js:data:: name
The name of the plugin.
.. js:data:: description
A human-readable description of the plugin.
.. js:data:: filename
The filename of the plugin file.
.. js:data:: version
The plugin's version number string.
Storage
-------
.. js:class:: Storage
Storage objects allow actions to store data locally on the client, using an
API that is similar to localStorage, but is asynchronous.
.. js:function:: getItem(key)
Retrieves a value from storage.
:param key: Key to look up in storage.
:returns: A Promise that resolves with the value found in storage, or
``null`` if the key doesn't exist.
.. js:function:: setItem(key, value)
Inserts a value into storage under the given key.
:param key: Key to insert the value under.
:param value: Value to store.
:returns: A Promise that resolves when the value has been stored.
.. js:function:: removeItem(key)
Removes a value from storage.
:param key: Key to remove.
:returns: A Promise that resolves when the value has been removed.

Просмотреть файл

@ -57,11 +57,12 @@ Installation
:ref:`pip-install-error`
How to troubleshoot errors during ``pip install``.
4. Install frontend dependencies using npm:
4. Install frontend dependencies and build the frontend code using npm:
.. code-block:: bash
npm install
npm run build
5. Create a Postgres database for Normandy. By default it is assumed to be named
``normandy``:

Просмотреть файл

@ -43,6 +43,9 @@ steps, as they don't affect your setup if nothing has changed:
# Add any new initial data (does not duplicate data).
python manage.py initial_data
# Build frontend files
./node_modules/.bin/webpack --config ./webpack.config.js --update-actions
Building the Documentation
--------------------------
You can build the documentation with the following command:
@ -69,18 +72,33 @@ make adding these hashes easier.
.. _hashin: https://github.com/peterbe/hashin
Preprocessing Assets with webpack
-----------------------
.. _process-webpack:
We use webpack to create asset bundles of static resources. You can build an
Preprocessing Assets with Webpack
---------------------------------
We use Webpack_ to create asset bundles of static resources. You can build an
asset bundle by running:
.. code-block:: bash
./node_modules/.bin/webpack --config ./webpack.config.js
npm run build
Running the command with ``--watch`` will automatically rebuild your bundles as
you make changes.
You can also run the watch command to automatically rebuild your bundles as you
make changes:
.. code-block:: bash
npm run watch
Running the command with ``--update-actions`` will automatically call
``manage.py update_actions`` when action code is built. Arguments are separated
from the rest of the command by ``--``:
.. code-block:: bash
npm run watch -- --update-actions
.. _Webpack: http://webpack.github.io/
Self-Repair Setup
-----------------
@ -124,3 +142,24 @@ To generate an API key for privillaged API access:
4. Select the user account you wish to generate a key for in the user list
dropdown and click the Save button.
5. Retrieve the API token from the list view under the "Key" column.
Adding and Updating Actions
---------------------------
The code and argument schemas for Actions is stored on the filesystem, but must
also be updated in the database to be used by the site.
To add a new action:
1. Create a new directory in ``normandy/recipes/static/actions`` containing a
``package.json`` file for your action and the JavaScript code for it.
2. Add the entry point for your action to ``webpack.config.js``.
3. Add the action name and path to the ``ACTIONS`` setting in ``settings.py``.
4. :ref:`Build the action code using Webpack <process-webpack>`.
5. Update the database by running ``update_actions``:
.. code-block:: bash
python manage.py update_actions
To update an existing action, follow steps 4 and 5 above after making your
changes.

Просмотреть файл

@ -11,6 +11,7 @@ JavaScript to various clients based on certain rules.
dev/install
dev/workflow
dev/architecture
dev/driver
dev/troubleshooting
qa/docker
ops/config

Просмотреть файл

@ -99,6 +99,8 @@ Running Normandy
docker run -it --env-file=.env mozilla/normandy:latest ./manage.py createsuperuser
# Load inital database data
docker run -it --env-file=.env mozilla/normandy:latest ./manage.py initial_data
# Update the actions
docker run -it --env-file=.env mozilla/normandy:latest ./manage.py update_actions
# Run the web server
docker run -it -p 8000:8000 --env-file=.env mozilla/normandy:latest

Просмотреть файл

@ -1,8 +1,8 @@
// Karma configuration
module.exports = function(config) {
config.set({
var karmaConfig = {
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: 'normandy/control/tests/',
basePath: '',
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
@ -10,13 +10,17 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
'index.js'
'node_modules/babel-polyfill/dist/polyfill.js',
'node_modules/jasmine-promises/dist/jasmine-promises.js',
'normandy/control/tests/index.js',
'normandy/recipes/tests/actions/index.js',
],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
'index.js': ['webpack'],
'normandy/control/tests/index.js': ['webpack', 'sourcemap'],
'normandy/recipes/tests/actions/index.js': ['webpack', 'sourcemap'],
'normandy/control/static/control/js/components/*.jsx': ['react-jsx'],
},
@ -43,7 +47,7 @@ module.exports = function(config) {
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['nyan'],
reporters: ['spec'],
// web server port
port: 9876,
@ -69,5 +73,20 @@ module.exports = function(config) {
// Concurrency level
// how many browser should be started simultaneous
concurrency: Infinity,
});
};
// Add JUnit reporting if we're running on CircleCI.
var reportDir = process.env.CIRCLE_TEST_REPORTS;
if (reportDir) {
karmaConfig.reporters.push('junit');
karmaConfig.junitReporter = {
// results will be saved as $outputDir/$browserName.xml
outputDir: reportDir,
// if included, results will be saved as $outputDir/$browserName/$outputFile
outputFile: 'normandy-actions.xml',
};
}
config.set(karmaConfig);
};

Просмотреть файл

@ -1,9 +1,6 @@
import json
import os
from django.core.management.base import BaseCommand
from normandy.recipes.models import Action, ReleaseChannel
from normandy.recipes.models import ReleaseChannel
class Command(BaseCommand):
@ -22,7 +19,6 @@ class Command(BaseCommand):
def handle(self, *args, **options):
self.add_release_channels()
self.add_default_action()
def add_release_channels(self):
self.stdout.write('Adding Release Channels...', ending='')
@ -36,31 +32,3 @@ class Command(BaseCommand):
for slug, name in channels.items():
ReleaseChannel.objects.get_or_create(slug=slug, defaults={'name': name})
self.stdout.write('Done')
def add_default_action(self):
self.stdout.write('Adding default Actions...', ending='')
with open(os.path.join(os.path.dirname(__file__), 'data', 'console-log.js')) as f:
action_impl = f.read()
arguments_schema = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Log a message to the console",
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"description": "Message to log to the console",
"type": "string",
"default": ""
}
}
}
Action.objects.get_or_create(name='console-log', defaults={
'implementation': action_impl,
'arguments_schema_json': json.dumps(arguments_schema),
})
self.stdout.write('Done')

Просмотреть файл

@ -8,6 +8,7 @@ class RecipePreview extends React.Component {
super(props);
this.state = {
recipeAttempted: false,
recipeExecuted: false,
errorRunningRecipe: null,
};
@ -23,9 +24,13 @@ class RecipePreview extends React.Component {
attemptPreview() {
const {recipe} = this.props;
const {recipeExecuted} = this.state;
const {recipeAttempted} = this.state;
if (recipe && !recipeAttempted) {
this.setState({
recipeAttempted: true
});
if (recipe && !recipeExecuted) {
runRecipe(recipe, {testing: true}).then(res => {
this.setState({
recipeExecuted: true

Просмотреть файл

@ -1,16 +0,0 @@
from django.conf import settings
from rest_framework import permissions
class NotInUse(permissions.BasePermission):
"""Only allows editing of objects if object.in_use is False."""
message = 'Cannot edit this object while it is in use'
def has_object_permission(self, request, view, obj):
unsafe_method = request.method not in permissions.SAFE_METHODS
if not settings.CAN_EDIT_ACTIONS_IN_USE and unsafe_method and obj.in_use:
return False
else:
return True

Просмотреть файл

@ -11,14 +11,12 @@ from normandy.recipes.models import (Action, Recipe, Approval, ApprovalRequest,
class ActionSerializer(serializers.ModelSerializer):
arguments_schema = serializers.JSONField()
implementation = serializers.CharField(write_only=True)
implementation_url = ActionImplementationHyperlinkField()
class Meta:
model = Action
fields = [
'name',
'implementation',
'implementation_url',
'arguments_schema',
]

Просмотреть файл

@ -15,7 +15,6 @@ from normandy.base.api.permissions import AdminEnabledOrReadOnly
from normandy.base.api.renderers import JavaScriptRenderer
from normandy.base.decorators import reversion_transaction
from normandy.recipes.models import Action, Client, Recipe, ApprovalRequest, ApprovalRequestComment
from normandy.recipes.api.permissions import NotInUse
from normandy.recipes.api.serializers import (
ActionSerializer,
ClientSerializer,
@ -26,27 +25,14 @@ from normandy.recipes.api.serializers import (
)
class ActionViewSet(UpdateOrCreateModelViewSet):
"""Viewset for viewing and uploading recipe actions."""
class ActionViewSet(viewsets.ReadOnlyModelViewSet):
"""Viewset for viewing recipe actions."""
queryset = Action.objects.all()
serializer_class = ActionSerializer
permission_classes = [
permissions.DjangoModelPermissionsOrAnonReadOnly,
NotInUse,
AdminEnabledOrReadOnly,
]
lookup_field = 'name'
lookup_value_regex = r'[_\-\w]+'
@reversion_transaction
def create(self, *args, **kwargs):
return super().create(*args, **kwargs)
@reversion_transaction
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
class ActionImplementationView(generics.RetrieveAPIView):
"""

Просмотреть файл

Просмотреть файл

Просмотреть файл

@ -0,0 +1,92 @@
import json
import os
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from reversion import revisions as reversion
from webpack_loader.utils import get_loader
from normandy.recipes.models import Action, Recipe
class Command(BaseCommand):
help = 'Updates the actions in the database with the latest built code.'
def add_arguments(self, parser):
parser.add_argument(
'action_name',
nargs='*',
type=str,
help='Only update the specified actions'
)
parser.add_argument(
'--no-disable',
action='store_true',
dest='no-disable',
default=False,
help='Do not disable recipes using actions that are updated'
)
@transaction.atomic
@reversion.create_revision()
def handle(self, *args, **options):
disabled_recipes = []
action_names = settings.ACTIONS.keys()
if options['action_name']:
action_names = [name for name in action_names if name in options['action_name']]
for name in action_names:
self.stdout.write('Updating action {}...'.format(name), ending='')
implementation = get_implementation(name)
arguments_schema = get_arguments_schema(name)
# Create a new action or update the existing one.
try:
action = Action.objects.get(name=name)
should_update = (
action.implementation != implementation
or action.arguments_schema != arguments_schema
)
if should_update:
action.implementation = implementation
action.arguments_schema = arguments_schema
action.save()
# As a precaution, disable any recipes that are
# being used by an action that was just updated.
if not options['no-disable']:
recipes = Recipe.objects.filter(action=action, enabled=True)
disabled_recipes += list(recipes)
recipes.update(enabled=False)
except Action.DoesNotExist:
action = Action(
name=name,
implementation=implementation,
arguments_schema=arguments_schema
)
action.save()
self.stdout.write('Done')
if disabled_recipes:
self.stdout.write('\nThe following recipes were disabled while updating actions:')
for recipe in disabled_recipes:
self.stdout.write(recipe.name)
def get_implementation(action_name):
chunks = get_loader('ACTIONS').get_assets()['chunks']
implementation_path = chunks[action_name][0]['path']
with open(implementation_path) as f:
return f.read()
def get_arguments_schema(action_name):
action_directory = settings.ACTIONS[action_name]
with open(os.path.join(action_directory, 'package.json')) as f:
action_metadata = json.load(f)
return action_metadata['normandy']['argumentsSchema']

Просмотреть файл

@ -0,0 +1,9 @@
import {Action, registerAction} from '../utils';
export default class ConsoleLogAction extends Action {
async execute() {
this.normandy.log(this.recipe.arguments.message, 'info');
}
}
registerAction('console-log', ConsoleLogAction);

Просмотреть файл

@ -0,0 +1,24 @@
{
"name": "console-log",
"version": "0.0.1",
"private": true,
"main": "./index.js",
"normandy": {
"driverVersion": "1.x",
"argumentsSchema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Log a message to the console",
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"description": "Message to log to the console",
"type": "string",
"default": ""
}
}
}
}
}

Просмотреть файл

@ -0,0 +1,214 @@
import {Action, registerAction, weightedChoose} from '../utils';
const VERSION = 52; // Increase when changed.
const LAST_SHOWN_DELAY = 1000 * 60 * 60 * 24 * 7; // 7 days
export class HeartbeatFlow {
constructor(action) {
this.action = action;
let {normandy, recipe, survey, client, location} = action;
let flashPlugin = client.plugins['Shockwave Flash'];
let plugins = {};
for (let pluginName in client.plugins) {
let plugin = client.plugins[pluginName];
plugins[plugin.name] = plugin.version;
}
this.data = {
// Required fields
response_version: 2,
experiment_version: '-',
person_id: 'NA',
survey_id: recipe.arguments.surveyId,
flow_id: normandy.uuid(),
question_id: survey.message,
updated_ts: Date.now(),
question_text: survey.message,
variation_id: recipe.revision_id.toString(),
// Optional fields
score: null,
max_score: 5,
flow_began_ts: Date.now(),
flow_offered_ts: 0,
flow_voted_ts: 0,
flow_engaged_ts: 0,
platform: 'UNK',
channel: client.channel,
version: client.version,
locale: normandy.locale,
country: (location.countryCode || 'unknown').toLowerCase(),
build_id: '-',
partner_id: '-',
profile_age: 0,
profile_usage: {},
addons: {
addons: [],
},
extra: {
crashes: {},
engage: [],
numflows: 0,
searchEngine: client.searchEngine,
syncSetup: client.syncSetup,
defaultBrowser: client.isDefaultBrowser,
plugins: plugins,
flashVersion: flashPlugin ? flashPlugin.version : undefined,
doNotTrack: navigator.doNotTrack === '1',
},
is_test: normandy.testing,
};
}
get id() {
return this.data.flow_id;
}
save() {
this.data.updated_ts = Date.now();
let {normandy} = this.action;
normandy.saveHeartbeatFlow(this.data);
}
addLink(href, source) {
this.data.extra.engage.push([Date.now(), href, source]);
}
setPhaseTimestamp(phase, timestamp) {
let key = `flow_${phase}_ts`;
if (key in this.data && this.data[key] === 0) {
this.data[key] = timestamp;
}
}
setScore(score) {
this.data.score = score;
}
}
export default class ShowHeartbeatAction extends Action {
constructor(normandy, recipe) {
super(normandy, recipe);
this.storage = normandy.createStorage(recipe.id);
}
async execute() {
let {surveys, defaults, surveyId} = this.recipe.arguments;
let lastShown = await this.getLastShownDate();
let shouldShowSurvey = (
this.normandy.testing
|| lastShown === null
|| Date.now() - lastShown > LAST_SHOWN_DELAY
);
if (!shouldShowSurvey) {
return;
}
this.location = await this.normandy.location();
this.client = await this.normandy.client();
this.survey = this.chooseSurvey(surveys, defaults);
let flow = new HeartbeatFlow(this);
flow.save();
let extraTelemetryArgs = {
surveyId: surveyId,
surveyVersion: this.recipe.revision_id,
};
if (this.normandy.testing) {
extraTelemetryArgs.testing = 1;
}
// A bit redundant but the action argument names shouldn't necessarily rely
// on the argument names showHeartbeat takes.
let heartbeat = await this.normandy.showHeartbeat({
message: this.survey.message,
engagementButtonLabel: this.survey.engagementButtonLabel,
thanksMessage: this.survey.thanksMessage,
flowId: flow.id,
postAnswerUrl: await this.annotatePostAnswerUrl(this.survey.postAnswerUrl),
learnMoreMessage: this.survey.learnMoreMessage,
learnMoreUrl: this.survey.learnMoreUrl,
extraTelemetryArgs: extraTelemetryArgs,
});
heartbeat.on('NotificationOffered', data => {
flow.setPhaseTimestamp('offered', data.timestamp);
flow.save();
});
heartbeat.on('LearnMore', () => {
flow.addLink(this.survey.learnMoreUrl, 'notice');
flow.save();
});
heartbeat.on('Voted', data => {
flow.setScore(data.score);
flow.setPhaseTimestamp('voted', data.timestamp);
flow.save();
});
this.setLastShownDate();
}
setLastShownDate() {
// Returns a promise, but there's nothing to do if it fails.
this.storage.setItem('lastShown', Date.now());
}
async getLastShownDate() {
let lastShown = Number.parseInt(await this.storage.getItem('lastShown'), 10);
return Number.isNaN(lastShown) ? null : lastShown;
}
async annotatePostAnswerUrl(url) {
let args = [
['source', 'heartbeat'],
['surveyversion', VERSION],
['updateChannel', this.client.channel],
['fxVersion', this.client.version],
];
// Append testing parameter if in testing mode.
if (this.normandy.testing) {
args.push(['testing', 1]);
}
let params = args.map(([a, b]) => `${a}=${b}`).join('&');
if (url.indexOf('?') !== -1) {
url += '&' + params;
} else {
url += '?' + params;
}
return url;
}
/**
* From the given list of surveys, choose one based on their relative
* weights and return it.
*
* @param {array} surveys Array of weighted surveys from the arguments
* object.
* @param {object} defaults Default values for survey attributes if they aren't
* specified.
* @return {object} The chosen survey, with the defaults applied.
*/
chooseSurvey(surveys, defaults) {
let finalSurvey = Object.assign({}, weightedChoose(surveys));
for (let prop in defaults) {
if (!finalSurvey[prop]) {
finalSurvey[prop] = defaults[prop];
}
}
return finalSurvey;
}
}
registerAction('show-heartbeat', ShowHeartbeatAction);

Просмотреть файл

@ -0,0 +1,131 @@
{
"name": "show-heartbeat",
"version": "0.0.1",
"private": true,
"main": "./index.js",
"normandy": {
"driverVersion": "1.x",
"argumentsSchema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Show a Heartbeat survey.",
"description": "This action can show a single survey, or choose a single survey from multiple weighted ones.",
"type": "object",
"required": [
"surveyId",
"surveys"
],
"properties": {
"surveyId": {
"type": "string",
"description": "Slug uniquely identifying this survey in telemetry",
"propertyOrder": 100
},
"defaults": {
"$ref": "#/definitions/survey",
"title": "Default values",
"description": "Default values used for missing values in the list of surveys",
"propertyOrder": 200
},
"surveys": {
"type": "array",
"format": "tabs",
"minItems": 1,
"items": {
"$ref": "#/definitions/weightedSurvey",
"headerTemplate": "Survey {{ self.title }}"
},
"propertyOrder": 300
}
},
"definitions": {
"title": {
"description": "Descriptive title. Not shown to users",
"type": "string",
"default": ""
},
"message": {
"description": "Message to show to the user",
"type": "string",
"default": ""
},
"engagementButtonLabel": {
"description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
"type": "string",
"default": ""
},
"thanksMessage": {
"description": "Thanks message to show to the user after they've rated Firefox",
"type": "string",
"default": ""
},
"postAnswerUrl": {
"description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
"type": "string",
"default": ""
},
"learnMoreMessage": {
"description": "Message to show to the user to learn more",
"type": "string",
"default": ""
},
"learnMoreUrl": {
"description": "URL to show to the user when they click Learn More",
"type": "string",
"default": ""
},
"survey": {
"type": "object",
"properties": {
"message": {
"$ref": "#/definitions/message",
"propertyOrder": 100
},
"engagementButtonLabel": {
"$ref": "#/definitions/engagementButtonLabel",
"propertyOrder": 200
},
"thanksMessage": {
"$ref": "#/definitions/thanksMessage",
"propertyOrder": 300
},
"postAnswerUrl": {
"$ref": "#/definitions/postAnswerUrl",
"propertyOrder": 400
},
"learnMoreMessage": {
"$ref": "#/definitions/learnMoreMessage",
"propertyOrder": 500
},
"learnMoreUrl": {
"$ref": "#/definitions/learnMoreUrl",
"propertyOrder": 600
}
}
},
"weightedSurvey": {
"allOf": [
{"$ref": "#/definitions/survey"},
{
"properties": {
"title": {
"$ref": "#/definitions/title",
"propertyOrder": 50
},
"weight": {
"type": "integer",
"description": "Frequency relative to other surveys",
"minimum": 1,
"default": 1,
"propertyOrder": 2000
}
},
"required": ["weight"]
}
]
}
}
}
}
}

Просмотреть файл

@ -0,0 +1,59 @@
export class Action {
constructor(normandy, recipe) {
this.normandy = normandy;
this.recipe = recipe;
}
}
/**
* From the given list of objects, choose one based on their relative
* weights and return it. Choices are assumed to be objects with a `weight`
* property that is an integer.
*
* Weights define the probability a choices will be shown relative to other
* weighted choices. If two choices have weights 10 and 20, the second one will
* appear twice as often as the first.
*
* @param {array} choices Array of weighted choices.
* @return {object} The chosen choice.
*/
export function weightedChoose(choices) {
if (choices.length < 1) {
return null;
}
let maxWeight = choices.map(c => c.weight).reduce((a, b) => a + b, 0);
let chosenWeight = Math.random() * maxWeight;
for (let choice of choices) {
chosenWeight -= choice.weight;
if (chosenWeight <= 0) {
return choice;
}
}
// We shouldn't hit this, but if we do, return the last choice.
return choices[choices.length - 1];
}
// Attempt to find the global registerAction, and fall back to a noop if it's
// not available.
export let registerAction = null;
try {
registerAction = global.registerAction;
} catch (err) {
// Not running in Node.
}
if (!registerAction) {
try {
registerAction = window.registerAction;
} catch (err) {
// Not running in a browser.
}
}
// If it still isn't found, just shim it.
if (!registerAction) {
registerAction = function() { };
}

Просмотреть файл

@ -0,0 +1,5 @@
{
"env": {
"jasmine": true
}
}

Просмотреть файл

@ -0,0 +1,15 @@
import {mockNormandy} from './utils';
import ConsoleLogAction from '../../static/actions/console-log/index';
describe('ConsoleLogAction', function() {
beforeEach(function() {
this.normandy = mockNormandy();
});
it('should log a message to the console', async function() {
let action = new ConsoleLogAction(this.normandy, {arguments: {message: 'test message'}});
await action.execute();
expect(this.normandy.log).toHaveBeenCalledWith('test message', 'info');
});
});

Просмотреть файл

@ -0,0 +1,2 @@
var testsContext = require.context("./", false, /\.js$/);
testsContext.keys().forEach(testsContext);

Просмотреть файл

@ -0,0 +1,287 @@
import {mockNormandy, pluginFactory} from './utils';
import ShowHeartbeatAction from '../../static/actions/show-heartbeat/index';
function surveyFactory(props={}) {
return Object.assign({
title: 'test survey',
message: 'test message',
engagementButtonLabel: '',
thanksMessage: 'thanks!',
postAnswerUrl: 'http://example.com',
learnMoreMessage: 'Learn More',
learnMoreUrl: 'http://example.com',
weight: 100,
}, props);
}
function recipeFactory(props={}) {
return Object.assign({
id: 1,
revision_id: 1,
arguments: {
surveyId: 'mysurvey',
defaults: {},
surveys: [surveyFactory()],
},
}, props);
}
describe('ShowHeartbeatAction', function() {
beforeEach(function() {
this.normandy = mockNormandy();
});
it('should run without errors', async function() {
let action = new ShowHeartbeatAction(this.normandy, recipeFactory());
await action.execute();
});
it('should not show heartbeat if it has shown within the past 7 days', async function() {
let recipe = recipeFactory();
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.mock.storage.data['lastShown'] = '100';
spyOn(Date, 'now').and.returnValue(10);
await action.execute();
expect(this.normandy.showHeartbeat).not.toHaveBeenCalled();
});
it('should show heartbeat in testing mode regardless of when it was last shown', async function() {
let recipe = recipeFactory();
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.testing = true;
this.normandy.mock.storage.data['lastShown'] = '100';
spyOn(Date, 'now').and.returnValue(10);
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalled();
});
it("should show heartbeat if it hasn't shown within the past 7 days", async function() {
let recipe = recipeFactory();
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.mock.storage.data['lastShown'] = '100';
spyOn(Date, 'now').and.returnValue(9999999999);
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalled();
});
it('should show heartbeat if the last-shown date cannot be parsed', async function() {
let recipe = recipeFactory();
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.mock.storage.data['lastShown'] = 'bigo310s0baba';
spyOn(Date, 'now').and.returnValue(10);
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalled();
});
it('should pass the correct arguments to showHeartbeat', async function() {
let showHeartbeatArgs = {
message: 'test message',
thanksMessage: 'thanks!',
learnMoreMessage: 'Learn More',
learnMoreUrl: 'http://example.com',
};
let recipe = recipeFactory(showHeartbeatArgs);
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.uuid.and.returnValue('fake-uuid');
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(
jasmine.objectContaining(showHeartbeatArgs)
);
});
it('should generate a UUID and pass it to showHeartbeat', async function() {
let recipe = recipeFactory();
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.uuid.and.returnValue('fake-uuid');
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
flowId: 'fake-uuid',
}));
});
it('should annotate the post-answer URL with extra query args', async function() {
let url = 'https://example.com';
let recipe = recipeFactory();
recipe.arguments.surveys[0].postAnswerUrl = url;
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.mock.client.version = '42.0.1';
this.normandy.mock.client.channel = 'nightly';
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
postAnswerUrl: (url + '?source=heartbeat&surveyversion=52' +
'&updateChannel=nightly&fxVersion=42.0.1'),
}));
});
it('should annotate the post-answer URL if it has an existing query string', async function() {
let url = 'https://example.com?foo=bar';
let recipe = recipeFactory();
recipe.arguments.surveys[0].postAnswerUrl = url;
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.mock.client.version = '42.0.1';
this.normandy.mock.client.channel = 'nightly';
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
postAnswerUrl: (url + '&source=heartbeat&surveyversion=52' +
'&updateChannel=nightly&fxVersion=42.0.1'),
}));
});
it('should annotate the post-answer URL with a testing param in testing mode', async function() {
let url = 'https://example.com';
let recipe = recipeFactory();
recipe.arguments.surveys[0].postAnswerUrl = url;
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.testing = true;
this.normandy.mock.client.version = '42.0.1';
this.normandy.mock.client.channel = 'nightly';
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
postAnswerUrl: (url + '?source=heartbeat&surveyversion=52' +
'&updateChannel=nightly&fxVersion=42.0.1&testing=1'),
}));
});
it('should pass some extra telemetry arguments to showHeartbeat', async function() {
let recipe = recipeFactory({revision_id: 42});
recipe.arguments.surveyId = 'my-survey';
let action = new ShowHeartbeatAction(this.normandy, recipe);
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
extraTelemetryArgs: {
surveyId: 'my-survey',
surveyVersion: 42,
},
}));
});
it('should include a testing argument in extraTelemetryArgs when in testing mode', async function() {
let recipe = recipeFactory({revision_id: 42});
recipe.arguments.surveyId = 'my-survey';
let action = new ShowHeartbeatAction(this.normandy, recipe);
this.normandy.testing = true;
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
extraTelemetryArgs: {
surveyId: 'my-survey',
surveyVersion: 42,
testing: 1,
},
}));
});
it('should set the last-shown date', async function() {
let action = new ShowHeartbeatAction(this.normandy, recipeFactory());
spyOn(Date, 'now').and.returnValue(10);
expect(this.normandy.mock.storage.data['lastShown']).toBeUndefined();
await action.execute();
expect(this.normandy.mock.storage.data['lastShown']).toEqual('10');
});
it('should choose a random survey based on the weights', async function() {
// This test relies on the order of surveys passed in, which sucks.
let survey20 = surveyFactory({message: 'survey20', weight: 20});
let survey30 = surveyFactory({message: 'survey30', weight: 30});
let survey50 = surveyFactory({message: 'survey50', weight: 50});
let recipe = recipeFactory({arguments: {surveys: [survey20, survey30, survey50]}});
spyOn(Math, 'random').and.returnValues(0.1, 0.4);
let action = new ShowHeartbeatAction(this.normandy, recipe);
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
message: survey20.message,
}));
// If the random number changes, return a different survey.
this.normandy = mockNormandy();
action = new ShowHeartbeatAction(this.normandy, recipe);
await action.execute();
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
message: survey30.message,
}));
});
it('should save flow data via normandy.saveHeartbeatFlow', async function() {
let recipe = recipeFactory();
let survey = recipe.arguments.surveys[0];
let action = new ShowHeartbeatAction(this.normandy, recipe);
let client = this.normandy.mock.client;
client.plugins = {
'Shockwave Flash': pluginFactory({
name: 'Shockwave Flash',
version: '2.5.0',
}),
'otherplugin': pluginFactory({
name: 'otherplugin',
version: '7',
}),
};
spyOn(Date, 'now').and.returnValue(10);
this.normandy.testing = true;
await action.execute();
let emitter = this.normandy.mock.heartbeatEmitter;
emitter.emit('NotificationOffered', {timestamp: 20});
emitter.emit('LearnMore', {timestamp: 30});
emitter.emit('Voted', {timestamp: 40, score: 3});
// Checking per field makes recognizing which field failed
// _much_ easier.
let flowData = this.normandy.saveHeartbeatFlow.calls.mostRecent().args[0];
expect(flowData.response_version).toEqual(2);
expect(flowData.survey_id).toEqual(recipe.arguments.surveyId);
expect(flowData.question_id).toEqual(survey.message);
expect(flowData.updated_ts).toEqual(10);
expect(flowData.question_text).toEqual(survey.message);
expect(flowData.variation_id).toEqual(recipe.revision_id.toString());
expect(flowData.score).toEqual(3);
expect(flowData.flow_began_ts).toEqual(10);
expect(flowData.flow_offered_ts).toEqual(20);
expect(flowData.flow_voted_ts).toEqual(40);
expect(flowData.channel).toEqual(client.channel);
expect(flowData.version).toEqual(client.version);
expect(flowData.locale).toEqual(this.normandy.locale);
expect(flowData.country).toEqual(this.normandy.mock.location.countryCode);
expect(flowData.is_test).toEqual(true);
expect(flowData.extra.plugins).toEqual({
'Shockwave Flash': '2.5.0',
'otherplugin': '7',
});
expect(flowData.extra.flashVersion).toEqual(client.plugins['Shockwave Flash'].version);
expect(flowData.extra.engage).toEqual([
[10, survey.learnMoreUrl, 'notice'],
]);
expect(flowData.extra.searchEngine).toEqual(client.searchEngine);
expect(flowData.extra.syncSetup).toEqual(client.syncSetup);
expect(flowData.extra.defaultBrowser).toEqual(client.isDefaultBrowser);
});
});

Просмотреть файл

@ -0,0 +1,93 @@
import EventEmitter from 'wolfy87-eventemitter';
export class MockStorage {
constructor() {
this.data = {};
}
getItem(key) {
let value = this.data[key];
return Promise.resolve(value !== undefined ? value : null);
}
setItem(key, value) {
this.data[key] = String(value);
return Promise.resolve();
}
removeItem(key) {
delete this.data[key];
return Promise.resolve();
}
}
export function pluginFactory(props={}) {
return Object.assign({
name: 'Plugin',
description: 'A plugin',
filename: '/tmp/fake/path',
version: 'v1.0',
}, props);
}
export function mockNormandy() {
let normandy = {
mock: {
storage: new MockStorage(),
heartbeatEmitter: new EventEmitter(),
location: {
countryCode: 'us',
},
client: {
version: '41.0.1',
channel: 'release',
isDefaultBrowser: true,
searchEngine: 'google',
syncSetup: true,
plugins: {},
},
},
testing: false,
locale: 'en-US',
location() {
return Promise.resolve(this.mock.location);
},
log() {
return;
},
createStorage() {
return this.mock.storage;
},
showHeartbeat() {
return Promise.resolve(this.mock.heartbeatEmitter);
},
client() {
return Promise.resolve(this.mock.client);
},
uuid() {
return 'fake-uuid';
},
saveHeartbeatFlow() {
return Promise.resolve();
},
};
let toSpy = [
'location',
'log',
'createStorage',
'showHeartbeat',
'client',
'uuid',
'saveHeartbeatFlow',
];
for (let method of toSpy) {
spyOn(normandy, method).and.callThrough();
}
return normandy;
}

Просмотреть файл

@ -12,8 +12,7 @@ from reversion.models import Version
from normandy.base.api.permissions import AdminEnabledOrReadOnly
from normandy.base.tests import Whatever, UserFactory
from normandy.base.utils import aware_datetime
from normandy.recipes.models import Action, Recipe, ApprovalRequest, ApprovalRequestComment
from normandy.recipes.api.permissions import NotInUse
from normandy.recipes.models import Recipe, ApprovalRequest, ApprovalRequestComment
from normandy.recipes.tests import (
ActionFactory,
ApprovalRequestFactory,
@ -50,135 +49,14 @@ class TestActionAPI(object):
}
]
def test_it_can_create_actions(self, api_client):
res = api_client.post('/api/v1/action/', {
'name': 'foo',
'implementation': 'foobar',
'arguments_schema': {'type': 'object'},
})
assert res.status_code == 201
action = Action.objects.all()[0]
assert action.name == 'foo'
assert action.implementation == 'foobar'
assert action.arguments_schema == {'type': 'object'}
def test_it_can_edit_actions(self, api_client):
ActionFactory(name='foo', implementation='original')
res = api_client.patch('/api/v1/action/foo/', {'implementation': 'changed'})
assert res.status_code == 200
action = Action.objects.all()[0]
assert action.name == 'foo'
assert action.implementation == 'changed'
def test_put_creates_and_edits(self, api_client):
"""
PUT requests should create objects, or edit them if they already
exist.
"""
res = api_client.put('/api/v1/action/foo/', {
'name': 'foo',
'implementation': 'original',
'arguments_schema': {}
})
assert res.status_code == 201
action = Action.objects.all()[0]
assert action.implementation == 'original'
res = api_client.put('/api/v1/action/foo/', {
'name': 'foo',
'implementation': 'changed',
'arguments_schema': {}
})
assert res.status_code == 200
action.refresh_from_db()
assert action.implementation == 'changed'
def test_it_can_delete_actions(self, api_client):
ActionFactory(name='foo', implementation='foobar')
assert Action.objects.exists()
res = api_client.delete('/api/v1/action/foo/')
assert res.status_code == 204
assert not Action.objects.exists()
def test_name_validation(self, api_client):
"""Ensure the name field accepts _any_ valid slug."""
# Slugs can contain alphanumerics plus _ and -.
res = api_client.post('/api/v1/action/', {
'name': 'foo-bar_baz2',
'implementation': 'foobar',
'arguments_schema': {'type': 'object'},
})
assert res.status_code == 201
action = ActionFactory(name='foo-bar_baz2')
action = Action.objects.all()[0]
assert action.name == 'foo-bar_baz2'
assert action.implementation == 'foobar'
assert action.arguments_schema == {'type': 'object'}
def test_it_cant_edit_actions_in_use(self, api_client, settings):
RecipeFactory(action__name='active', enabled=True)
settings.CAN_EDIT_ACTIONS_IN_USE = False
res = api_client.patch('/api/v1/action/active/', {'implementation': 'foobar'})
assert res.status_code == 403
assert res.data['detail'] == NotInUse.message
res = api_client.delete('/api/v1/action/active/')
assert res.status_code == 403
assert res.data['detail'] == NotInUse.message
def test_it_can_edit_actions_in_use_with_setting(self, api_client, settings):
RecipeFactory(action__name='active', enabled=True)
settings.CAN_EDIT_ACTIONS_IN_USE = True
res = api_client.patch('/api/v1/action/active/', {'implementation': 'foobar'})
res = api_client.get('/api/v1/action/foo-bar_baz2/')
assert res.status_code == 200
res = api_client.delete('/api/v1/action/active/')
assert res.status_code == 204
def test_available_if_admin_enabled(self, api_client, settings):
settings.ADMIN_ENABLED = True
res = api_client.get('/api/v1/action/')
assert res.status_code == 200
assert res.data == []
def test_readable_if_admin_disabled(self, api_client, settings):
settings.ADMIN_ENABLED = False
res = api_client.get('/api/v1/action/')
assert res.status_code == 200
def test_not_writable_if_admin_disabled(self, api_client, settings):
settings.ADMIN_ENABLED = False
res = api_client.post('/api/v1/action/')
assert res.status_code == 403
assert res.data['detail'] == AdminEnabledOrReadOnly.message
def test_it_creates_revisions_on_create(self, api_client):
res = api_client.post('/api/v1/action/', {
'name': 'foo',
'implementation': 'foobar',
'arguments_schema': {'type': 'object'},
})
assert res.status_code == 201
action = Action.objects.all()[0]
assert len(reversion.get_for_object(action)) == 1
def test_it_creates_revisions_on_update(self, api_client):
ActionFactory(name='foo', implementation='original')
res = api_client.patch('/api/v1/action/foo/', {'implementation': 'changed'})
assert res.status_code == 200
action = Action.objects.all()[0]
assert len(reversion.get_for_object(action)) == 1
assert res.data['name'] == action.name
@pytest.mark.django_db

Просмотреть файл

@ -0,0 +1,128 @@
from unittest.mock import patch
from django.core.management import call_command
import pytest
from normandy.recipes.models import Action
from normandy.recipes.tests import ActionFactory, RecipeFactory
@pytest.yield_fixture
def mock_action(settings):
implementations = {}
schemas = {}
settings.ACTIONS = {}
impl_patch = patch(
'normandy.recipes.management.commands.update_actions.get_implementation',
lambda name: implementations[name]
)
schema_patch = patch(
'normandy.recipes.management.commands.update_actions.get_arguments_schema',
lambda name: schemas[name]
)
def _mock_action(name, implementation, schema):
settings.ACTIONS[name] = '/fake/path'
implementations[name] = implementation
schemas[name] = schema
with impl_patch, schema_patch:
yield _mock_action
@pytest.mark.django_db
class TestUpdateActions(object):
def test_it_works(self):
"""
Verify that the update_actions command doesn't throw an error.
"""
call_command('update_actions')
def test_it_creates_new_actions(self, mock_action):
mock_action('test-action', 'console.log("foo");', {'type': 'int'})
call_command('update_actions')
assert Action.objects.count() == 1
action = Action.objects.all()[0]
assert action.name == 'test-action'
assert action.implementation == 'console.log("foo");'
assert action.arguments_schema == {'type': 'int'}
def test_it_updates_existing_actions(self, mock_action):
action = ActionFactory(
name='test-action',
implementation='old_impl',
arguments_schema={},
)
mock_action(action.name, 'new_impl', {'type': 'int'})
call_command('update_actions')
assert Action.objects.count() == 1
action.refresh_from_db()
assert action.implementation == 'new_impl'
assert action.arguments_schema == {'type': 'int'}
def test_it_disables_recipes_when_updating(self, mock_action):
recipe = RecipeFactory(
action__name='test-action',
action__implementation='old',
enabled=True
)
action = recipe.action
mock_action(action.name, 'impl', action.arguments_schema)
call_command('update_actions')
recipe.refresh_from_db()
assert not recipe.enabled
def test_it_doesnt_disable_recipes_if_action_doesnt_change(self, mock_action):
recipe = RecipeFactory(
action__name='test-action',
action__implementation='impl',
action__arguments_schema={},
enabled=True,
)
action = recipe.action
mock_action(action.name, action.implementation, action.arguments_schema)
call_command('update_actions')
recipe.refresh_from_db()
assert recipe.enabled
def test_it_only_updates_given_actions(self, mock_action):
update_action = ActionFactory(name='update-action', implementation='old')
dont_update_action = ActionFactory(name='dont-update-action', implementation='old')
mock_action(update_action.name, 'new', update_action.arguments_schema)
mock_action(dont_update_action.name, 'new', dont_update_action.arguments_schema)
call_command('update_actions', 'update-action')
update_action.refresh_from_db()
assert update_action.implementation == 'new'
dont_update_action.refresh_from_db()
assert dont_update_action.implementation == 'old'
def test_it_ignores_missing_actions(self, mock_action):
dont_update_action = ActionFactory(name='dont-update-action', implementation='old')
mock_action(dont_update_action.name, 'new', dont_update_action.arguments_schema)
call_command('update_actions', 'missing-action')
dont_update_action.refresh_from_db()
assert dont_update_action.implementation == 'old'
def test_it_can_skip_disabling_recipes(self, mock_action):
recipe = RecipeFactory(
action__name='test-action',
action__implementation='old',
enabled=True
)
action = recipe.action
mock_action(action.name, 'impl', action.arguments_schema)
call_command('update_actions', '--no-disable')
recipe.refresh_from_db()
assert recipe.enabled

Просмотреть файл

@ -116,9 +116,19 @@ class Core(Configuration):
'DEFAULT': {
'BUNDLE_DIR_NAME': 'bundles/',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json')
},
'ACTIONS': {
'BUNDLE_DIR_NAME': 'bundles/',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats-actions.json')
}
}
# Action names and the path they are located at.
ACTIONS = {
'console-log': os.path.join(BASE_DIR, 'normandy/recipes/static/actions/console-log'),
'show-heartbeat': os.path.join(BASE_DIR, 'normandy/recipes/static/actions/show-heartbeat'),
}
class Base(Core):
"""Settings that may change per-environment, some with defaults."""
@ -190,7 +200,6 @@ class Base(Core):
CDN_URL = values.URLValue(None)
# Normandy settings
CAN_EDIT_ACTIONS_IN_USE = values.BooleanValue(False)
ADMIN_ENABLED = values.BooleanValue(True)
ACTION_IMPLEMENTATION_CACHE_TIME = values.IntegerValue(60 * 60 * 24 * 365)
NUM_PROXIES = values.IntegerValue(0)

Просмотреть файл

@ -7,12 +7,15 @@
"url": "git://github.com/mozilla/normandy.git"
},
"scripts": {
"start": "webpack --config ./webpack.config.js --watch"
"watch": "webpack --config ./webpack.config.js --watch",
"build": "webpack --config ./webpack.config.js",
"test": "karma start"
},
"license": "MPL-2.0",
"dependencies": {
"babel-polyfill": "^6.7.2",
"babel-preset-stage-2": "^6.5.0",
"babel-runtime": "^6.6.1",
"classnames": "^2.2.5",
"cssmin": "^0.4.3",
"font-awesome": "^4.5.0",
@ -38,6 +41,7 @@
"babel": "^6.5.2",
"babel-core": "^6.7.7",
"babel-loader": "^6.2.4",
"babel-plugin-transform-runtime": "^6.8.0",
"babel-preset-es2015": "^6.6.0",
"babel-preset-react": "^6.5.0",
"css-loader": "^0.23.1",
@ -46,11 +50,14 @@
"file-loader": "^0.8.5",
"imports-loader": "^0.6.5",
"jasmine-core": "^2.4.1",
"jasmine-promises": "^0.4.1",
"karma": "^0.13.22",
"karma-firefox-launcher": "^1.0.0",
"karma-jasmine": "^1.0.2",
"karma-nyan-reporter": "^0.2.4",
"karma-junit-reporter": "^1.0.0",
"karma-react-jsx-preprocessor": "^0.1.1",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
"karma-webpack": "^1.7.0",
"postcss-loader": "^0.9.1",
"react-addons-test-utils": "^15.0.2",
@ -60,6 +67,7 @@
"sass-loader": "^3.2.0",
"style-loader": "^0.13.1",
"webpack": "^1.13.0",
"webpack-bundle-tracker": "0.0.93"
"webpack-bundle-tracker": "0.0.93",
"yargs": "^4.7.0"
}
}

Просмотреть файл

@ -1,2 +1,3 @@
[flake8]
max-line-length=99
ignore=W503

Просмотреть файл

@ -1,52 +1,117 @@
var path = require('path')
var webpack = require('webpack')
var path = require('path');
var webpack = require('webpack');
var BundleTracker = require('webpack-bundle-tracker')
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var argv = require('yargs').argv;
var child_process = require('child_process');
module.exports = {
context: __dirname,
entry: {
selfrepair: './normandy/selfrepair/static/js/self_repair',
control: [
'./normandy/control/static/control/js/index',
'./normandy/control/static/control/admin/sass/control.scss',
'./node_modules/font-awesome/scss/font-awesome.scss',
]
},
const BOLD = '\u001b[1m';
const END_BOLD = '\u001b[39m\u001b[22m';
output: {
path: path.resolve('./assets/bundles/'),
filename: '[name]-[hash].js',
chunkFilename: '[id].bundle.js'
},
plugins: [
new BundleTracker({ filename: './webpack-stats.json' }),
new ExtractTextPlugin('[name]-[hash].css'),
new webpack.ProvidePlugin({
'fetch': 'exports?self.fetch!isomorphic-fetch'
}),
],
module.exports = [
{
context: __dirname,
module: {
loaders: [
{
test: /(\.|\/)(jsx|js)$/,
exclude: /node_modules/,
loader: 'babel',
'query': {
presets: ['es2015', 'react', 'stage-2']
}
},
{
test: /\.scss$/,
loader: ExtractTextPlugin.extract('style', 'css?sourceMap!postcss!sass?sourceMap')
},
{
test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
loader: 'file-loader'
}
entry: {
selfrepair: './normandy/selfrepair/static/js/self_repair',
control: [
'./normandy/control/static/control/js/index',
'./normandy/control/static/control/admin/sass/control.scss',
'./node_modules/font-awesome/scss/font-awesome.scss',
]
},
output: {
path: path.resolve('./assets/bundles/'),
filename: '[name]-[hash].js',
chunkFilename: '[id].bundle.js'
},
plugins: [
new BundleTracker({ filename: './webpack-stats.json' }),
new ExtractTextPlugin('[name]-[hash].css'),
new webpack.ProvidePlugin({
'fetch': 'exports?self.fetch!isomorphic-fetch'
}),
],
module: {
loaders: [
{
test: /(\.|\/)(jsx|js)$/,
exclude: /node_modules/,
loader: 'babel',
'query': {
presets: ['es2015', 'react', 'stage-2']
}
},
{
test: /\.scss$/,
loader: ExtractTextPlugin.extract('style', 'css?sourceMap!postcss!sass?sourceMap')
},
{
test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
loader: 'file-loader'
}
],
}
},
{
entry: {
'console-log': './normandy/recipes/static/actions/console-log/index',
'show-heartbeat': './normandy/recipes/static/actions/show-heartbeat/index',
},
plugins: [
new BundleTracker({filename: './webpack-stats-actions.json'}),
new webpack.optimize.DedupePlugin(),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}}),
// Small plugin to update the actions in the database if
// --update-actions was passed.
function updateActions() {
this.plugin('done', function(stats) {
if (argv['update-actions']) {
// Don't disable actions since this is mostly for development.
var cmd = 'python manage.py update_actions --no-disable';
child_process.exec(cmd, function(err, stdout, stderr) {
console.log('\n' + BOLD + 'Updating Actions' + END_BOLD);
console.log(stdout);
if (stderr) {
console.error(stderr);
}
});
}
});
},
],
output: {
path: path.resolve('./assets/bundles/'),
filename: '[name]-[hash].js'
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: {
presets: ['es2015', 'stage-2'],
plugins: [
['transform-runtime', {
polyfill: false,
regenerator: true
}]
]
}
}
]
}
}
}
]